PowerToys Keyboard Manager 深度解析:低级键盘钩子、重映射配置格式与 SendInput 注入的边界情况处理
本文基于 PowerToys 仓库中 Keyboard Manager(KBM)模块的开发者文档,系统讲解该模块的整体架构:它如何通过 Windows 低级键盘钩子(WH_KEYBOARD_LL)拦截并重映射按键与快捷键、配置文件(settings.json 与 profile 文件)的完整 JSON 格式、事件处理函数 HandleKeyboardHookEvent 的检查顺序,以及为绕过操作系统输入逻辑而设计的诸多 workaround(虚拟键事件、Num Lock 状态回滚、日文输入法修饰键互斥等)。读完本文,你将理解 KBM 从配置解析到按键注入的完整数据流,并能在排查钩子失效、按键“卡住”、NumPad 行为异常等问题时快速定位到对应源码位置。
模块架构总览:Runner、独立引擎与编辑器
KBM 是 PowerToys runner 加载的一个 PowerToy 模块,其入口在 dllmain.cpp。开发者文档指出,KeyboardManager 类有三个核心成员:
- 指向当前
KeyboardManager对象的静态指针:低级钩子回调hook_proc必须是静态函数,无法直接访问类成员,因此需要一个静态指针在回调中拿到对象实例。这一点在现行代码中依然成立,见 KeyboardManager.h 中keyboardManagerObjectPtr的声明及其注释 “Only global or static variables can be accessed in a hook procedure CALLBACK”。 Input对象:封装所有读写键盘状态的输入操作,将其抽象成InputInterface的目的是让重映射逻辑可以被单元测试 mock(对应 MockedInput.cpp)。KeyboardManagerState(State)对象:保存所有重映射数据,兼作连接 UI 与后端的“视图模型”,负责在两者间共享公共数据。
从当前仓库的源码结构看,文档描述的“运行在 runner 进程内”的架构已经演进:runner 侧的 KeyboardManager 类在 enable() 时通过 start_engine 用 ShellExecuteExW 启动独立的 PowerToys.KeyboardManagerEngine.exe 进程(以 runner 的 PID 作为参数传入),并用 SetPriorityClass(m_hProcess, REALTIME_PRIORITY_CLASS) 将其提升到实时优先级,以保证钩子能尽量处于系统钩子链的最高优先位置。disable() 则在 stop_engine 中先 SetEvent 一个共享终止事件并等待 1.5 秒,超时后 TerminateProcess 强制结束。文档 Enable/Disable 一节说明的设计动机在此架构下依然适用:启用时挂上低级钩子、禁用时卸载钩子,允许用户在 PowerToys 之后启动的其他钩子应用(如 AutoHotkey)抢占输入顺序后,通过重新开关 KBM 把自己重新带回最高优先级钩子;禁用时还会关闭所有活跃的 KBM UI 窗口,因为 KBM 编辑器的 “Type” 按钮复用同一个键盘钩子来捕获用户输入,KBM 关闭后这些窗口无法正常工作。
此外,runner 侧还负责打开编辑器:launch_editor()(dllmain.cpp)会先检查编辑器进程是否存活,存活则 AllowSetForegroundWindow + EnumWindows 将其窗口置前;否则启动 WinUI3Apps\PowerToys.KeyboardManagerEditorUI.exe(新版 WinUI3 编辑器,由 useNewEditor 设置控制,默认开启)。编辑器还支持一个默认 Win+Shift+Q 的全局热键,解析逻辑在 parse_hotkey 中(EditorShortcut 属性,未配置时回退到 Win+Shift+Q),这与文档 “Custom Action to launch KBM UI” 一节描述的 call_custom_action 打开 Remap Keys / Remap Shortcuts 窗口属于同一职责——在现有代码中该职责由 runner 集中热键(on_hotkey)与共享事件 PowerToys_KeyboardManager_Event_EditorWindow(KeyboardManagerConstants.h 的 EditorWindowEventName)共同承担。
配置格式:settings.json 与 profile 文件
KBM 使用两组设置文件,这一格式由文档完整定义,且与当前源码 KeyboardManagerConstants.h 和 MappingConfiguration.cpp 中解析的键名一一对应。
主设置文件 settings.json
{
"properties": {
"activeConfiguration": { "value": "default" },
"keyboardConfigurations": { "value": ["default"] }
},
"name": "Keyboard Manager",
"version": "1"
}
activeConfiguration存储当前激活的重映射配置(profile)名;keyboardConfigurations存储用户拥有的全部 profile 列表。
引入双 profile 结构的目的是为将来的 profiles 功能预留空间,避免破坏性变更——用户可以切换不同的重映射方案。加载侧的对应实现在 MappingConfiguration::LoadSettings:先 load_from_settings_file 取出 activeConfiguration,再按 模块保存目录\<profile名>.json 的路径读取该 profile 文件。
profile 文件(default.json)
{
"remapKeys": {
"inProcess": [
{ "originalKeys": "91", "newRemapKeys": "162;70" },
{ "originalKeys": "92", "newRemapKeys": "162;70" }
]
},
"remapShortcuts": {
"global": [
{ "originalKeys": "164;37", "newRemapKeys": "162;65" },
{ "originalKeys": "162;68", "newRemapKeys": "91" }
],
"appSpecific": [
{
"originalKeys": "91;162;65",
"newRemapKeys": "162;86",
"targetApp": "msedge"
}
]
}
}
格式规则(文档明确给出,加载代码完全遵循):
originalKeys是触发重映射需要按下的键/快捷键,newRemapKeys是实际要执行的目标键/快捷键;- 两者都是分号分隔的虚拟键码(VK code)字符串。
remapKeys中originalKeys必须只含一个键码(上例91即VK_LWIN);remapShortcuts中必须至少含两个键码(上例164;37即Win+左方向键); remapKeys下的inProcess子键是历史设计产物:当时考虑过加入基于注册表的重映射方案(SharpKeys 的做法),若采用则放在另一个子键下,inProcess专指钩子方式的重映射。因需求不足该方案被搁置,inProcess子键则被保留。解析代码见 LoadSingleKeyRemaps;remapShortcuts分为global(对所有应用生效)与appSpecific(仅当targetApp进程处于焦点时生效)。targetApp必须是进程名,带不带.exe扩展名均可(如msedge或msedge.exe)。
在此基础格式上,当前仓库的 profile 已扩展出更多条目:remapKeysToText / remapShortcutsToText(键或快捷键映射到文本,对应常量 NewTextSettingName 即 unicodeText)、operationType(1 = 运行程序,2 = 打开 URI)、exactMatch、runProgramFilePath 等运行程序参数。这些扩展的完整解析逻辑可在 LoadShortcutRemaps 与 LoadAppSpecificShortcutRemaps 中看到;保存侧的 SaveSettingsToFile 通过 WriteJsonAtomically 先写临时文件再 MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) 原子替换,避免写入中断损坏配置。
设置加载机制:启动一次 + 事件驱动
文档“Loading settings”一节描述的时序在现行代码中更加清晰:
- 引擎进程启动时,KeyboardManager 构造函数 调用
LoadSettings()(失败会重试一次,见 LoadSettings),随后注册一个对共享事件PowerToys_KeyboardManager_Event_Settings(SettingsEventName)的监听; - UI 在用户点击 Remap Keys / Remap Shortcuts 窗口的 OK 按钮时才写盘,
SaveSettingsToFile写完后SetEvent该共享事件(MappingConfiguration.cpp); - 引擎收到事件后的
changeSettingsCallback会加锁置loadingSettings = true,重新LoadSettings()并重新构建映射表——只有“从空到有”才补挂钩子(PostThreadMessageW(mainThreadId, StartHookMessageID, ...)),只有“从有到空”才卸载钩子(构造函数)。
这个 loadingSettings 标志正是下文 HandleKeyboardHookEvent 第一项检查的来源:映射表更新期间按键事件一律放行,防止重映射“半生效”造成混乱。
低级键盘钩子处理器
静态回调与钩子挂载
低级钩子回调不能是成员函数,因此文档强调 hook_proc 声明为 static 并通过静态指针访问对象。当前实现见 HookProc:
LRESULT CALLBACK KeyboardManager::HookProc(int nCode, const WPARAM wParam, const LPARAM lParam)
{
LowlevelKeyboardEvent event{};
if (nCode == HC_ACTION)
{
event.lParam = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
event.wParam = wParam;
event.lParam->vkCode = Helpers::EncodeKeyNumpadOrigin(event.lParam->vkCode, event.lParam->flags & LLKHF_EXTENDED);
if (keyboardManagerObjectPtr->HandleKeyboardHookEvent(&event) == 1)
{
// 抑制 Num Lock key down 时,回滚其开关状态
if (event.lParam->vkCode == VK_NUMLOCK && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)
&& event.lParam->dwExtraInfo != KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
{
KeyboardEventHandlers::SetNumLockToPreviousState(keyboardManagerObjectPtr->inputHandler);
}
return 1;
}
}
return CallNextHookEx(hookHandleCopy, nCode, wParam, lParam);
}
要点有三:
- 返回
1即吞掉该事件,否则CallNextHookEx转发给下一个钩子; EncodeKeyNumpadOrigin(Helpers.cpp)利用LLKHF_EXTENDED位区分 NumPad 上的方向键/Insert/Delete 与主键盘版本,并在高位打上标记位,保证重映射目标与按键来源一致(这是文档未展开、但在当前源码中可见的增强);- Num Lock 被抑制时立刻调用
SetNumLockToPreviousState回滚状态,对应下文的特殊场景。
钩子挂载与卸载在 StartLowlevelKeyboardHook:SetWindowsHookEx(WH_KEYBOARD_LL, HookProc, ...),失败时用 show_last_error_message 弹错误框并发送 Trace::Error 遥测(这正是文档 Telemetry 一节中 KeyboardManager_Error 事件目前唯一的触发点)。
HandleKeyboardHookEvent 的检查顺序
HandleKeyboardHookEvent 是所有重映射逻辑的入口,文档列出的检查顺序在源码中逐条对应:
- 映射表正在更新(源码中为
loadingSettings,文档旧版对应KeyboardManagerState.AreRemappingsEnabled):返回 0,事件正常转发; - 编辑器窗口正在运行:当前源码通过
WaitForSingleObject(editorIsRunningEvent, 0)检查PowerToys_KeyboardManager_Event_EditorWindow事件,编辑器打开期间整体暂停重映射(返回 0)。文档描述的两个 Type 按钮子场景——DetectSingleRemapKeyUIBackend(Remap keys 窗口左列)与DetectShortcutUIBackend(data, true/false)(Remap keys 右列 / Remap shortcuts 窗口)——的钩子处理逻辑,在文档的配套篇目 Keyboard Manager Common 的 “DetectSingleRemapKeyUIBackend and DetectShortcutUIBackend” 小节有详解; KEYBOARDMANAGER_SUPPRESS_FLAG检查:若dwExtraInfo == 0x111(定义于 KeyboardManagerConstants.h)则返回 1 直接抑制。该标志是 KBM 与 OS 之间的“内部信道”:KBM 自己通过SendInput发出的、不希望被自身钩子再处理的事件都带这个 extraInfo;HandleSingleKeyRemapEvent:单键重映射,命中则返回 1 抑制原事件;HandleAppSpecificShortcutRemapEvent:应用级快捷键重映射;HandleSingleKeyToTextRemapEvent:单键转文本(当前源码中在应用级与全局级快捷键之间,文档版本尚无此环节);HandleOSLevelShortcutRemapEvent:全局快捷键重映射。
各处理器的详细实现见文档配套篇目 Keyboard Event Handlers。
顺序本身是文档强调的一个关键设计决策,必须理解:
- 单键重映射必须先于快捷键重映射。反例:若用户把
Ctrl重映射为X,又把Ctrl+A重映射为Y——如果先做快捷键匹配,Ctrl按下瞬间就会变成X,系统永远看不到Ctrl+A。因此所有单键重映射结果会“透传”进快捷键重映射的匹配过程(SetModifierKeyEvents的shortcutToCompare/keyToBeReleased参数即用于此,见 Helpers.cpp)。这也是把功能拆成 “Remap keys” 与 “Remap shortcuts” 两个窗口、而非一个大表的根本原因。 - 应用级必须先于全局级:同一快捷键在某应用和全局各有一条重映射时,该应用聚焦时优先用应用级规则。
SendInput 注入的两个关键细节
重映射的“新键”是通过 SendInput 注入的,文档 “SendInput Special Scenarios” 指出了两个必须处理的细节,实现集中在 SetKeyEvent:
扩展键(Extended keys)
方向键、右 Ctrl/Alt、Del/Home/Ins 等键必须带 KEYEVENTF_EXTENDEDKEY 标志发送,否则会落到 NumPad 的同名键上,NumLock 开启时行为异常。SetKeyEvent 中调用 IsExtendedKey 判断并补上该标志,其键表覆盖 VK_RCONTROL、VK_RMENU、VK_NUMLOCK、VK_SNAPSHOT、VK_CANCEL、导航键、休眠、媒体与音量键、浏览器键等。文档指出这一缺失曾导致三类问题(NumPad 版本被误发),SetKeyEvent 的单元测试在 SetKeyEventTests.cpp。
扫描码(Scan code)
部分应用(如 Windows Terminal)会过滤扫描码为 0 的按键事件。即使不设置 KEYEVENTF_SCANCODE 标志,INPUT 结构中的 wScan 字段仍会被发送(默认 0),因此代码用 MapVirtualKey(keyCode, MAPVK_VK_TO_VSC) 从虚拟键码反查扫描码填入 wScan(Helpers.cpp)。注释中同时说明:若虚拟键码不对应物理键(如保留键),MapVirtualKey 返回 0,此时按 0 发送。
特殊场景与 Workaround
由于 KBM 走的是低级钩子而非 OS 级输入处理,某些与系统输入逻辑直接交互的场景必须用 workaround 处理。以下四个场景完整继承自文档。
虚拟键事件(Dummy key events)
部分修饰键存在“单独按下再松开(中间不按其他键)就会触发动作”的行为——例如 Win 键单独松开会打开开始菜单、Alt 单独按会聚焦菜单栏。当重映射产生“非预期的修饰键 press/release 序列”时(比如快捷键目标不含 Win 而原快捷键含 Win),需要在两个状态之间插入一个虚拟键事件打断该序列。
实现见 SetDummyKeyEvent:使用未文档化的虚拟键码 0xFF(DUMMY_KEY 常量)发送一对 key down + key up 事件。文档说明该键码至今未发现副作用;并且从“最初只发 key up”演进为“先 key down 再 key up”,因为只发 key up 与部分应用(文档引用的实例为 Slack)存在兼容性问题。虚拟键事件同样带 suppress 标志,保证只会被 OS 处理而不会被其他钩子/应用看到。
在钩子中抑制 Num Lock
Num Lock 的开关状态在 OS 更新后才会被低级钩子拦截——即使你在钩子里把 Num Lock 事件吞掉,它的开关状态也已经被翻转了。workaround 是:每当抑制一个 Num Lock key down 事件时,KBM 额外发送一组 Num Lock 的 key up + key down(使状态翻转回原值),这组事件在 dwExtraInfo 中带 KEYBOARDMANAGER_SUPPRESS_FLAG,因此会在钩子入口被 KBM 自己抑制,效果是“只有 OS 看到这两次翻转”。实现位于 HookProc 中的分支 与 KeyboardEventHandlers::SetNumLockToPreviousState(KeyboardEventHandlers.cpp)。文档同时声明其前提假设:KBM 是最后注册的钩子(若 AutoHotkey 等应用把 Num Lock 又映射到别的键,该逻辑会被破坏)。这也解释了为什么 KBM 需要实时优先级以及启用/禁用时重新挂钩的机制。
日文 IME 下修饰键与 Caps Lock 的交互
日文输入法的 Windows 键盘可用 Shift/Alt/Ctrl + Caps Lock 切换输入法选项,这类组合在低级钩子之前就被系统检测到。因此把 Caps Lock 重映射为修饰键(或反向)时,会存在“修饰键与 Caps Lock 同时处于按下”的中间态,导致 OS 在事件到达低级钩子前就吞掉了修饰键的 key up——修饰键从此“卡住”。
workaround 思路(文档说明是与 AutoHotkey 团队讨论后确定的):在处理修饰键 key down 时,提前发送一个带 KEYBOARDMANAGER_SUPPRESS_FLAG 的修饰键 key up,让 OS 先看不到“两者同时按下”。以 Ctrl → Caps Lock 为例,在发送 Caps Lock key down 并抑制 Ctrl 之前,先发送一个被抑制的 Ctrl key up;Caps Lock → Ctrl 方向则在发送 Ctrl key down 之后、抑制 Caps Lock 之前发送被抑制的 Ctrl key up。代码位于 KeyboardEventHandlers 的 ResetIfModifierKeyForLowerLevelKeyHandlers。对应的回归测试覆盖了两条路径:
- 单键重映射场景:SingleKeyRemappingTests.cpp;
- 快捷键重映射场景:OSLevelShortcutRemappingTests.cpp。
文档还诚实地指出该 workaround 并未覆盖全部场景:若用户在重映射触发后、松开被重映射键之前又按下修饰键,修饰键仍可能卡住,该问题在文档引用时仍属未解决状态,解决需要对单键重映射代码做较大重构。
UIPI 限制(未解决)
SendInput 对某些按键(媒体播放/暂停键、计算器键等)无法直接生效——注入这些键需要 UAC 权限才能驱动对应的系统行为。文档给出的正确解法是为调用 SendInput 的可执行文件设置 UIAccess 标志,这样也能顺带解决“提升窗口聚焦时 KBM 需以管理员运行才能拦截按键”的问题。但 UIAccess 有硬性约束:可执行文件必须签名、必须位于受保护路径(如 Program Files)。
值得注意的是,这一节描述“KBM 目前跑在 runner 进程中,等 KBM 拆成独立可执行文件后再做”——而当前仓库恰好已经完成了拆分:钩子逻辑运行于独立的 PowerToys.KeyboardManagerEngine.exe(KeyboardManagerEngine 工程)。因此可以推断 UIAccess 改造的前提条件已基本具备,但截至当前代码该标志尚未启用,UIPI 限制仍是已知未解决问题。
被放弃的替代方案:注册表与驱动
文档 “Other remapping approaches” 一节完整保留了两条被降级的技术路线,理解它们有助于理解为什么钩子方案成为最终选择:
注册表方案
即 SharpKeys 的做法:利用 Windows 的键盘扫描码映射注册表项(Scancode mapper),基于扫描码重映射按键。
- 优点:所有场景生效,无 UAC/提权问题;进程不需要常驻,重映射在登录密码输入界面同样生效;
- 缺点:修改 HKLM 需要管理员权限;需要重启才能生效;无法重映射快捷键;“随时生效”也是双刃剑——如果把某个键锁死在密码里,用户会被卡住。
由于不支持快捷键且需要重启,钩子方案胜出。
驱动方案
用驱动(文档点名开源驱动 Interception)在更底层拦截输入:
- 优点:不依赖钩子注册顺序,KBM 总能先于低级钩子拿到输入;能区分不同物理键盘,支持多键盘差异化重映射;
- 缺点:驱动的任何 bug 或崩溃都可能造成系统级后果。
因潜在副作用过大被降级。
遥测事件
Keyboard Manager 发出以下遥测事件(实现见 trace.h / trace.cpp,引擎侧另有 KeyboardManagerEngineLibrary/trace.h):
| 事件 | 记录内容 | 触发点 |
|---|---|---|
KeyboardManager_EnableKeyboardManager |
KBM 开关状态的布尔值 | runner 侧 enable/disable(dllmain.cpp 中 Trace::EnableKeyboardManager(true/false)) |
KeyboardManager_KeyRemapCount |
键→键与键→快捷键的重映射条数 | “Remap a key” 窗口保存时(ApplySingleKeyRemappings 末尾) |
KeyboardManager_OSLevelShortcutRemapCount |
全局快捷键→快捷键与快捷键→键的重映射条数 | “Remap a shortcut” 窗口保存时(ApplyShortcutRemappings 中) |
KeyboardManager_AppSpecificShortcutRemapCount |
应用级快捷键重映射条数 | 紧跟上一条事件之后发出 |
KeyboardManager_Error |
方法名、错误码与错误消息 | 当前仅用于 SetWindowsHookEx 失败(StartLowlevelKeyboardHook) |
引擎启动加载配置后还会额外发送一条“当前已配置的重映射统计”(LoadSettings 中的 Trace::SendKeyAndShortcutRemapLoadedConfiguration),这是文档版本之后新增的遥测点。
小结
KBM 的实现是一个典型的“在 OS 输入栈中做精细控制”的案例:钩子注册顺序决定优先级、dwExtraInfo 标志位是自注入事件的免疫标记、0xFF 虚拟键与 KEYEVENTF_EXTENDEDKEY/扫描码细节保证了注入行为的正确性,而 Num Lock 与日文 IME 的 workaround 则展示了低级钩子与 OS 既有输入逻辑之间必须逐一手动协调的边界。结合 Keyboard Event Handlers、Keyboard Manager Common、Keyboard Manager UI 三篇配套文档,以及 KeyboardManagerEngineTest、KeyboardManagerEditorTest 与 UITests 下的测试工程,可以完整还原 KBM 从配置文件到屏幕输出的全链路。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
