authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-02 14:19:00+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 21:19:00-07:00
logb8262b41228e03b2e1e14b701be449034349c8ba
tree615055127f900902c7eacb6c81c287ee227c847f
parentcde12790d721ab2a90471750fd75d9f5bf13f57d
signature Signed by PGP key B5690EEEBB952194

fix(desktop): hold-to-quit no longer gets stuck (#9141)


4 files changed, 159 insertions(+), 8 deletions(-)

apps/desktop/src/window/DesktopWindow.test.ts+23
......@@ -105,6 +105,8 @@ function makeFakeBrowserWindow() {
105105 restore: vi.fn(),
106106 setBackgroundColor: vi.fn(),
107107 setAutoHideCursor: vi.fn(),
108 setFullScreen: vi.fn(),
109 setOpacity: vi.fn(),
108110 setTitle: vi.fn(),
109111 setTitleBarOverlay: vi.fn(),
110112 show: vi.fn(),
......@@ -127,6 +129,8 @@ function makeFakeBrowserWindow() {
127129 setZoomLevel: webContents.setZoomLevel,
128130 setBackgroundThrottling: webContents.setBackgroundThrottling,
129131 setAutoHideCursor: window.setAutoHideCursor,
132 setFullScreen: window.setFullScreen,
133 setOpacity: window.setOpacity,
130134 webContentsListeners,
131135 windowListeners,
132136 };
......@@ -394,6 +398,25 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
394398 });
395399
396400describe("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
397420 it("restores bounds only when the window fits within a connected display", () => {
398421 const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 };
399422 const displays = [
apps/desktop/src/window/DesktopWindow.ts+18
......@@ -207,6 +207,21 @@ export function isRetryableDevelopmentRendererLoadFailure(input: {
207207 );
208208}
209209
210export 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
210225function getWindowTitleBarOptions(
211226 shouldUseDarkColors: boolean,
212227 platform: NodeJS.Platform,
......@@ -570,6 +585,9 @@ export const make = Effect.gen(function* () {
570585 window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint);
571586 }
572587 },
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),
573591 quit: () => {
574592 void runPromise(electronApp.quit);
575593 },
apps/desktop/src/window/QuitHold.test.ts+83-2
......@@ -32,11 +32,13 @@ function makeHarness(options?: {
3232 getMode?: () => Promise<QuitConfirmationMode>;
3333}) {
3434 const notifications: Array<QuitShortcutHintEvent> = [];
35 const concealWindow = vi.fn();
3536 const quit = vi.fn();
3637 const handler = makeQuitShortcutHandler({
3738 platform: options?.platform ?? "darwin",
3839 getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")),
3940 notify: (event) => notifications.push(event),
41 concealWindow,
4042 quit,
4143 });
4244 const preventDefault = vi.fn();
......@@ -57,7 +59,7 @@ function makeHarness(options?: {
5759 await send(makeInput({ isAutoRepeat: true, ...repeatOverrides }));
5860 }
5961 };
60 return { notifications, quit, preventDefault, send, holdFor };
62 return { notifications, concealWindow, quit, preventDefault, send, holdFor };
6163}
6264
6365describe("makeQuitShortcutHandler", () => {
......@@ -82,10 +84,11 @@ describe("makeQuitShortcutHandler", () => {
8284 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
8385 });
8486
85 it("quits after a completed hold is released", async () => {
87 it("conceals a completed hold, then quits after release", async () => {
8688 const harness = makeHarness();
8789 await harness.send(makeInput({}));
8890 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
91 expect(harness.concealWindow).toHaveBeenCalledTimes(1);
8992 expect(harness.quit).not.toHaveBeenCalled();
9093 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
9194 expect(harness.quit).not.toHaveBeenCalled();
......@@ -94,6 +97,81 @@ describe("makeQuitShortcutHandler", () => {
9497 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
9598 });
9699
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
97175 it("waits for Q release when Cmd is released first", async () => {
98176 const harness = makeHarness();
99177 await harness.send(makeInput({}));
......@@ -115,6 +193,7 @@ describe("makeQuitShortcutHandler", () => {
115193 await harness.send(makeInput({ type: "keyUp" }));
116194 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
117195 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
196 expect(harness.concealWindow).not.toHaveBeenCalled();
118197 expect(harness.quit).not.toHaveBeenCalled();
119198 });
120199
......@@ -130,6 +209,7 @@ describe("makeQuitShortcutHandler", () => {
130209 it("quits without showing a hint in direct mode", async () => {
131210 const harness = makeHarness({ mode: "direct" });
132211 await harness.send(makeInput({}));
212 expect(harness.concealWindow).not.toHaveBeenCalled();
133213 expect(harness.quit).toHaveBeenCalledTimes(1);
134214 expect(harness.notifications).toEqual([]);
135215 });
......@@ -223,6 +303,7 @@ describe("makeQuitShortcutHandler", () => {
223303 await harness.send(makeInput({}));
224304 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
225305 await harness.send(makeInput({}));
306 expect(harness.concealWindow).not.toHaveBeenCalled();
226307 expect(harness.quit).toHaveBeenCalledTimes(1);
227308 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
228309 });
apps/desktop/src/window/QuitHold.ts+35-6
......@@ -11,9 +11,12 @@ export const QUIT_DOUBLE_PRESS_MS = 500;
1111// release: macOS suppresses a letter keyUp while the command key is down, so a
1212// tap release can go completely unseen and a release-based timer would quit
1313// 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
1515// auto-repeat disabled fall back to the application menu Quit action.
1616export 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.
19const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2;
1720
1821export interface QuitHoldKeyInput {
1922 readonly type: string;
......@@ -29,6 +32,7 @@ export interface QuitShortcutOptions {
2932 readonly platform: NodeJS.Platform;
3033 readonly getMode: () => Promise<QuitConfirmationMode>;
3134 readonly notify: (event: QuitShortcutHintEvent) => void;
35 readonly concealWindow: () => void;
3236 readonly quit: () => void;
3337}
3438
......@@ -45,6 +49,8 @@ export function makeQuitShortcutHandler(
4549 let quitOnRelease = false;
4650 let heldSince = 0;
4751 let lastPressAt = 0;
52 let lastRepeatAt = 0;
53 let repeatCadenceMs = 0;
4854 // Incremented when a press is superseded or explicitly cancelled. A plain
4955 // key release does not invalidate its pending mode read: direct mode and a
5056 // completed second press must still be honored after that read settles.
......@@ -64,6 +70,8 @@ export function makeQuitShortcutHandler(
6470 holding = false;
6571 armed = false;
6672 quitOnRelease = false;
73 lastRepeatAt = 0;
74 repeatCadenceMs = 0;
6775 if (keepHint) return;
6876
6977 mode = undefined;
......@@ -80,6 +88,15 @@ export function makeQuitShortcutHandler(
8088 options.quit();
8189 };
8290
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
83100 return (event, input) => {
84101 const key = input.key.toLowerCase();
85102 if (input.type === "keyUp") {
......@@ -91,20 +108,31 @@ export function makeQuitShortcutHandler(
91108 if (!quitOnRelease) {
92109 release(false, true);
93110 } else {
94 watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);
111 quitAfterQuietPeriod();
95112 }
96113 }
97114 return;
98115 }
99116 if (input.type !== "keyDown") return;
100117
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) {
102125 event.preventDefault();
103 clearWatchdog();
126 if (key === "q") {
127 if (modifierDown) {
128 quitAfterQuietPeriod();
129 } else {
130 clearWatchdog();
131 }
132 }
104133 return;
105134 }
106135
107 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
108136 if (!modifierDown || input.alt || input.shift || key !== "q") {
109137 // Re-pressing the platform modifier is the first half of a second full
110138 // quit shortcut, so it must not cancel an active double-press window.
......@@ -129,7 +157,8 @@ export function makeQuitShortcutHandler(
129157 if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
130158 armed = false;
131159 quitOnRelease = true;
132 clearWatchdog();
160 options.concealWindow();
161 quitAfterQuietPeriod();
133162 }
134163 return;
135164 }