気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

C# - グローバルキーフックでキーの捕捉と入力を行う

f:id:AonaSuzutsuki:20181015203246p:plain

こんにちは、久しぶりにプログラミング関連のネタです。
以前MabinogiKeyConverterというアプリケーションを公開して記事にも適当に書いたのですが、そこで使用しているグローバルキーフックについて残しておきます。
C#では実行アプリケーション上でキーイベントの捕捉をデフォルトで対応していますが、その外となるとそのままでは対応できません。
通常はそれで事足りるものですが、キーコンバータなどのシステムに関わるような場合はWindows上全てのアプリケーションで適応させたいのでデフォルトの機能では実現できないんですよね。
そこでWin32APIによるグローバルキーフックでキー操作を掌握します。

フックとは

フックというのはあるスレッド上で発生したイベントを監視、捕捉してそれに応じて処理を行います。
今回触るのはキーボードフックだけなのですが、マウスイベントや様々なイベントが無意識のうちにシステム上では飛び交っています。
要するにC#上で実装しているようなイベントをシステムに広げて取得するような雰囲気でしょうか。
で、このフックには二種類あって、ローカルフックグローバルフックがあります。

ローカルフックは一つのスレッドに対して監視するもので、自身のアプリケーションで主に使用するようです。
私自身は使った事ないのですが、自プロセス上でもシステムが関連するような通常拾えないイベントを拾う際に使用するのでしょうか?

グローバルフックは全てのスレッドに対して監視をかけます。
全てのスレッドとは言い換えると全てのアプリケーションのイベントを拾うこととほぼ同義でしょうか。
なのでシステムフックとも呼ばれていてシステムの広範囲に及ぶようなアプリケーションはしばしばこのフックが使われています。
ただし、広範囲に影響が及ぶのでシステムが異常を起こす可能性もあります。
例えば、マウスフックとキーボードフックどっちもしくじると入力全般死にます。
また、C#ではグローバルフックを使用できるものの、C++ほど柔軟なことはできないようです。

実装

キー入力を捕捉する

キーボードフックのみならず、グローバルフックを掛ける場合はWin32APIのuser32.dll内部にあるSetWindowsHookEx関数を使用します。
関数のインタフェースについては公式のドキュメントを見ていただきたいのですが、ここにWH_KEYBOARD_LL(0x000D)を指定してやることでキーボードの低レベルなイベントを拾うことができます。

SetWindowsHookEx意外にも関数がいろいろ出てきていますが、順を追って説明していきます。

まずHookメソッドによりSetWindowsHookExを呼び出してフックを掛けます。

public void Hook()
{
    if (hookId == IntPtr.Zero)
    {
        proc = HookProcedure;
        using (var curProcess = Process.GetCurrentProcess())
        {
            using (ProcessModule curModule = curProcess.MainModule)
            {
                hookId = SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(curModule.ModuleName), 0);
            }
        }
    }
}

この時に第一引数をWH_KEYBOARD_LLに、第二引数をコールバック用のデリゲートを、第三引数には実行中のプロセスのメインモジュールのハンドルを指定します。第四引数はドキュメントのまんまなのでスルー。
なお、メインモジュールのハンドルはWin32APIのGetModuleHandle関数で取得できます。
で、この時に地味に重要なのがデリゲートをフィールド変数に置くことです。
これをしないとGCにより回収されてしまってCallbackOnCollectedDelegate例外で詰みます。

次に、UnHookメソッドでフックを外します。

public void UnHook()
{
    UnhookWindowsHookEx(hookId);
    hookId = IntPtr.Zero;
}

これはSetWindowsHookEx関数が返すIDを渡すだけでいいので説明はいらないでしょう。

最後にHookProcedureですが、イベントが発生した際に実行されるコールバック関数です。

public virtual IntPtr HookProcedure(int nCode, IntPtr wParam, IntPtr lParam)
{
    return CallNextHookEx(hookId, nCode, wParam, lParam);
}

...

