adams-toolkit

codigo-fonte GPL-3.0 · espelho oficial · commit b208bae5
Core/KeyboardRepeat.cs · 172 linhas · raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;

namespace AdamsToolkit.Core;

/// <summary>
/// "FilterKeys" — repetição do teclado em ms SÓ para W/A/S/D.
///
/// O FilterKeys do Windows é global (não dá para limitar a teclas), por isso isto é um
/// hook WH_KEYBOARD_LL próprio: o 1º KEYDOWN físico passa,
/// os auto-repeats do Windows dessa tecla são ENGOLIDOS e um timer nosso injeta KEYDOWNs
/// (SendInput, scancode, marker) ao atraso/intervalo escolhidos até ao KEYUP. Resto do
/// teclado intacto; nada é escrito no Windows — desligar = o PC fica exatamente como estava.
/// Só com o FiveM em foco (fora dele passthrough). Persiste em keyrepeat.json + RestoreOnBoot.
/// </summary>
public static class KeyboardRepeat
{
    public const int MinRepeatMs = 10, MaxRepeatMs = 500, MinDelayMs = 100, MaxDelayMs = 1000;

    public class State { public bool Enabled { get; set; } public int DelayMs { get; set; } = 500; public int RepeatMs { get; set; } = 33; } // padrão real do Windows

    private static readonly string StateFile = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AdamsToolkit", "keyrepeat.json");

    public static State Cfg { get; private set; } = Load();
    public static bool IsActive => _hook != IntPtr.Zero;

    private static State Load()
    {
        try { if (File.Exists(StateFile)) return JsonSerializer.Deserialize<State>(File.ReadAllText(StateFile)) ?? new(); } catch { }
        return new();
    }
    private static void Save()
    {
        try { Directory.CreateDirectory(Path.GetDirectoryName(StateFile)!); File.WriteAllText(StateFile, JsonSerializer.Serialize(Cfg)); } catch { }
    }

