0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Windows で xeyes を作る

Posted at

xeyes とは

X Window System 上でマウスカーソルの動きを目玉が追うアレです。

なんとなく Windows 上で再現してみたくなったので作ってみました。

環境

Windows 11 24H2
rustc 1.85.1

成果物

xeyes

解説

まずマウスカーソルの位置を取得する必要があります。自分のウィンドウ内でマウスカーソルが動いたときはウィンドウプロシージャにWM_MOUSEMOVEメッセージが来るのでマウスカーソルの位置を取得できますが、自分のウィンドウ外にマウスカーソルがある場合はWM_MOUSEMOVEが来ないのでマウスカーソル位置を取得できません。自分のウィンドウ外のマウスカーソル位置を取得するにはグローバルフックという仕組みを使用します。

グローバルフック

グローバルフックはデスクトップ上のすべてのスレッドのウィンドウメッセージを監視するというなかなか危険な香りがする仕組みです。

マイクロソフト公式のドキュメントにあるように、グローバルフックの使用は避けるべきです。誤った使用をするとウィンドウが固まってしまってどうにも出来なくなり、強制電源シャットダウンせざるを得なくなる場合があります。

フックの概要

グローバル フックは、デバッグ目的でのみ使用する必要があります。それ以外の場合は、それらを避ける必要があります。 グローバル フックはシステムのパフォーマンスを低下させ、同じ種類のグローバル フックを実装する他のアプリケーションとの競合を引き起こします。

すべてのスレッドのウィンドウメッセージを監視するフックプロシージャは dll に含める必要があります。なので、フックを担当する hook.dll と目玉を描画する xeyes.exe に分割することにします。

ワークスペース

今回は cargo のワークスペースという仕組みを使います。ワークスペースは関連するクレートをまとめて管理する仕組みです。フォルダ構成は以下のようになります。

:file_folder: xeyes(ワークスペース用のフォルダ)
├ Cargo.toml
:file_folder: xeyes(xeyes クレート用のフォルダ)
│ ├ Cargo.toml
│ ├ build.rs(後述)
│ └ :file_folder: src
│   └ main.rs (xeyes.exe のソース)
:file_folder: hook(hook クレート用のフォルダ)
│ ├ Cargo.toml
│ └ :file_folder: src
│   └ lib.rs (hook.dll のソース)
:file_folder: target (ビルドの成果物)
  └ :file_folder: debug
    ├ xeyes.exe
    ├ hook.dll
    └ hook.dll.lib (暗黙的動的リンク用のインポートライブラリ)

ビルドの成果物はワークスペースフォルダ直下の target フォルダ内にまとめて生成されます。

ワークスペース用の Cargo.toml は以下のようになります。ワークスペース内に xeyes と hook クレートがあるということを cargo に示します。

xeyes\Cargo.toml
[workspace]
resolver = "3"
members = [
  "hook",
  "xeyes",
]

hook クレート

hook クレートの Cargo.toml は以下のようになります。dll が必要なのでクレートタイプをcdylibとしています。

xeyes\hook\Cargo.toml
[package]
name = "hook"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
anyhow = "1.0"

[dependencies.windows]
version = "0.61"
features = [
  "Win32_UI_WindowsAndMessaging",
  "Win32_Security",
  "Win32_System_Memory",
  "Win32_System_SystemServices",
]

hook クレートのソースコードを順を追って説明していきます。DllMainという関数を書いておくと dll がロード・アンロードされるときに自動的に呼び出されます。ロードされるときにHINSTANCEという、実行しているプログラムの識別情報みたいなものが Windows から渡されるので、これをグローバル変数に保存しておきます。

xeyes\hook\src\lib.rs
thread_local! {
    static HINST: Cell<Option<HINSTANCE>> = const { Cell::new(None) };
}

#[unsafe(no_mangle)]
pub extern "system" fn DllMain(hinst: HINSTANCE, reason: u32, _: *const ()) -> bool {
    match reason {
        // ロードされたとき
        DLL_PROCESS_ATTACH => {
            // hinst を HINST グローバル変数に保存
            HINST.set(Some(hinst));
            create_memory_map()
        }
        // アンロードされたとき
        DLL_PROCESS_DETACH => delete_memory_map(),
        _ => Ok(()),
    }
    .is_ok()
}

グローバルフックを実現するにあたって、フックハンドルと xeyes ウィンドウのウィンドウハンドルを共有メモリ空間に保存する必要があります。

MSVC の C/C++ で書くのであれば、以下のように#pragma data_segで囲うことによって複数プロセスで同じメモリ領域を共有できます。しかし、Rust にはそのような仕組みがないので、Windows のメモリマップという仕組みを利用します。(C/C++ で作った共有メモリ領域を FFI で Rust から読み込むことでもグローバルフックは可能です。)