public override IntPtr HookProcedure(int nCode, IntPtr wParam, IntPtr lParam)
{
    if (nCode >= 0 && (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN))
    {
        var kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
        var vkCode = (int)kb.vkCode;
        OnKeyDownEvent(vkCode);
    }
    else if (nCode >= 0 && (wParam == (IntPtr)WM_KEYUP || wParam == (IntPtr)WM_SYSKEYUP))
    {
        var kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
        var vkCode = (int)kb.vkCode;
        OnKeyUpEvent(vkCode);
    }

    return base.HookProcedure(nCode, wParam, lParam);
}

この中にイベントが発生した際の処理を記述します。
ただ、C#などのイベントとは異なり、CallNextHookEx関数を実行しないとキーボード入力全部が破棄されます
引数ですが、詳しい話は見つけられませんでしたが実際に触って見る感じでは、wParamにはイベントの種類が、lParamにはイベントパラメータのポインタが入っています。
nCodeはよくわかりませんが、0以上だと有効なイベントってことでしょうか・・・。
で、今回はアップとダウン別にしてC#のイベント定義しておきました。

もう一つ注意点として、ここで得られるキーコードは仮想キーコードなのでC#上で扱う場合はそのまま扱えないので注意が必要です。
.NetにKeyInterop.KeyFromVirtualKey関数なるものがあるのでそれでC#上のキーに変換できそう。

なお、コンソールアプリケーションで上のコードは動きますが、メッセージループさせるのにダミーウィンドウが表示されます。
その関係でPresentationFramework、PresentationCore、WindowsBaseの3つの参照が必要です。
また、閉じればフックも外して終了します。

キー入力を破棄する

キーの取得でもちょっと触れましたが、コールバック関数上でCallNextHookEx関数を返さなければキー入力を破棄することができます。
とはいえ関数なので何か返さないといけないのですが、new IntPtr(1)を返すことで破棄できます

public override IntPtr HookProcedure(int nCode, IntPtr wParam, IntPtr lParam)
{
    return new IntPtr(1);
}

CallNextHookEx関数ではほぼ触れられてませんでしたが、new IntPtr(0)を返すと破棄されなかったので0だと成功でそれ以外だと失敗ってことでしょうか。

キーをソフト的に入力する

キーをソフト的に入力する場合はSendInput関数を利用します。

ちょっと長いですが、KeyDownメソッドが入力するメソッドです。
このメソッドではSendInputに入力情報を乗せるのですが、INPUT構造体がやや厄介。


[StructLayout(LayoutKind.Explicit)]
public struct INPUT
{
    [FieldOffset(0)]
    public int type;
    [FieldOffset(4)]
    public MOUSEINPUT no;
    [FieldOffset(4)]
    public KEYBDINPUT ki;
    [FieldOffset(4)]
    public HARDWAREINPUT hi;
};

まずINPUT構造体から。
INPUT構造体ではunionが出てくるため宣言の仕方が少し特殊です。
unionのことをちょっとだけ触れておくと、メモリの一部を共有するような変数の扱い方をします。
要するに、MOUSEINPUT構造体もKEYBDINPUT構造体もHARDWAREINPUT構造体も同じ場所を使う感じでしょうか。
そこで構造体の上部に[StructLayout(LayoutKind.Explicit)]を宣言し、各変数部の位置を[FieldOffset(4)]などのように個別に指定します。
これでunionをC#でも表現できます。
ただ、キーボード入力だけでもKEYBDINPUT構造体以外も実装しないとうまく動いてくれません。
unionだからイケルと思ったのですがこういうものなのでしょうか・・・。

○追記
unionは同じメモリ領域の複数の変数で共有しますが、構造体によってサイズが異なるので全て入れておかないと最終的なサイズが本来必要となるはずのサイズにならないだけだと思います。
詳しく調べてないので憶測ですが。


