Files
2026-10-24 13:02:19 +08:00

525 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.InteropServices;
using System.Text;
namespace anjianjingling.Capture
{
/// <summary>
/// 操作录制器:WH_KEYBOARD_LL / WH_MOUSE_LL 低级钩子采集真实键鼠事件,
/// 结束时聚合为可直接回放的 Lua 脚本。
/// </summary>
public sealed class InputRecorder
{
// ===================== Win32 =====================
private const int WH_KEYBOARD_LL = 13;
private const int WH_MOUSE_LL = 14;
private const int WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101;
private const int WM_SYSKEYDOWN = 0x0104, WM_SYSKEYUP = 0x0105;
private const int WM_MOUSEMOVE = 0x0200;
private const int WM_LBUTTONDOWN = 0x0201, WM_LBUTTONUP = 0x0202;
private const int WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205;
private const int WM_MBUTTONDOWN = 0x0207, WM_MBUTTONUP = 0x0208;
private const int WM_MOUSEWHEEL = 0x020A;
private const int WM_QUIT = 0x0012;
private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
private struct KBDLLHOOKSTRUCT
{
public uint vkCode;
public uint scanCode;
public uint flags;
public uint time;
public IntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X, Y; }
[StructLayout(LayoutKind.Sequential)]
private struct MSLLHOOKSTRUCT
{
public POINT pt;
public uint mouseData;
public uint flags;
public uint time;
public IntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll")]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll")]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
[DllImport("user32.dll")]
private static extern bool GetKeyboardState(byte[] lpKeyState);
[DllImport("user32.dll")]
private static extern short GetKeyState(int nVirtKey);
[DllImport("user32.dll")]
private static extern int ToUnicodeEx(uint wVirtKey, uint wScanCode, byte[] lpKeyState,
[Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pwszBuff, int cchBuff, uint wFlags, IntPtr dwhkl);
[DllImport("user32.dll")]
private static extern IntPtr GetKeyboardLayout(uint idThread);
[DllImport("user32.dll")]
private static extern int GetKeyboardLayoutList(int nBuff, [Out] IntPtr[]? lpList);
// ===================== 事件模型 =====================
private enum Kind { Move, ButtonDown, ButtonUp, Wheel, KeyDown, KeyUp }
private sealed class RecEvent
{
public long T;
public Kind Kind;
public int X, Y;
public string Btn = "left";
public int Vk;
public string KeyName = "";
public string? Ch;
public int Wheel;
public bool IsPrintable => Ch is { Length: > 0 };
}
private readonly List<RecEvent> _events = new();
private readonly object _lock = new();
private long _t0;
private Thread? _thread;
private uint _threadId;
private IntPtr _kbHook, _msHook;
private HookProc? _kbProc, _msProc;
private readonly ManualResetEventSlim _ready = new(false);
private volatile bool _recording;
public bool IsRecording => _recording;
public event Action<bool>? StateChanged;
/// <summary>录制鼠标移动轨迹(关闭后只记录点击)。</summary>
public bool RecordMouseMove { get; set; } = true;
/// <summary>把连续可打印字符聚合成 typeText("...")。</summary>
public bool SmartText { get; set; } = true;
/// <summary>生成脚本时单次延时上限(毫秒),超过则截断。</summary>
public int MaxDelayMs { get; set; } = 2000;
/// <summary>录制期间忽略的虚拟键(默认吞掉本程序热键 F8~F12)。</summary>
public HashSet<int> IgnoredVk { get; } = new() { 0x77, 0x78, 0x79, 0x7A, 0x7B };
// ===================== 开始 / 停止 =====================
public void Start()
{
if (_recording) return;
lock (_lock) _events.Clear();
_t0 = Environment.TickCount64;
_recording = true;
_thread = new Thread(HookThread)
{
IsBackground = true,
Name = "InputRecorderHook"
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
_ready.Wait(2000);
StateChanged?.Invoke(true);
}
private void HookThread()
{
_threadId = GetCurrentThreadId();
_kbProc = KbProc;
_msProc = MsProc;
IntPtr hMod = GetModuleHandle(null);
_kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, hMod, 0);
_msHook = SetWindowsHookEx(WH_MOUSE_LL, _msProc, hMod, 0);
_ready.Set();
while (GetMessage(out MSG msg, IntPtr.Zero, 0, 0) > 0)
{
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
if (_kbHook != IntPtr.Zero) UnhookWindowsHookEx(_kbHook);
if (_msHook != IntPtr.Zero) UnhookWindowsHookEx(_msHook);
_kbHook = _msHook = IntPtr.Zero;
}
private IntPtr KbProc(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0 && _recording)
{
try
{
var kb = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam);
int msg = wParam.ToInt32();
bool down = msg is WM_KEYDOWN or WM_SYSKEYDOWN;
bool up = msg is WM_KEYUP or WM_SYSKEYUP;
if ((down || up) && !IgnoredVk.Contains((int)kb.vkCode))
{
var e = new RecEvent
{
T = Environment.TickCount64 - _t0,
Kind = down ? Kind.KeyDown : Kind.KeyUp,
Vk = (int)kb.vkCode,
KeyName = KeyName((int)kb.vkCode)
};
if (down)
{
var ch = ToChar((int)kb.vkCode, (int)kb.scanCode);
if (ch.HasValue && !CtrlAltDown()) e.Ch = ch.Value.ToString();
}
lock (_lock) _events.Add(e);
}
}
catch { }
}
return CallNextHookEx(_kbHook, nCode, wParam, lParam);
}
private IntPtr MsProc(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0 && _recording)
{
try
{
var ms = Marshal.PtrToStructure<MSLLHOOKSTRUCT>(lParam);
int msg = wParam.ToInt32();
var e = new RecEvent
{
T = Environment.TickCount64 - _t0,
X = ms.pt.X,
Y = ms.pt.Y
};
switch (msg)
{
case WM_MOUSEMOVE:
if (!RecordMouseMove) break;
e.Kind = Kind.Move;
lock (_lock)
{
// 采样节流:同位置或 15ms 内的移动丢弃
if (_events.Count > 0)
{
var last = _events[^1];
if (last.Kind == Kind.Move)
{
if (e.T - last.T < 15 && Math.Abs(e.X - last.X) < 3 && Math.Abs(e.Y - last.Y) < 3)
break;
}
}
_events.Add(e);
}
break;
case WM_LBUTTONDOWN:
case WM_RBUTTONDOWN:
case WM_MBUTTONDOWN:
e.Kind = Kind.ButtonDown;
e.Btn = msg == WM_RBUTTONDOWN ? "right" : msg == WM_MBUTTONDOWN ? "middle" : "left";
lock (_lock) _events.Add(e);
break;
case WM_LBUTTONUP:
case WM_RBUTTONUP:
case WM_MBUTTONUP:
e.Kind = Kind.ButtonUp;
e.Btn = msg == WM_RBUTTONUP ? "right" : msg == WM_MBUTTONUP ? "middle" : "left";
lock (_lock) _events.Add(e);
break;
case WM_MOUSEWHEEL:
e.Kind = Kind.Wheel;
e.Wheel = (short)((ms.mouseData >> 16) & 0xFFFF) / 120;
lock (_lock) _events.Add(e);
break;
}
}
catch { }
}
return CallNextHookEx(_msHook, nCode, wParam, lParam);
}
/// <summary>停止录制并生成 Lua 脚本。</summary>
public string Stop()
{
if (!_recording) return "";
_recording = false;
StateChanged?.Invoke(false);
if (_threadId != 0) PostThreadMessage(_threadId, WM_QUIT, IntPtr.Zero, IntPtr.Zero);
_thread?.Join(1500);
_thread = null;
_threadId = 0;
List<RecEvent> snapshot;
lock (_lock) snapshot = new List<RecEvent>(_events);
return Generate(snapshot);
}
public void Abort()
{
if (!_recording) return;
_recording = false;
StateChanged?.Invoke(false);
if (_threadId != 0) PostThreadMessage(_threadId, WM_QUIT, IntPtr.Zero, IntPtr.Zero);
_thread?.Join(1500);
_thread = null;
_threadId = 0;
lock (_lock) _events.Clear();
}
// ===================== 键名 / 字符 =====================
private static bool CtrlAltDown()
=> (GetKeyState(0x11) & 0x8000) != 0 || (GetKeyState(0x12) & 0x8000) != 0;
private static char? ToChar(int vk, int scan)
{
try
{
var state = new byte[256];
if (!GetKeyboardState(state)) return null;
var sb = new StringBuilder(8);
var layout = GetKeyboardLayout(0);
int n = ToUnicodeEx((uint)vk, (uint)scan, state, sb, sb.Length, 0, layout);
if (n == 1 && sb.Length > 0)
{
char c = sb[0];
if (c >= 32 && c != 127) return c;
}
}
catch { }
return null;
}
private static readonly Dictionary<int, string> VkNames = new()
{
[0x0D] = "ENTER", [0x1B] = "ESC", [0x20] = "SPACE", [0x09] = "TAB",
[0x08] = "BACKSPACE", [0x2E] = "DELETE", [0x2D] = "INSERT",
[0x24] = "HOME", [0x23] = "END", [0x21] = "PAGEUP", [0x22] = "PAGEDOWN",
[0x25] = "LEFT", [0x26] = "UP", [0x27] = "RIGHT", [0x28] = "DOWN",
[0x11] = "CTRL", [0x12] = "ALT", [0x10] = "SHIFT", [0x5B] = "WIN",
[0x14] = "CAPSLOCK", [0x90] = "NUMLOCK",
[0x6A] = "MULTIPLY", [0x6B] = "ADD", [0x6D] = "SUBTRACT", [0x6E] = "DECIMAL", [0x6F] = "DIVIDE",
[0xBA] = "SEMICOLON", [0xBB] = "EQUAL", [0xBC] = "COMMA", [0xBD] = "MINUS",
[0xBE] = "PERIOD", [0xBF] = "SLASH", [0xC0] = "BACKQUOTE",
[0xDB] = "LBRACKET", [0xDC] = "BACKSLASH", [0xDD] = "RBRACKET", [0xDE] = "QUOTE",
};
private static string KeyName(int vk)
{
if (VkNames.TryGetValue(vk, out var n)) return n;
if (vk >= 0x70 && vk <= 0x87) return "F" + (vk - 0x6F);
if (vk >= 0x60 && vk <= 0x69) return "NUMPAD" + (vk - 0x60);
if (vk >= 0x30 && vk <= 0x39) return ((char)vk).ToString();
if (vk >= 0x41 && vk <= 0x5A) return ((char)vk).ToString();
return "VK" + vk.ToString("X2");
}
private static bool IsModifier(int vk) => vk is 0x11 or 0x12 or 0x10 or 0x5B or 0x5C
or 0xA0 or 0xA1 or 0xA2 or 0xA3 or 0xA4 or 0xA5;
private static string ModName(int vk) => vk switch
{
0x11 or 0xA2 or 0xA3 => "Ctrl",
0x12 or 0xA4 or 0xA5 => "Alt",
0x10 or 0xA0 or 0xA1 => "Shift",
0x5B or 0x5C => "Win",
_ => ""
};
// ===================== Lua 生成 =====================
private string Generate(List<RecEvent> ev)
{
var sb = new StringBuilder();
sb.AppendLine("-- 由操作录制生成(" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ")");
sb.AppendLine("-- 修改坐标/延时后可直接运行;模板图片放 images/ 目录");
sb.AppendLine();
long lastT = 0;
var mods = new List<string>();
var textBuf = new StringBuilder();
int i = 0;
void FlushText()
{
if (textBuf.Length == 0) return;
sb.AppendLine($"typeText(\"{Escape(textBuf.ToString())}\")");
textBuf.Clear();
}
void EmitDelay(long t)
{
long gap = t - lastT;
if (gap < 30) return;
if (gap > MaxDelayMs) gap = MaxDelayMs;
FlushText();
sb.AppendLine($"delay({gap})");
lastT = t;
}
while (i < ev.Count)
{
var e = ev[i];
switch (e.Kind)
{
case Kind.Move:
{
// 合并连续移动,只保留最终落点
int j = i;
while (j + 1 < ev.Count && ev[j + 1].Kind == Kind.Move) j++;
var end = ev[j];
EmitDelay(e.T);
sb.AppendLine($"moveTo({end.X}, {end.Y})");
lastT = end.T;
i = j + 1;
continue;
}
case Kind.ButtonDown:
{
// 找 500ms 内的同名弹起 → 单击
int j = i + 1;
RecEvent? up = null;
while (j < ev.Count)
{
if (ev[j].Kind == Kind.ButtonUp && ev[j].Btn == e.Btn && ev[j].T - e.T < 500) { up = ev[j]; break; }
if (ev[j].Kind == Kind.ButtonDown) break;
j++;
}
EmitDelay(e.T);
string fn = e.Btn switch { "right" => "rightClick", "middle" => "middleClick", _ => "leftClick" };
if (up != null)
{
sb.AppendLine($"{fn}({e.X}, {e.Y})");
lastT = up.T;
i = j + 1;
continue;
}
sb.AppendLine($"moveTo({e.X}, {e.Y})");
sb.AppendLine($"mouse.down(\"{e.Btn}\")");
i++;
continue;
}
case Kind.ButtonUp:
EmitDelay(e.T);
sb.AppendLine($"mouse.up(\"{e.Btn}\")");
i++;
continue;
case Kind.Wheel:
EmitDelay(e.T);
sb.AppendLine($"wheel({e.Wheel})");
i++;
continue;
case Kind.KeyDown:
{
if (IsModifier(e.Vk))
{
string m = ModName(e.Vk);
if (m.Length > 0 && !mods.Contains(m)) mods.Add(m);
i++;
continue;
}
// 找对应弹起
int j = i + 1;
while (j < ev.Count && !(ev[j].Kind == Kind.KeyUp && ev[j].Vk == e.Vk)) j++;
bool paired = j < ev.Count;
if (mods.Count > 0 && mods.Any(m => m is "Ctrl" or "Alt" or "Win"))
{
EmitDelay(e.T);
sb.AppendLine($"hotkey(\"{string.Join("+", mods.Append(e.KeyName))}\")");
// 跳过本次按下到弹起之间的所有事件
i = paired ? j + 1 : i + 1;
lastT = paired ? ev[j].T : e.T;
continue;
}
if (SmartText && e.IsPrintable)
{
textBuf.Append(e.Ch);
i = paired ? j + 1 : i + 1;
lastT = paired ? ev[j].T : e.T;
continue;
}
EmitDelay(e.T);
sb.AppendLine($"keyPress(\"{e.KeyName}\")");
i = paired ? j + 1 : i + 1;
lastT = paired ? ev[j].T : e.T;
continue;
}
case Kind.KeyUp:
// 修饰键弹起
if (IsModifier(e.Vk))
{
string m = ModName(e.Vk);
if (m.Length > 0) mods.Remove(m);
i++;
continue;
}
EmitDelay(e.T);
sb.AppendLine($"keyUp(\"{e.KeyName}\")");
i++;
continue;
}
i++;
}
FlushText();
return sb.ToString();
}
private static string Escape(string s)
=> s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n");
}
}