LoginSignup
4
3

PythonによるWindowsメッセージフック・前編

Posted at

 PythonからWin32apiを使ってWindowsの低レベルのマウスの入力イベントを監視する方法を紹介します。同様のことはpyhookというモジュールを使ってもできるようです。

環境

  • Windows11
  • Python 3.11.0
  • pywin32 306

メッセージフックとは

 マイクロソフトが公開しているwin32apiのリファレンスには、フックの概要として次のように説明されています。

フックは、アプリケーションがメッセージ、マウスアクション、キーストロークなどのイベントをインターセプトできるメカニズムです。特定の種類のイベントをインターセプトする関数は、 フック プロシージャと呼ばれます。 フック プロシージャは、受け取る各イベントに対して動作し、イベントを変更または破棄できます。

 ここで紹介する低レベルのマウスイベントのフックは、マウスアクションをWindowsのシステムが処理する前に、何らかの処理を割り込ませて情報を取得しようというものです。

注意
この処理はWindowsのシステムの文脈で行われるので、この処理の記述がまずいと最悪の場合システムにマウスアクションが正しく伝わらない事態もあり得ます。

 メッセージフックにはwin32apiの三つの関数を使います。

  • SetWindowsHookExA()
    フックプロシージャをインストールする
  • CallNextHookEx()
    本来メッセージを処理するはずだったフックプロシージャにフック情報を渡す
  • UnhookWindowsHookEx()
    フックプロシージャをアンインストールする

 これらの関数はすべてUser32.dllから利用できます。Pythonからはctypesモジュールを使えば利用することができます。

ctypesの基本を簡単に

 ctypes は Python のための外部関数ライブラリです。ctypesモジュールを使えば、Pythonからの利用を想定されていないようなDLL(動的リンクライブラリ)を利用できます。
 ctypesの詳細については手に余るので、ここではメッセージフックに必要な最低限と思われることに絞って紹介します。

DLLのロード

 ctypesを使ってDLLにアクセスする方法はいくつかありますが、ここではCDLL系クラスのインスタンスを利用する方法を紹介します。
 CDLL系のクラスはコンストラクタに「.dll」ファイルをパスで指定するだけで、DLLをインスタンスとしてロードします。このインスタンスを介して、インポートしたモジュールのようにDLLを利用できるようになります。
 CDLL系クラスは、対象になるDLLのスタイルに応じたものを選ぶ必要があります。win32apiのDLLにはWinDLLクラスを使います。

python sample01.py
import ctypes
user32 = ctypes.WinDLL('user32', use_last_error=True)
print(user32.SetWindowsHookExA)
<_FuncPtr object at 0x0000021825D79A50>

 一見パスを指定しているようには見えませんが、これはWindowsの環境変数の都合でファイル名だけの記述でパスとして通じています。二番目の引数はエラーメッセージに関するもので、エラーメッセージを利用する気がないのならあえてTrueにセットする必要はないと思います。

DLL関数に渡す引数

 DLLの関数に渡す値は、ctypesモジュールで定義された互換型を利用します。基本的にはctypesのマニュアルにある基本データ型の一覧表から、対応するものを選ぶ形になります。
 win32apiのDLLを扱う場合ではctypes.wintypesモジュールが便利です。wintypesにはwin32apiで利用される様々な型の互換型が、まったく同じ名前で定義されています。win32apiのDLL関数に渡す値は、apiのリファレンスで指定されているものと同じ名前の互換型で与えれば間違いないでしょう。

DLL関数からの戻り値

 DLL関数から得られる戻り値は、互換型ではなくPythonの基本型として得られます。この点で注意が必要なのは、DLL関数の出力をすぐに別のDLL関数に渡したい、という時でも互換型への変換が必要になるということです。

構造体と関数の互換型

 メッセージフックを行う上で必要になる、構造体とコールバック関数の互換型について少し詳しく書きます。

構造体

 構造体はc言語などにあるクラスに似た仕組みです。構造体はいくつかの変数のまとまりからなる型として、自由に定義できるものです。マウスアクションのフックで取得できるデータはtagMSLLHOOKSTRUCTという構造体として得られます。次のように定義されています。

typedef struct tagMSLLHOOKSTRUCT {
  POINT     pt;
  DWORD     mouseData;
  DWORD     flags;
  DWORD     time;
  ULONG_PTR dwExtraInfo;
} MSLLHOOKSTRUCT, *LPMSLLHOOKSTRUCT, *PMSLLHOOKSTRUCT;

 この構造体は「POINT型の変数」を一つ、「DWORD型の変数」を三つ、「ULONG_PTR型の変数」を一つ、計5つの変数からなるひとつの型になります。
 Pythonでこのような構造体のデータを受け取るためには、ctypesのStructureクラスの継承クラスを利用します。

