2021年12月11日土曜日

WPFのMenuを動的に生成する(もちろんMVVM)

今の時代メニューなんて流行らないんですかね。まあそういう私もRibbon UIでソフトを作ることが多いのであまりメニューを使うことも無いのですが、たまに使った際に動的なMenuを生成しようとしてググったところあまり良い記事が無かったのでここでまとめておきます。

動的なメニュー

こういうやつです。

今回はNIC(Network Interface Card)を列挙してメニュー項目として登録しています。当然NICはパソコンによって搭載しているものが違うため、ソフトウェアを実行するまで名前はわかりません。よって、ソフト実行中に(=動的に)メニュー項目を追加する必要が出てくるのです。

もちろんNICじゃなくても構いません。COMポートでも良いし、「最近使ったファイル」でも良いです。そういうところで動的なメニューが欲しくなることはしばしばあります。 

ItemsSource

さて、もはやこれが答えですが、MenuItemクラスにはItemsSourceプロパティがあります。勘のいいひとは気づくと思いますが、このプロパティに子アイテムのObservableCollectionをバインディングしてあげれば終わりです。基本的な作戦はこれになります。

<DockPanel>
	<Menu DockPanel.Dock="Top">
		<MenuItem Header="File" >
			<MenuItem Header="Open NIC" ItemsSource="{Binding NICs}" />
			<MenuItem Header="Close NIC" />
		</MenuItem>
	</Menu>
	<Grid />
</DockPanel>
public class MainWindowViewModel : ViewModel
{
	MainWindowModel model;
	public MainWindowViewModel()
	{
		model = MainWindowModel.GetInstance();
		NICs = model.NICs;
	}

	public void Initialize()
	{
		model.LoadNICList();
	}

	public ReadOnlyReactiveCollection<string> NICs { get; }

}
public class MainWindowModel
{
	#region Singleton

	static MainWindowModel? instance = null;
	public static MainWindowModel GetInstance()
	{
		if(instance == null)
			instance = new MainWindowModel();
		return instance;
	}

	#endregion

	MainWindowModel()
	{
		_NICs = new ReactiveCollection<string>();
		NICs = _NICs.ToReadOnlyReactiveCollection();
	}

	public void LoadNICList()
	{
		foreach (var capture in LibPcapLiveDeviceList.Instance.OrderBy(p => p.Interface.FriendlyName))
			_NICs.Add($"{capture.Interface.FriendlyName} ({capture.Description})");
	}

	readonly ReactiveCollection<string> _NICs;
	public ReadOnlyReactiveCollection<string> NICs { get; }
}

通知可能コレクションは私はReactivePropertyのReactiveCollection派なのでこんなコードになっていますが、そこは好みに合わせてなんでもいいです。ちなみにMVVMインフラはLivetを使っていて、NICの列挙にはSharpPcapを使っています。

一応コードの解説をしておくと、Windowロード時にViewModelのInitializeメソッドが呼ばれるため、そこでModelにNIC名の取得を指示しています。ModelではNICs通知可能コレクションにNIC名を追加し、ViewModelを介してMenuItemのItemsSourceにバインディングされます。これだけでもう最初に示した画像のような動的なメニューは完成です。

MVVM化

さて、stringのコレクションをItemsSourceにバインドすることで動的にメニューを生成することができましたが、すでに気づいている人もいるかもしれませんが、このままではそれらのメニューを押した時のイベントに対応することができません。さすがにそれではメニューとして使い物にならないでしょう。ほかにも、動的に生成したメニューを無効化したりチェックマークを入れたりしたくなることもあるかと思います。

そこで、MenuItem一つにつき一つのViewModelを持たせる方法で実装をします。MenuItemに限らず、MVVMではMainWindowViewModelで書ききれないもの、個数が変化する要素などがその典型ですが、別のViewModelを作ってバインディングしてしまうのがベストプラクティスです。

public class NICMenuItemViewModel : ViewModel
{
	NICMenuItemModel model;

	public NICMenuItemViewModel(NICMenuItemModel model)
	{
		this.model = model;

		MenuHeader = model.MenuHeader;
		NICName = model.NICName;
		
		MenuCommand = new ReactiveCommand();
		MenuCommand.Subscribe(() => model.Invoke());
	}

	public string MenuHeader { get; }
	public string NICName { get; }

	public ReactiveCommand MenuCommand { get; }
}
public class NICMenuItemModel
{
	Action<string> menuclicked;

	public NICMenuItemModel(string header, string name, Action<string> menuclicked)
	{
		MenuHeader = header;
		NICName = name;
		this.menuclicked = menuclicked;
	}

	public string MenuHeader { get; }
	public string NICName { get; }

	public void Invoke()
	{
		menuclicked?.Invoke(NICName);
	}
}

まずこれがMenuItemに対応するViewModelとModelです。MenuItemに表示されるテキストを表すMenuHeaderプロパティと、その項目に対応するNICNameプロパティを用意しています。通常は通知可能プロパティにすべきですが、不変で困らないのでそこはサボっています。

