[C#] そこそこ使いやすいUIオートメーションの拡張メソッド

Windowsで使える[UIオートメーション](https://docs.microsoft.com/ja-jp/windows/win32/winauto/entry-uiauto-win32)（.NET Frameworkではなく、COMオブジェクトの方）で、そこそこ使いやすい拡張メソッドを作りました。

# セットアップ
ありがたいことに、UIオートメーションのCOMオブジェクトをC#クラスに宣言しているNugetライブラリがあるので使わせていただく。🙏

https://www.nuget.org/packages/Interop.UIAutomationClient

https://github.com/FlaUI/UIAutomation-Interop

```shell
dotnet new console
dotnet add package Interop.UIAutomationClient
```

後述の[UIA.cs](#uiacs)を作成しておく。（Program.csに直接コピペOK👍）

# 使用例
下記はマイクの音量を0にするコード。
サウンド設定からマイクのプロパティを開いてレベルタブを表示した状態で実行する。
※ マイクの音量が勝手に変わってしまう挙動を抑えるために実装したモノ（この仕様よく分からない）

```csharp
using Interop.UIAutomationClient;

UIA.Instance.AutoSetFocus = 0; // 操作したときにウィンドウがアクティブにならないようにする

var root = UIA.Instance.GetRootElement(); // ルート（デスクトップ）を取得する

// タイトル 'サウンド' のウィンドウを取得
// -> その子の中でタイトル 'マイクのプロパティ' を取得
// -> その子孫の中でクラス名 'msctls_trackbar32' を取得
// -> 存在しなければ例外
var micTrackbar = root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.FindDescendant(e => e.CurrentClassName == "msctls_trackbar32")
    ?? throw new InvalidOperationException("マイクのトラックバーが見つかりません。");

// RangeValuePatternを取得
var rangeValue = micTrackbar.GetPattern<IUIAutomationRangeValuePattern>();
rangeValue.SetValue(0); // 音量を0に設定
```

# `Dump`メソッド
「クラス名とかわかんねーよ」というときのために`Dump`メソッドがあります。

```csharp
root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.Dump();
```

とすると、標準出力にコントロール名、オートメーションID、クラス名、名前が出力されます。

```
Window[50032], Id: , Class: #32770, Name: マイクのプロパティ
 Pane[50033], Id: , Class: #32770, Name:
  Pane[50033], Id: , Class: #32770, Name:
   Group[50026], Id: 1510, Class: Button, Name: マイク
   Slider[50015], Id: 1511, Class: msctls_trackbar32, Name: マイク
    Button[50000], Id: , Class: , Name: 左へドラッグ
    Thumb[50027], Id: , Class: , Name: 表示位置
    Button[50000], Id: , Class: , Name: 右へドラッグ
   Text[50020], Id: 1516, Class: Static, Name: 89
   CheckBox[50002], Id: 1512, Class: Button, Name: ミュート(M)
 Button[50000], Id: 1, Class: Button, Name: OK
 Button[50000], Id: 2, Class: Button, Name: キャンセル
 Button[50000], Id: 12321, Class: Button, Name: 適用(A)
 Tab[50018], Id: 12320, Class: SysTabControl32, Name:
  TabItem[50019], Id: , Class: , Name: 全般
  TabItem[50019], Id: , Class: , Name: 聴く
  TabItem[50019], Id: , Class: , Name: レベル
  TabItem[50019], Id: , Class: , Name: 詳細
 TitleBar[50037], Id: TitleBar, Class: , Name:
  MenuBar[50010], Id: SystemMenuBar, Class: , Name: システム
   MenuItem[50011], Id: , Class: , Name: システム
  Button[50000], Id: Close, Class: , Name: 閉じる
```

また、`Dump(2)`とすると2階層まで出力するように制限します。

# `DumpPatterns`メソッド
さらに、「IUIAutomationRangeValuePattern？どれ使えばいいの？？」というときは、`DumpPatterns`メソッドを使います。

```csharp
root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.FindDescendant(e => e.CurrentClassName == "msctls_trackbar32")
    ?.DumpPatterns();
```

とすると、利用できるパターンとプロパティ値を列挙します。

```
# Pattern: RangeValuePattern[10003]
CurrentValue: 89
CurrentIsReadOnly: 0
CurrentMaximum: 100
CurrentMinimum: 0
CurrentLargeChange: 0
CurrentSmallChange: 1
# Pattern: LegacyIAccessiblePattern[10018]
CurrentChildId: 0
CurrentName: マイク
CurrentValue: 89
CurrentDescription:
CurrentRole: 51
CurrentState: 1048576
CurrentHelp:
CurrentKeyboardShortcut:
CurrentDefaultAction:
```

`Dump`と`DumpProps`は元の`IUIAutomationElement`を返すので、メソッドチェーンの間に挟めます。（デバッグ目的）

```csharp
var element = root
    .GetChildren().FirstOrDefault(c => c.CurrentName == "サウンド")
    ?.Dump(1)
    ?.GetChildren().FirstOrDefault(c => c.CurrentName == "マイクのプロパティ")
    ?.Dump(1)
    ?.FindDescendant(e => e.CurrentClassName == "msctls_trackbar32")
    ?.Dump(1).DumpPatterns();
```


# UIA.cs

```csharp
using Interop.UIAutomationClient;

static class UIA
{
    public static readonly CUIAutomation8Class Instance = new CUIAutomation8Class();

    public static IEnumerable<IUIAutomationElement> GetChildren(this IUIAutomationElement parent)
    {
        var walker = Instance.RawViewWalker;
        for (var child = walker.GetFirstChildElement(parent); child != null; child = walker.GetNextSiblingElement(child))
        {
            yield return child;
        }
    }

    public static IUIAutomationElement? FindDescendant(this IUIAutomationElement parent, Func<IUIAutomationElement, bool> predicate)
    {
        foreach (var child in parent.GetChildren())
        {
            if (predicate(child)) return child;

            var hit = child.FindDescendant(predicate);
            if (hit != null) return hit;
        }

        return null;
    }


    private static Dictionary<Type, int>? patternIdDict;

    public static T GetPattern<T>(this IUIAutomationElement element) => element.GetPatternAs<T>() ?? throw new InvalidOperationException($"Pattern type '{typeof(T)}' is null.");

    public static T? GetPatternAs<T>(this IUIAutomationElement element)
    {
        if (patternIdDict == null)
        {
            patternIdDict = new Dictionary<Type, int>();
            var assembly = typeof(IUIAutomationElement).Assembly;
            foreach (var patternIdField in typeof(UIA_PatternIds).GetFields())
            {
                var patternName = patternIdField.Name[4..^2];
                var patternId = (int)patternIdField.GetValue(null)!;
                var patternTypeName = $"Interop.UIAutomationClient.IUIAutomation{patternName}";
                var patternType = assembly.GetType(patternTypeName) ?? throw new InvalidOperationException($"{patternTypeName} not found.");
                patternIdDict.Add(patternType, patternId);
            }
        }

        if (!patternIdDict.TryGetValue(typeof(T), out var id))
        {
            throw new ArgumentException($"Type '{typeof(T)}' is not UIA pattern type.", nameof(T));
        }

        return (T?)element.GetCurrentPattern(id);
    }

    public static IUIAutomationElement Dump(this IUIAutomationElement element, int limitNest = -1)
    {
        element.Dump(limitNest, 0);
        return element;
    }

    private static void Dump(this IUIAutomationElement element, int limitNest, int nest)
    {
        Console.Write(new string(' ', nest));
        Console.WriteLine($"{GetControlTypeName(element.CurrentControlType)}[{element.CurrentControlType}], Id: {element.CurrentAutomationId}, Class: {element.CurrentClassName}, Name: {element.CurrentName}");

        if (limitNest >= 0 && nest >= limitNest) return;

        foreach (var child in element.GetChildren())
        {
            child.Dump(limitNest, nest + 1);
        }
    }

    private static Dictionary<int, string>? controlTypeNameDict;

    private static string GetControlTypeName(int id)
    {
        if (controlTypeNameDict == null)
        {
            controlTypeNameDict =
                typeof(UIA_ControlTypeIds)
                .GetFields()
                .ToDictionary(f => (int)f.GetValue(null)!, f => f.Name[4..^13]);
        }

        return controlTypeNameDict.TryGetValue(id, out var name) ? name : string.Empty;
    }


    public static IUIAutomationElement DumpPatterns(this IUIAutomationElement element)
    {
        if (valueDumpers == null)
        {
            var dumpers = new List<Action<IUIAutomationElement>>();
            var assembly = typeof(IUIAutomationElement).Assembly;
            foreach (var patternIdField in typeof(UIA_PatternIds).GetFields())
            {
                var patternName = patternIdField.Name[4..^2];
                var patternId = (int)patternIdField.GetValue(null)!;
                var patternTypeName = $"Interop.UIAutomationClient.IUIAutomation{patternName}";
                var patternType = assembly.GetType(patternTypeName) ?? throw new InvalidOperationException($"{patternTypeName} not found.");
                var patternProps = patternType.GetProperties().Where(p => !p.Name.StartsWith("Cached")).ToArray();
                dumpers.Add(element =>
                {
                    var pattern = element.GetCurrentPattern(patternId);
                    if (pattern == null) return;

                    Console.WriteLine($"# Pattern: {patternName}[{patternId}]");
                    foreach (var prop in patternProps)
                    {
                        try
                        {
                            Console.WriteLine($"{prop.Name}: {prop.GetValue(pattern)}");
                        }
                        catch (Exception ex)
                        {
                            Console.WriteLine($"{prop.Name}: ERROR! {ex.Message}");
                        }
                    }
                });
            }

            valueDumpers = dumpers;
        }

        foreach (var dumper in valueDumpers)
        {
            dumper(element);
        }

        return element;
    }

    private static List<Action<IUIAutomationElement>>? valueDumpers;
}
```

# おわりに
.NET 6になってからVSCodeを使ってお手軽にC#書けて最高最高！
ちなみにPowerShellでは使えません。COMオブジェクトの扱いが違うので。C#でラッパークラスを作って間接的に操作するしかありません。😢