// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. // Chrome-style hold-to-quit. The quit accelerator is intercepted in // before-input-event (which runs before the native menu accelerator), and the // app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS // and released. // A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap // within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application // menu itself is untouched and quits immediately. export const QUIT_HOLD_DURATION_MS = 1200; // A second quick tap of the shortcut is the user insisting: quit immediately. export const QUIT_DOUBLE_TAP_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // modifier keyUp so repeats cannot reach the next app. Keyboards with // auto-repeat disabled fall back to the application menu Quit action. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; export type QuitHoldState = "down" | "up"; export interface QuitHoldKeyInput { readonly type: string; readonly key: string; readonly meta: boolean; readonly control: boolean; readonly alt: boolean; readonly shift: boolean; readonly isAutoRepeat: boolean; } export interface QuitHoldOptions { readonly platform: NodeJS.Platform; readonly isEnabled: () => Promise; readonly notify: (state: QuitHoldState) => void; readonly quit: () => void; } export function makeQuitHoldHandler( options: QuitHoldOptions, ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { const modifierKey = options.platform === "darwin" ? "meta" : "control"; let watchdog: NodeJS.Timeout | undefined; let holding = false; // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; // Incremented on every new press and every release/quit so a pending // isEnabled() resolution from a superseded press cannot arm (or quit for) // the current one. let generation = 0; const clearWatchdog = () => { if (watchdog !== undefined) { clearTimeout(watchdog); watchdog = undefined; } }; const release = () => { if (!holding) return; const shouldNotify = armed || quitOnRelease; generation += 1; holding = false; armed = false; quitOnRelease = false; clearWatchdog(); if (shouldNotify) options.notify("up"); }; // Dismisses any overlay first: if the quit is cancelled downstream the // renderer must not be left with a stuck "Hold to Quit" hint. const quitNow = () => { release(); options.quit(); }; return (event, input) => { const key = input.key.toLowerCase(); if (input.type === "keyUp") { if (key === "q") { const shouldQuit = quitOnRelease; release(); if (shouldQuit) options.quit(); } else if (key === modifierKey) { if (!quitOnRelease) { release(); } else { watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); } } return; } if (input.type !== "keyDown") return; if (quitOnRelease && input.isAutoRepeat && key === "q") { event.preventDefault(); clearWatchdog(); return; } const modifierDown = options.platform === "darwin" ? input.meta : input.control; if (!modifierDown || input.alt || input.shift || key !== "q") { // Any other key (or an extra modifier) pressed mid-hold breaks the // gesture; without this the hold timer keeps running through the // interruption and the next qualifying repeat would quit early. The // interrupted press also stops counting toward a double tap — but only // here, not in release(), which runs mid-restart on an unseen-release // re-press and must not wipe that press's own tap timestamp. if (holding && !input.isAutoRepeat) { lastPressAt = 0; release(); } return; } event.preventDefault(); if (input.isAutoRepeat) { if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { armed = false; quitOnRelease = true; clearWatchdog(); } return; } const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; // A fresh keydown while "holding" means the key came back down after a // release macOS never delivered — so both branches below see real taps. if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { quitNow(); return; } if (holding) release(); generation += 1; const pressGeneration = generation; holding = true; heldSince = now; void options.isEnabled().then( (enabled) => { if (generation !== pressGeneration) return; if (!enabled) { // Hold-to-quit disabled: a single press quits immediately. quitNow(); return; } armed = true; options.notify("down"); // No auto-repeat by then means the key was released (possibly with a // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { watchdog = undefined; release(); }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); }, // A failed settings read must never strand the quit request. () => { if (generation !== pressGeneration) return; quitNow(); }, ); }; }