ViewModelにはさらにMenuCommandというプロパティを用意しており、メニューがクリックされたときはこのコマンドが発動するようにしています。それが押されるとModelのInvokeメソッドが呼ばれ、Model生成時にコンストラクタで渡していたActionが発動するようにしています。この辺りはEventで実装するなどアレンジは自由かと思います。

public class MainWindowModel
{
	#region Singleton

	static MainWindowModel? instance = null;
	public static MainWindowModel GetInstance()
	{
		if(instance == null)
			instance = new MainWindowModel();
		return instance;
	}

	#endregion

	MainWindowModel()
	{
		_NICs = new ReactiveCollection<NICMenuItemModel>();
		NICs = _NICs.ToReadOnlyReactiveCollection();
	}

	public void LoadNICList()
	{
		foreach (var capture in LibPcapLiveDeviceList.Instance.OrderBy(p => p.Interface.FriendlyName))
			_NICs.Add(new NICMenuItemModel($"{capture.Interface.FriendlyName} ({capture.Description})", capture.Name, name => OpenNIC(name)));
	}

	public void OpenNIC(string name)
	{
		//throw new NotImplementedException();
	}

	readonly ReactiveCollection<NICMenuItemModel> _NICs;
	public ReadOnlyReactiveCollection<NICMenuItemModel> NICs { get; }
}
public class MainWindowViewModel : ViewModel
{
	MainWindowModel model;
	public MainWindowViewModel()
	{
		model = MainWindowModel.GetInstance();
		NICs = model.NICs.ToReadOnlyReactiveCollection(p => new NICMenuItemViewModel(p));
	}

	public void Initialize()
	{
		model.LoadNICList();
	}

	public ReadOnlyReactiveCollection<NICMenuItemViewModel> NICs { get; }
}

MainWindowModelではNICロード時にNICMenuItemModelインスタンスを生成しています。MainWindowViewModelではModelのNICsプロパティの中身をModelからViewModelに変換してViewModelのNICsとして保持しています。この辺りはMVVMのお決まりパターンです。

最後にXAMLです。

<DockPanel>
	<Menu DockPanel.Dock="Top">
		<MenuItem Header="File" >
			<MenuItem Header="Open NIC" ItemsSource="{Binding NICs}" >
				<MenuItem.ItemContainerStyle>
					<Style TargetType="MenuItem">
						<Setter Property="Header" Value="{Binding MenuHeader}" />
						<Setter Property="Command" Value="{Binding MenuCommand}" />
					</Style>
				</MenuItem.ItemContainerStyle>
			</MenuItem>
			<MenuItem Header="Close NIC" />
		</MenuItem>
	</Menu>
	<Grid />
</DockPanel>

NICsプロパティをItemsSourceにバインディングしただけではメニューの表示項目はNICMenuItemViewModel.ToString()の実行結果になってしまいます。そこで、ItemContainerStyleを使い、各子要素のHeaderプロパティをViewModelのMenuHeaderプロパティ、CommandプロパティをViewModelのMenuCommandプロパティにバインディングしています。

これで意図したとおり動的に生成したメニューをクリックしたらいろいろ伝わって最終的にMainWindowModelのOpenNICメソッドが呼ばれるようになります。ここで煮るなり焼くなり好きにすれば良いでしょう。

まとめ

今回はMVVMを保ちながらWPFのMenuを動的に生成する方法を見てきました。なんだかついでにMVVMの実装実例を紹介する記事にもなってしまいましたね。

MVVMでは数が減ったり増えたりするものにはItemsSourceに要素用のViewModelを作ってバインディング、これさえ覚えておけばメニュー以外でもいろいろと応用できそうです。

2021年11月23日火曜日

弾性衝突で円周率を求める話

最近、YouTubeでこんな動画を見かけました。

2つの物体を衝突させる話です。左端には壁があり、2つの物体を並べ、右側の物体を左に向かって滑らせます。摩擦はなく、すべての衝突が弾性衝突だとすると、左と右の物体の質量比が$100^n$のとき、左の物体の衝突回数は円周率の小数第$n$位までを整数で表したものになるというのです。

少し調べてみると比較的有名なことのようですが、私は初耳でした。私はうっかり解説動画から見てしまったので、自分で考える間もなくなぜそうなるかを知ってしまったのですが、もしも初めて聞いた人は答えを見ずに自分で考えてみることをお勧めします。何もヒントなしに証明できた人がいたとしたら大したものです。

自分で考えてみる 

さて、解説動画を見ているとところどころで天才的なひらめきが出てくるのですが、いったんそれは忘れて少し考えてみましょう。

右向きに$x$軸を取り、右側の物体を物体1とし、その質量と速度をそれぞれ$m_1$, $v_1$、左側の物体を物体2としk、その質量と速度をそれぞれ$m_2$, $v_2$と置きます。

