外付けテンキーの各キーに好きな操作を割り当てる Windows アプリを作っています。実現には「テンキーのキーを Windows に渡さず、自分のアプリだけで処理する」必要があり、低レベルキーボードフック(WH_KEYBOARD_LL)と Raw Input を組み合わせる設計で PoC を 4 回回しました。
その過程で、公式ドキュメントを読んだだけでは分からず、実測しないと判断できなかったことが 4 つありました。日本語の情報がほとんど無かった部分なので、数字ごと残しておきます。
環境は Windows 11、対象デバイスは Bluetooth テンキー iCLEVER IC-KP-08(22 キー)です。
1. フックで抑止したキーは、Raw Input にも届かない
最初に確かめたかったのはこれです。
当初の設計は「Raw Input でどのデバイスから来たキーかを見分け、フックで抑止する」でした。低レベルフックはデバイスを区別できません。フックのコールバックに渡ってくる KBDLLHOOKSTRUCT には、押されたキーの情報はあってもデバイスの情報が無いためです。一方 Raw Input はデバイスハンドルを持っています。そこで両方を受け取り、突き合わせて判定するつもりでした。
この設計は、フックで抑止したキーが Raw Input 側に残っていることが前提になります。残っていなければ突き合わせる相手がいません。
実測結果です。
| PoC | フックで抑止した数 | そのキーの Raw Input 到達数 |
|---|---|---|
| #1 | 242 | 0 |
| #2 | 44 | 0 |
2 回とも 0 件でした。
0 件という数字は、測定そのものが死んでいても同じ 0 になります。 そこが一番の懸念だったのですが、今回は対照が取れています。
- 同じ抑止期間中、抑止しなかったキー(通常キーボードの文字キー、矢印キーなど)の Raw Input は正常に届いていた。PoC #1 の Raw Input 総数は 421 件で、うち対象テンキーからが 169 件。受信機構は動いている
- さらに強い対照が偶然得られました。PoC #1 では実装のバグで NumLock だけが抑止されず、そのときだけ Raw Input が届いています(263.6 秒と 274.2 秒に計 4 件)。同じセッション・同じデバイス・同じキーで、抑止したかどうかだけが違う
つまり 抑止しなければ届き、抑止すれば届かない。例外なしでした。 受信できない状態で 0 を数えていたわけではありません。
低レベルフックは Raw Input の配信より上流にある、という構造だと考えると説明がつきます。
LowLevelKeyboardProc で非 0 を返すとイベントがそこで破棄されることはドキュメントに書かれていますが、それが Raw Input の配送も止めるかどうかは、私が探した範囲では明記された記述を見つけられませんでした。実測では止まります。
この 1 点で当初の設計は成立しないことが確定し、フック単独で判定し、Raw Input はデバイスの接続検知だけに使う方式へ切り替えました。
2. 同じキーを、フックと Raw Input が違うスキャンコードで報告する
PoC #1 で、テンキーのキーはすべて抑止できたのに NumLock だけ抑止できませんでした。抑止対象のスキャンコード一覧に入れていたのにすり抜けます。
原因は、報告される値がフックと Raw Input で違っていたことでした。
| キー | 低レベルフックの報告 | Raw Input の報告 |
|---|---|---|
| NumLock |
E0 + 0x45(拡張ビットあり) |
0x45(拡張ビットなし) |
| 右 Shift | E0 + 0x36 |
0x36 |
私は Raw Input のログを見ながら抑止対象の一覧を作っていたので、0x45 を登録していました。しかしフックに来るのは E0 + 0x45 で、一致せず素通りしていたわけです。
抑止の可否を決めているのはフックなので、判定はフック側の報告に合わせる必要があります。 拡張ビットを見る実装に直したところ、NumLock も抑止されるようになりました。
なお、ここで拡張ビットを「あり」で要求しておくと副次的な利点があります。Pause キーはフック側では非拡張の 0x45 として現れるため、拡張ビットを条件に入れておけば Pause を巻き込みません。
3. フックにそもそも現れないキーがある
このテンキーには電卓アプリを起動するキーが付いています。アプリ側で使いたかったので抑止しようとしたのですが、できませんでした。
ログを見ると状況がはっきりします。
41.57s RAW_KEY scan=0x00 ext=true vk=0xb7 ← Raw Input には現れている
(対応する HOOK_PASS / HOOK_SUPPRESS が 1 件も無い)
0xB7 は VK_LAUNCH_APP2 です。Raw Input には届いているのに、低レベルキーボードフックには一度も現れません。HID のコンシューマコントロールとしてキーボードとは別経路で配送されているためで、キーボードフックの管轄外でした。
見えないものは止められないので、押すたびに電卓が起動します。ドライバーを書かずに抑止する手段は見つかりませんでした。結論として、このキーは「割り当て対象にしないキー」として扱っています。
ついでに、22 キーのうち何が使えて何が使えないかも確定しました。
使えない 5 キー
| キー | 実際に送っているもの | 使えない理由 |
|---|---|---|
| ESC | 0x01 |
通常キーボードの Esc と同一。抑止すると Esc が PC 全体で効かなくなる |
| TAB | 0x0F |
同上 |
| Backspace | 0x0E |
同上 |
| = |
E0+0x36 + 0x0C(右 Shift + 主列の -) |
抑止すると - と右 Shift を巻き込む |
| 電卓 | フックに現れない | 上記のとおり |
逆に言うと、残る 17 キー(0〜9 . / * - + Enter NumLock)のスキャンコードは、テンキーレスキーボードでは発生しません。 ここが設計上いちばん効いた事実でした。デバイスを見分けられなくても、スキャンコードが排他的なら、抑止しても通常のキーボードには一切影響しません。当初 Raw Input にやらせようとしていたデバイス判別が、実は要らなかったということです。
4. 昇格ウィンドウが前面だと、フックは何も見えないまま、キーは相手に届く
管理者権限で起動したウィンドウ(昇格ウィンドウ)が前面にあるあいだ、非昇格プロセスのフックにはキーが一切来ません。UIPI(User Interface Privilege Isolation)による制限です。
PoC #1 と #2 で、フックの生存確認に使っていた合成キーが戻ってこない区間が計 15 回ありました。15 回とも昇格した PowerShell が前面のときで、フォーカスを戻した瞬間に復活しています。フックのコールバック処理時間は上限 300ms に対して桁違いに小さく、タイムアウトではありません。
問題はその先です。見えないあいだ、キーはどこへ行くのか。
PoC #4 で確定しました。抑止 ON の状態で昇格した PowerShell を前面にしてテンキーの 1 を押すと、1 がそのまま入力されました。同時刻のログには HOOK_SUPPRESS も RAW_KEY も 1 件もありません。
| 前面のウィンドウ | アプリから見えるか | キーの行き先 |
|---|---|---|
| 通常のウィンドウ | 見える | アプリが捕捉して抑止する |
| 昇格ウィンドウ | 見えない | そのアプリへ通常のテンキー入力として届く |
これは仕様として受け入れるしかありませんでした。実用上は、たとえばプレゼン中に管理者権限のウィンドウが前面にあると、割り当てたキーが数字や矢印として相手に流れ込みます。
「見えない」を「壊れた」と判定してはいけない
ここで自分の実装をひとつ壊しました。
途中のバージョンで、入力の捕捉が無言で止まる不具合を踏んだため、「生存確認が 5 回続けて途絶えたら故障とみなす」ウォッチドッグを入れました。その次の実行で「入力の捕捉が停止し、復旧しません」と表示されたのですが、これは誤検知でした。
判定に使われた 5 回の途絶はすべて昇格ウィンドウが前面のときのもので、フォーカスを戻した 158.5 秒の時点でフックは何事もなく復活しています(以降ハートビート 28 件、通常の捕捉 69 件)。フックは一度も壊れていませんでした。
UIPI による無音と、本物の故障が、ログ上まったく同じ形をしていたわけです。昇格ウィンドウを 10 秒眺めているだけで、アプリが自分を故障と宣言してしまう。 修正は単純で、途絶を検出したらまず前面ウィンドウが昇格しているかを確認し、昇格していれば故障カウントに数えない、としました。
ちなみに元の「無言で止まる」不具合のほうは、WM_INPUT を DefWindowProc に渡していなかったのが原因でした。Raw Input のメッセージを自分で処理して握り潰すと、配送そのものが止まります。これはアプリ側が自分で Raw Input を枯渇させる既知の経路です。
結論:4 回の PoC で決まった設計
| 決めたこと | 根拠になった実測 |
|---|---|
| Raw Input をキーの判別に使わない。接続検知だけに使う | 抑止したキーは Raw Input に来ない(0/242、0/44) |
| 抑止対象の判定はフックの報告するスキャンコードで行う。拡張ビットも見る | 同じキーをフックと Raw Input が違う値で報告する |
| 割り当て対象は 17 キーに限定する | 5 キーは通常キーボードと衝突するか、フックに現れない |
| デバイスの判別機構を作らない | 17 キーのスキャンコードがテンキーレス機と排他だから |
| 昇格ウィンドウ前面時は「効かない状態」として画面に出す | 見えないうえにキーは相手へ届く。隠すと事故になる |
| 生存確認の途絶は、昇格状態を確認してから故障と判定する | UIPI の無音と故障がログ上区別できなかった |
同じ構成(外付けデバイスのキーを OS に渡さず横取りする)を検討している方は、設計を決める前に「抑止したキーが Raw Input に残るか」だけは自分の環境で測っておくことをおすすめします。ここが 0 だと、デバイス判別を前提にした設計が丸ごと崩れます。私は崩してから気づきました。
この記事で扱っていないこと
アプリ本体の実装(キー割り当ての保持、オーバーレイ表示、マクロ実行)には触れていません。上に書いたのは、そこへ辿り着く前に OS の側で確かめる必要があった事実だけです。
なお、この PoC から作ったアプリは形になっていて、外付けテンキーを 16 キー分のコントローラーとして使えるようにするものです。会社の PC で毎日使うために作りました。BOOTH で配布しています。