Bakulog

獏の夢日記的な何か。

【C#】Windowsでデスクトップアイコンの矩形領域を取得する

「やりたくなったのでやりました」という脈絡のないネタです。オリジナリティはそんなに無いです。

もくじ

  1. やったこと
  2. コード置き場
  3. 仕組み
  4. 注意点
  5. 参考

1. やったこと

本記事では、下記のツイートでやっていることの一部を解説します。

https://twitter.com/baku_dreameater/status/922074014603866113

 

上記の動画では、プログラムからデスクトップアイコンの座標領域を読み取って使用しています1。この処理を実現するには、デスクトップ上の各アイコンについてアイコン名および領域(位置と高さ、幅)を拾ってくる必要があります。本記事では、これらのデータを拾ってくる方法を紹介します。

2. コード置き場

以下の通りGitHubに置いてあります。ライセンスは考えるのが面倒なのでPublic Domainです。

3. 仕組み

置いたソースのうち、DesktopIconGetter.csがメインなので、このソースを上から追っていきます。基本的にWin32APIをひたすら叩いて進みます。

3.1. 初期化

public void Initialize()
{
    if (IsValid)
    {
        return;
    }

    // get the handle of the desktop listview
    IntPtr hWnd = NativeMethods.FindWindow("Progman", "Program Manager");
    hWnd = NativeMethods.FindWindowEx(hWnd, IntPtr.Zero, "SHELLDLL_DefView", null);
    hWnd = NativeMethods.FindWindowEx(hWnd, IntPtr.Zero, "SysListView32", "FolderView");
    _windowHandle = hWnd;

    NativeMethods.GetWindowThreadProcessId(hWnd, out uint vProcessId);

    IntPtr handleProcess = NativeMethods.OpenProcess(
        NativeMethods.PROCESS_VM_OPERATION |
        NativeMethods.PROCESS_VM_READ |
        NativeMethods.PROCESS_VM_WRITE,
        false,
        vProcessId
        );

    if (handleProcess != IntPtr.Zero)
    {
        _processHandle = handleProcess;
        _virtualMemoryHandle = NativeMethods.VirtualAllocEx(
            handleProcess,
            IntPtr.Zero,
            4096,
            NativeMethods.MEM_RESERVE | NativeMethods.MEM_COMMIT, NativeMethods.PAGE_READWRITE
            );
        IsValid = true;
    }

    UpdateCount();
}

ここでやっている処理は以下の三つです。

  • 「デスクトップアイコン一覧」のウィンドウを探しに行ってウィンドウハンドルを取得
  • プロセスにアクセスするためにオープン
  • プロセスとメモリのやり取りするために領域を確保

オープンしたプロセスのハンドルとプロセス用メモリは、最終的に解放しないといけないので、 今回の例ではIDisposableインターフェースを実装して対応しています。

また、上記のコードで使っている"Progman"や"SHELLDLL_DefView"といった文字列(ウィンドウクラス名)が 一体どっから出てきたのかという事ですが、これはinspect.exeでウィンドウツリーを見ると確認できます。 inspect.exeの利用経験がない場合は、下記とかを参考に導入してください。

今回扱ってるデスクトップアイコンへ至るウィンドウ階層はこう。

  • デスクトップ
    • "Program Manager"ウィンドウ
      • ""ウィンドウ
        • "デスクトップ"一覧
          • アイコン0
          • アイコン1
          • アイコン2
          • アイコン3
          • ...

inspect.exeでこんな感じに見えていれば想定通りです。

3.2. アイコンの個数を取得

public void UpdateCount()
{
    if (!IsValid)
    {
        Initialize();
    }

    ItemCount = NativeMethods.SendMessage(_windowHandle, NativeMethods.LVM_GETITEMCOUNT, 0, 0);
}

これはAPIに任せきりなので「こう書いてね」としか言えません。 しいて言えばLVM_GETITEMCOUNTのページにそう書いてあります、という程度です。

3.3. アイコン一覧の取得

public IEnumerable<DesktopIconInfo> GetAllIconInfo(bool checkCount)
{
    if (checkCount)
    {
        UpdateCount();
    }

    if (ItemCount == 0)
    {
        return Enumerable.Empty<DesktopIconInfo>();
    }

    //遅延評価したくない+パフォーマンス下がると嫌なので配列使用
    var result = new DesktopIconInfo[ItemCount];
    for (int i = 0; i < result.Length; i++)
    {
        var bound = GetIconBoundAt(i);
        result[i] = new DesktopIconInfo(
            GetIconNameAt(i),
            bound.Left,
            bound.Top,
            bound.Right,
            bound.Bottom
            );
    }
    return result;
}

APIの仕様上、アイコン名とアイコンの領域は別々のサブルーチンに分けています。 まずアイコン名の取得方法から。

