| author | |
| committer | |
| log | b8262b41228e03b2e1e14b701be449034349c8ba |
| tree | 615055127f900902c7eacb6c81c287ee227c847f |
| parent | cde12790d721ab2a90471750fd75d9f5bf13f57d |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 159 insertions(+), 8 deletions(-)
apps/desktop/src/window/DesktopWindow.test.ts+23| ... | ... | @@ -105,6 +105,8 @@ function makeFakeBrowserWindow() { |
| 105 | 105 | restore: vi.fn(), |
| 106 | 106 | setBackgroundColor: vi.fn(), |
| 107 | 107 | setAutoHideCursor: vi.fn(), |
| 108 | setFullScreen: vi.fn(), | |
| 109 | setOpacity: vi.fn(), | |
| 108 | 110 | setTitle: vi.fn(), |
| 109 | 111 | setTitleBarOverlay: vi.fn(), |
| 110 | 112 | show: vi.fn(), |
| ... | ... | @@ -127,6 +129,8 @@ function makeFakeBrowserWindow() { |
| 127 | 129 | setZoomLevel: webContents.setZoomLevel, |
| 128 | 130 | setBackgroundThrottling: webContents.setBackgroundThrottling, |
| 129 | 131 | setAutoHideCursor: window.setAutoHideCursor, |
| 132 | setFullScreen: window.setFullScreen, | |
| 133 | setOpacity: window.setOpacity, | |
| 130 | 134 | webContentsListeners, |
| 131 | 135 | windowListeners, |
| 132 | 136 | }; |
| ... | ... | @@ -394,6 +398,25 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 394 | 398 | }); |
| 395 | 399 | |
| 396 | 400 | describe("DesktopWindow", () => { |
| 401 | it("leaves fullscreen before concealing a pending quit", () => { | |
| 402 | const fakeWindow = makeFakeBrowserWindow(); | |
| 403 | ||
| 404 | DesktopWindow.concealPendingQuitWindow(fakeWindow.window); | |
| 405 | assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); | |
| 406 | ||
| 407 | fakeWindow.setOpacity.mockClear(); | |
| 408 | fakeWindow.isFullScreen.mockReturnValue(true); | |
| 409 | DesktopWindow.concealPendingQuitWindow(fakeWindow.window); | |
| 410 | assert.deepEqual(fakeWindow.setFullScreen.mock.calls, [[false]]); | |
| 411 | assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); | |
| 412 | ||
| 413 | fakeWindow.setOpacity.mockClear(); | |
| 414 | fakeWindow.isFullScreen.mockReturnValue(false); | |
| 415 | fakeWindow.isDestroyed.mockReturnValue(true); | |
| 416 | DesktopWindow.concealPendingQuitWindow(fakeWindow.window); | |
| 417 | assert.equal(fakeWindow.setOpacity.mock.calls.length, 0); | |
| 418 | }); | |
| 419 | ||
| 397 | 420 | it("restores bounds only when the window fits within a connected display", () => { |
| 398 | 421 | const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; |
| 399 | 422 | const displays = [ |
apps/desktop/src/window/DesktopWindow.ts+18| ... | ... | @@ -207,6 +207,21 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { |
| 207 | 207 | ); |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | export function concealPendingQuitWindow( | |
| 211 | window: Pick< | |
| 212 | Electron.BrowserWindow, | |
| 213 | "isDestroyed" | "isFullScreen" | "setFullScreen" | "setOpacity" | |
| 214 | >, | |
| 215 | ): void { | |
| 216 | if (window.isDestroyed()) return; | |
| 217 | if (window.isFullScreen()) { | |
| 218 | window.setFullScreen(false); | |
| 219 | } | |
| 220 | // Electron implements window opacity on macOS and Windows. Linux keeps the | |
| 221 | // release-gated quit behavior but cannot make the pending window disappear. | |
| 222 | window.setOpacity(0); | |
| 223 | } | |
| 224 | ||
| 210 | 225 | function getWindowTitleBarOptions( |
| 211 | 226 | shouldUseDarkColors: boolean, |
| 212 | 227 | platform: NodeJS.Platform, |
| ... | ... | @@ -570,6 +585,9 @@ export const make = Effect.gen(function* () { |
| 570 | 585 | window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); |
| 571 | 586 | } |
| 572 | 587 | }, |
| 588 | // Keep the transparent window focused until the physical shortcut is | |
| 589 | // released so its remaining repeats cannot reach the next app. | |
| 590 | concealWindow: () => concealPendingQuitWindow(window), | |
| 573 | 591 | quit: () => { |
| 574 | 592 | void runPromise(electronApp.quit); |
| 575 | 593 | }, |
apps/desktop/src/window/QuitHold.test.ts+83-2| ... | ... | @@ -32,11 +32,13 @@ function makeHarness(options?: { |
| 32 | 32 | getMode?: () => Promise<QuitConfirmationMode>; |
| 33 | 33 | }) { |
| 34 | 34 | const notifications: Array<QuitShortcutHintEvent> = []; |
| 35 | const concealWindow = vi.fn(); | |
| 35 | 36 | const quit = vi.fn(); |
| 36 | 37 | const handler = makeQuitShortcutHandler({ |
| 37 | 38 | platform: options?.platform ?? "darwin", |
| 38 | 39 | getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), |
| 39 | 40 | notify: (event) => notifications.push(event), |
| 41 | concealWindow, | |
| 40 | 42 | quit, |
| 41 | 43 | }); |
| 42 | 44 | const preventDefault = vi.fn(); |
| ... | ... | @@ -57,7 +59,7 @@ function makeHarness(options?: { |
| 57 | 59 | await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); |
| 58 | 60 | } |
| 59 | 61 | }; |
| 60 | return { notifications, quit, preventDefault, send, holdFor }; | |
| 62 | return { notifications, concealWindow, quit, preventDefault, send, holdFor }; | |
| 61 | 63 | } |
| 62 | 64 | |
| 63 | 65 | describe("makeQuitShortcutHandler", () => { |
| ... | ... | @@ -82,10 +84,11 @@ describe("makeQuitShortcutHandler", () => { |
| 82 | 84 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); |
| 83 | 85 | }); |
| 84 | 86 | |
| 85 | it("quits after a completed hold is released", async () => { | |
| 87 | it("conceals a completed hold, then quits after release", async () => { | |
| 86 | 88 | const harness = makeHarness(); |
| 87 | 89 | await harness.send(makeInput({})); |
| 88 | 90 | await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); |
| 91 | expect(harness.concealWindow).toHaveBeenCalledTimes(1); | |
| 89 | 92 | expect(harness.quit).not.toHaveBeenCalled(); |
| 90 | 93 | await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); |
| 91 | 94 | expect(harness.quit).not.toHaveBeenCalled(); |
| ... | ... | @@ -94,6 +97,81 @@ describe("makeQuitShortcutHandler", () => { |
| 94 | 97 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); |
| 95 | 98 | }); |
| 96 | 99 | |
| 100 | it("keeps a concealed hold committed when another key is pressed", async () => { | |
| 101 | const harness = makeHarness(); | |
| 102 | await harness.send(makeInput({})); | |
| 103 | await harness.holdFor(QUIT_HOLD_DURATION_MS); | |
| 104 | ||
| 105 | await harness.send(makeInput({ key: "Shift", shift: true })); | |
| 106 | expect(harness.concealWindow).toHaveBeenCalledTimes(1); | |
| 107 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 108 | ||
| 109 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | |
| 110 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 111 | }); | |
| 112 | ||
| 113 | it("keeps a concealed hold committed through a fresh Cmd+Q press", async () => { | |
| 114 | const harness = makeHarness(); | |
| 115 | await harness.send(makeInput({})); | |
| 116 | await harness.holdFor(QUIT_HOLD_DURATION_MS); | |
| 117 | ||
| 118 | await harness.send(makeInput({})); | |
| 119 | expect(harness.concealWindow).toHaveBeenCalledTimes(1); | |
| 120 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 121 | ||
| 122 | await harness.send(makeInput({ type: "keyUp" })); | |
| 123 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 124 | }); | |
| 125 | ||
| 126 | it("quits when a completed hold goes quiet without release events", async () => { | |
| 127 | const harness = makeHarness(); | |
| 128 | await harness.send(makeInput({})); | |
| 129 | await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2); | |
| 130 | ||
| 131 | // If neither keyUp reaches the handler, continued repeats must keep the | |
| 132 | // app alive. Once they stop, the quiet period is the release signal. | |
| 133 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 134 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | |
| 135 | ||
| 136 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 137 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); | |
| 138 | }); | |
| 139 | ||
| 140 | it("waits for slow repeats to stop before quitting", async () => { | |
| 141 | const harness = makeHarness(); | |
| 142 | await harness.send(makeInput({})); | |
| 143 | ||
| 144 | vi.advanceTimersByTime(300); | |
| 145 | await harness.send(makeInput({ isAutoRepeat: true })); | |
| 146 | vi.advanceTimersByTime(900); | |
| 147 | await harness.send(makeInput({ isAutoRepeat: true })); | |
| 148 | ||
| 149 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | |
| 150 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 151 | ||
| 152 | vi.advanceTimersByTime(300); | |
| 153 | await harness.send(makeInput({ isAutoRepeat: true })); | |
| 154 | vi.advanceTimersByTime(1_799); | |
| 155 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 156 | vi.advanceTimersByTime(1); | |
| 157 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 158 | }); | |
| 159 | ||
| 160 | it("uses the initial repeat delay when the first repeat completes the hold", async () => { | |
| 161 | const harness = makeHarness(); | |
| 162 | await harness.send(makeInput({})); | |
| 163 | ||
| 164 | vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + 100); | |
| 165 | await harness.send(makeInput({ isAutoRepeat: true })); | |
| 166 | ||
| 167 | vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); | |
| 168 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 169 | vi.advanceTimersByTime(1_999); | |
| 170 | expect(harness.quit).not.toHaveBeenCalled(); | |
| 171 | vi.advanceTimersByTime(1); | |
| 172 | expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 173 | }); | |
| 174 | ||
| 97 | 175 | it("waits for Q release when Cmd is released first", async () => { |
| 98 | 176 | const harness = makeHarness(); |
| 99 | 177 | await harness.send(makeInput({})); |
| ... | ... | @@ -115,6 +193,7 @@ describe("makeQuitShortcutHandler", () => { |
| 115 | 193 | await harness.send(makeInput({ type: "keyUp" })); |
| 116 | 194 | expect(harness.notifications).toEqual([HOLD_DOWN, UP]); |
| 117 | 195 | vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); |
| 196 | expect(harness.concealWindow).not.toHaveBeenCalled(); | |
| 118 | 197 | expect(harness.quit).not.toHaveBeenCalled(); |
| 119 | 198 | }); |
| 120 | 199 | |
| ... | ... | @@ -130,6 +209,7 @@ describe("makeQuitShortcutHandler", () => { |
| 130 | 209 | it("quits without showing a hint in direct mode", async () => { |
| 131 | 210 | const harness = makeHarness({ mode: "direct" }); |
| 132 | 211 | await harness.send(makeInput({})); |
| 212 | expect(harness.concealWindow).not.toHaveBeenCalled(); | |
| 133 | 213 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 134 | 214 | expect(harness.notifications).toEqual([]); |
| 135 | 215 | }); |
| ... | ... | @@ -223,6 +303,7 @@ describe("makeQuitShortcutHandler", () => { |
| 223 | 303 | await harness.send(makeInput({})); |
| 224 | 304 | vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); |
| 225 | 305 | await harness.send(makeInput({})); |
| 306 | expect(harness.concealWindow).not.toHaveBeenCalled(); | |
| 226 | 307 | expect(harness.quit).toHaveBeenCalledTimes(1); |
| 227 | 308 | expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); |
| 228 | 309 | }); |
apps/desktop/src/window/QuitHold.ts+35-6| ... | ... | @@ -11,9 +11,12 @@ export const QUIT_DOUBLE_PRESS_MS = 500; |
| 11 | 11 | // release: macOS suppresses a letter keyUp while the command key is down, so a |
| 12 | 12 | // tap release can go completely unseen and a release-based timer would quit |
| 13 | 13 | // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after |
| 14 | // modifier keyUp so repeats cannot reach the next app. Keyboards with | |
| 14 | // repeats stop so they cannot reach the next app. Keyboards with | |
| 15 | 15 | // auto-repeat disabled fall back to the application menu Quit action. |
| 16 | 16 | export const QUIT_HOLD_RELEASE_GRACE_MS = 600; |
| 17 | // A slow repeat rate can exceed the fixed grace. Waiting for two observed | |
| 18 | // cadences keeps the timer behind the next repeat without slowing normal rates. | |
| 19 | const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2; | |
| 17 | 20 | |
| 18 | 21 | export interface QuitHoldKeyInput { |
| 19 | 22 | readonly type: string; |
| ... | ... | @@ -29,6 +32,7 @@ export interface QuitShortcutOptions { |
| 29 | 32 | readonly platform: NodeJS.Platform; |
| 30 | 33 | readonly getMode: () => Promise<QuitConfirmationMode>; |
| 31 | 34 | readonly notify: (event: QuitShortcutHintEvent) => void; |
| 35 | readonly concealWindow: () => void; | |
| 32 | 36 | readonly quit: () => void; |
| 33 | 37 | } |
| 34 | 38 | |
| ... | ... | @@ -45,6 +49,8 @@ export function makeQuitShortcutHandler( |
| 45 | 49 | let quitOnRelease = false; |
| 46 | 50 | let heldSince = 0; |
| 47 | 51 | let lastPressAt = 0; |
| 52 | let lastRepeatAt = 0; | |
| 53 | let repeatCadenceMs = 0; | |
| 48 | 54 | // Incremented when a press is superseded or explicitly cancelled. A plain |
| 49 | 55 | // key release does not invalidate its pending mode read: direct mode and a |
| 50 | 56 | // completed second press must still be honored after that read settles. |
| ... | ... | @@ -64,6 +70,8 @@ export function makeQuitShortcutHandler( |
| 64 | 70 | holding = false; |
| 65 | 71 | armed = false; |
| 66 | 72 | quitOnRelease = false; |
| 73 | lastRepeatAt = 0; | |
| 74 | repeatCadenceMs = 0; | |
| 67 | 75 | if (keepHint) return; |
| 68 | 76 | |
| 69 | 77 | mode = undefined; |
| ... | ... | @@ -80,6 +88,15 @@ export function makeQuitShortcutHandler( |
| 80 | 88 | options.quit(); |
| 81 | 89 | }; |
| 82 | 90 | |
| 91 | const quitAfterQuietPeriod = () => { | |
| 92 | clearWatchdog(); | |
| 93 | const quietPeriodMs = Math.max( | |
| 94 | QUIT_HOLD_RELEASE_GRACE_MS, | |
| 95 | repeatCadenceMs * QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER, | |
| 96 | ); | |
| 97 | watchdog = setTimeout(quitNow, quietPeriodMs); | |
| 98 | }; | |
| 99 | ||
| 83 | 100 | return (event, input) => { |
| 84 | 101 | const key = input.key.toLowerCase(); |
| 85 | 102 | if (input.type === "keyUp") { |
| ... | ... | @@ -91,20 +108,31 @@ export function makeQuitShortcutHandler( |
| 91 | 108 | if (!quitOnRelease) { |
| 92 | 109 | release(false, true); |
| 93 | 110 | } else { |
| 94 | watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); | |
| 111 | quitAfterQuietPeriod(); | |
| 95 | 112 | } |
| 96 | 113 | } |
| 97 | 114 | return; |
| 98 | 115 | } |
| 99 | 116 | if (input.type !== "keyDown") return; |
| 100 | 117 | |
| 101 | if (quitOnRelease && input.isAutoRepeat && key === "q") { | |
| 118 | const modifierDown = options.platform === "darwin" ? input.meta : input.control; | |
| 119 | if (input.isAutoRepeat && modifierDown && key === "q") { | |
| 120 | const now = Date.now(); | |
| 121 | repeatCadenceMs = now - (lastRepeatAt === 0 ? heldSince : lastRepeatAt); | |
| 122 | lastRepeatAt = now; | |
| 123 | } | |
| 124 | if (quitOnRelease) { | |
| 102 | 125 | event.preventDefault(); |
| 103 | clearWatchdog(); | |
| 126 | if (key === "q") { | |
| 127 | if (modifierDown) { | |
| 128 | quitAfterQuietPeriod(); | |
| 129 | } else { | |
| 130 | clearWatchdog(); | |
| 131 | } | |
| 132 | } | |
| 104 | 133 | return; |
| 105 | 134 | } |
| 106 | 135 | |
| 107 | const modifierDown = options.platform === "darwin" ? input.meta : input.control; | |
| 108 | 136 | if (!modifierDown || input.alt || input.shift || key !== "q") { |
| 109 | 137 | // Re-pressing the platform modifier is the first half of a second full |
| 110 | 138 | // quit shortcut, so it must not cancel an active double-press window. |
| ... | ... | @@ -129,7 +157,8 @@ export function makeQuitShortcutHandler( |
| 129 | 157 | if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { |
| 130 | 158 | armed = false; |
| 131 | 159 | quitOnRelease = true; |
| 132 | clearWatchdog(); | |
| 160 | options.concealWindow(); | |
| 161 | quitAfterQuietPeriod(); | |
| 133 | 162 | } |
| 134 | 163 | return; |
| 135 | 164 | } |