Skip to content

Commit 88208dd

Browse files
committed
fix(native): resync stuck keys
1 parent 8754b0c commit 88208dd

6 files changed

Lines changed: 239 additions & 0 deletions

File tree

packages/native-helpers/swift-helper/Sources/SwiftHelper/KeycodeMap.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,23 @@ private let macOSKeycodeToKey: [Int: String] = [
8484
50: "`",
8585
]
8686

87+
/// Reverse lookup: key name to keycode
88+
private let macOSKeyToKeycode: [String: Int] = {
89+
var reverse: [String: Int] = [:]
90+
for (keyCode, name) in macOSKeycodeToKey {
91+
reverse[name] = keyCode
92+
}
93+
return reverse
94+
}()
95+
8796
/// Convert a macOS CGKeyCode to a key name string
8897
/// Returns nil if the keycode is not mapped
8998
func keyCodeToName(_ keyCode: Int) -> String? {
9099
return macOSKeycodeToKey[keyCode]
91100
}
101+
102+
/// Convert a key name string to a macOS CGKeyCode
103+
/// Returns nil if the key name is not mapped
104+
func nameToKeyCode(_ name: String) -> Int? {
105+
return macOSKeyToKeycode[name]
106+
}

packages/native-helpers/swift-helper/Sources/SwiftHelper/ShortcutManager.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CoreGraphics
12
import Foundation
23

34
/// Represents the state of modifier keys at a given moment
@@ -100,6 +101,50 @@ class ShortcutManager {
100101
pressedRegularKeys.remove(key)
101102
}
102103

