2019年9月14日土曜日

キャプチャソフト開発 その4 キーボードフックを使ってみる

二か月ぶりのキャプチャソフト制作の関連投稿です。

***注意***
この記事の技術的内容には自信がありません。
参考にする際には気を付けてください。
それでもよろしければ。
***  ***

開発中のキャプチャソフトのふんわりとした仕様として、「シャッターキーはPauseやScrLkキーなど、使用頻度の低いキーに固定」という項目があります。
例えば、ゲームのキャプチャソフトとして有名なロイロ ゲーム レコーダーでは、F6キーを押すことで録画開始・停止を行います。

キャプチャソフトでは、ソフト自体のウィンドウは被写体のウィンドウに隠れてアクティブではなくなっています。
自分が映りこむことは都合が悪いからです。

「Hello World」など、シングルスレッドで簡単に実装できる通常のプログラムでは、メインスレッドがウィンドウを作成し、そのウィンドウに対するキー入力メッセージをメッセージプロシージャで受け取ります。
しかし先述の都合のため、キャプチャソフトではキー入力メッセ―ジがウィンドウプロシージャに入ってこないのです。

アクティブでない隠れたウィンドウから、キー入力メッセージを取得するためには、すべてのスレッドに対して送信されるメッセージを知る必要が生じます。
そこで出てくるのが、「グローバルフック」の一種である「キーボードフック」です。
キーボードフックは、キー入力メッセージをメインスレッドのウインドウプロシージャに渡す役割を担う存在であり、DLLとして実装します。

参考:
システムフック
ローカルフック

復習:キャプチャソフト開発 その3 DLLを作ってみる
前回は明示的にDLLを読み込ませました。
今回は簡単のため、暗黙的に読み込ませます。

以下、余談です。
正直、グローバルフックの動作を完全に理解できていません。
フックの動作を把握するためには、ウィンドウ、プロセス、システム、スレッド、CPU、メッセージキュー、フックプロシージャといった、Windows OSの動作に関する広範な知識を関連づける必要があるからです。
Windowsの内部動作に関してはまだまだ勉強中ですので、フックの動作に関しては、もう少しWindowsの理解が進んでから、振り返りたいと思います。

頭で理解するインプット型の学習はもちろん大事ですが、実際にコードを組んで走らせるアウトプット型の学習も大事だ!
...と自分に言い聞かせつつ、とりあえず今は「動くものができればいい」という魂胆で実装します。
自分のPCなら弄り倒して壊してしまっても、誰も文句を言わないでしょう。
閑話休題。

メインソースファイル

#include <stdio.h>
#include <wchar.h>
#include <windows.h>
#include <windowsx.h>
#include "./resource.h"
#include "./util.h"

namespace {
constexpr wchar_t WINDOW_NAME[] = L"Template window";
constexpr wchar_t CLASS_NAME[] = L"Template class";
}  // namespace

BOOL Cls_OnCreate(HWND hwnd, LPCREATESTRUCT lpCreateStruct) {
  (void)hwnd;
  (void)lpCreateStruct;
  // Start the keyboard hook.
  if (!SetKeyHook(hwnd)) {
#ifdef DEBUG
    fwprintf(stderr, L"Failed to start hook\n");
#endif
    FreeConsole();
    return 0;
  }
  return TRUE;
}

void Cls_OnDestroy(HWND hwnd) {
  (void)hwnd;
  // Stop the keyboard hook.
  if (!RemoveKeyHook()) {
#ifdef DEBUG
    fwprintf(stderr, L"Failed to stop hook\n");
#endif
  }
  PostQuitMessage(0);
}

void Cls_OnClose(HWND hwnd) { DestroyWindow(hwnd); }

void Cls_OnCommand(HWND hwnd, int id, HWND hWndCtl, UINT codeNotify) {
  (void)hwnd;
  (void)id;
  (void)hWndCtl;
  (void)codeNotify;
}

void Cls_OnKeyHook(HWND hwnd, WPARAM wParam, LPARAM lParam) {
  (void)hwnd;
  const UINT vk = (UINT)wParam;
  const BOOL fDown = ((lParam & 0x80000000) == 0) ? TRUE : FALSE;
  if (fDown == TRUE) {
    fwprintf(stderr, L"key down, %2x:\n", vk);
  } else {
    fwprintf(stderr, L"key up, %2x:\n", vk);
  }
}

LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam,
                         LPARAM lParam) {
  switch (message) {
    HANDLE_MSG(hwnd, WM_CREATE, Cls_OnCreate);
    HANDLE_MSG(hwnd, WM_DESTROY, Cls_OnDestroy);
    HANDLE_MSG(hwnd, WM_COMMAND, Cls_OnCommand);
    HANDLE_MSG(hwnd, WM_CLOSE, Cls_OnClose);
    HANDLE_MSG(hwnd, WM_KEYHOOK, Cls_OnKeyHook);
    default:
      return DefWindowProc(hwnd, message, wParam, lParam);
  }
}

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
                    LPTSTR lpsCmdLine, int nCmdShow) {
  (void)hPrevInstance;
  (void)lpsCmdLine;

#ifdef DEBUG
  FILE* fp = nullptr;
  AllocConsole();
  _wfreopen_s(&fp, L"CONOUT$", L"w", stdout);
  _wfreopen_s(&fp, L"CONOUT$", L"w", stderr);
  _wfreopen_s(&fp, L"CONIN$", L"r", stdin);
#endif

#ifdef DEBUG
  fwprintf(stdout, L"Hello world to stdout!\n");
  fwprintf(stderr, L"Hello world to stderr!\n");
#endif

  WNDCLASS wc;
  wc.style = CS_HREDRAW | CS_VREDRAW;
  wc.lpfnWndProc = WndProc;
  wc.cbClsExtra = 0;
  wc.cbWndExtra = 0;
  wc.hInstance = hInstance;
  wc.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_ICON1));
  wc.hCursor = LoadCursor(NULL, IDC_ARROW);
  wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
  wc.lpszMenuName = 0;
  wc.lpszClassName = CLASS_NAME;
  if (!RegisterClass(&wc)) {
    return FALSE;
  }

  HWND hwnd = CreateWindow(CLASS_NAME, WINDOW_NAME, WS_OVERLAPPEDWINDOW,
                           CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
                           CW_USEDEFAULT, NULL, NULL, hInstance, NULL);
  if (hwnd == NULL) {
    return FALSE;
  }
  ShowWindow(hwnd, nCmdShow);
  UpdateWindow(hwnd);

  MSG msg;
  while (GetMessage(&msg, NULL, 0, 0) > 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
  }

#ifdef DEBUG
  FreeConsole();
#endif
  return 0;
}


フックの登録と削除をON_CREATEとON_DESTROYのタイミングで実行しています。
関数やユーザーメッセージをまとめてあります。


フック関連のヘッダファイル

#ifndef _UTIL_H_
#define _UTIL_H_

#include <windows.h>

#ifndef DLLAPI
#define DLLAPI extern "C" __declspec(dllimport)
#endif

// Application-defined message identifiers.
#define WM_KEYHOOK (WM_APP + 1)

// void Cls_OnKeyHook(HWND hwnd, WPARAM wParam, LPARAM lParam)
#define HANDLE_WM_KEYHOOK(hwnd, wParam, lParam, fn) \
  ((fn)((hwnd), (WPARAM)(wParam), (LPARAM)(lParam)), 0L)

DLLAPI bool SetKeyHook(HWND hWnd);
DLLAPI bool RemoveKeyHook();

#endif  // _UTIL_H_


フックの登録と削除をON_CREATEとON_DESTROYのタイミングで実行しています。
関数やユーザーメッセージをまとめてあります。

  • SetKeyHook
    キーボードフックを掛けるためのラッパー関数です。
  • RemoveKeyHook
    キーボードフックを外すためのラッパー関数です。
  • WM_KEYHOOK、Cls_OnKeyHook
    フックプロシージャからウインドウプロシージャに送られるメッセージのための、ユーザ定義メッセージ定数と、それに対応するメッセージクラッカです。


フック関連のソースファイル
#define DLLAPI extern "C" __declspec(dllexport)

#include "./util.h"
#include <stdio.h>
#include <windows.h>

#pragma data_seg(".shared")
DLLAPI HWND hWndDest = NULL;
DLLAPI HHOOK hHook = NULL;
#pragma data_seg()
#pragma comment(linker, "/SECTION:.shared,rws")

namespace {
HINSTANCE hInstance;
}  // namespace

DLLAPI LRESULT CALLBACK KeyProc(int code, WPARAM wParam, LPARAM lParam) {
  if (code < 0) {
    // The hook procedure must pass the message to the CallNextHookEx
    // without further processing, then should return the return value.
    return CallNextHookEx(hHook, code, wParam, lParam);
  }
  switch (code) {
    case HC_ACTION:
      PostMessage(hWndDest, WM_KEYHOOK, wParam, lParam);
      break;
    case HC_NOREMOVE:
      break;
    default:
      break;
  }
  return CallNextHookEx(hHook, code, wParam, lParam);
}

