authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-18 20:41:07+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-18 13:41:07-04:00
logf3cb7f509595d304c6e2ccc7afe05c95ec45a7d8
tree404ea3ae1827fff593be8fe92aa306c9291749be
parenta87f691bd40c9e3c87715e3ce99ab39a584bdd1e
signature Signed by PGP key B5690EEEBB952194

fix(desktop): prevent quit shortcut spillover (#7397)


2 files changed, 57 insertions(+), 19 deletions(-)

apps/desktop/src/window/QuitHold.test.ts+23-7
......@@ -77,17 +77,32 @@ describe("makeQuitHoldHandler", () => {
7777 expect(harness.notifications).toEqual(["down", "up"]);
7878 });
7979
80 it("quits once the shortcut auto-repeats past the hold duration", async () => {
80 it("quits after a completed hold is released", async () => {
8181 const harness = makeHarness();
8282 await harness.send(makeInput({}));
83 await harness.holdFor(QUIT_HOLD_DURATION_MS - 200);
83 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
84 expect(harness.quit).not.toHaveBeenCalled();
85 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
8486 expect(harness.quit).not.toHaveBeenCalled();
85 await harness.holdFor(400);
87 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
8688 expect(harness.quit).toHaveBeenCalledTimes(1);
87 // Exactly one hint cycle for the whole hold.
8889 expect(harness.notifications).toEqual(["down", "up"]);
8990 });
9091
92 it("waits for Q release when Cmd is released first", async () => {
93 const harness = makeHarness();
94 await harness.send(makeInput({}));
95 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
96 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
97 harness.preventDefault.mockClear();
98 await harness.send(makeInput({ meta: false, isAutoRepeat: true }));
99 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
100 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 2);
101 expect(harness.quit).not.toHaveBeenCalled();
102 await harness.send(makeInput({ type: "keyUp", meta: false }));
103 expect(harness.quit).toHaveBeenCalledTimes(1);
104 });
105
91106 it("does not quit when the hold stops before the duration", async () => {
92107 const harness = makeHarness();
93108 await harness.send(makeInput({}));
......@@ -107,12 +122,11 @@ describe("makeQuitHoldHandler", () => {
107122 expect(harness.quit).not.toHaveBeenCalled();
108123 });
109124
110 it("quits immediately on a single press when disabled", async () => {
125 it("quits without showing a hint when hold-to-quit is disabled", async () => {
111126 const harness = makeHarness({ enabled: false });
112127 await harness.send(makeInput({}));
113128 expect(harness.quit).toHaveBeenCalledTimes(1);
114 // The hint is dismissed in case the quit gets cancelled downstream.
115 expect(harness.notifications).toEqual(["down", "up"]);
129 expect(harness.notifications).toEqual([]);
116130 });
117131
118132 it("discards a stale isEnabled resolution from a superseded press", async () => {
......@@ -138,6 +152,7 @@ describe("makeQuitHoldHandler", () => {
138152 // Press #2 resolves enabled and completes a full hold.
139153 resolvers[1]?.(true);
140154 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
155 await harness.send(makeInput({ type: "keyUp" }));
141156 expect(harness.quit).toHaveBeenCalledTimes(1);
142157 });
143158
......@@ -196,6 +211,7 @@ describe("makeQuitHoldHandler", () => {
196211 await harness.send(makeInput({ meta: false, control: true }));
197212 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
198213 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });
214 await harness.send(makeInput({ type: "keyUp", meta: false, control: true }));
199215 expect(harness.quit).toHaveBeenCalledTimes(1);
200216 });
201217});
apps/desktop/src/window/QuitHold.ts+34-12
......@@ -2,7 +2,8 @@
22
33// Chrome-style hold-to-quit. The quit accelerator is intercepted in
44// 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.
5// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS
6// and released.
67// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap
78// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
89// menu itself is untouched and quits immediately.
......@@ -10,11 +11,11 @@ export const QUIT_HOLD_DURATION_MS = 1200;
1011// A second quick tap of the shortcut is the user insisting: quit immediately.
1112export const QUIT_DOUBLE_TAP_MS = 500;
1213// "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.
14// release: macOS suppresses a letter keyUp while the command key is down, so a
15// tap release can go completely unseen and a release-based timer would quit
16// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
17// modifier keyUp so repeats cannot reach the next app. Keyboards with
18// auto-repeat disabled fall back to the application menu Quit action.
1819export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
1920
2021export type QuitHoldState = "down" | "up";
......@@ -42,8 +43,9 @@ export function makeQuitHoldHandler(
4243 const modifierKey = options.platform === "darwin" ? "meta" : "control";
4344 let watchdog: NodeJS.Timeout | undefined;
4445 let holding = false;
45 // Set once isEnabled resolves true; auto-repeats may only quit when armed.
46 // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed.
4647 let armed = false;
48 let quitOnRelease = false;
4749 let heldSince = 0;
4850 let lastPressAt = 0;
4951 // Incremented on every new press and every release/quit so a pending
......@@ -60,14 +62,16 @@ export function makeQuitHoldHandler(
6062
6163 const release = () => {
6264 if (!holding) return;
65 const shouldNotify = armed || quitOnRelease;
6366 generation += 1;
6467 holding = false;
6568 armed = false;
69 quitOnRelease = false;
6670 clearWatchdog();
67 options.notify("up");
71 if (shouldNotify) options.notify("up");
6872 };
6973
70 // Dismisses the overlay first: if the quit is cancelled downstream the
74 // Dismisses any overlay first: if the quit is cancelled downstream the
7175 // renderer must not be left with a stuck "Hold to Quit" hint.
7276 const quitNow = () => {
7377 release();
......@@ -77,11 +81,27 @@ export function makeQuitHoldHandler(
7781 return (event, input) => {
7882 const key = input.key.toLowerCase();
7983 if (input.type === "keyUp") {
80 if (key === "q" || key === modifierKey) release();
84 if (key === "q") {
85 const shouldQuit = quitOnRelease;
86 release();
87 if (shouldQuit) options.quit();
88 } else if (key === modifierKey) {
89 if (!quitOnRelease) {
90 release();
91 } else {
92 watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);
93 }
94 }
8195 return;
8296 }
8397 if (input.type !== "keyDown") return;
8498
99 if (quitOnRelease && input.isAutoRepeat && key === "q") {
100 event.preventDefault();
101 clearWatchdog();
102 return;
103 }
104
85105 const modifierDown = options.platform === "darwin" ? input.meta : input.control;
86106 if (!modifierDown || input.alt || input.shift || key !== "q") {
87107 // Any other key (or an extra modifier) pressed mid-hold breaks the
......@@ -101,7 +121,9 @@ export function makeQuitHoldHandler(
101121
102122 if (input.isAutoRepeat) {
103123 if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
104 quitNow();
124 armed = false;
125 quitOnRelease = true;
126 clearWatchdog();
105127 }
106128 return;
107129 }
......@@ -121,7 +143,6 @@ export function makeQuitHoldHandler(
121143 const pressGeneration = generation;
122144 holding = true;
123145 heldSince = now;
124 options.notify("down");
125146 void options.isEnabled().then(
126147 (enabled) => {
127148 if (generation !== pressGeneration) return;
......@@ -131,6 +152,7 @@ export function makeQuitHoldHandler(
131152 return;
132153 }
133154 armed = true;
155 options.notify("down");
134156 // No auto-repeat by then means the key was released (possibly with a
135157 // suppressed keyUp) or repeat is disabled; either way, don't quit.
136158 watchdog = setTimeout(() => {