104+
/// Check if a key is actually pressed using CGEventSource
105+
private func isKeyActuallyPressed(_ keyCode: CGKeyCode) -> Bool {
106+
return CGEventSource.keyState(.combinedSessionState, key: keyCode)
107+
}
108+
109+
/// Validate all tracked key states against actual OS state.
110+
/// Removes any keys that are not actually pressed (stuck keys).
111+
/// Returns true if state was valid, false if corrections were made.
112+
func validateAndResyncKeyState() -> Bool {
113+
lock.lock()
114+
defer { lock.unlock() }
115+
116+
var stateValid = true
117+
118+
// Validate Fn key state
119+
// Fn key code is 0x3F (63)
120+
let fnKeyCode: CGKeyCode = 0x3F
121+
if fnKeyDown && !isKeyActuallyPressed(fnKeyCode) {
122+
logToStderr("[ShortcutManager] Resync: Fn key was stuck, clearing")
123+
fnKeyDown = false
124+
stateValid = false
125+
}
126+
127+
// Validate regular keys
128+
var staleKeys: [String] = []
129+
for keyName in pressedRegularKeys {
130+
if let keyCode = nameToKeyCode(keyName) {
131+
if !isKeyActuallyPressed(CGKeyCode(keyCode)) {
132+
staleKeys.append(keyName)
133+
}
134+
}
135+
}
136+
137+
if !staleKeys.isEmpty {
138+
for key in staleKeys {
139+
pressedRegularKeys.remove(key)
140+
}
141+
logToStderr("[ShortcutManager] Resync: Regular keys were stuck, cleared: \(staleKeys)")
142+
stateValid = false
143+
}
144+
145+
return stateValid
146+
}
147+
103148
/// Check if this key event should be consumed (prevent default behavior)
104149
/// Called from event tap callback for keyDown/keyUp events only
105150
func shouldConsumeKey(keyCode: Int, modifiers: ModifierState) -> Bool {

packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ func eventTapCallback(
8484
}
8585

8686
if ShortcutManager.shared.shouldConsumeKey(keyCode: Int(keyCode), modifiers: modifiers) {
87+
// Before consuming, validate that all tracked keys are actually pressed.
88+
// This prevents stuck keys (missed keyUp events) from blocking input.
89+
if !ShortcutManager.shared.validateAndResyncKeyState() {
90+
// State was invalid (some keys were stuck), re-check with corrected state
91+
let correctedModifiers = ModifierState(
92+
fn: event.flags.contains(.maskSecondaryFn),
93+
cmd: event.flags.contains(.maskCommand),
94+
ctrl: event.flags.contains(.maskControl),
95+
alt: event.flags.contains(.maskAlternate),
96+
shift: event.flags.contains(.maskShift)
97+
)
98+
if !ShortcutManager.shared.shouldConsumeKey(keyCode: Int(keyCode), modifiers: correctedModifiers) {
99+
// After correction, we should NOT consume - let the key through
100+
return Unmanaged.passRetained(event)
101+
}
102+
}
87103
// CONSUME - prevent default behavior (e.g., cursor movement for arrow keys)
88104
return nil
89105
}

packages/native-helpers/windows-helper/src/ShortcutManager.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Runtime.InteropServices;
45

56
namespace WindowsHelper
67
{
@@ -23,6 +24,9 @@ public struct ModifierState
2324
/// </summary>
2425
public class ShortcutManager
2526
{
27+
[DllImport("user32.dll")]
28+
private static extern short GetAsyncKeyState(int vKey);
29+
2630
private static readonly Lazy<ShortcutManager> _instance = new(() => new ShortcutManager());
2731
public static ShortcutManager Instance => _instance.Value;
2832

@@ -87,6 +91,41 @@ public void RemoveRegularKey(string key)
8791
}
8892
}
8993

94+
/// <summary>
95+
/// Check if a key is actually pressed using GetAsyncKeyState.
96+
/// </summary>
97+
private bool IsKeyActuallyPressed(int vkCode)
98+
{
99+
// High-order bit is set if key is currently down
100+
return (GetAsyncKeyState(vkCode) & 0x8000) != 0;
101+
}
102+
103+
/// <summary>
104+
/// Validate all tracked regular keys against actual OS state.
105+
/// Removes any keys that are not actually pressed (stuck keys).
106+
/// Returns the list of keys that were removed.
107+
/// </summary>
108+
public List<string> ValidateAndClearStaleKeys()
109+
{
110+
var staleKeys = new List<string>();
111+
112+
lock (_lock)
113+
{
114+
var keysToCheck = _pressedRegularKeys.ToList();
115+
foreach (var keyName in keysToCheck)
116+
{
117+
var vkCode = VirtualKeyMap.GetVkCode(keyName);
118+
if (vkCode.HasValue && !IsKeyActuallyPressed(vkCode.Value))
119+
{
120+
_pressedRegularKeys.Remove(keyName);
121+
staleKeys.Add(keyName);
122+
}
123+
}
124+
}
125+
126+
return staleKeys;
127+
}
128+
90129
/// <summary>
91130
/// Check if this key event should be consumed (prevent default behavior).
92131
/// Called from ShortcutMonitor hook callback for keyDown/keyUp events only.

packages/native-helpers/windows-helper/src/ShortcutMonitor.cs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public class ShortcutMonitor
3232
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
3333
private static extern IntPtr GetModuleHandle(string lpModuleName);
3434

35+
[DllImport("user32.dll")]
36+
private static extern short GetAsyncKeyState(int vKey);
37+
3538
[StructLayout(LayoutKind.Sequential)]
3639
private struct KBDLLHOOKSTRUCT
3740
{
@@ -235,6 +238,26 @@ private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
235238

236239
if (ShortcutManager.Instance.ShouldConsumeKey((int)kbStruct.vkCode, modifierState))
237240
{
241+
// Before consuming, validate that all tracked keys are actually pressed.
242+
// This prevents stuck keys (missed keyUp events) from blocking input system-wide.
243+
if (!ValidateKeyStateBeforeConsume())
244+
{
245+
// State was invalid (some keys were stuck), re-check with corrected state
246+
var correctedModifierState = new ModifierState
247+
{
248+
Win = winPressed,
249+
Ctrl = ctrlPressed,
250+
Alt = altPressed,
251+
Shift = shiftPressed
252+
};
253+
254+
if (!ShortcutManager.Instance.ShouldConsumeKey((int)kbStruct.vkCode, correctedModifierState))
255+
{
256+
// After correction, we should NOT consume - let the key through
257+
return CallNextHookEx(hookId, nCode, wParam, lParam);
258+
}
259+
}
260+
238261
// Consume - prevent default behavior (e.g., cursor movement for arrow keys)
239262
return (IntPtr)1;
240263
}
@@ -346,5 +369,84 @@ private void LogToStderr(string message)
346369
Console.Error.WriteLine($"[{timestamp}] [ShortcutMonitor] {message}");
347370
Console.Error.Flush();
348371
}
372+
373+
/// <summary>
374+
/// Check if a key is actually pressed using GetAsyncKeyState.
375+
/// </summary>
376+
private bool IsKeyActuallyPressed(int vkCode)
377+
{
378+
// High-order bit is set if key is currently down
379+
return (GetAsyncKeyState(vkCode) & 0x8000) != 0;
380+
}
381+
382+
/// <summary>
383+
/// Validate that all tracked key states match actual OS state.
384+
/// If any key is not actually pressed, resync state and return false.
385+
/// This prevents stuck keys from causing keys to be consumed incorrectly.
386+
/// </summary>
387+
private bool ValidateKeyStateBeforeConsume()
388+
{
389+
bool stateValid = true;
390+
391+
// Validate modifier keys
392+
if (leftShiftPressed && !IsKeyActuallyPressed(VK_LSHIFT))
393+
{
394+
LogToStderr("Resync: leftShift was stuck, clearing");
395+
leftShiftPressed = false;
396+
stateValid = false;
397+
}
398+
if (rightShiftPressed && !IsKeyActuallyPressed(VK_RSHIFT))
399+
{
400+
LogToStderr("Resync: rightShift was stuck, clearing");
401+
rightShiftPressed = false;
402+
stateValid = false;
403+
}
404+
if (leftCtrlPressed && !IsKeyActuallyPressed(VK_LCONTROL))
405+
{
406+
LogToStderr("Resync: leftCtrl was stuck, clearing");
407+
leftCtrlPressed = false;
408+
stateValid = false;
409+
}
410+
if (rightCtrlPressed && !IsKeyActuallyPressed(VK_RCONTROL))
411+
{
412+
LogToStderr("Resync: rightCtrl was stuck, clearing");
413+
rightCtrlPressed = false;
414+
stateValid = false;
415+
}
416+
if (leftAltPressed && !IsKeyActuallyPressed(VK_LMENU))
417+
{
418+
LogToStderr("Resync: leftAlt was stuck, clearing");
419+
leftAltPressed = false;
420+
stateValid = false;
421+
}
422+
if (rightAltPressed && !IsKeyActuallyPressed(VK_RMENU))
423+
{
424+
LogToStderr("Resync: rightAlt was stuck, clearing");
425+
rightAltPressed = false;
426+
stateValid = false;
427+
}
428+
if (leftWinPressed && !IsKeyActuallyPressed(VK_LWIN))
429+
{
430+
LogToStderr("Resync: leftWin was stuck, clearing");
431+
leftWinPressed = false;
432+
stateValid = false;
433+
}
434+
if (rightWinPressed && !IsKeyActuallyPressed(VK_RWIN))
435+
{
436+
LogToStderr("Resync: rightWin was stuck, clearing");
437+
rightWinPressed = false;
438+
stateValid = false;
439+
}
440+
441+
// Validate regular keys tracked in ShortcutManager
442+
var staleKeys = ShortcutManager.Instance.ValidateAndClearStaleKeys();
443+
if (staleKeys.Count > 0)
444+
{
445+
LogToStderr($"Resync: Regular keys were stuck, cleared: [{string.Join(", ", staleKeys)}]");
446+
stateValid = false;
447+
}
448+
449+
return stateValid;
450+
}
349451
}
350452
}

