首页
/ PowerToys Keyboard Manager 深度解析:低级键盘钩子、重映射配置格式与 SendInput 注入的边界情况处理

PowerToys Keyboard Manager 深度解析:低级键盘钩子、重映射配置格式与 SendInput 注入的边界情况处理

2026-09-06 11:34:32作者:牧宁李

本文基于 PowerToys 仓库中 Keyboard Manager(KBM)模块的开发者文档,系统讲解该模块的整体架构:它如何通过 Windows 低级键盘钩子(WH_KEYBOARD_LL)拦截并重映射按键与快捷键、配置文件(settings.json 与 profile 文件)的完整 JSON 格式、事件处理函数 HandleKeyboardHookEvent 的检查顺序,以及为绕过操作系统输入逻辑而设计的诸多 workaround(虚拟键事件、Num Lock 状态回滚、日文输入法修饰键互斥等)。读完本文,你将理解 KBM 从配置解析到按键注入的完整数据流,并能在排查钩子失效、按键“卡住”、NumPad 行为异常等问题时快速定位到对应源码位置。

PowerToys Keyboard Manager 日文输入法修饰键与 Caps Lock 交互示意图

模块架构总览:Runner、独立引擎与编辑器

KBM 是 PowerToys runner 加载的一个 PowerToy 模块,其入口在 dllmain.cpp。开发者文档指出,KeyboardManager 类有三个核心成员:

  • 指向当前 KeyboardManager 对象的静态指针:低级钩子回调 hook_proc 必须是静态函数,无法直接访问类成员,因此需要一个静态指针在回调中拿到对象实例。这一点在现行代码中依然成立,见 KeyboardManager.hkeyboardManagerObjectPtr 的声明及其注释 “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_engineShellExecuteExW 启动独立的 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_EditorWindowKeyboardManagerConstants.hEditorWindowEventName)共同承担。

配置格式:settings.json 与 profile 文件

KBM 使用两组设置文件,这一格式由文档完整定义,且与当前源码 KeyboardManagerConstants.hMappingConfiguration.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)字符串remapKeysoriginalKeys 必须只含一个键码(上例 91VK_LWIN);remapShortcuts 中必须至少含两个键码(上例 164;37Win+左方向键);
  • remapKeys 下的 inProcess 子键是历史设计产物:当时考虑过加入基于注册表的重映射方案(SharpKeys 的做法),若采用则放在另一个子键下,inProcess 专指钩子方式的重映射。因需求不足该方案被搁置,inProcess 子键则被保留。解析代码见 LoadSingleKeyRemaps
  • remapShortcuts 分为 global(对所有应用生效)与 appSpecific(仅当 targetApp 进程处于焦点时生效)。targetApp 必须是进程名,带不带 .exe 扩展名均可(如 msedgemsedge.exe)。

在此基础格式上,当前仓库的 profile 已扩展出更多条目:remapKeysToText / remapShortcutsToText(键或快捷键映射到文本,对应常量 NewTextSettingNameunicodeText)、operationType(1 = 运行程序,2 = 打开 URI)、exactMatchrunProgramFilePath 等运行程序参数。这些扩展的完整解析逻辑可在 LoadShortcutRemapsLoadAppSpecificShortcutRemaps 中看到;保存侧的 SaveSettingsToFile 通过 WriteJsonAtomically 先写临时文件再 MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) 原子替换,避免写入中断损坏配置。

设置加载机制:启动一次 + 事件驱动

文档“Loading settings”一节描述的时序在现行代码中更加清晰:

  1. 引擎进程启动时,KeyboardManager 构造函数 调用 LoadSettings()(失败会重试一次,见 LoadSettings),随后注册一个对共享事件 PowerToys_KeyboardManager_Event_SettingsSettingsEventName)的监听;
  2. UI 在用户点击 Remap Keys / Remap Shortcuts 窗口的 OK 按钮时才写盘,SaveSettingsToFile 写完后 SetEvent 该共享事件(MappingConfiguration.cpp);
  3. 引擎收到事件后的 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 转发给下一个钩子;
  • EncodeKeyNumpadOriginHelpers.cpp)利用 LLKHF_EXTENDED 位区分 NumPad 上的方向键/Insert/Delete 与主键盘版本,并在高位打上标记位,保证重映射目标与按键来源一致(这是文档未展开、但在当前源码中可见的增强);
  • Num Lock 被抑制时立刻调用 SetNumLockToPreviousState 回滚状态,对应下文的特殊场景。

钩子挂载与卸载在 StartLowlevelKeyboardHookSetWindowsHookEx(WH_KEYBOARD_LL, HookProc, ...),失败时用 show_last_error_message 弹错误框并发送 Trace::Error 遥测(这正是文档 Telemetry 一节中 KeyboardManager_Error 事件目前唯一的触发点)。

HandleKeyboardHookEvent 的检查顺序

HandleKeyboardHookEvent 是所有重映射逻辑的入口,文档列出的检查顺序在源码中逐条对应:

  1. 映射表正在更新(源码中为 loadingSettings,文档旧版对应 KeyboardManagerState.AreRemappingsEnabled):返回 0,事件正常转发;
  2. 编辑器窗口正在运行:当前源码通过 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” 小节有详解;
  3. KEYBOARDMANAGER_SUPPRESS_FLAG 检查:若 dwExtraInfo == 0x111(定义于 KeyboardManagerConstants.h)则返回 1 直接抑制。该标志是 KBM 与 OS 之间的“内部信道”:KBM 自己通过 SendInput 发出的、不希望被自身钩子再处理的事件都带这个 extraInfo;
  4. HandleSingleKeyRemapEvent:单键重映射,命中则返回 1 抑制原事件;
  5. HandleAppSpecificShortcutRemapEvent:应用级快捷键重映射;
  6. HandleSingleKeyToTextRemapEvent:单键转文本(当前源码中在应用级与全局级快捷键之间,文档版本尚无此环节);
  7. HandleOSLevelShortcutRemapEvent:全局快捷键重映射。