C/C++
#pragma data_seg("SHARED")
	extern "C" HHOOK gHook = NULL;
	extern "C" HWND gHwnd = NULL;
#pragma data_seg()

NAME という定数にXEyesMemoryMapObjectという文字列を設定しています。これはメモリマップを識別するための名前で任意の文字列で良いです。他と被りにくい名前にしておくとよいでしょう。この名前を使って共有メモリ領域のデータにアクセスします。

xeyes\hook\src\lib.rs
thread_local! {
    static MAP_FILE: Cell<Option<HANDLE>> = const { Cell::new(None) };
}

// フックハンドルとウィンドウハンドルを保持する構造体
#[derive(Debug, Clone, Copy)]
struct ShareData {
    hook: HHOOK,
    hwnd: HWND,
}

// メモリーマップの名前
const NAME: PCWSTR = w!("XEyesMemoryMapObject");

fn create_memory_map() -> Result<()> {
    // メモリーマップを作製
    let hmapfile = unsafe {
        CreateFileMappingW(
            INVALID_HANDLE_VALUE,
            None,
            PAGE_READWRITE,
            0,
            size_of::<ShareData>() as _,
            NAME,
        )?
    };
    // メモリーマップのハンドルをグローバル変数に保存
    MAP_FILE.set(Some(hmapfile));
    Ok(())
}

fn delete_memory_map() -> Result<()> {
    // メモリーマップを削除
    if let Some(mut mapfile) = MAP_FILE.get() {
        // メモリーマップのハンドル (HANDLE 構造体) は Free トレイトを実装しているので
        // free() を呼べば、CloseHandle(hmapfile) を実行してくれる
        unsafe { mapfile.free() };
    }
    Ok(())
}

つぎに作成したメモリーマップ内のデータ(ShareData)へのビュー(参照のようなもの?)を簡単にするためのMapViewという構造体を作っておきます。

xeyes\hook\src\lib.src
struct MapView {
    // 開いたビューのハンドル
    handle: HANDLE,
    // 共有データのポインタ
    map_view_address: MEMORY_MAPPED_VIEW_ADDRESS,
}

impl MapView {
    fn new() -> Result<Self> {
        // ビューを開く
        let handle = unsafe { OpenFileMappingW(FILE_MAP_ALL_ACCESS.0, false, NAME)? };

        // 共有データのアドレスを取得
        let map_view_address =
            unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, size_of::<ShareData>()) };
        ensure!(!map_view_address.Value.is_null(), "failed to get map view.");

        Ok(Self {
            handle,
            map_view_address,
        })
    }
}

// Deref とか DerefMut を実装しておくことで共有データの
// ShareData に直接アクセスしているかのように扱うことが可能になる
impl Deref for MapView {
    type Target = ShareData;
    fn deref(&self) -> &Self::Target {
        unsafe { &*(self.map_view_address.Value as *const ShareData) }
    }
}

impl DerefMut for MapView {
    fn deref_mut(&mut self) -> &mut Self::Target {
        unsafe { &mut *(self.map_view_address.Value as *mut ShareData) }
    }
}

// ビューの使用が終わったらちゃんと後始末しよう
// これがないとメモリリークする(たぶん)
// メモリリークはよくない
impl Drop for MapView {
    fn drop(&mut self) {
        unsafe { UnmapViewOfFile(self.map_view_address).ok() };
        unsafe { self.handle.free() };
    }
}

つぎに Windows に「グローバルフックするよ~」ということを設定する処理です。set_hook関数は xeyes.exe 側から呼び出す必要がある(FFI で xeyes ウィンドウのウィンドウハンドルを hook.dll に渡す)ので、no_mangleアトリビュートを付けています。

xeyes\hook\src\lib.rs
// デマングルされないようにするための属性
// edition 2024 から no_mangle は unsafe になった
#[unsafe(no_mangle)]
pub fn set_hook(hwnd: HWND) -> bool {
    set_hook_impl(hwnd).is_ok()
}

fn set_hook_impl(hwnd: HWND) -> Result<()> {
    let mut share_data = MapView::new()?;
    // MapView に DerefMut が実装されているので 
    // ShareData に直接アクセスしているように扱える
    // 共有メモリ領域に xeyes ウィンドウのウィンドウハンドルを保存
    share_data.hwnd = hwnd;

    // グローバルフックの設定
    let hhook = unsafe { SetWindowsHookExW(WH_MOUSE_LL, Some(hook_proc), HINST.get(), 0)? };

    // 共有メモリ領域にフックハンドルを保存
    share_data.hook = hhook;
    Ok(())
}

#[unsafe(no_mangle)]
pub fn end_hook() -> bool {
    end_hook_impl().is_ok()
}

