170 lines · 5.8 KB · TypeScript
RawDownload
// @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<boolean>;  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();      },    );  };}