packages/native-helpers/windows-helper/src/VirtualKeyMap.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public static class VirtualKeyMap
2525

2626
private static readonly Dictionary<int, string> VkCodeToKey = new()
2727
{
28+
// Note: Reverse lookup dictionary (KeyToVkCode) is auto-generated below
2829
// Letters (A-Z: 0x41-0x5A)
2930
{ 0x41, "A" },
3031
{ 0x42, "B" },
@@ -106,6 +107,18 @@ public static class VirtualKeyMap
106107
{ 0xC0, "`" }, // VK_OEM_3
107108
};
108109

110+
// Reverse lookup: key name to VK code (auto-generated from VkCodeToKey)
111+
private static readonly Dictionary<string, int> KeyToVkCode;
112+
113+
static VirtualKeyMap()
114+
{
115+
KeyToVkCode = new Dictionary<string, int>();
116+
foreach (var kvp in VkCodeToKey)
117+
{
118+
KeyToVkCode[kvp.Value] = kvp.Key;
119+
}
120+
}
121+
109122
/// <summary>
110123
/// Convert a Windows Virtual Key code to a key name string.
111124
/// Returns null if the keycode is not mapped.
@@ -114,5 +127,14 @@ public static class VirtualKeyMap
114127
{
115128
return VkCodeToKey.TryGetValue(vkCode, out var name) ? name : null;
116129
}
130+
131+
/// <summary>
132+
/// Convert a key name string to a Windows Virtual Key code.
133+
/// Returns null if the key name is not mapped.
134+
/// </summary>
135+
public static int? GetVkCode(string keyName)
136+
{
137+
return KeyToVkCode.TryGetValue(keyName, out var vkCode) ? vkCode : null;
138+
}
117139
}
118140
}

0 commit comments

Comments
 (0)