【C#】UIAutomationを利用してMicrosoft Edgeのタブを閉じる

## はじめに
業務アプリケーションの開発において、ユーザーがログアウトする際にMicrosoft Edgeで開いている特定のWebページだけを自動で閉じる必要が出てきました。Edge全体のプロセスを終了させる方法も考えましたが、閉じたくないWebページもあり、特定のタブのみを対象にする必要がありました。

本記事では、C#とUIAutomationを利用して、指定したタブだけを閉じる方法を記します。ここで紹介するコードは、2024年8月時点で動作確認したものとなります。

## 目次
- [はじめに](#はじめに)
- [やりたいこと](#やりたいこと)
- [環境](#環境)
- [UIAutomationとは](#uiautomationとは)
- [サンプルコード](#サンプルコード)
- [サンプルコードの説明](#サンプルコードの説明)
- [選択しなかった代替案](#選択しなかった代替案)
- [おわりに](#おわりに)
- [参考文献](#参考文献)

## やりたいこと
この状態から、タブのタイトルである「Google」という文字列を指定しタブを閉じて、
![before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3867776/db0d39a7-08f7-150e-c7ea-49fadf9e7d13.png)

こうしたい。
![after.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3867776/a38051d0-6ee5-cd4b-23cd-3c4050372187.png)

## 環境
- OS：Windows 11
- ブラウザ：Microsoft Edge 107.0.1418.52、127.0.2651.98
- 開発言語：C#（.NET8）
- IDE：Visual Studio 2022

## UIAutomationとは
まず、Microsoft Learnによる説明は以下の通りです。
> UI オートメーションは、デスクトップ上のほとんどのユーザー インターフェイス (UI) 要素へのプログラムによるアクセスを提供し、スクリーン リーダーなどの補助技術製品が UI に関する情報をエンド ユーザーに提供したり、標準入力方式以外の方法で UI を操作したりできるようにします。 また、UI オートメーションにより、自動テスト スクリプトが UI と対話できるようになります。

https://learn.microsoft.com/ja-jp/dotnet/framework/ui-automation/ui-automation-overview

この技術は、デスクトップアプリケーション内のツリー構造にあるメニューやボタンなどの子要素に、外部プロセスからアクセスして操作するために使用される、ということのようです。

なお、UIAutomationは.NET Framework 4.8.1まではフレームワークに含まれていましたが、.NET Core以降のフレームワークでは公式にはサポートされていません。これは、UIAutomationがWindows専用の技術であるのに対し、.NET Core以降ではクロスプラットフォーム対応を重視しているためです。しかし、.NET 8でも使えることを確認しましたので、次節でその手順を説明します。

## サンプルコード

### 準備
C#でUIAutomationを利用するためには、次の2つのアセンブリを参照する必要があります。
- UIAutomationClient.dll
- UIAutomationTypes.dll

.NET Frameworkでは、参照マネージャーのアセンブリの検索でこれらを見つけることができますが、.NET 8では出てきません。しかし、これらのDLLはWindows SDKに含まれるため、以下のようにDLLを直接参照することで利用できるようになります。

![reference.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3867776/564461fa-20ea-fa35-49d6-044b0481228c.png)

パスはSDKのバージョンやインストール場所によって異なる可能性がありますが、私の環境では下記を参照に追加しました。
- C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8.1\UIAutomationClient.dll
- C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8.1\UIAutomationTypes.dll

これで、`System.Windows.Automation`名前空間をusingできるようになりました。

### 呼び出し側のコード
Edgeのタブを閉じる処理を1つのstaticクラスにまとめました。先にそのクラスを呼び出す側のコードを示します。引数に閉じたいタブのタイトルを指定します。複数のタブが同じタイトルを持っていれば、それらをすべて閉じます。Edgeのウィンドウが複数起動している場合も、すべてのウィンドウのタブが対象となります。
```c#
EdgeAutomation.CloseTab("タブのタイトル");
```

### Edgeのタブを閉じるクラス
呼び出される側のEdgeのタブを閉じる処理を持つクラスのコードを示します。
```c#:EdgeAutomation.cs
using System.Diagnostics;
using System.Windows.Automation;

namespace CloseEdgeTabSampleNET8;

public static class EdgeAutomation
{
	public static void CloseTab(string targetTabTitle)
	{
		if (string.IsNullOrEmpty(targetTabTitle))
		{
			return;
		}

		Process? edgeProcess = FindEdgeMainProcess();

		if (edgeProcess == null)
		{
			return;
		}

		AutomationElementCollection edgeWindows = FindEdgeWindows(edgeProcess.Id);

		if (edgeWindows.Count == 0)
		{
			return;
		}

		foreach (AutomationElement edgeWindow in edgeWindows)
		{
			AutomationElementCollection tabItems = FindEdgeTabs(edgeWindow);

			foreach (AutomationElement tabItem in tabItems)
			{
				try
				{
					if (tabItem.Current.Name.Contains(targetTabTitle))
					{
						CloseTab(tabItem);
					}
				}
				catch (ElementNotAvailableException ex)
				{
					Console.WriteLine(ex.Message);
				}
			}
		}
	}

	private static Process? FindEdgeMainProcess()
	{
		foreach (Process proc in Process.GetProcessesByName("msedge"))
		{
			if (proc.MainWindowHandle != IntPtr.Zero)
			{
				return proc;
			}
		}

		return null;
	}

	private static AutomationElementCollection FindEdgeWindows(int processId)
	{
		return AutomationElement.RootElement.FindAll(
			TreeScope.Children,
			new AndCondition(
				new PropertyCondition(
					AutomationElement.ProcessIdProperty,
					processId),
				new PropertyCondition(
					AutomationElement.ControlTypeProperty,
					ControlType.Window)));
	}

	private static AutomationElementCollection FindEdgeTabs(AutomationElement edgeWindow)
	{
		return edgeWindow.FindAll(
			TreeScope.Descendants,
			new PropertyCondition(
				AutomationElement.ControlTypeProperty,
				ControlType.TabItem));
	}

	private static AutomationElement? FindCloseButton(AutomationElement tabItem)
	{
		// バージョン 127.0.2651.98→EdgeTabCloseButton
		// バージョン 107.0.1418.52→TabCloseButton
		return tabItem.FindFirst(
			TreeScope.Descendants,
			new OrCondition(
				new AndCondition(
					new PropertyCondition(
						AutomationElement.ControlTypeProperty,
						ControlType.Button),
					new PropertyCondition(
						AutomationElement.ClassNameProperty,
						"EdgeTabCloseButton")),
				new AndCondition(
					new PropertyCondition(
						AutomationElement.ControlTypeProperty,
						ControlType.Button),
					new PropertyCondition(
						AutomationElement.ClassNameProperty,
						"TabCloseButton"))));
	}

	private static void CloseTab(AutomationElement tabItem)
	{
		AutomationElement? closeButton = FindCloseButton(tabItem);

		if (closeButton == null)
		{
			return;
		}

		InvokePattern? invokePattern =
			closeButton.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;

		if (invokePattern == null)
		{
			return;
		}

		invokePattern.Invoke();
	}
}
```
## サンプルコードの説明
UIAutomationを使ってEdgeを操作するには、まずEdgeのメインプロセスを取得します。Edgeでタブを開くとEdgeのプロセスが複数起動しますが、メインプロセスを経由してツリー構造配下の子要素にアクセスすることになるようです。
```c#
private static Process? FindEdgeMainProcess()
{
	foreach (Process proc in Process.GetProcessesByName("msedge"))
	{
		if (proc.MainWindowHandle != IntPtr.Zero)
		{
			return proc;
		}
	}

	return null;
}
```
---
次にEdgeのウィンドウを取得します。ルート配下の要素を取得するために、`AutomationElement.RootElement`というstaticオブジェクトが用意されています。`FindAll`メソッドの第一引数は、検索の範囲です。ウィンドウはプロセスの直下にあるため、直接の子のみを対象とする`Children`を指定しています。第二引数は、プロセスIDと要素の種別として`Window`を指定しています。Edgeのウィンドウが複数存在する場合、戻り値が複数となります。
```c#
private static AutomationElementCollection FindEdgeWindows(int processId)
{
	return AutomationElement.RootElement.FindAll(
		TreeScope.Children,
		new AndCondition(
			new PropertyCondition(
				AutomationElement.ProcessIdProperty,
				processId),
			new PropertyCondition(
				AutomationElement.ControlTypeProperty,
				ControlType.Window)));
}
```
---
次はウィンドウ配下のタブを取得します。タブはEdgeのツリー構造のどこに位置しているか定かでないため、`FindAll`メソッドの第一引数に子孫を含む`Descendants`を指定しています。第二引数にはコントロールの種別として`TabItem`を指定しています。
```c#
private static AutomationElementCollection FindEdgeTabs(AutomationElement edgeWindow)
{
	return edgeWindow.FindAll(
		TreeScope.Descendants,
		new PropertyCondition(
			AutomationElement.ControlTypeProperty,
			ControlType.TabItem));
}
```
---
タブを閉じるためには、タブが持つ「閉じるボタン」を探し出し、それを擬似的にクリックすることで実現します。「閉じるボタン」を見つけるにはクラス名を指定するのですが、Edgeのバージョンによって差異があるようです。私が検証できたのは2つのバージョンのみのため、他のバージョンでは動かないことがあるかもしれません。
```c#
private static AutomationElement? FindCloseButton(AutomationElement tabItem)
{
	// バージョン 127.0.2651.98→EdgeTabCloseButton
	// バージョン 107.0.1418.52→TabCloseButton
	return tabItem.FindFirst(
		TreeScope.Descendants,
		new OrCondition(
			new AndCondition(
				new PropertyCondition(
					AutomationElement.ControlTypeProperty,
					ControlType.Button),
				new PropertyCondition(
					AutomationElement.ClassNameProperty,
					"EdgeTabCloseButton")),
			new AndCondition(
				new PropertyCondition(
					AutomationElement.ControlTypeProperty,
					ControlType.Button),
				new PropertyCondition(
					AutomationElement.ClassNameProperty,
					"TabCloseButton"))));
}
 ```
 ---
最後にタブを閉じます。見つかった「閉じるボタン」を擬似的にクリックするには、`InvokePattern`というオブジェクトにキャストし、`Invoke()`メソッドをコールすることで行います。
```c#
private static void CloseTab(AutomationElement tabItem)
{
	AutomationElement? closeButton = FindCloseButton(tabItem);

	if (closeButton == null)
	{
		return;
	}

	InvokePattern? invokePattern =
		closeButton.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;

	if (invokePattern == null)
	{
		return;
	}

	invokePattern.Invoke();
}
```

## 選択しなかった代替案

### WebDriver
C#でEdgeのタブを閉じるにはどうしたらいいかChatGPTに聞くと、WebDriverの利用を進められました。確かにタブを閉じることはできたのですが、WebDriverが起動したEdgeしか操作することができませんでした。今回の要件では、ユーザーが手動で開いたEdgeも対象となっていました。もしかしたら何かやりようがあったかもしれませんが、私のスキルでは実現できず、WebDriverの利用は断念しました。


https://learn.microsoft.com/ja-jp/microsoft-edge/webdriver-chromium/?tabs=c-sharp

## おわりに
この記事では、C#とUIAutomationを使ってMicrosoft Edgeの特定のタブを閉じる方法を紹介しました。参考になった場合は、ぜひコメントやフィードバックをお願いします。もっと良い方法や改善点があれば、教えていただけると幸いです。

## 参考文献
- [【Microsoft Learn】System.Windows.Automation 名前空間](https://learn.microsoft.com/ja-jp/dotnet/api/system.windows.automation?view=windowsdesktop-8.0)
UIAutomationのAPIリファレンス
- [【Stack Overflow】How to close tabs on Microsoft Edge programmatically](https://stackoverflow.com/questions/49469807/how-to-close-tabs-on-microsoft-edge-programmatically)
Edgeのタブを閉じるプログラムのヒントになったディスカッション
- [【<<//Sqripts>】UIオートメーション〜コピペで体験Windowsアプリの自動操作](https://sqripts.com/2023/05/11/47993/)
理解につながったUIAutomationを使ったアプリの自動操作方法の解説記事