From 49864cc8563e412ebe9051a6118484a398c55d0d Mon Sep 17 00:00:00 2001 From: ProfetGit Date: Thu, 30 Jul 2026 11:35:58 +0300 Subject: [PATCH] fix: bind OEM keys by physical position, not character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys like the one left of 1 were stored as the character they produce and mapped to a hard-coded VK. That VK is only correct on US layouts: on FI/SE the key is § with VK_OEM_5, not ` with VK_OEM_3. Non-ASCII characters were rejected outright because the token length check counted UTF-8 bytes, so § could not be used as a hotkey or as an auto-press key at all. Punctuation/OEM keys are now stored by physical code and resolved to a VK through the active layout via MapVirtualKeyW, with VkKeyScanW as a fallback for layout-specific characters that arrive without a usable code. Two follow-on fixes for the hold-to-spam-the-same-key case this enables: - The hotkey/auto-press-key conflict guard existed to stop the clicker from retriggering its own hotkey through GetAsyncKeyState. While the low-level hooks run, press state comes from hardware events only (injected input carries AUTOCLICKER_EXTRA_INFO and is skipped), so Hold mode is exempt. Toggle mode still blocks it. - With the duty cycle off the key was pressed and released with a 0ms hold. Desktop apps register that, games do not: they sample the keyboard once per frame. Keyboard cadences of 50ms or longer now floor the hold at 20ms; faster cadences keep 0 so the batched SendInput path stays available. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 ++ src-tauri/src/engine/worker.rs | 23 ++++- src-tauri/src/hotkeys.rs | 99 ++++++++++++++++++- src/components/panels/SimplePanel.tsx | 1 + .../advanced/sections/ClickerTypeSection.tsx | 1 + .../advanced/sections/HotkeySection.tsx | 1 + src/hotkeys.ts | 49 +++++++-- 7 files changed, 172 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b63038..d30e7c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Unreleased +## Changed +- Punctuation/OEM keys (the key left of 1, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`) are now stored by their physical position instead of the character they type, and resolved through the active keyboard layout. Non-US layouts get the key they actually pressed. +- Hold mode now allows the hotkey and the auto-press key to be the same key, so you can hold a key to spam that same key. Toggle mode still blocks it since that really would feed back on itself. +- Keyboard auto-press now holds the key for at least 20ms when the duty cycle is off (only for intervals of 50ms or longer). Games sample the keyboard once per frame and silently ignored the old instant press. +## Fixed +- Fixed keys that produce a non-ASCII character (§, ö, ä, ü, ç, ...) being rejected as both a hotkey and an auto-press key. + # v3.9.1 - 21.07.2026 (d.m.y) ## Fixed - Fixed Presets not saving the clicker activation hotkey. diff --git a/src-tauri/src/engine/worker.rs b/src-tauri/src/engine/worker.rs index 48155c26..89aceb35 100644 --- a/src-tauri/src/engine/worker.rs +++ b/src-tauri/src/engine/worker.rs @@ -276,8 +276,15 @@ pub fn start_clicker_inner(app: &AppHandle) -> AppResult { let settings = state.settings.lock().unwrap_or_else(poisoned_inner).clone(); let config = build_config(&settings)?; - // Prevent feedback loop: keyboard key must not match a modifier-free hotkey - if config.input_type == crate::engine::InputType::Keyboard && config.key_code > 0 { + // Prevent feedback loop: keyboard key must not match a modifier-free hotkey. + // Hold mode is exempt while the low-level hooks run: press state then comes + // from hardware events only, so auto-pressing the held key cannot retrigger + // or self-cancel the hotkey (e.g. hold § to spam § into a game macro). + let allow_same_key = settings.mode == "Hold" && crate::hotkeys::hooks_active(); + if !allow_same_key + && config.input_type == crate::engine::InputType::Keyboard + && config.key_code > 0 + { let hotkey_binding = state .registered_hotkey .lock() @@ -697,9 +704,19 @@ impl ClickerContext { config.duty }; let cycle_ms = (config.interval_secs * 1000.0).max(1.0) as u32; - let hold_ms = + let mut hold_ms = ((config.interval_secs * duty.max(0.0) / 100.0 * 1000.0) as u32).min(cycle_ms); + // Games sample the keyboard once per frame, so a key that goes down and + // up within the same frame is never observed. With the duty cycle off the + // hold is 0 ms, which desktop apps still register but games drop. Give + // slow keyboard cadences a floor above one 60 Hz frame; fast cadences keep + // hold 0 so the batched SendInput fast path stays available. + const MIN_GAME_VISIBLE_HOLD_MS: u32 = 20; + if is_keyboard && hold_ms == 0 && cycle_ms >= 50 { + hold_ms = MIN_GAME_VISIBLE_HOLD_MS.min(cycle_ms / 2); + } + Self { is_keyboard, down_flag, diff --git a/src-tauri/src/hotkeys.rs b/src-tauri/src/hotkeys.rs index 687876b1..976fa1d2 100644 --- a/src-tauri/src/hotkeys.rs +++ b/src-tauri/src/hotkeys.rs @@ -118,6 +118,10 @@ pub fn parse_hotkey_binding(hotkey: &str) -> AppResult { pub fn parse_hotkey_main_key(token: &str, original_hotkey: &str) -> AppResult<(i32, String)> { let lower = token.trim().to_ascii_lowercase(); + if let Some(binding) = parse_physical_code_token(&lower) { + return Ok(binding); + } + if let Some(binding) = parse_named_key_token(&lower) { return Ok(binding); } @@ -156,6 +160,13 @@ pub fn parse_hotkey_main_key(token: &str, original_hotkey: &str) -> AppResult<(i } } + // Layout-specific single characters (§, ö, ä, ü, ç, ...) reach here when the + // capture only produced a printable key with no usable code. Ask the active + // layout which VK produces that character. + if let Some(vk) = vk_from_layout_char(&lower) { + return Ok((vk, lower)); + } + Err(AppError::Hotkey(format!( "Couldn't recognize '{token}' as a valid key in '{original_hotkey}'" ))) @@ -189,6 +200,14 @@ fn physical_key_state() -> &'static [AtomicBool; 256] { .get_or_init(|| Box::leak(Box::new(std::array::from_fn(|_| AtomicBool::new(false))))) } +/// True once the low-level keyboard/mouse hooks are installed. While they are, +/// hotkey state comes from real hardware events only (injected input carries +/// `AUTOCLICKER_EXTRA_INFO` and is skipped), so the clicker cannot retrigger +/// its own hotkey. +pub fn hooks_active() -> bool { + HOOKS_ACTIVE.load(Ordering::Relaxed) +} + fn is_physical_vk_down(vk: i32) -> bool { if !(0..256).contains(&vk) { return false; @@ -555,6 +574,54 @@ fn binding(vk: i32, token: &str) -> (i32, String) { (vk, token.to_string()) } +/// Physical (scancode-addressed) keys whose VK differs per keyboard layout. +/// Returns `(set-1 scancode, US-layout fallback VK, canonical token)`. +fn physical_code_scancode(token: &str) -> Option<(u32, i32, &'static str)> { + match token { + "backquote" | "grave" | "section" => Some((0x29, VK_OEM_3 as i32, "Backquote")), + "minus" => Some((0x0C, VK_OEM_MINUS as i32, "Minus")), + "equal" => Some((0x0D, VK_OEM_PLUS as i32, "Equal")), + "bracketleft" => Some((0x1A, VK_OEM_4 as i32, "BracketLeft")), + "bracketright" => Some((0x1B, VK_OEM_6 as i32, "BracketRight")), + "backslash" => Some((0x2B, VK_OEM_5 as i32, "Backslash")), + "semicolon" => Some((0x27, VK_OEM_1 as i32, "Semicolon")), + "quote" => Some((0x28, VK_OEM_7 as i32, "Quote")), + "comma" => Some((0x33, VK_OEM_COMMA as i32, "Comma")), + "period" => Some((0x34, VK_OEM_PERIOD as i32, "Period")), + "slash" => Some((0x35, VK_OEM_2 as i32, "Slash")), + "intlro" => Some((0x73, VK_OEM_102 as i32, "IntlRo")), + "intlyen" => Some((0x7D, VK_OEM_5 as i32, "IntlYen")), + _ => None, + } +} + +fn parse_physical_code_token(token: &str) -> Option<(i32, String)> { + let (scancode, fallback_vk, canonical) = physical_code_scancode(token)?; + let mapped = unsafe { MapVirtualKeyW(scancode, MAPVK_VSC_TO_VK_EX) } as i32; + let vk = if mapped == 0 { fallback_vk } else { mapped }; + Some(binding(vk, canonical)) +} + +fn vk_from_layout_char(token: &str) -> Option { + let mut chars = token.chars(); + let ch = chars.next()?; + if chars.next().is_some() { + return None; + } + + let scan = unsafe { VkKeyScanW(ch as u16) }; + if scan == -1 { + return None; + } + + let vk = (scan & 0xFF) as i32; + if vk > 0 { + Some(vk) + } else { + None + } +} + fn parse_named_key_token(token: &str) -> Option<(i32, String)> { match token { "<" | ">" | "intlbackslash" | "oem102" | "nonusbackslash" => { @@ -675,7 +742,37 @@ fn parse_function_key_token(token: &str) -> Option<(i32, String)> { #[cfg(test)] mod tests { - use super::{format_hotkey_binding, modifiers_match, parse_hotkey_binding}; + use super::{ + format_hotkey_binding, modifiers_match, parse_hotkey_binding, vk_from_layout_char, + }; + + #[test] + fn physical_code_tokens_round_trip() { + for token in [ + "Backquote", + "Minus", + "Equal", + "BracketLeft", + "BracketRight", + "Backslash", + "Semicolon", + "Quote", + "Comma", + "Period", + "Slash", + ] { + let binding = parse_hotkey_binding(token).expect("code token should parse"); + assert_eq!(binding.key_token, token); + assert!(binding.main_vk > 0, "{token} resolved to no VK"); + assert_eq!(format_hotkey_binding(&binding), token); + } + } + + #[test] + fn layout_chars_resolve_to_vk() { + assert_eq!(vk_from_layout_char("a"), Some(0x41)); + assert_eq!(vk_from_layout_char("ab"), None); + } #[test] fn numpad_tokens_round_trip() { diff --git a/src/components/panels/SimplePanel.tsx b/src/components/panels/SimplePanel.tsx index 988757cf..498fff4d 100644 --- a/src/components/panels/SimplePanel.tsx +++ b/src/components/panels/SimplePanel.tsx @@ -152,6 +152,7 @@ function SimplePanel({ settings, update }: SimplePanelProps) { settings.hotkey, settings.keyboardKey, keyboardKeyCaseIsUpper, + settings.mode, ); const hotkeyConflicts = hasConflict ? ["Auto-press key"] : []; const autoPressKeyConflicts = hasConflict ? ["Hotkey"] : []; diff --git a/src/components/panels/advanced/sections/ClickerTypeSection.tsx b/src/components/panels/advanced/sections/ClickerTypeSection.tsx index 5eb70386..19790e96 100644 --- a/src/components/panels/advanced/sections/ClickerTypeSection.tsx +++ b/src/components/panels/advanced/sections/ClickerTypeSection.tsx @@ -48,6 +48,7 @@ export default function ClickerTypeSection({ settings, update }: Props) { settings.hotkey, settings.keyboardKey, keyboardKeyCaseIsUpper, + settings.mode, ); const autoPressKeyConflicts = hasConflict ? ["Hotkey"] : []; diff --git a/src/components/panels/advanced/sections/HotkeySection.tsx b/src/components/panels/advanced/sections/HotkeySection.tsx index e15f09e5..f9c70290 100644 --- a/src/components/panels/advanced/sections/HotkeySection.tsx +++ b/src/components/panels/advanced/sections/HotkeySection.tsx @@ -20,6 +20,7 @@ export default function HotkeySection({ settings, update }: Props) { settings.hotkey, settings.keyboardKey, settings.keyboardKeyCase === "upper", + settings.mode, ); const hotkeyConflicts = hasConflict ? ["Auto-press key"] : []; diff --git a/src/hotkeys.ts b/src/hotkeys.ts index a9cf3285..3b6efc45 100644 --- a/src/hotkeys.ts +++ b/src/hotkeys.ts @@ -60,6 +60,34 @@ const SHIFTED_SYMBOL_BASE_MAP: Record = { ">": "<", }; +// Punctuation/OEM keys whose produced character depends on the keyboard layout +// (e.g. Backquote is "`" on US but "§" on FI/SE). Stored by physical code so the +// binding follows the key, not the character; the backend resolves the VK via +// the active layout. Values are the US-layout fallback label. +const PHYSICAL_CODE_FALLBACK_LABELS: Record = { + Backquote: "`", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", + IntlBackslash: "<", + IntlRo: "\\", + IntlYen: "\\", +}; + +const PHYSICAL_CODE_BY_LOWER: Record = Object.fromEntries( + Object.keys(PHYSICAL_CODE_FALLBACK_LABELS).map((code) => [ + code.toLowerCase(), + code, + ]), +); + const NUMPAD_CODE_MAP: Record = { Numpad0: "numpad0", Numpad1: "numpad1", @@ -363,8 +391,8 @@ function mainKeyFromCode( key: string, location?: number, ): string | null { - if (code === "IntlBackslash") { - return "IntlBackslash"; + if (PHYSICAL_CODE_FALLBACK_LABELS[code]) { + return code; } if (/^Key[A-Z]$/.test(code)) { @@ -426,8 +454,12 @@ function displayTokenFromStoredValue( const trimmed = token.trim(); if (!trimmed) return trimmed; - if (trimmed === "IntlBackslash") { - return layoutMap?.get("IntlBackslash") ?? "<"; + const physicalCode = PHYSICAL_CODE_BY_LOWER[trimmed.toLowerCase()]; + if (physicalCode) { + return ( + layoutMap?.get(physicalCode) ?? + PHYSICAL_CODE_FALLBACK_LABELS[physicalCode] + ); } if (/^Key[A-Z]$/.test(trimmed)) { @@ -511,8 +543,9 @@ function normalizeStoredMainKey( const trimmed = token.trim(); if (!trimmed) return trimmed; - if (trimmed === "IntlBackslash") { - return "IntlBackslash"; + const physicalCode = PHYSICAL_CODE_BY_LOWER[trimmed.toLowerCase()]; + if (physicalCode) { + return physicalCode; } if (/^Key[A-Z]$/.test(trimmed)) { @@ -693,8 +726,12 @@ export function conflictsWithAutoPressKey( hotkey: string, keyboardKey: string, keyboardKeyCaseIsUpper: boolean, + mode?: string, ): boolean { if (!hotkey || !keyboardKey) return false; + // Hold mode intentionally supports "hold a key to spam that same key": the + // backend tracks the hotkey from hardware events only, so there is no loop. + if (mode === "Hold") return false; const mainKey = hotkeyMainKey(hotkey); const modifiers = hotkeyModifiers(hotkey); const kbKey = keyboardKey.toLowerCase();