feat(B.1 #5): per-OS hotkey capability matrix with inline rejection copy
Adds src/lib/utils/hotkeyValidity.ts with validateHotkey(combo, os)
and wires it into HotkeyRecorder.svelte. Rules:
- X11/Wayland/Linux: reject single-key combos unless the trigger
is F13..F24 (the conventional global-shortcut escape hatch).
- Windows: reject combos whose only modifier is a right-hand
variant (RCtrl/RAlt) — matches Handy #966's failure mode where
RegisterHotKey silently ignores them.
- macOS: reject Fn-only combos and bare-key combos (non-function
keys).
- unknown OS: pass through — better to ship a flawed combo than
reject one we can't validate.
When validation fails, the recorder leaves the previous known-good
hotkey intact and surfaces an inline warning-coloured sentence
explaining why, with actionable copy ("add a modifier", "use the
left-hand equivalent", etc.). The save button disappears because
settings.globalHotkey is never written to — no state drift.
Matches Handy #917 / #1019 / #966 / #956, brief item #5.
Co-authored-by: jars <jakejars@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
import { osInfo } from "$lib/utils/osInfo";
|
||||
import { validateHotkey, type HotkeyOs } from "$lib/utils/hotkeyValidity";
|
||||
|
||||
let recording = $state(false);
|
||||
let captured = $state(false);
|
||||
let rejectionReason = $state<string | null>(null);
|
||||
|
||||
function currentOs(): HotkeyOs {
|
||||
const info = osInfo();
|
||||
if (!info) return "unknown";
|
||||
const name = (info.os || "").toLowerCase();
|
||||
if (name.startsWith("mac")) return "macos";
|
||||
if (name.startsWith("win")) return "windows";
|
||||
if (name.startsWith("linux")) return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Linux webkit2gtk fires e.key === "Super" for the Windows/Super key;
|
||||
// Firefox/older Chrome may fire "OS" or "Hyper". All of these should be
|
||||
@@ -109,7 +122,20 @@
|
||||
}
|
||||
|
||||
parts.push(resolveTriggerKey(e));
|
||||
settings.globalHotkey = parts.join("+");
|
||||
const candidate = parts.join("+");
|
||||
|
||||
// Per-OS capability matrix — reject combos the backend can't
|
||||
// reliably bind before they land in settings.globalHotkey (brief
|
||||
// item #5). Leaves the previous, known-good hotkey intact.
|
||||
const check = validateHotkey(candidate, currentOs());
|
||||
if (!check.valid) {
|
||||
rejectionReason = check.reason;
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
rejectionReason = null;
|
||||
settings.globalHotkey = candidate;
|
||||
saveSettings();
|
||||
recording = false;
|
||||
captured = true;
|
||||
@@ -138,6 +164,7 @@
|
||||
function startRecording() {
|
||||
recording = true;
|
||||
captured = false;
|
||||
rejectionReason = null;
|
||||
}
|
||||
|
||||
let chips = $derived(settings.globalHotkey.split("+"));
|
||||
@@ -155,29 +182,36 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
|
||||
{recording
|
||||
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
|
||||
: captured
|
||||
? 'bg-bg-input border-border animate-pulse-warm'
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={recording}
|
||||
>
|
||||
<span class="sr-only" aria-live="polite">{srStatus}</span>
|
||||
{#if recording}
|
||||
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
|
||||
{:else}
|
||||
{#each chips as chip, i}
|
||||
{#if i > 0}
|
||||
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
|
||||
{/if}
|
||||
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
|
||||
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
|
||||
{/each}
|
||||
<div class="inline-flex flex-col items-start gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
|
||||
{recording
|
||||
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
|
||||
: captured
|
||||
? 'bg-bg-input border-border animate-pulse-warm'
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={recording}
|
||||
>
|
||||
<span class="sr-only" aria-live="polite">{srStatus}</span>
|
||||
{#if recording}
|
||||
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
|
||||
{:else}
|
||||
{#each chips as chip, i}
|
||||
{#if i > 0}
|
||||
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
|
||||
{/if}
|
||||
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
|
||||
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</button>
|
||||
{#if rejectionReason}
|
||||
<span class="text-[11px] text-warning" role="alert">
|
||||
Couldn't bind that combo — {rejectionReason}.
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
149
src/lib/utils/hotkeyValidity.ts
Normal file
149
src/lib/utils/hotkeyValidity.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Per-OS hotkey capability matrix.
|
||||
*
|
||||
* Different global-hotkey backends refuse different combos. Rather
|
||||
* than let the user save a combo the OS will silently ignore, we
|
||||
* validate before save and surface a clear "why" string.
|
||||
*
|
||||
* Covered failures (brief item #5):
|
||||
* - X11: single-key combos (no modifier) get swallowed by the user's
|
||||
* editor / IRC client / whatever; they are useless as globals.
|
||||
* Handy #917 / #1019.
|
||||
* - Windows: right-hand modifiers (RCtrl, RAlt) don't always register
|
||||
* through RegisterHotKey; the user gets a dead shortcut. Handy
|
||||
* #966. We reject a combo whose only modifier is a right-hand one
|
||||
* — if the user binds Ctrl+R we don't care whether left or right
|
||||
* Ctrl fired it.
|
||||
* - macOS: Fn-only combos require an NSEvent private API that
|
||||
* RegisterHotKey-equivalents don't expose (Whispering #549 area).
|
||||
* - Wayland (Linux): single-key combos are rejected even more
|
||||
* aggressively than X11 because compositors vary on whether they
|
||||
* forward a bare key to a global listener at all.
|
||||
*
|
||||
* All rules are pure — the UI calls `validateHotkey(combo, os)` and
|
||||
* gates save on `result.valid`. No backend round-trip required.
|
||||
*/
|
||||
|
||||
export type HotkeyOs = "windows" | "macos" | "linux" | "unknown";
|
||||
|
||||
export interface HotkeyValidity {
|
||||
/** Whether this combo is safe to save. */
|
||||
valid: boolean;
|
||||
/**
|
||||
* User-visible "why not" string when `valid` is false. Null when
|
||||
* the combo is OK. Copy is written for inline display — no leading
|
||||
* capitalisation assumption, no trailing period needed.
|
||||
*/
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface HotkeyParts {
|
||||
modifiers: string[]; // e.g. ["Ctrl", "Shift"]
|
||||
trigger: string; // e.g. "R" — may be empty if we're mid-record
|
||||
}
|
||||
|
||||
/** Tokenise a `+`-joined hotkey the way the kon-hotkey parser does,
|
||||
* but without demanding a fully-valid combo (we validate next). The
|
||||
* last part is always the trigger key; everything before is a
|
||||
* modifier. An empty/whitespace combo returns empty parts. */
|
||||
export function parseHotkey(combo: string): HotkeyParts {
|
||||
const trimmed = combo?.trim?.() ?? "";
|
||||
if (!trimmed) return { modifiers: [], trigger: "" };
|
||||
const tokens = trimmed.split("+").map((t) => t.trim()).filter(Boolean);
|
||||
if (tokens.length === 0) return { modifiers: [], trigger: "" };
|
||||
const trigger = tokens[tokens.length - 1];
|
||||
const modifiers = tokens.slice(0, -1);
|
||||
return { modifiers, trigger };
|
||||
}
|
||||
|
||||
// Fn and function-key helpers.
|
||||
const FUNCTION_KEYS = new Set([
|
||||
"F1", "F2", "F3", "F4", "F5", "F6",
|
||||
"F7", "F8", "F9", "F10", "F11", "F12",
|
||||
"F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20",
|
||||
"F21", "F22", "F23", "F24",
|
||||
]);
|
||||
|
||||
function normaliseModifier(mod: string): string {
|
||||
const m = mod.trim().toLowerCase();
|
||||
if (m === "cmd" || m === "meta" || m === "super" || m === "win") return "super";
|
||||
if (m === "option") return "alt";
|
||||
return m;
|
||||
}
|
||||
|
||||
function isRightHandModifier(mod: string): boolean {
|
||||
const m = mod.trim().toLowerCase();
|
||||
return m === "rctrl" || m === "ralt" || m === "rshift" || m === "rsuper" || m === "rmeta";
|
||||
}
|
||||
|
||||
function isFnModifier(mod: string): boolean {
|
||||
return mod.trim().toLowerCase() === "fn";
|
||||
}
|
||||
|
||||
/** Validate a combo for the given OS. `unknown` OS passes unchanged
|
||||
* — we'd rather ship a flawed combo than reject one we can't check. */
|
||||
export function validateHotkey(combo: string, os: HotkeyOs): HotkeyValidity {
|
||||
const parts = parseHotkey(combo);
|
||||
if (!parts.trigger) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "pick a key — hotkeys need at least one non-modifier",
|
||||
};
|
||||
}
|
||||
|
||||
const hasModifier = parts.modifiers.length > 0;
|
||||
const normalisedMods = parts.modifiers.map(normaliseModifier);
|
||||
|
||||
if (os === "linux" || os === "unknown") {
|
||||
// X11 AND Wayland: single-key globals are a footgun everywhere on
|
||||
// Linux. Function keys F13+ are the conventional escape hatch
|
||||
// (they're not on anyone's keyboard, so they don't collide) —
|
||||
// allow them single-key.
|
||||
if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"add Ctrl, Shift, Alt, or Super — single keys get eaten by whichever app is focused",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "windows") {
|
||||
// Right-hand-only modifiers break RegisterHotKey in some layouts.
|
||||
const onlyRightHand =
|
||||
parts.modifiers.length > 0
|
||||
&& parts.modifiers.every(isRightHandModifier);
|
||||
if (onlyRightHand) {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"right-hand-only modifiers (RCtrl / RAlt) don't always register as global shortcuts on Windows — use the left-hand equivalent",
|
||||
};
|
||||
}
|
||||
if (!hasModifier) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "Windows requires at least one modifier on a global hotkey",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "macos") {
|
||||
if (normalisedMods.some(isFnModifier) && parts.modifiers.length === 1) {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"Fn-only combos aren't available to Tauri global shortcuts on macOS — pair Fn with Cmd/Ctrl/Option, or pick a different trigger",
|
||||
};
|
||||
}
|
||||
if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"add Cmd, Ctrl, Option, or Shift — macOS requires a modifier on bare-key global shortcuts",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, reason: null };
|
||||
}
|
||||
Reference in New Issue
Block a user