[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT
{
    public short wVk;
    public short wScan;
    public int dwFlags;
    public int time;
    public UIntPtr dwExtraInfo;
};

次にKEYBDINPUT構造体です。
wVkは仮想キーコードを指定します。
wScanは入力したい文字を指定するらしいですが、今回は特に使いません。
dwFlagsは入力に関するオプションで、通常はKEYEVENTF_KEYDOWN(キーダウン)かKEYEVENTF_KEYUP(キーアップ)を指定します。
ただ、キーボードによれば拡張機能などもあり、それをソフト的に入力する場合はKEYEVENTF_EXTENDEDKEYをOR演算で追加します。
timeはドキュメント見ても意味不明でした。
dwExtraInfoは後ほども使いますが、入力に関する付加情報を指定する項目です。

その他の構造体はそのままC#に書き換えるだけな上に使わないので割愛。


これらを踏まえてキーダウンとアップを見てみます。

public INPUT KeyDown(int key, bool isExtend = false)
{
    INPUT input = new INPUT
    {
        type = INPUT_KEYBOARD,
        ki = new KEYBDINPUT()
        {
            wVk = (short)key,
            wScan = (short)MapVirtualKey((short)key, 0),
            dwFlags = ((isExtend) ? (KEYEVENTF_EXTENDEDKEY) : 0x0) | KEYEVENTF_KEYDOWN,
            time = 0,
            dwExtraInfo = MAGIC_NUMBER
        },
    };

    SendInput(1, ref input, Marshal.SizeOf(input));
    return input;
}

SendInputは第一引数に入力回数、第二引数にINPUT構造体、第三引数にINPUT構造体の実体のサイズを指定します。
なお、KeyDownにてMapVirtualKey関数を用いていますが、仮想キーコードとスキャンコードを相互変換する関数です。
先程の通りに仮想キーコードを入れてキーダウンのオプションを入れているだけで特に小難しいことはしていません。


ソフト的に入力した場合はキーを離すという物理的な動作がないため、キーアップができません。
なのでプログラマによるキーアップ処理が必要です。

public void KeyUp(INPUT input, bool isExtend = false)
{
    input.ki.dwFlags = ((isExtend) ? (KEYEVENTF_EXTENDEDKEY) : 0x0) | KEYEVENTF_KEYUP;
    SendInput(1, ref input, Marshal.SizeOf(input));
}

キーアップはキーダウンとほぼ同じことをするのですが、キーダウンの後に発生するのが普通なのでキーダウンのINPUTに対してdwFlagsをKEYEVENTF_KEYUPにしてやればキーアップ処理になります。

キー捕捉とソフト入力を同時に使用した例をGistに上げておいたので参考にしてみてください。
⚠Warning⚠ All input on keyboard exnchange to [A]. It is not exchanged mouse input. · GitHub

今回の例は単一入力だけなのでKeyDownの後KeuUpしていますが、長押しなどは判定がちょっと難しいです。
そこで次の判別方法を用います。

ソフト的に入力したキーかどうかを判別する

長くなってきましたが、最後です。
ソフト的に入力したとしてもフックしたキーボードイベントは発生します。
今回のような、入力したらまた入力するみたいなコードだとソフト的な入力に対してもイベントが発生して永遠に入力が発生することになります。
そんな時にKEYBDINPUT構造体のdwExtraInfoを使用します
dwExtraInfoは追加で情報を入れる箇所で、物理的なキー入力の場合は0のようです。
なのでここに0以外の何らかの値を入れておいてプロシージャの中で判定してやる事でソフト的な入力を判定することができます。

...
            time = 0,
            dwExtraInfo = MAGIC_NUMBER
        },
...

今回は定数にしていますが、どこからの入力かの判定も行いたい場合は適切な値を考えないといけなさそうです。

MabinogiKeyConverterの実装に際してグローバルキーフックを使いましたが、改めてまとめなおすと意外にもC#のイベントとさほどかわりませんね。
とはいえP/Invoke特有の処理や例外がまとめなおすと出てきたので改めて勉強になりました。
delegateのGC回収されて例外出るのは理解すれば当たり前のように思えますが、初めはなんで出ているのかわかりませんでしたよ。
ざっと書いたのでわかりづらいかもしれませんが、参考になればと思います。

追記

今更ですが、dwExtraInfoをUIntPtrに変更しました。