Technically Impossible

Lets look at the weak link in your statement. Anything "Technically Impossible" basically means we haven't figured out how yet.

C#でのイベントフック - キーボード入力イベントをフックする場合

Zoom Meetingsをはじめとするビデオ・コミュニケーションが、日常利用において何も例外的な存在ではない時代となった。それはオンライン・ミーティングに限らず、大人数が出席する、いわゆるカンファレンスでも用いられている。

このような時、あると便利なのが画面キャプチャ、スクリーンショットのためのツールだ。特にプレゼンテーション資料が配布されない場合、今まさに移っている画面をメモ代わりにキャプチャする。

Windows標準の操作では、スクリーンショットを取り、画像編集ソフトにコピーし、画像ファイルとして保存する、という手順を経る必要がある。これをボタン一つで、ファイル出力まで完結させたいと思い、とあるフリーウェアを使っていた。そして、これが気づかないうちに無反応になるときがあるのだ。
この現象があまりに頻発するので、ツールを自作することにした。そして作業をしているうちに気付くのだ。スクリーンキャプチャ・ツールは、イベントドリブンなライブラリ呼び出しで完結できるものではなく、Win32 API呼び出しが避けられないことに。それは次の場面で必要とされる。

  1. Active Window(最前面のWindow)を検出する。
  2. 特定キー押下のタイミングを検出する。

この投稿では、2の話題を含めた、イベントフックのコードを紹介する。
なお、ソースコード全体は投稿末尾に掲載している。

Hook(フック)

システムを監視し、特定のイベントが発生したときに処理を横取りすることをHook(フック)と言う。イベントドリブンなプログラムにおいて、発生したイベントに対応するプログラムをイベントハンドラと呼ぶ。
Visual Studioなどでは、これが部品として提供されているものの、それが動作する範囲外で同じことを実現しようと思えば、期待するイベントを独自に監視し、フックする仕組みを実装しなければならない。

典型的手順 - SetWindowsHookEx

Windows上のイベントを監視しHookする手順は、イベントの種類に限らず共通だ。Win32 APIの「SetWindowsHookEx」を呼び出す。

HHOOK SetWindowsHookExA(
  [in] int       idHook,
  [in] HOOKPROC  lpfn,
  [in] HINSTANCE hmod,
  [in] DWORD     dwThreadId
);

SetWindowsHookExA function (winuser.h) - Win32 apps | Microsoft Learn

それぞれの引数は次のように定義されている。

idHook 監視対象イベント
lpfn イベント発生時(Hookしたとき)に呼び出すメソッド名
hmod lpfnが含まれるモジュール
dwThreadId Hook対象となるスレッド
idHook

引用元にあらかじめ定義されている定数一覧が収録されている。キーボード・イベントなら「WH_KEYBOARD_LL」、マウス・イベントなら「WH_MOUSE_LL」と言った具合だ。

lpfn

Hookしたときの実行処理を含むメソッド名を引き渡す。後述するプログラムでは、キーボード・イベント発生の度に、メソッド「HookCallback」を呼び出している。

hmod

Hook処理(lpfn)が収録されているモジュール名を引き渡す。例えばHookプログラムと、メソッドが同一クラスに存在する場合、自分自身を引き渡すことになる。この場合は、次のように指定する。

GetModuleHandle(Process.GetCurrentProcess().MainModule);
dwThreadId

Hook対象となるスレッドIDを引き渡す。既存の全スレッドをHook対象とする場合は「0」を指定する。

キーボード・フックの例

次のサイトに掲載されている「全てのキー入力を捨てる」のプログラムが、まさに典型例だ。
qiita.com

public void Hook()
{
    using (Process curProcess = Process.GetCurrentProcess())
    using (ProcessModule curModule = curProcess.MainModule)
    {
        // フックを行う
        // 第1引数   フックするイベントの種類
        //   13はキーボードフックを表す
        // 第2引数 フック時のメソッドのアドレス
        //   フックメソッドを登録する
        // 第3引数   インスタンスハンドル
        //   現在実行中のハンドルを渡す
        // 第4引数   スレッドID
        //   0を指定すると、すべてのスレッドでフックされる
        hookPtr = SetWindowsHookEx(
            13,
            HookCallback,
            GetModuleHandle(curModule.ModuleName),
            0
        );
    }
}

int HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
    // フックしたキー
    Console.WriteLine((Keys)(short)Marshal.ReadInt32(lParam));

    // 1を戻すとフックしたキーが捨てられます
    return 1;
}

例えば、あらかじめプロジェクトの設定として、対象となるキーと押下イベントを指定しているものとする。

snap_trigger == Properties.Settings.Default.trigger_event;
key.ToString() == Properties.Settings.Default.trigger_key;

目的のキーで、目的の押下イベントが発生したときに、スクリーンショットを取得するプログラム*1を呼び出そうと思えば、メソッド「HookCallback」を次のように書き換えると良い。

int HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
    // フックしたキー
    var snap_trigger = (int)wParam;
    Keys key = (Keys)(short)Marshal.ReadInt32(lParam);

    Console.WriteLine(snap_trigger);
    Console.WriteLine(key);

    if (snap_trigger == Properties.Settings.Default.trigger_event && key.ToString() == Properties.Settings.Default.trigger_key)
    {
        Capture capt = new Capture();
        capt.snapActiveWindow();
        capt.snapScreen();
    }

    return 0;
}

実装例 - 1 Push Snap

1 Push Snapは、windowキャプチャからファイル保存までを1ボタンの操作に集約したツールだ。ここで紹介しているコードと画面キャプチャ*2を組み合わせて作成した。

この開発のために、まさかキー・ロガー的な仕組みを実装しなければならないとは、想像もしていなかった。別解的なアプローチも存在した。Alt + PrintScn押下イベントを独自に生成し、クリップボードから画像を取得する方法だ。
個人的にはキー・ロガー的な仕組みの方が自然に感じたので、こちらを採用した。

GitHubでコードを公開しているので、関心があれば参照してほしい。
impsbl.hatenablog.jp