525 lines
20 KiB
C#
525 lines
20 KiB
C#
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");
|
||
}
|
||
}
|