python sample02.py
import ctypes
import ctypes.wintypes as win
class MsStructuer(ctypes.Structure):#ctypes.Structureクラスの継承
    _fields_ = [('point', win.POINT),
                ('mouseDate', win.DWORD),
                ('flags', win.DWORD),
                ('time', win.DWORD),
                ('dwExtraInfo', ctypes.c_ulong)]

 このように_fields_属性に、対象になる構造体の要素を「名前(任意)」と「型」からなる「タプル」のリストで指定することで、特定の構造体に対するPythonの互換クラスを作成できます。
 ここで指定する要素の名前は、各要素にアクセスするためのPythonクラスの属性の名前になります。また、型は互換型で指定しますが、POINTDWORDctypes.wintypesにあるものをそのまま利用できます。

コールバック関数

 SetWindowsHookExA関数にはコールバック関数として、Pythonで記述したフックプロシージャを渡すことになります。しかし、他の変数同様にPythonの関数オブジェクトをそのままSet~関数に渡すことはできません。互換性のある方に変換して渡す必要があります。
 マウスアクションのフックでSetWindowsHookExA関数が期待しているコールバック関数は次のようなものです。

LRESULT CALLBACK LowLevelMouseProc(
  _In_ int    nCode,
  _In_ WPARAM wParam,
  _In_ LPARAM lParam
);

 これは「LRESULT型」を返す「int型」と「WPARAM型」と「LPARAM型」を引数にとる関数の定義です。Pythonで記述されるフックプロシージャはまず、三つの引数を受け取って一つの値を返す、という要件を満たしている必要があります。
 Pythonの関数オブジェクトを上のような関数と互換性のある形に変換するには、まずctypes.WINFUNCTYPE関数を使ってWinFunctionTypeクラスを作成します。

python sample03.py
HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, win.WPARAM, win.LPARAM)

 ctypes.WINFUNCTYPE関数には、目的のコールバック関数の「戻り値の型」と「引数の型」を指定します。戻り値である「LRESULT型」は、win32apiによくある「long型の整数値」によるなんらかのメッセージの表現です。wintypesに「LRESULT型」がなかったのでc_longで代用しました。
 この関数の戻り値として得られたWinFunctionTypeクラスに、Pythonの関数オブジェクトを渡してインスタンスを作ると、ようやくコールバック関数の互換オブジェクトが得られます。

python sample04.py
def func(nCode, wParam, lParam):
    return 0
HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, win.WPARAM, win.LPARAM)
callback = HOOKPROC(func)

 このcallbackであればSetWindowsHookExA関数は受け取ってくれます。

Set~、Call~、Unhook~関数の詳細

 あとはSet~、Call~、Unhook~の使い方と、フックプロシージャの詳細を詰めれば、マウスフックのPythonスクリプトに進むことができます。もうちょっとです。
 SetWindowsHookExAは以下のように定義されています。

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

 引数の意味するところはだいたい以下のような感じです。

  • idHook
    整数値の識別子でもって、フックの種類を指定
  • lpfn
    フックプロシージャのポインタを指定。上で作ったcallbackを渡す部分です
  • hmod
    フックプロシージャを含むDLLのハンドルを指定。フックプロシージャがSet~関数の呼び出し元と同じコード内に記述されている場合はnull、つまりPythonではNoneを指定します
  • dwThreadId
    フックプロシージャを関連付けるスレッドの整数値による識別子。0を指定することですべてのスレッドが対象になります
  • 戻り値:HHOOK
    関数が成功した場合「HHOOK型」によるフックプロシージャへのハンドルを、失敗した場合はnullを返す。この「HHOOK型」はUnhookWindowsHookExの引数として利用するので、大切に保管しましょう

 つづいてCallNextHookExの定義です。

LRESULT CallNextHookEx(
  [in, optional] HHOOK  hhk,
  [in]           int    nCode,
  [in]           WPARAM wParam,
  [in]           LPARAM lParam
);
  • hhk
    リファレンスによると、この引数は無視されます、とのことなのでなんでもいいです
  • nCode, wParam, lParam
    これらの引数にはフックプロシージャが受け取る引数をそのまま渡してやります
  • 戻り値:LRESULT
    フックプロシージャはこの戻り値を返す必要がある

 つまりCallNextHookExは、フックプロシージャの中で呼び出す必要があるということです。フックプロシージャのreturnの部分でCallNextHookExを呼び出すのがセオリーのようです。
 UnhookWindowsHookExです。