こういうのはそれぞれの物体の位置と速度を計算するとドツボにはまります。使用すべきはエネルギー保存則と運動量保存則です。2つの物体が衝突するときは弾性衝突なので2つの物体のエネルギーの合計も運動量の合計も保存します。左の物体が壁にぶつかるときは、弾性衝突なのでエネルギーは保存しますが運動量は正負反転します。

まずは2つの物体が衝突するときのことを考えてみましょう。

\[\left\{
\begin{array}{l}
\dfrac{1}{2}m_1v_1^2+\dfrac{1}{2}m_2v_2^2=k \\
m_1v_1+m_2v_2=p
\end{array}
\right. \]

何の変哲もない、ただのエネルギー保存則と運動量保存則です。私は凡人なので、これを連立させて2次方程式を解こうとしました。$v_2$を削除し$v_1$を求めます。ただの2次方程式なので解の公式を使えば求められますが、結構計算量が多くしんどい計算となります。

\[v_1=\dfrac{p\pm{m_2\sqrt{2k(\frac{1}{m_1}+\frac{1}{m_2})-\frac{1}{m_1m_2}p^2}}}{m_1+m_2}\]

ルートの中身を$m_1$と$m_2$が対称になるように整理するのがポイントです。これを運動量保存則の式に当てはめて$v_2$を計算します。

\[v_2=\dfrac{p\mp{m_1\sqrt{2k(\frac{1}{m_1}+\frac{1}{m_2})-\frac{1}{m_1m_2}p^2}}}{m_1+m_2}\]

ルートの中身を対称にしたおかげでかなりきれいに計算できました。

この式が意味するところは、「2つの物体のエネルギーの合計が$k$、運動量の合計が$p$の場合、$v_1$と$v_2$の取りうる組み合わせは2ペアある」ということになります。これはすなわち物体の衝突前の状態と衝突後の状態です。衝突の後は物体1はより右向きの速度が上がりますので、下側の符号が衝突前、上側の符号が衝突後の速度ということになりますね。

さて、物体同士が衝突した後に物体2が壁にぶつかると物体2の運動量の正負が反転します。すなわち、物体1,2の運動量の合計としては

\[p=m_1v_1-m_2v_2\]

と表せます。これを使えば、物体1,2がぶつかってから物体2が壁にぶつかるという1サイクルでの運動量の変化を漸化式で表すことができるようになります。わかりやすく、サイクル開始時点での運動量を$p_n$、終了時点での運動量を$p_{n+1}$としてみましょう。

\[\begin{eqnarray}p_{n+1}&=&m_1\dfrac{p_n+m_2\sqrt{2k(\frac{1}{m_1}+\frac{1}{m_2})-\frac{1}{m_1m_2}p_n^2}}{m_1+m_2}-m_2\dfrac{p_n-m_1\sqrt{2k(\frac{1}{m_1}+\frac{1}{m_2})-\frac{1}{m_1m_2}p_n^2}}{m_1+m_2}\nonumber\\&=&\dfrac{(m_1-m_2)p_n+2m_1m_2\sqrt{2k(\frac{1}{m_1}+\frac{1}{m_2})-\frac{1}{m_1m_2}p_n^2}}{m_1+m_2}\nonumber\end{eqnarray}\]

さて、運動量$p$に関する漸化式ができました。この漸化式のnを数えれば衝突回数がわかるはずです(※1)。終了条件はもう衝突が起こらなくなるということで、それはすなわち運動量の絶対値が初期運動量の絶対値以上になったときと言えます(※2)。

※1:nが1増えるたびに衝突は2回起こります。
※2:これは正確には壁にぶつからなくなる条件で、この後に物体同士でぶつかるかは検証する必要があります。

シミュレーション

ここまで来たので、今までの計算が正しいことを確認するために実際にプログラムを書いて漸化式を数値的に解いてみます。

double n = 7;
double v1 = -1;
double v2 = 0;
double m1 = Math.Pow(10, n * 2);
double m2 = 1;

double p = m1 * v1 + m2 * v2;
double p0abs = Math.Abs(p);
double k = (m1 * v1 * v1 + m2 * v2 * v2) / 2;

uint collision = 0;

while(true) {
	var pprev = p;
	var sqrt = CalcSqrt(p, k, m1, m2);
	p = CalcNextMomentum(sqrt, p, m1, m2);
	collision += 2;

	if(Math.Abs(p) >= p0abs) {  // 合計運動量が初期運動量の反対向きを超えていたらもう壁にはぶつからない
		// -v2 > v1 ⇔ v1 + v2 < 0 だったらもう1回物体同士でぶつかる
		if(2 * pprev + (m2 - m1) * sqrt < 0)
			collision++;
		break;
	}
}

Console.WriteLine($"Pi = {collision / Math.Pow(10, n)}");


static double CalcSqrt(double p, double k, double m1, double m2)
{
	return Math.Sqrt(2 * k * (1 / m1 + 1 / m2) - 1 / (m1 * m2) * p * p);
}

static double CalcNextMomentum(double sqrt, double p, double m1, double m2)
{
	return ((m1 - m2) * p + 2 * m1 * m2 * sqrt) / (m1 + m2);
}

余談ですがC#10.0/.NET6.0になってnamespaceやMainメソッドが省略されるようになりました。このままのコピペでコンパイルが通ります。

運動量の漸化式を計算するメソッドがCalcNextMomentumですが、平方根部分のみを別途計算するCalcSqrtメソッドを用意しています。運動量を計算していって運動量の絶対値が初期運動量の絶対値以上となったらもうこれ以上の壁への衝突は発生しないものとして、物体同士で最後にぶつかるかどうかの判定へ移ります。

この時点での2つの物体の速度は、1回前のループで計算した運動量に対して計算した$v_1$と$-v_2$になるはずです。その大小関係ですので分子のみの差をとって判定しています。ここでルート部分が再利用できるため、メソッドを分けてわざわざルート部分の値を保存していたのです。

このプログラムは小数第7位までの計算($n=7$)まで正常に動きます。それ以上は浮動小数点型の有効桁数が足りずに正確に計算できず、いつまでたってもループを抜け出せなくなってしまいます。

漸化式を解く

この漸化式解けるのか…?

まとめ

というわけで、多分私がこの問題だけを見て答えを見ていなかったら、ここまで解いて詰んで終わっていたでしょう。あの「エネルギー保存則円の方程式運動量保存則直線の方程式とみたてて平面上に表すと、衝突回数が円周運動量保存則の直線傾き角の2倍割ったものと考えることができる」とかいう天才的なひらめき、いったいどういう練習をしたら思いつけるようになるんだか。

2021年4月24日土曜日

Tap.NET 0.2.0リリース

Tap.NETをリリースしてから3年近く経ちましたが、最近個人的にまたこの仮想NICを使う機会が増えて、しばしば自分で使っています。

そうすると初めていろいろと不満が出てくるわけです。特にクリティカルなのは、世の中はとっくに.NET Frameworkはオワコン化して.NET Coreに移り変わっているというのに、このライブラリは.NET Frameworkのしかもx86ビルドを要求してきます。どう見ても過去の遺物です。

というわけで、少なくとも.NET Core 3.1以上のAnyCPUビルドで使えるようにしたいなーと思い、エッセラホイサと移植作業を行いました。C++/CLIからC#で焼き直しましたので、ほぼ完全に焼き直しています。その際、いくつか破壊的変更もしています。ご了承下さい。

使い方

Nugetからインストールしてください。.NET Core 3.1のパッケージしか入れていませんが、今後作るソフトではこれだけ入っていれば困らないでしょう。

TAP.NET 0.2.0

インスタンス生成方法

旧バージョンではGUIDを与えて初期化するコンストラクターしかありませんでしたが、今バージョンからは無引数のコンストラクラーを用意し、ワンタッチでデバイスを開けるようにしました。今まで入れていなかったのは、単にC++/CLIで実装するのが面倒だっただけですが、C#に移植することでこういうのも簡単にできるようになりました。

using var tap = new Tap();

送信方法

これは以前と何ら変わりません。

using var tap = new Tap();

var ethernetPacket = new EthernetPacket(PhysicalAddress.Parse("90-90-90-90-90-90"), PhysicalAddress.Parse("80-80-80-80-80-80"), PacketDotNet.EthernetType.None) {
    PayloadPacket = new IPv4Packet(IPAddress.Parse("192.168.1.1"), IPAddress.Parse("192.168.1.2")) {
        PayloadPacket = new UdpPacket(123, 456),
    },
};
tap.SendData(ethernetPacket.Bytes);

(これをコンパイルするにはPacket.NETが必要です)

Packet.NETの力でUDPパケットを作りだし、そのバイト列をSendDataメソッドに渡していますそれだけです。もちろんここにはEthernetパケットを渡してあげる必要があります。

受信方法

こちらは前バージョンから少し変えました。

using var tap = new Tap();

tap.DestinationMacFilter = PhysicalAddress.Parse("FF-FF-FF-FF-FF-FF");
tap.EthernetTypeFilter = TapDotNet.EthernetType.IPv4;

tap.DataReceived += (sender, e) => {
    Console.WriteLine(Packet.ParsePacket(LinkLayers.Ethernet, e.Data));
    Console.WriteLine();
};
tap.StartReceiving();

(これをコンパイルするにもPacket.NETが必要です)

データの受信を開始するにはStartReceiving()メソッド、停止するにはStopReceiving()メソッドを呼ぶように変更しています。ですがそんなに違和感のある仕様ではないでしょう。

データを受信したらDataReceivedイベントが発生しますが、イベントが大量に発生するとEventArgsのインスタンス化等でそれなりにコストがかかるため、フィルター機能を用意しています。Ethernetフレームのみを対象のフィルターで、DestinationMacFilter、SourceMacFilter、EthernetTypeFilterに何かしらの値を入れることで、その値を含むパケットを受信したときのみイベントが発生するようになります。これらのフィルターをnullにするとその項目は素通しとなります。EthernetTypeはFlagsの列挙体ですので、複数指定も可能です。細かな仕様が若干違う点もありますが、Ethernetフレームに対してフィルターを掛けられるのは以前のバージョンと同じです。

中身の実装の話

さて、C#/.NET Coreに移植するにあたっていくつか出てきた話を。

P/Invoke

C++/CLIからC#への移植と言っても、基本的にはC++/CLIから直接呼び出していたWind32APIをP/Invokeという機能でC#から呼び出すようにするだけです。TAP-Windowsを利用するためには、CreateFileW関数でデバイスをオープン、DeviceIoControl関数で有効化し、WriteFile関数でデータ送信、ReadFile関数でデータを受信、最後はCloseHandle関数でデバイスをクローズしますので、最低限この5つの関数をC#から使えるようにする必要があります。

internal class Kernel32
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    internal static extern IntPtr CreateFileW(
        [MarshalAs(UnmanagedType.LPWStr)] string filename,
        [MarshalAs(UnmanagedType.U4)] FileAccess access,
        [MarshalAs(UnmanagedType.U4)] FileShare share,
        IntPtr securityAttributes,
        [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
        [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
        IntPtr templateFile);


    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool CloseHandle(IntPtr hObject);

    [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    internal static extern bool DeviceIoControl(
        IntPtr hDevice,
        uint ioControlCode,
        [MarshalAs(UnmanagedType.LPArray)] [In] byte[] inBuffer,
        int ninBufferSize,
        [MarshalAs(UnmanagedType.LPArray)] [Out] byte[] outBuffer,
        int noutBufferSize,
        out uint bytesReturned,
        [In] IntPtr overlapped
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool WriteFile(
        IntPtr hFile, [In] byte[] lpBuffer,
        uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten,
        IntPtr lpOverlapped);

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern bool ReadFile(
        IntPtr hFile, [Out] byte[] lpBuffer,
        uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead,
        IntPtr lpOverlapped);
}

その他後述しますが、IO中断絡みでの新規対応が必要でしたので、それ周りの関数も同様に準備する必要があります。

CreateFile関数ですが、System.IO.FileAccess列挙体などの値を直接使うようです。最初、Wind32APIで渡すDWORD値をそのまま入れたらうまくデバイスが開けなくてしばらく格闘していました。

// TAPデバイスを開く
hTap = Kernel32.CreateFileW(path, FileAccess.ReadWrite, FileShare.None, IntPtr.Zero, FileMode.Open, FileAttributes.System, IntPtr.Zero);
if(hTap.ToInt64() == -1)
    throw new InvalidOperationException("Cannot open the device");

Thread.Abort()メソッドの廃止

フレームワーク側の変更として、Thread.Abort()メソッドの廃止というものがありました。.NET Frameworkでは問題なく動作していましたが、.NET Coreではサポート外となるようです。

データの受信処理では、受信用のスレッドを生成し、そこでReadFileを呼び出してパケットを受信しています。今までは終了処理時にThread.Abort()メソッドで強制的にスレッドを停止させ終了していたのですが、その方法は使えなくなってしまいました。

その代替方法はCancellationTokenとのことですが、CancellationTokenはあくまでも処理の中断要求を指示して、別スレッドがそのフラグを見たときに中断処理を行うというものです。しかし、受信用スレッドではデータの受信が無ければReadFile関数から処理は帰って来ませんので、いつまで経ってもCancellationTokenのフラグを見ることができず終了できません。

そこで、Win32APIのCancelSynchronousIo関数を使います。これは、特定のスレッドで同期的にIO処理をしている際に、処理を中断させることが出きる関数です。この関数に与えるハンドルはIOデバイスのハンドルではなくスレッドのハンドルになるので注意が必要です。

public void StopReceiving()
{
    if(IsReceiving) {
        canceltokensource!.Cancel();

        // ReadFileの中断
        var thread = Kernel32.OpenThread(0x40000000, false, threadid);
        Kernel32.CancelSynchronousIo(thread);
        Kernel32.CloseHandle(thread);

        watcher!.Join();
        watcher = null;
        canceltokensource = null;
    }
}

void ReceiveThread()
{
    threadid = Kernel32.GetCurrentThreadId();

    var buffer = new byte[1518];

    while(!canceltoken.IsCancellationRequested) {
        if(Kernel32.ReadFile(hTap, buffer, (uint)buffer.Length, out var length, IntPtr.Zero)) {
            if(Filter(buffer, length)) {
                var data = new byte[length];
                Array.Copy(buffer, data, length);
                RaiseDataReceived(data);
            }
        } else {
            var error = System.Runtime.InteropServices.Marshal.GetLastWin32Error();
            if(error != 0 && error != 995) {
                RaiseErrorHappened("ReadFile Error", error);
                break;
            }
        }
    }
}

C#で生成したスレッドではWin32のスレッドIDを知るすべはありませんので、まずはスレッド立ち上げ時にGetCurrentThreadId関数でスレッドIDを保存しておきます。

スレッド終了時は、それをもとにスレッドハンドルを取得し、CancelSynchronousIo関数を呼び出してIOの中止指示を出します。

ReadFile関数は、IOが中止されるとfalseを返し、エラーコード995(ERROR_OPERATION_ABORTED)を残します。これは意図したエラーですので、エラーイベントには流さないように配慮しています。

これにより、ReadFile関数がパケットの受信を待機している最中でも、強制的にそれを中断させてスレッドを終了させることができるようになりました。


てなところで、.NET CoreのAnyCPUからTAP-Windowsを触れるようになりました。

今までずっとprereleaseモードで公開していたからかダウンロード数はあまり伸びていませんが、類似ライブラリは今のところありませんので(多分)、それなりにみんな使ってくれないかなーと期待しています。

えっ?わざわざ仮想NICに外からパケットを流し込みたい人なんてそうそういない?そんなあ。

2021年3月21日日曜日

Directory.CreateDirectory()を実行する前にディレクトリが作成可能かを確認する

ここ数日悩んでいた件です。

C#にはDirectory.CreateDirectory()というメソッドがあり、指定したパスにディレクトリを作ることができます。しかし、当然どんなところにでも作れるわけではなく、例えば存在しないドライブに書き込もうとした、管理者権限じゃないと書き込めないフォルダだった("C:\Program Files"など)、書き込み権限の無いフォルダだったなどの理由で、このメソッドは実行時に失敗する(例外を吐く)可能性があります。

そこで、実際にディレクトリを作るわけではなく、事前にディレクトリを作ることができるのかを確認する方法を模索していました。

 

結論を言います。

良いから「ディレクトリを1回作ってみて、作れたらそのディレクトリを削除する」という方法で片付けろ。

 

いろいろ調べていたのですが、これ以上良い方法が見つかりませんでした(逆に何かいい方法知っている人いたら教えてください…)。

フォルダの書き込み権限を確認する

一応、C#でもフォルダの書き込み権限を確認することはできます。ただし、Windows依存のコードになります。

var access = new DirectoryInfo(path).GetAccessControl();
var rules = access.GetAccessRules(true, true, typeof(NTAccount));

ただし、このGetAccessControl()はFileSystemAclExtensions.GetAccessControl()拡張メソッドで、Nuget経由でパッケージをインストールする必要があります。

このGetAccessRules()メソッドが返す値が、いわゆるフォルダを右クリック→プロパティ→セキュリティ→詳細設定で出てくるフォルダの読み書き権限に相当するものとなります。

今実行中のアプリケーションとして書き込めるかどうかを知りたいだけなのに、ファイルシステムの権限情報が全部出てくるのです。今の自分はこのどれに相当するのかを見分けなければなりません。暗礁に乗り上げてきた気がしてきました。

実行中のアプリケーションのプリンシパルを取得する

実行中のアプリケーションのプリンシパルを取得しようとしたら、WindowsIdentity.Current()を呼ぶことになります。

そのメソッドが返したWindowsIdentityインスタンスのNameプロパティを見ると現在実行中のユーザー名、Groupsプロパティを見ると自身が属するグループ名が出てきます。これらを前項のプリンシパルと突き合わせれば良いのかと思いきや、どうもそれは完全ではないようです。例えば、ネットワークドライブ上のフォルダはそれでうまくいきませんでした。

 

そもそも、OS上では充分に抽象化された「ディレクトリ」いう概念でプログラムを書いていたのに、一気に具体的なファイルシステムがどうとか、Windowsのアクセスコントロール機能がどうとか、そういうレイヤーのプログラムを書くことを強いられてしまいます。世の中には多種多様な記録媒体やファイルシステムがあり、それらすべてについて正確に書き込みができるかどうかを判断するコードをこのレベルで書くのはそう簡単にできるものではありません。

ですので、最初に書いた通り、ひとまずディレクトリを作ってみて確かめるのがやはり一番いい方法という結論に私は達しました。 

ディレクトリを作って削除するのも、実際のファイルシステムを汚している感があったり、後のDeleteで誤ったフォルダを削除してしまわないように慎重にコードを書く必要があったり、そもそも例外発生前提でプログラムを書く必要があるのでそれなりに神経を使う必要があったりと、こちらも何かと苦労があります。ですから、書き込み権限を事前に確認して終わらせようという発想になっていたはずです。

はあ、こういうの、標準でDirectory.CanCreateDirectory()みたいなメソッドが用意されてワンタッチに判定してもらえるようになったりしないのかなあ。しないんだろうなあ。抽象化(=様々なOS/ファイルシステムで正常に動作するように)するのが難しいから用意されていないんだろうなあ…。

何かいい方法ご存知の方いれば教えてください。

2021年3月14日日曜日

SliderのAutoToolTipPlacementをカスタマイズする

SliderのAutoToolTip

WPFのSliderにはAutoToolTipPlacementというプロパティがあり、これにNone以外の値を設定することで、Sliderを動かしている最中に自動的につまみ(Thumb)に連動するツールチップを表示させ、値をリアルタイムに確認できるようになります。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" />

非常に手軽で強力ですね。

しかし、これが案外WPFらしからぬ機能なのです。というのもWPFはXAMLの力でUIがかなり柔軟に書けるのが特徴なはずなのですが、このツールチップのテンプレートはおろか、数値のフォーマットすら指定できません。申し訳程度にできるのは、AutoToolTipPrecisionプロパティを使って小数点以下の表示桁数を指定する程度です。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="2" />

そのため、例えばDateTimeのような、単純な数値以外を扱うSliderを作っただけで詰んでしまいます。

【MainWindow.xaml】

<Window
    x:Class="SliderTest.Views.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
    xmlns:v="clr-namespace:SliderTest.Views"
    xmlns:vc="clr-namespace:SliderTest.Views.Converters"
    xmlns:vm="clr-namespace:SliderTest.ViewModels"
    Title="MainWindow"
    Width="525"
    Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Window.Resources>
        <vc:DateTimeTickConverter x:Key="DateTimeTickConverter" />
    </Window.Resources>

    <i:Interaction.Triggers>
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodName="Initialize" MethodTarget="{Binding}" />
        </i:EventTrigger>

        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>
    </i:Interaction.Triggers>

    <Grid >
        <Slider VerticalAlignment="Center" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="0"
                Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
                Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
                Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" />
    </Grid>
</Window>

【DateTimeTickConverter.cs】

[ValueConversion(typeof(DateTime), typeof(long))]
public class DateTimeTickConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is DateTime val)
            return val.Ticks;
        else
            return DependencyProperty.UnsetValue;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is double val)
            return new DateTime((long)val);
        else
            return DependencyProperty.UnsetValue;
    }
}