DLLAPI bool SetKeyHook(HWND hwnd) {
  hHook = SetWindowsHookEx(WH_KEYBOARD, KeyProc, hInstance, 0);
  if (hHook == NULL) {
    hWndDest = NULL;
    return false;
  }
  hWndDest = hwnd;
  return true;
}

DLLAPI bool RemoveKeyHook() {
  if (UnhookWindowsHookEx(hHook) == 0) {
    return false;
  }
  hHook = NULL;
  hWndDest = NULL;
  return true;
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
  (void)lpvReserved;
  switch (fdwReason) {
    case DLL_PROCESS_ATTACH:
      hInstance = hinstDLL;
      //MessageBox(NULL, L"DLL_THREAD_ATTACH", L"key_hook.dll", MB_OK);
      break;
    case DLL_THREAD_ATTACH:
      break;
    case DLL_THREAD_DETACH:
      break;
    case DLL_PROCESS_DETACH:
      break;
  }
  return TRUE;
}


(2019年10月3日 追記)
DLL_PROCESS_ATTACH内でhHookとhWndDestを初期化していたのを削除した。

メイクファイル

TARGET = main.exe
PDB = main.pdb
MAP = main.map
RES =
SRC = main.cc util.cc
OBJ = $(OBJDIR)\main.obj
DLL = util.dll
LIB2 = util.lib
OBJDIR = build

CC = "$(VCINSTALLDIR)\cl.exe"
LINK = "$(VCINSTALLDIR)\link.exe"
CPPFLAGS = /nologo /W4 /Zi /O2 /MT /EHsc /Fd"$(TMPDIR)/" /DUNICODE /D_UNICODE \
      /DDEBUG
LIB1 = "kernel32.lib" "user32.lib" "gdi32.lib" "winspool.lib" "comdlg32.lib" \
    "advapi32.lib" "shell32.lib" "ole32.lib" "oleaut32.lib" "uuid.lib" \
     "odbc32.lib" "odbccp32.lib" "libcmt.lib"
LFLAGS = $(LIB1) /NOLOGO /SUBSYSTEM:WINDOWS /DEBUG /PROFILE

ALL: $(TARGET)

$(TARGET): $(OBJ) $(LIB2) $(DLL) $(RES)
 $(LINK) $(LIB2) $(LFLAGS) /OUT:$(TARGET) /PDB:"$(PDB)" /MAP:"$(MAP)" \
 $(OBJ) $(RES)

.cc{$(OBJDIR)}.obj:
 @[ -d $(OBJDIR) ] || mkdir $(OBJDIR)
 $(CC) $(CPPFLAGS) /Fo"$(OBJDIR)\\" /c $<

.cc.lib:
 $(CC) $(CPPFLAGS) /LD $(LIB1) $<

clean:
 rm $(OBJ) $(TARGET) *.map *.pdb *.ilk *.obj *.lib *.dll *.exp



DLLが暗黙的にリンクされるようになっています。
util.objが中間生成物用に作ったbuildディレクトリに入らないという欠陥がありますが、落ち着かないというほかに大きな問題はないので目をつぶっています。

実行の様子



メモ帳に「abcd123」と入力し、プリントスクリーンしました。
フックのプロシージャがキーイベントを横取りし、メッセージを呼び出し元のメインスレッドのウィンドウプロシージャに渡しています。
ウィンドウプロシージャ―では、フックプロシージャから受け取った内容を基に、コンソールにキーを押した・離したという情報を出力しています。

試しにデスクトップの何もないところ、Chromeの検索窓、Minttyで走らせているvim、そしてコンソール自体をそれぞれアクティブにして同じことを試し、うまくいくことを確認しました。
しかしながら、Notepad++(テキストエディタ)では、コンソールで確認できないばかりか、コンソールにフォーカスを戻しても、入力を受け付けない状態になってしまいました。
何か実装に抜け穴があるのかもしれませんが、先述の通りフックに関する理解が甘いため、原因が特定できていません

うまくいく場合とうまくいかない場合があるコードですが、とりあえずTODOとして残しておいて、先へと進もうと思います。
要素単体で問題があることを把握しておけば、問題の原因が分かった際に、仮組されたキャプチャソフトに修正を反映させることは容易でしょう。
最も、将来的に他の要素で別の問題が発生し、その問題と絡まって問題が複雑化する可能性は大いにありますが…

※(2019年10月3日 追記)
DLL_PROCESS_ATTACH内でhHookとhWndDestを初期化していたのを削除したところ、解決ました、多分。
DllMainが呼ばれるのはプログラムの起動時のみではないようです。

最新のコード

このページのソースコードはGitHubにも上げておきます。

0 件のコメント:

コメントを投稿

コメント表示は承認制に設定しています