各处理器的详细实现见文档配套篇目 Keyboard Event Handlers

顺序本身是文档强调的一个关键设计决策,必须理解:

  • 单键重映射必须先于快捷键重映射。反例:若用户把 Ctrl 重映射为 X,又把 Ctrl+A 重映射为 Y——如果先做快捷键匹配,Ctrl 按下瞬间就会变成 X,系统永远看不到 Ctrl+A。因此所有单键重映射结果会“透传”进快捷键重映射的匹配过程(SetModifierKeyEventsshortcutToCompare / keyToBeReleased 参数即用于此,见 Helpers.cpp)。这也是把功能拆成 “Remap keys” 与 “Remap shortcuts” 两个窗口、而非一个大表的根本原因。
  • 应用级必须先于全局级:同一快捷键在某应用和全局各有一条重映射时,该应用聚焦时优先用应用级规则。

SendInput 注入的两个关键细节

重映射的“新键”是通过 SendInput 注入的,文档 “SendInput Special Scenarios” 指出了两个必须处理的细节,实现集中在 SetKeyEvent

扩展键(Extended keys)

方向键、右 Ctrl/AltDel/Home/Ins 等键必须带 KEYEVENTF_EXTENDEDKEY 标志发送,否则会落到 NumPad 的同名键上,NumLock 开启时行为异常。SetKeyEvent 中调用 IsExtendedKey 判断并补上该标志,其键表覆盖 VK_RCONTROLVK_RMENUVK_NUMLOCKVK_SNAPSHOTVK_CANCEL、导航键、休眠、媒体与音量键、浏览器键等。文档指出这一缺失曾导致三类问题(NumPad 版本被误发),SetKeyEvent 的单元测试在 SetKeyEventTests.cpp

扫描码(Scan code)

部分应用(如 Windows Terminal)会过滤扫描码为 0 的按键事件。即使不设置 KEYEVENTF_SCANCODE 标志,INPUT 结构中的 wScan 字段仍会被发送(默认 0),因此代码用 MapVirtualKey(keyCode, MAPVK_VK_TO_VSC) 从虚拟键码反查扫描码填入 wScanHelpers.cpp)。注释中同时说明:若虚拟键码不对应物理键(如保留键),MapVirtualKey 返回 0,此时按 0 发送。

特殊场景与 Workaround

由于 KBM 走的是低级钩子而非 OS 级输入处理,某些与系统输入逻辑直接交互的场景必须用 workaround 处理。以下四个场景完整继承自文档。

虚拟键事件(Dummy key events)

部分修饰键存在“单独按下再松开(中间不按其他键)就会触发动作”的行为——例如 Win 键单独松开会打开开始菜单、Alt 单独按会聚焦菜单栏。当重映射产生“非预期的修饰键 press/release 序列”时(比如快捷键目标不含 Win 而原快捷键含 Win),需要在两个状态之间插入一个虚拟键事件打断该序列。

实现见 SetDummyKeyEvent:使用未文档化的虚拟键码 0xFFDUMMY_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::SetNumLockToPreviousStateKeyboardEventHandlers.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 先看不到“两者同时按下”。以 CtrlCaps Lock 为例,在发送 Caps Lock key down 并抑制 Ctrl 之前,先发送一个被抑制的 Ctrl key up;Caps LockCtrl 方向则在发送 Ctrl key down 之后、抑制 Caps Lock 之前发送被抑制的 Ctrl key up。代码位于 KeyboardEventHandlersResetIfModifierKeyForLowerLevelKeyHandlers。对应的回归测试覆盖了两条路径:

文档还诚实地指出该 workaround 并未覆盖全部场景:若用户在重映射触发后、松开被重映射键之前又按下修饰键,修饰键仍可能卡住,该问题在文档引用时仍属未解决状态,解决需要对单键重映射代码做较大重构。

UIPI 限制(未解决)

SendInput 对某些按键(媒体播放/暂停键、计算器键等)无法直接生效——注入这些键需要 UAC 权限才能驱动对应的系统行为。文档给出的正确解法是为调用 SendInput 的可执行文件设置 UIAccess 标志,这样也能顺带解决“提升窗口聚焦时 KBM 需以管理员运行才能拦截按键”的问题。但 UIAccess 有硬性约束:可执行文件必须签名、必须位于受保护路径(如 Program Files)。

值得注意的是,这一节描述“KBM 目前跑在 runner 进程中,等 KBM 拆成独立可执行文件后再做”——而当前仓库恰好已经完成了拆分:钩子逻辑运行于独立的 PowerToys.KeyboardManagerEngine.exeKeyboardManagerEngine 工程)。因此可以推断 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/disabledllmain.cppTrace::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 HandlersKeyboard Manager CommonKeyboard Manager UI 三篇配套文档,以及 KeyboardManagerEngineTestKeyboardManagerEditorTestUITests 下的测试工程,可以完整还原 KBM 从配置文件到屏幕输出的全链路。

登录后查看全文
热门项目推荐
相关项目推荐