    // ---------------- Win32 ----------------
    private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
    [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
    [DllImport("user32.dll", SetLastError = true)] private static extern bool UnhookWindowsHookEx(IntPtr hhk);
    [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
    [DllImport("kernel32.dll")] private static extern IntPtr GetModuleHandle(string? lpModuleName);
    [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
    [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
    [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
    [DllImport("user32.dll")] private static extern uint MapVirtualKey(uint uCode, uint uMapType);

    private const int WH_KEYBOARD_LL = 13;
    private const int WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101, WM_SYSKEYDOWN = 0x0104, WM_SYSKEYUP = 0x0105;
    private const uint LLKHF_INJECTED = 0x10, KEYEVENTF_SCANCODE = 0x0008, INPUT_KEYBOARD = 1;
    private static readonly UIntPtr Marker = (UIntPtr)0x5A17B0;

    [StructLayout(LayoutKind.Sequential)] private struct KBDLLHOOKSTRUCT { public uint vkCode, scanCode, flags, time; public UIntPtr dwExtraInfo; }
    [StructLayout(LayoutKind.Sequential)] private struct KEYBDINPUT { public ushort wVk, wScan; public uint dwFlags, time; public UIntPtr dwExtraInfo; }
    [StructLayout(LayoutKind.Explicit)]
    private struct INPUTUNION { [FieldOffset(0)] public KEYBDINPUT ki; [FieldOffset(0)] public long pad1; [FieldOffset(8)] public long pad2; [FieldOffset(16)] public long pad3; [FieldOffset(24)] public long pad4; }
    [StructLayout(LayoutKind.Sequential)] private struct INPUT { public uint type; public INPUTUNION u; }

    // ---------------- estado ----------------
    private static readonly uint[] Keys = { 0x57, 0x41, 0x53, 0x44 }; // W A S D
    private static readonly Dictionary<uint, Timer> _timers = new();
    private static readonly object _lock = new();
    private static readonly LowLevelKeyboardProc _proc = Callback;
    private static IntPtr _hook = IntPtr.Zero;
    private static IntPtr _lastHwnd; private static bool _lastIsFiveM; private static long _lastTick;

    /// <summary>Liga com os valores dados (na thread da UI). Guarda e fica ligado até Repor.</summary>
    public static string? Apply(int delayMs, int repeatMs)
    {
        Cfg = new State { Enabled = true, DelayMs = Math.Clamp(delayMs, MinDelayMs, MaxDelayMs), RepeatMs = Math.Clamp(repeatMs, MinRepeatMs, MaxRepeatMs) };
        Save();
        return Start();
    }

    /// <summary>Desliga: nada foi mudado no Windows, o teclado volta a ser 100% o do PC.</summary>
    public static string? RestoreDefault()
    {
        Stop();
        Cfg = new State(); // Enabled=false + sliders voltam ao padrão real do Windows (500 / 33)
        Save();
        return null;
    }

    public static (int delay, int repeat) Current() => (Cfg.DelayMs, Cfg.RepeatMs);
    public static string? RestoreOnBoot() => Cfg.Enabled ? Start() : null;

    private static string? Start()
    {
        if (IsActive) return null;
        _hook = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle(null), 0);
        return _hook == IntPtr.Zero ? "Não consegui instalar o hook do teclado (erro " + Marshal.GetLastWin32Error() + ")." : null;
    }

    private static void Stop()
    {
        if (_hook != IntPtr.Zero) { UnhookWindowsHookEx(_hook); _hook = IntPtr.Zero; }
        lock (_lock) { foreach (var t in _timers.Values) t.Dispose(); _timers.Clear(); }
    }

    private static bool FiveMForeground()
    {
        var now = Environment.TickCount64;
        var h = GetForegroundWindow();
        if (h == _lastHwnd && now - _lastTick < 2000) return _lastIsFiveM;
        _lastHwnd = h; _lastTick = now;
        try
        {
            GetWindowThreadProcessId(h, out var pid);
            var n = System.Diagnostics.Process.GetProcessById((int)pid).ProcessName;
            _lastIsFiveM = n.StartsWith("FiveM", StringComparison.OrdinalIgnoreCase) || n.StartsWith("GTA5", StringComparison.OrdinalIgnoreCase);
        }
        catch { _lastIsFiveM = false; }
        return _lastIsFiveM;
    }

    private static void SendDown(uint vk)
    {
        var inp = new INPUT
        {
            type = INPUT_KEYBOARD,
            u = new INPUTUNION { ki = new KEYBDINPUT { wVk = 0, wScan = (ushort)MapVirtualKey(vk, 0), dwFlags = KEYEVENTF_SCANCODE, dwExtraInfo = Marker } },
        };
        SendInput(1, new[] { inp }, Marshal.SizeOf<INPUT>());
    }

    private static IntPtr Callback(int nCode, IntPtr wParam, IntPtr lParam)
    {
        if (nCode < 0) return CallNextHookEx(_hook, nCode, wParam, lParam);
        var k = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam);
        var msg = (int)wParam;
        var vk = k.vkCode;
        if (Array.IndexOf(Keys, vk) < 0 || (k.flags & LLKHF_INJECTED) != 0 || k.dwExtraInfo == Marker)
            return CallNextHookEx(_hook, nCode, wParam, lParam);
        if (!FiveMForeground())
        {
            lock (_lock) { if (_timers.Remove(vk, out var t0)) t0.Dispose(); }
            return CallNextHookEx(_hook, nCode, wParam, lParam);
        }

        if (msg is WM_KEYDOWN or WM_SYSKEYDOWN)
        {
            lock (_lock)
            {
                if (_timers.ContainsKey(vk)) return (IntPtr)1; // auto-repeat do Windows → engolir, o nosso timer trata
                var key = vk;
                Timer? self = null;
                self = new Timer(_ =>
                {
                    // BUG (v1.66-1.67): Timer.Dispose() no KEYUP não espera por um callback já a
                    // correr → um KEYDOWN injetado chegava DEPOIS do KEYUP físico e o jogo ficava
                    // com a tecla presa (boneco a andar sozinho até ao próximo toque). Agora o
                    // envio é feito sob o lock e só se ESTE timer ainda for o da tecla — o KEYUP
                    // remove-o sob o mesmo lock, por isso nunca há down depois do up.
                    lock (_lock)
                    {
                        if (!_timers.TryGetValue(key, out var cur) || !ReferenceEquals(cur, self)) return;
                        SendDown(key);
                    }
                }, null, Cfg.DelayMs, Cfg.RepeatMs);
                _timers[vk] = self;
            }
            return CallNextHookEx(_hook, nCode, wParam, lParam); // 1º down físico passa
        }
        if (msg is WM_KEYUP or WM_SYSKEYUP)
        {
            lock (_lock) { if (_timers.Remove(vk, out var t)) t.Dispose(); }
        }
        return CallNextHookEx(_hook, nCode, wParam, lParam);
    }
}