fn end_hook_impl() -> Result<()> {
    let mut share_data = MapView::new()?;
    // HHOOK は Free トレイトを実装しているので
    // free() を呼ぶだけで UnhookWindowsHookExW(hook) が呼ばれる
    unsafe { share_data.hook.free() };
    Ok(())
}

最後にフックプロシージャの処理です。ここではなるべく重い処理は避けるべきでしょう。重い処理をするとシステムのパフォーマンスに重大な影響を与えます。下のコードではマウスが動いたら xeyes ウィンドウにマウスが動いたという情報(MSLLHOOKSTRUCT)だけを伝えています。

xeyes\hook\src\lib.rs
// xeyes ウィンドウにマウスカーソルが動いたということを
// 知らせるためのメッセージ(自分で適当に設定してよい)
pub const WM_HOOK_MOUSE_POS: u32 = WM_USER + 42;

unsafe extern "system" fn hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
    // マウスが動いたときだけ処理する
    if code == HC_ACTION as _ && wparam.0 == WM_MOUSEMOVE as _ {
        // 共有メモリのデータを取得
        let Ok(share_data) = MapView::new() else {
            // 取得できなかったら次のフックプロシージャに処理を渡す
            return unsafe { CallNextHookEx(None, code, wparam, lparam) };
        };
        // xeyes ウィンドウにマウスが動いたことを通知する
        unsafe { SendMessageW(share_data.hwnd, WM_HOOK_MOUSE_POS, None, Some(lparam)) };
    }
    unsafe { CallNextHookEx(None, code, wparam, lparam) }
}

xeyes クレート

xeyes クレートの Cargo.toml は以下のようになります。dependenciesに hook クレートを追加しておくことで、xeyes ワークスペースでcargo runしたときに hook クレートが先にビルドされます。その後 xeyes.exe が実行されるので、先に生成された hook.dll がロードされるという寸法です。

xeyes\xeyes\Cargo.toml
[package]
name = "xeyes"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0"
hook = { path = "../hook" }

[dependencies.windows]
version = "0.61"
features = [
  "Win32_Graphics_Gdi",
  "Win32_UI_WindowsAndMessaging",
]

Windows には dll をロードするのに二つの方法があります。「暗黙的リンク」と「明示的リンク」です。今回は hook.dll をロードするのに「暗黙的リンク」を使用します。hook クレートをビルドすると target フォルダ下に hook.dll.lib というファイルが生成されます。これは暗黙的リンクに必要なインポートライブラリです。(静的ライブラリと同じ lib という拡張子だからわかりにくいんじゃ!:rage:)このインポートライブラリは xeyes クレートに静的にリンクする必要があるので、rustc に hook.dll.lib の場所を教えてます。

xeyes\xeyes\build.rs
fn main() {
    // デバッグビルドのとき
    #[cfg(debug_assertions)]
    println!("cargo:rustc-link-search=target/debug");

    // リリースビルドのとき
    #[cfg(not(debug_assertions))]
    println!("cargo:rustc-link-search=target/release");
}

xeyes クレートのソースコードを解説しますが、大事なのはウィンドウプロシージャ部分だけなのでそこだけを解説します。ウィンドウが生成されたら、hook.dll のset_hook関数を呼び出してグローバルフックを開始します。ウィンドウが削除されるときにend_hookを呼び出してグローバルフックを解除します。hook.dll からマウスが動いたというメッセージが来たら、グローバル変数にマウスカーソルの位置を保存しておきウィンドウをクリアして再描画させます。描画の要求が来たら目玉を描画します。

xeyes\xeyes\src\main.rs
// hook.dll.lib にリンクしようとする
#[link(name = "hook.dll", kind = "static")]
unsafe extern "C" {
    fn set_hook(hwnd: HWND) -> bool;
    fn end_hook() -> bool;
}

// マウスカーソル位置を保存しておくためのグローバル変数
thread_local! {
    static POS: Cell<Option<POINT>> = const { Cell::new(None) };
}

unsafe extern "system" fn wnd_proc(
    hwnd: HWND,
    msg: u32,
    wparam: WPARAM,
    lparam: LPARAM,
) -> LRESULT {
    match msg {
        // ウィンドウが生成されたとき
        WM_CREATE => unsafe {
            // グローバルフックの開始
            set_hook(hwnd);
        },
        // ウィンドウが削除されたとき
        WM_DESTROY => unsafe {
            // グローバルフックの解除
            end_hook();
            PostQuitMessage(0)
        },
        // マウスカーソルが動いたとき
        WM_HOOK_MOUSE_POS => {
            // グローバル変数にマウスカーソル位置を保存しておく
            let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) };
            POS.set(Some(ms.pt));
            // ウィンドウを再描画させる
            _ = unsafe { InvalidateRect(Some(hwnd), None, true) };
        }
        // 描画が必要なとき
        WM_PAINT => {
            let mut ps = PAINTSTRUCT::default();
            let hdc = unsafe { BeginPaint(hwnd, &mut ps) };

            // 目玉を描画する処理。ここでは省略

            _ = unsafe { EndPaint(hwnd, &ps) };
        }
        _ => return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
    }
    LRESULT::default()
}