BOOL UnhookWindowsHookEx(
  [in] HHOOK hhk
);
  • hhk
    Set~の返した値を渡すことで、Set~がインストールしたフックプロシージャがアンインストールされます
  • 戻り値:BOOL
    フックプロシージャのアンインストールの成否を表すものです

 最後にフックプロシージャの受け取る三つの引数についてです。

  • nCode
    整数値による識別子。wParamとlParamがマウスメッセージに関する情報を持っていれば「0」。「0」はHC_ACTIONという定数として定義されている

  • wParam
    WPARAM型によるマウスメッセージの識別子。WM_LBUTTONDOWN、WM_LBUTTONUP、WM_MOUSEMOVE、WM_MOUSEWHEEL、WM_RBUTTONDOWN、M_RBUTTONUPというWPARAM型の定数として定義されている

  • lParam
    MSLLHOOKSTRUCT 構造体へのポインタ。構造体そのものではなく、その位置だけが示される形なので、lParamから構造体の情報を取得するにはStructureクラスのfrom_address関数を使います

 PythonからnCodeとwParam用の定数として定義された値は、pywin32に含まれるwin32conというモジュールから利用できます。SetWindowsHookExAidHookで指定するフック識別子も同様です。

Pythonによるマウスフックスクリプト

 以上のことを押さえておけば、次のPythonスクリプトがおおむね理解できるかと思います。マウスの左クリックに応じて、アクションの発生した時間と画面上での座標が、逐次コンソールウィンドウにprintされるというものです。
 

python sample04.py
import win32con
import ctypes
import ctypes.wintypes as win

user32 = ctypes.WinDLL('user32', use_last_error=True)

class MsStructuer(ctypes.Structure):
    _fields_ = [('point', win.POINT),
                ('mouseDate', win.DWORD),
                ('flags', win.DWORD),
                ('time', win.DWORD),
                ('dwExtraInfo', ctypes.c_ulong)]

class KeyLogger:
    def __init__(self):
        self.lUser32 = user32
        self.hooked = None

    def installHookProc(self,pointer):
        self.hooked = self.lUser32.SetWindowsHookExA(win32con.WH_MOUSE_LL, pointer, None, 0)
        if not self.hooked:
            return False
        return True
    
    def uninstalHookProc(self):
        if self.hooked is None:
            return
        self.lUser32.UnhookWindowsHookEx(self.hooked)
        self.hooked = None

def hookProc(nCode, wParam, lParam):
    if nCode == win32con.HC_ACTION and wParam == win32con.WM_LBUTTONDOWN:
        ms = MsStructuer.from_address(lParam)
        print(f'{ms.time}:{ms.point.x}, {ms.point.y}')#timeはミリ秒単位かな
    if nCode == win32con.HC_ACTION and wParam == win32con.WM_RBUTTONDOWN:
        print('右クリックが押されました。フックを解除します')
        kl.uninstalHookProc()
        return user32.CallNextHookEx(0, nCode, win.WPARAM(wParam), win.LPARAM(lParam))
    return user32.CallNextHookEx(0, nCode, win.WPARAM(wParam), win.LPARAM(lParam))

HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, win.WPARAM, win.LPARAM)
callback = HOOKPROC(hookProc)

kl = KeyLogger()
bool = kl.installHookProc(callback)
msg = win.MSG()
user32.GetMessageA(ctypes.byref(msg), 0, 0, 0)

 最後の二行、win32apiのGetMessageA関数を呼び出すための記述は、Pythonスクリプトを終了させないための、とりあえずのものです。なぜそのような措置が必要なのかは、メッセージフックにおいてPythonスクリプトが果たしている役割について注目するとわかります。

 このPythonスクリプトがやっているのは、フックプロシージャや各クラスの定義以外の部分では、KeyLoggerクラスのインスタンスを作成して、フックプロシージャをインストールしているだけです。フックプロシージャを実行するのはWindowsのシステムなので、Pythonスクリプトの役割はこれ以上なにもないからです。
 しかし、やることがないからといってスクリプトに終了してもらうわけにはいきません。スクリプトが終了してしまえば、フックプロシージャの記述が失われてしまい、自動的にフックが解除されてしまうからです。

 GetMessageA関数は、指定したウィンドウにメッセージが届くのを「待機」する関数です。この場合は、届くはずのないメッセージを待ち続ける形で、Pythonスクリプトの終了を防いでいます。しかし、これではPythonインタプリタを停止させる以外スクリプトを止めるすべがないので、あくまでも「とりあえず」の手段です。

終わりに

 あまりに長くなりすぎたので、フックスクリプトが完成したところで、とりあえず、この記事を前半ということにしてまとめることにします。
 最後に示したスクリプトでは、行儀よく終了させる手段がないとか、フックプロシージャが取得した情報を活用する手段がないなど、いろいろと問題があります。続きの記事でこれらの問題をクリアした例と、低レベルのキーボードメッセージへの応用例を紹介したいと思います。

参考

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3