using System.Runtime.InteropServices;
using System.Text;
namespace anjianjingling.Capture
{
///
/// 操作录制器:WH_KEYBOARD_LL / WH_MOUSE_LL 低级钩子采集真实键鼠事件,
/// 结束时聚合为可直接回放的 Lua 脚本。
///
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 _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? StateChanged;
/// 录制鼠标移动轨迹(关闭后只记录点击)。
public bool RecordMouseMove { get; set; } = true;
/// 把连续可打印字符聚合成 typeText("...")。
public bool SmartText { get; set; } = true;
/// 生成脚本时单次延时上限(毫秒),超过则截断。
public int MaxDelayMs { get; set; } = 2000;
/// 录制期间忽略的虚拟键(默认吞掉本程序热键 F8~F12)。
public HashSet 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(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(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);
}
/// 停止录制并生成 Lua 脚本。
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 snapshot;
lock (_lock) snapshot = new List(_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 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 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();
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");
}
}