diff --git a/src/HotkeySettings.tsx b/src/HotkeySettings.tsx index c8df730..959e490 100644 --- a/src/HotkeySettings.tsx +++ b/src/HotkeySettings.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import { captureHotkeyFromEvent, type StoredHotkey } from "./hotkey/captureHotkey"; +import { captureHotkeyFromEvent, isCaptureCancelKey, type StoredHotkey } from "./hotkey/captureHotkey"; import { formatHotkey } from "./hotkey/keycodeMap"; -export const SHORTCUT_CAPTURE_PROMPT = "Press Option, Command, Control, Fn, Caps Lock, or F1–F19…"; +export const SHORTCUT_CAPTURE_PROMPT = "Press Option, Command, Control, Fn, Caps Lock, or F1–F19… (Esc to cancel)"; export const SHORTCUT_GUIDANCE = "F1–F12 require the top row to be in function-key mode. F13–F19 depend on keyboard support. Choose one standalone key: Option, Command, Control, Fn, or Caps Lock. OpenStream cannot reliably detect whether macOS or another app also uses a key. Fn and Caps Lock may not produce a usable event on every keyboard."; @@ -48,7 +48,25 @@ export default function HotkeySettings() { .finally(() => setShortcutChangePending(false)); } + // #435: the only way out of capture mode besides successfully picking a + // hotkey - Escape, Tab, or the whole app losing focus (see the blur + // listener below). Leaves the shortcut untouched, unlike applyShortcut. + function cancelCapture() { + if (!captureOpen) return; + captureOpen = false; + stopNativeCapture(); + setError(null); + setRecording(false); + } + function onKeyDown(event: KeyboardEvent) { + if (isCaptureCancelKey(event)) { + // Tab keeps its normal job - moving focus - Escape has no default + // action worth preserving here. + if (event.code !== "Tab") event.preventDefault(); + cancelCapture(); + return; + } event.preventDefault(); const result = captureHotkeyFromEvent(event); if (!result.ok) { @@ -62,11 +80,17 @@ export default function HotkeySettings() { // than a reliable DOM keydown, so the main process captures it separately. const unsubscribe = window.openstream.settings.onShortcutCaptured(applyShortcut); window.addEventListener("keydown", onKeyDown, true); + // #435: Cmd-Tabbing away (or any other way the window loses focus) + // while capturing shouldn't leave capture mode armed for when it comes + // back - the key that was about to be pressed was almost certainly + // meant for whatever the user switched to. + window.addEventListener("blur", cancelCapture); void window.openstream.settings.startShortcutCapture().catch(() => {}); return () => { captureOpen = false; window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", cancelCapture); unsubscribe(); stopNativeCapture(); }; diff --git a/src/hotkey/captureHotkey.test.ts b/src/hotkey/captureHotkey.test.ts index 12f70f8..5eae99b 100644 --- a/src/hotkey/captureHotkey.test.ts +++ b/src/hotkey/captureHotkey.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { captureHotkeyFromEvent, type CapturedKeyEvent } from "./captureHotkey"; +import { captureHotkeyFromEvent, isCaptureCancelKey, type CapturedKeyEvent } from "./captureHotkey"; import { STANDALONE_CAPS_LOCK_KEY_CODE, STANDALONE_COMMAND_KEY_CODE, @@ -80,3 +80,29 @@ describe("captureHotkeyFromEvent", () => { expect(captureHotkeyFromEvent(keyEvent(event))).toEqual({ ok: false, reason: "Unsupported key" }); }); }); + +// #435: without this, a keyboard-only user who opens capture mode has no +// way out of it - Escape and Tab were both just "Unsupported key" like any +// other rejected candidate, and the component preventDefaults every +// keydown while recording, so Tab's own default (moving focus) never ran +// either. A real keyboard trap, not a hypothetical one. +describe("isCaptureCancelKey", () => { + it.each(["Escape", "Tab"])("treats %s as a cancel, not a candidate hotkey", (code) => { + expect(isCaptureCancelKey({ code })).toBe(true); + }); + + it.each([ + "AltLeft", + "MetaLeft", + "ControlLeft", + "Fn", + "CapsLock", + "F1", + "KeyD", + "ArrowUp", + "ShiftLeft", + "Space", + ])("does not treat %s as a cancel", (code) => { + expect(isCaptureCancelKey({ code })).toBe(false); + }); +}); diff --git a/src/hotkey/captureHotkey.ts b/src/hotkey/captureHotkey.ts index aaf049c..2da8dca 100644 --- a/src/hotkey/captureHotkey.ts +++ b/src/hotkey/captureHotkey.ts @@ -40,3 +40,16 @@ export function captureHotkeyFromEvent(event: CapturedKeyEvent): CaptureResult { return { ok: true, hotkey: { keyCode, modifiers: [] } }; } + +// #435: neither Escape nor Tab is a candidate hotkey - captureHotkeyFromEvent +// already says so via UNSUPPORTED_KEY - but before this, "unsupported" just +// meant "keep waiting for a real one", with no way out. A keyboard-only user +// who opened capture mode had no way out of it at all: Tab doesn't move +// focus (the component preventDefaults every keydown while recording) and +// Escape doesn't cancel - a WCAG "no keyboard trap" violation, not a +// hypothetical one. The two keys need to close capture mode instead, a +// different question from which key makes a valid hotkey, so it's checked +// separately rather than folded into captureHotkeyFromEvent's result shape. +export function isCaptureCancelKey(event: Pick): boolean { + return event.code === "Escape" || event.code === "Tab"; +}