眼球がマウスカーソルを追う処理を解説します。まず、ウィンドウの座標から目玉の中心位置を割り出します。その中心からマウスカーソルまでの x 方向の距離(dx)と y 方向の距離(dy)を求めます。目玉の中心からマウスカーソルまでの直線距離を求めます(√(dx^2+dy^2))。半径 50 の円(点線の円)と目玉中心からマウスカーソル位置までの直線の交点座標(赤い点)を求めます。目玉は縦長の楕円なので X 座標だけ 1.76 (楕円の縦横比が 1.76 のため)で割って楕円上の点(黒い点)として、そこに眼球を描画します。

タイトルなし.png

xeyes\xeyes\src\main.rs
// 楕円を描画する処理
fn draw_circle(hdc: HDC, top: i32, left: i32, bottom: i32, right: i32) {
    // ペンを生成
    let pen = unsafe { CreatePen(PS_SOLID, 10, COLORREF::default()) };
    // Owned という構造体でラップすることでスコープを抜けるときに
    // 自動で DeleteObject(pen) が呼ばれる
    let pen = unsafe { Owned::new(pen) };
    let old_pen = unsafe { SelectObject(hdc, (*pen).into()) };

    // 楕円の描画
    _ = unsafe { Ellipse(hdc, left, top, right, bottom) };

    unsafe { SelectObject(hdc, old_pen) };
}

// 黒目を描画する処理
fn draw_iris(hdc: HDC, mouse_pos: POINT, center_of_eye: POINT, offset_x: f32) {
    // 目玉中心からマウスカーソル位置までの dx, dy
    let dx_from_eye = mouse_pos.x - center_of_eye.x;
    let dy_from_eye = mouse_pos.y - center_of_eye.y;

    // 目玉中心からマウスカーソルまでの直線距離
    let distance_from_eye = (dx_from_eye.pow(2) as f32 + dy_from_eye.pow(2) as f32).sqrt();

    if distance_from_eye > 0.0 {
        // マウスカーソルが目玉内にあるときの処理
        let dia = if distance_from_eye > 50.0 {
            50.0
        } else {
            distance_from_eye
        };
        // 眼球位置を計算
        let iris_pos = POINT {
            x: (dia * dx_from_eye as f32 / distance_from_eye / 1.76 + offset_x) as _,

            y: (dia * dy_from_eye as f32 / distance_from_eye + 80.0) as _,
        };
        // 眼球を描画
        draw_circle(
            hdc,
            iris_pos.y - 18,
            iris_pos.x - 10,
            iris_pos.y + 18,
            iris_pos.x + 10,
        );
    }
}

unsafe extern "system" fn wnd_proc(
    hwnd: HWND,
    msg: u32,
    wparam: WPARAM,
    lparam: LPARAM,
) -> LRESULT {
    match msg {
        // その他のメッセージ部分は省略
        
        WM_PAINT => {
            let mut ps = PAINTSTRUCT::default();
            let hdc = unsafe { BeginPaint(hwnd, &mut ps) };
            // 左目玉の描画
            draw_circle(hdc, 5, 5, 155, 90);
            // 右目玉の描画
            draw_circle(hdc, 5, 95, 155, 180);

            // マウスカーソル位置を取得
            let Some(mouse_pos) = POS.get() else {
                _ = unsafe { EndPaint(hwnd, &ps) };
                return LRESULT::default();
            };

            // ウィンドウ位置を取得
            let mut rect = RECT::default();
            _ = unsafe { GetWindowRect(hwnd, &mut rect) };

            // 左目玉の中心位置
            let center_of_left_eye = POINT {
                x: rect.left + 48,
                y: rect.top + 110,
            };
            // 右目玉の中心位置
            let center_of_right_eye = POINT {
                x: center_of_left_eye.x + 90,
                y: center_of_left_eye.y,
            };

            // 左眼球の描画
            draw_iris(hdc, mouse_pos, center_of_left_eye, 48.0);
            // 右眼球の描画
            draw_iris(hdc, mouse_pos, center_of_right_eye, 138.0);

            _ = unsafe { EndPaint(hwnd, &ps) };
        }
    }
}

以上です。

まとめ

いろいろな仕組みの勉強ができました。


その他参考にした記事

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?