【MainWindowViewModel.cs】

public class MainWindowViewModel : ViewModel
{
    public void Initialize()
    {
        var now = DateTime.Now;
        Minimum = now;
        Value = now;
        Maximum = now + TimeSpan.FromDays(1);
    }


    public DateTime Minimum
    {
        get { return _Minimum; }
        set { 
            if(_Minimum == value)
                return;
            _Minimum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Minimum;


    public DateTime Maximum
    {
        get { return _Maximum; }
        set { 
            if(_Maximum == value)
                return;
            _Maximum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Maximum;


    public DateTime Value
    {
        get { return _Value; }
        set { 
            if(_Value == value)
                return;
            _Value = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Value;
}

何の変哲もない、ただ単にDateTimeをConverter経由でSliderにバインディングするだけのプログラムです。

なのに、このようにツールチップに表示される値はDateTimeのTicksの値になってしまい、アプリユーザーにとっては無意味な数の羅列となってしまうのです。変換しているので当然なのですが、それはもちろんDateTimeがそのままではSliderのMinimum/Maximum/Valueにバインディングできないからです。

通常のWPF脳ならば、BindingのStringFormatみたいなのは無いかなとか、DataTemplateでToolTipをカスタマイズできないかなとか考えるわけです。が、どんなに探してもSliderにあるこの自動ツールチップに設定できるパラメーターは「ツールチップの表示位置」と「数値の小数点以下の桁数」のみなのです。困った困った。

先人の知恵

さて、本件ちょっとググると、まあ10年以上前にこの解決策を提示してくれている人はすぐに見つかるわけです。

Modifying the auto tooltip of a Slider 

比較的シンプルなコードで作っています。Sliderクラスを継承したFormattedSliderクラスを作り、そこにAutoToolTipFormatというプロパティを作る作戦ですね。

ただし、少しコードを読んでいくとビビります。というのも、Sliderクラス内部のprivateフィールドである「_autoToolTip」をリフレクションで無理矢理取り出し、それを改造することで数値のフォーマット化を実現しているのです。 うーん、さすがに無理矢理すぎる気が。現実的にはあまり問題無いのかもしれませんが、例えば.NETの内部実装のリファクタリングで_autoToolTipという名前が変えられただけで正常に動かないソフトになってしまいます。

それが許せる人はこれを使えば良いかもしれませんが、個人的にはちょっと禁忌に触れた黒魔術感があって嫌だなー…。

Behaviorによる実装

さて、やっと本題に入ります。私は黒魔術ではなく正攻法で攻めていきます。

上のようにSliderクラスを継承した新しいクラスを作っても良いのですが、WPFの場合は強力なカスタマイズ機能によりほとんどそのような手順は不要です。既存のクラスに添付して振る舞いを変えさせたければBehaviorでしょう。

【SliderThumbToolTipBehavior.cs】

public class SliderThumbToolTipBehavior : Behavior<Slider>
{
    ToolTip? tooltip;

    protected override void OnAttached()
    {
        base.OnAttached();

        AssociatedObject.AddHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.AddHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.AddHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        AssociatedObject.RemoveHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.RemoveHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.RemoveHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    void Thumb_DragStarted(object sender, DragStartedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb) {
            if(tooltip == null) {
                tooltip = new ToolTip();
                tooltip.Placement = PlacementMode.Custom;
                tooltip.PlacementTarget = thumb;
                tooltip.CustomPopupPlacementCallback = ToolTip_CustomPopupPlacementCallback;
            }

            thumb.ToolTip = tooltip;
            tooltip.Content = ToolTipTemplate.LoadContent();
            tooltip.IsOpen = true;
            TooltipReposition();
        }
    }

    void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
    {
        TooltipReposition();
    }

    void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb && tooltip != null) {
            tooltip.IsOpen = false;
        }
    }

    void TooltipReposition()
    {
        if(tooltip != null) {
            double temp;
            if(AssociatedObject.Orientation == Orientation.Horizontal) {
                temp = tooltip.HorizontalOffset;
                tooltip.HorizontalOffset += 0.125;
                tooltip.HorizontalOffset = temp;
            } else {
                temp = tooltip.VerticalOffset;
                tooltip.VerticalOffset += 0.125;
                tooltip.VerticalOffset = temp;
            }
        }
    }

    CustomPopupPlacement[] ToolTip_CustomPopupPlacementCallback(Size popupSize, Size targetSize, Point offset)
    {
        CustomPopupPlacement? ret = null;

        switch(Placement) {
            case AutoToolTipPlacement.TopLeft:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, -popupSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(-popupSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
            case AutoToolTipPlacement.BottomRight:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, targetSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(targetSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
        }

        if(ret != null)
            return new CustomPopupPlacement[] { ret.Value };
        else
            return Array.Empty<CustomPopupPlacement>();
    }



    public AutoToolTipPlacement Placement
    {
        get { return (AutoToolTipPlacement)GetValue(PlacementProperty); }
        set { SetValue(PlacementProperty, value); }
    }
    public static readonly DependencyProperty PlacementProperty =
        DependencyProperty.Register(nameof(Placement), typeof(AutoToolTipPlacement), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(AutoToolTipPlacement)));


    public DataTemplate ToolTipTemplate
    {
        get { return (DataTemplate)GetValue(ToolTipTemplateProperty); }
        set { SetValue(ToolTipTemplateProperty, value); }
    }
    public static readonly DependencyProperty ToolTipTemplateProperty =
        DependencyProperty.Register(nameof(ToolTipTemplate), typeof(DataTemplate), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(DataTemplate)));
}

【MainWindow.xaml】(抜粋)

<Slider VerticalAlignment="Center" 
        Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
        Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
        Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" >
    <i:Interaction.Behaviors>
        <vb:SliderThumbToolTipBehavior Placement="TopLeft" >
            <vb:SliderThumbToolTipBehavior.ToolTipTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Value, StringFormat=HH:mm:ss.fff}" />
                </DataTemplate>
            </vb:SliderThumbToolTipBehavior.ToolTipTemplate>
        </vb:SliderThumbToolTipBehavior>
    </i:Interaction.Behaviors>
</Slider>

まず、OnAttatchedで3つの添付イベントをリッスンし、つまみを握ったときにツールチップ表示、動かしたときにツールチップ位置変更、離したときにツールチップ非表示を行います。ミソは、このイベントのe.OriginalSourceにつまみ(Thumbクラス)のインスタンスが入っていると言うことですね。

このビヘイビア自体には2つの依存関係プロパティを用意しています。1つ目はPlacementで、これはSlider本家のAutoToolTipPlacementと同じ役目なので説明はいらないでしょう。2つ目はToolTipTemplateで、ツールチップの中身に表示するものを定義するテンプレートです。これを用意することにより、「値のフォーマット化」にとらわれず自由自在に表示内容を設定できるようにしています。

ツールチップの位置を変える方法ですが、残念ながら直接的に再計算を要求させる方法(ToolTip.CustomPopupPlacementCallbackを呼ばせる方法)は無いようです。ですので、不本意ながら「HorizontalOffset/VerticalOffsetを変更して戻す」という手段を用いることで再計算させています。2度再計算させちゃうので効率悪いんですがね。

XAMLではSlider本家のAutoToolTipPlacementは使わず、ビヘイビアのほうのPlacementを設定します。あとは、ToolTipTemplateにツールチップの内容を設定するだけです。

これで見事ツールチップに意図した通りのものを表示できるようになりました。めでたしめでたし。