Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/HotkeySettings.tsx
Original file line number Diff line number Diff line change
@@ -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.";

Expand Down Expand Up @@ -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) {
Expand All @@ -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();
};
Expand Down
28 changes: 27 additions & 1 deletion src/hotkey/captureHotkey.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
});
13 changes: 13 additions & 0 deletions src/hotkey/captureHotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CapturedKeyEvent, "code">): boolean {
return event.code === "Escape" || event.code === "Tab";
}
Loading