private string GetIconNameAt(int i)
{
    uint vNumberOfBytesRead = 0;
    var iconNameBytes = new byte[256];

    //前半. アイコン名の出力先アドレスを指定
    //配列形式にする理由はMarshalのAPIに渡すうえで都合いいから
    var lvItems = new LVITEM[]
    {
        new LVITEM()
        {
            mask = NativeMethods.LVIF_TEXT,
            iItem = i,
            iSubItem = 0,
            cchTextMax = iconNameBytes.Length,
            pszText = VirtualMemoryAfterLvItemHandle
        }
    };
    NativeMethods.WriteProcessMemory(
        ProcessHandle,
        VirtualMemoryHandle,
        Marshal.UnsafeAddrOfPinnedArrayElement(lvItems, 0),
        Marshal.SizeOf<LVITEM>(),
        ref vNumberOfBytesRead
        );

    //後半. 指定したアドレスにアイコン名が書き込まれたハズなので取得
    NativeMethods.SendMessage(
        WindowHandle,
        NativeMethods.LVM_GETITEMW,
        i,
        VirtualMemoryHandle.ToInt32()
        );
    NativeMethods.ReadProcessMemory(
        ProcessHandle,
        VirtualMemoryAfterLvItemHandle,
        Marshal.UnsafeAddrOfPinnedArrayElement(iconNameBytes, 0),
        iconNameBytes.Length,
        ref vNumberOfBytesRead
        );

    //そのままだとnull終端になってない(固定長で256バイトとってるせい)ので受け取った後でトリム
    string result = Encoding
        .Unicode
        .GetString(iconNameBytes, 0, (int)vNumberOfBytesRead)
        .TrimEnd('\0');

    var clear = new byte[iconNameBytes.Length];
    NativeMethods.WriteProcessMemory(
            ProcessHandle,
            VirtualMemoryAfterLvItemHandle,
            Marshal.UnsafeAddrOfPinnedArrayElement(clear, 0),
            clear.Length,
            ref vNumberOfBytesRead
            );

    return result;
}

やってる作業は3ステップで、個別に見ればとくに難しいことはしていません。 (個人的に見慣れないAPIがバンバン出てくるので緊張はしますが。)

  • アイコン名を書き込ませるための文字列へのポインタを用意
  • SendMessageを用いて、アイコン名を含めたデータの書き込みを要求
  • 書き込まれたデータのうち、アイコン名の部分だけ読み込む

アイコン名が取得できたら、今度はアイコンの領域取得です。

private Rect GetIconBoundAt(int i)
{
    //使わない
    uint numOfBytesRead = 0;

    //前半. Boundの種類を指定して渡す
    var rects = new Rect[]
    {
        new Rect()
        {
            Left = NativeMethods.LVIR_SELECTBOUNDS
        }
    };
    NativeMethods.WriteProcessMemory(
        ProcessHandle,
        VirtualMemoryHandle,
        Marshal.UnsafeAddrOfPinnedArrayElement(rects, 0),
        Marshal.SizeOf<Rect>(),
        ref numOfBytesRead
        );

    //後半. 指定したBoundを出力させて読み取る
    NativeMethods.SendMessage(
        WindowHandle,
        NativeMethods.LVM_GETITEMRECT,
        i,
        VirtualMemoryHandle.ToInt32());
    NativeMethods.ReadProcessMemory(
        ProcessHandle,
        VirtualMemoryHandle,
        Marshal.UnsafeAddrOfPinnedArrayElement(rects, 0),
        Marshal.SizeOf<Rect>(),
        ref numOfBytesRead
        );

    return rects[0];
}

こちらではアイコン名のときとメモリの読み書き位置が少し変わってますが、コンセプトは同じです。

注意としてはLVM_GETITEMRECTの説明にあるように、あらかじめ「この範囲の境界をちょうだい」というオプション値をRectのLeftに入れて渡す必要があります。この指定値が正しく入っていないと、SendMessageをしても境界のデータを書き込んでもらえなくなります。

4. 注意点

4.1. 動かないこともある

64bitなPCに対しては、コンパイル時に64bitの構成でビルドしないとダメみたいです。 理由はよくわかってませんが、なんかコケます。

また、64bitでビルドしてあってもたまにコケます。 私の環境ではコケた状態でinspect.exeを使って確認したところ、 そもそもウィンドウ階層が想定と違う形式になっていました。 スクショを撮り忘れてしまったのですが、たしかウィンドウ階層はこのようになっていました。

  • デスクトップ
    • ""ウィンドウ (想定外: "Progman"ではなく無名のウィンドウクラスになっている)
      • ""ウィンドウ
        • "デスクトップ"一覧 (
          • アイコン0
          • アイコン1
          • アイコン2
          • アイコン3
          • ...

こうなってしまった場合にアイコン一覧をうまく拾う方法はよく分かっていません。 手元のマシンの場合、この状態になってからWindowsを再起動したら元の(サンプルが動く)状態へ戻っていました。

4.2. アイコン名とファイルパスの対応づけ

アイコン名はほぼファイル名と同義であるため、フルパスも基本的にはすぐ得られます。

using System;
using System.IO;

    Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
        icon.Name
        );

ただしアイコンがショートカットである場合、上記のパスの末尾へさらに拡張子.lnkを追加したものが実際のファイルパスとなります。 デスクトップにはファイルそのものではなくショートカットが置かれるケースが多いので、要注意です。

4.3. Unityでやる場合

GitHubの例にもありますが、Unityでこの処理をする場合DllImportに拡張子なしのファイル名を渡す必要があります。 NativeMethodでは該当部分を#ifdefで切り替えてそれらしくしてあります。

    public static class NativeMethods
    {
#if UNITY_5_5_OR_NEWER
        private const string Kernel32Dll = "kernel32";
        private const string User32Dll = "user32";
#else
        private const string Kernel32Dll = "kernel32.dll";
        private const string User32Dll = "user32.dll";
#endif

        [DllImport(Kernel32Dll)]
        public static extern IntPtr VirtualAllocEx(
            IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect
            );

...

5. 参考


  1. 動画中では視線トラッキングという全く別の事もやっていますが、視線トラッキングについては別の記事に書きます。