なべひろBlog

プログラミングをメインに仕事に関するアレコレを発信しています。

WPFアプリケーションを自動更新してみる

はじめに

社内配布のアプリケーションで煩わしいのが更新後の配布です。

全ての人がパソコンに精通しているとは限りませんので、安全を考えるとパソコンを知っている人に依頼する事になります。

結局は誰かが煩わしい思いをするので、それなら特定のフォルダ(会社内で共通に使用しているネットワークドライブ)に新しいファイルを置き、アプリケーションが自動的にチェック後更新すれば解決できます。

最近はWPFばっかりなのでWPFで作ってみましたが、WinFormsも考え方は同じなので流用可能かと思います。

今回のサンプルはDドライブの特定のフォルダにあるファイルが最新版となり、このフォルダをチェックして更新作業を行います。

尚、記述はC#9.0までに追加された記述方法なので古いバージョンでは修正する必要があるかもしれません。

サンプルは

アップデートの考え方

アップデートは

  1. 更新されるファイル
  2. 運用中のフォルダには存在しないが更新フォルダには存在する
  3. 運用中のフォルダには存在するが更新フォルダには存在しない

の3つのケースがあります。

更新されるファイル

特に難しく考える必要はありません。

exeファイルやDLLファイルならバージョン情報で判断してもいいですし、バージョン管理が疎かな可能性もあるで日付で判断してもいいです。

バージョン情報を取得できないファイルは日付で判断するしかありません。

日付が同じでも中身が違う可能性も考えられますが、そもそもそんな手間をかけたファイルを作る意図が分からないので不要と割り切ります。

運用中のフォルダには存在しないが更新フォルダには存在する

アプリケーションの機能を更新したりNuGetで取得したファイルが更新されると増えたりする可能性があります。

これは無条件でコピーしても問題ないです。

運用中のフォルダには存在するが更新フォルダには存在しない

これは2つのパターンがあります。

一つは設定ファイルなどのアプリケーション自体が生成したファイル。

もう一つはNuGetで配布されている機能(ファイル)を使用した時、その機能がメジャーアップデートされファイル構成が大幅に変わってしまった時。

アプリケーションが生成するファイルは製作者自身が把握しているので問題はありませんね。

もう一つのNuGetで配布される機能の場合、後々「このファイル何だっけ?」といった事態になり面倒なので私は思い切って削除します。

このNuGetで配布されるファイル構成が変わって不要なファイルが存在するケースはごくまれなのであまり神経質になる事もありませんが、後で迷う事を考えると極力対処した方が良いかと思います。

以下は動作概要です。

処理メソットの引数

処理メソッドには3つの引数があります。

これは私が使いやすくするための引数なので、使い方によってアレンジした方が分かりやすいと思います。

path

これは必須ですね。

更新ファイルが存在している場所になります。

extension

チェックする拡張子を限定するか否かの設定です。

拡張子「だけ」のリストとして渡します。

通常のアプリ更新であればexeファイルとDLLファイルを更新すれば問題ない事が多いと思いますので、不要な処理を行わないためにもこの2つの文字列を引数として渡すのが無難かと思います。

exeとDLLの拡張子を指定すれば設定ファイルなどは除外されますので不用意に削除される事はありません。

holding

更新を行わないファイルの指定になります。(ファイル名+拡張子)

この記事を書いている時点でも必要か否か悩んでいる引数ではありますが、今後必要になるかもしれないので機能としては入れておきます。

ドライブが存在するか確認する

社内ネットワークであればNAS等共用するドライブが存在しているケースが多々あるかと思います。

今回はそんなケースを想定していますが、やり方は構成次第となります。

WiFi接続している状態で電波環境の悪い所ではネットワークドライブに接続できないケースがあります。

そんな状況下では更新動作をスキップする必要があるので以下のように処理します。

// 論理ドライブの一覧を取得する
List<string> drive = new(Directory.GetLogicalDrives());
if (drive.Any(d => d == Path.GetPathRoot(path)) == true)
{
	// この中で処理
}

分かりやすく書きましたが変数名driveはif文でしか使わないので省略して

if (new List<string>(Directory.GetLogicalDrives()).Any(d => d == Path.GetPathRoot(path)))
{
	// この中で処理
}

でもいいですね。

双方のリストを作成

自分のフォルダと更新ファイルが存在するフォルダにあるファイル一覧を取得します。

もっとスマートな記述があるかもしれませんが、何をやっているかを明確にするため冗長な記述になります。

List newFile = new();
List myFile = new();
// 更新するファイルの拡張子指定有無で取得方法を分ける
if (extension == null)
{
	newFile.AddRange(Directory.GetFiles(path, "*.*").Select(f => Path.GetFileName(f)).ToList());
	myFile.AddRange(Directory.GetFiles(Environment.CurrentDirectory, "*.*").Select(f => Path.GetFileName(f)).ToList());
}
else
{
	foreach (var ext in extension)
	{
		newFile.AddRange(Directory.GetFiles(path, $"*.{ext}").Select(f => Path.GetFileName(f)).ToList());
		myFile.AddRange(Directory.GetFiles(Environment.CurrentDirectory, $"*.{ext}").Select(f => Path.GetFileName(f)).ToList());
	}
}
違いがあるファイルを更新する

同一ファイルの比較、更新です。

更新する条件はバージョン情報が違う、または日付が違うのが条件です。

バージョン情報を持たないファイルはnullが返ってきますので日付での判断となります。

古いファイルですが、ここでおもむろに削除するのではなく、拡張子を変えておき例外が発生した時は戻せるようにします。

そして無事再起動できたらその後に削除します。

foreach文にある「Intersect」はMSDNの説明だと「2 つのシーケンスの積集合を生成します。」となっており、双方に存在する全てを取得します。

// どちらのフォルダーにも存在して更新するかもしれないファイルは比較して相違があればコピー
foreach (var f in myFile.Intersect(newFile))
{
	// コピーする条件はFileVersionの相違または日付の相違
	if ((FileVersionInfo.GetVersionInfo($@"{path}\{f}").FileVersion != FileVersionInfo.GetVersionInfo($@"{Environment.CurrentDirectory}\{f}").FileVersion) ||
		(File.GetLastWriteTime($@"{path}\{f}") != File.GetLastWriteTime($@"{Environment.CurrentDirectory}\{f}")))
	{
		isUpdate = true;
		// 古いファイルは異常発生時のリカバリのため今は拡張子を変えて残しておく
		File.Move($@"{Environment.CurrentDirectory}\{f}", $@"{Environment.CurrentDirectory}\{f}.delete");
		// 新しいファイルをコピー
		File.Copy($@"{path}\{f}", $@"{Environment.CurrentDirectory}\{f}", true);
	}
}
運用中のフォルダには存在しないが更新フォルダには存在するファイルのコピー

新しく追加されたDLLファイルなどをコピーします。

「双方のリストを作成」の説明で生成されたリストが基になるので拡張子が指定されていれば、その拡張子のファイルだけが対象となります。

foreach文にある「Except」はMSDNの説明だと「2 つのシーケンスの差集合を生成します。」となっており、双方に存在しない全てを取得します。

// 自分の運用フォルダには存在しないが更新ファイルには存在する一覧(無条件コピー)
foreach (var f in newFile.Except(myFile))
{
	isUpdate = true;
	File.Copy($@"{path}\{f}", $@"{Environment.CurrentDirectory}\{f}");
}
運用中のフォルダには存在するが更新フォルダには存在しないファイルの削除

不要なファイルは削除して管理の煩わしさを解消します。

但し、必要なファイルを消さないように注意してください。

メソッドの引数「holding」がnull以外の場合「Except」の二段構成で消すべきファイルの一覧から除外します。

// 自分の運用フォルダには存在するが更新ファイルには存在しない一覧(無条件削除)
if (holding == null)
{
	foreach (var f in myFile.Except(newFile))
	{
		isUpdate = true;
		File.Delete($@"{Environment.CurrentDirectory}\{f}");
	}
}
else
{
	foreach (var f in myFile.Except(holding).Except(newFile))
	{
		isUpdate = true;
		File.Delete($@"{Environment.CurrentDirectory}\{f}");
	}
}
再起動

更新が完了したらアプリケーションを再起動します。

念の為多重起動の処理も実験的に入れてみましたが(今回のサンプルには入ってません)多重起動と認識される事なく再起動できました。

再起動を識別するためコマンドライン引数として「/up」を追加しています。

次に起動したアプリケーションはこのコマンドライン引数を見て拡張子が「.delete」のファイルを削除して正常完了となります。

// 更新が発生していたら再起動
if (result == true && isUpdate == true)
{
	_ = MessageBox.Show("新しいバージョンのアプリケーションがありますので更新します。\r\n更新後は自動的に再起動しますのでしばらくお待ちください。", $"{Assembly.GetExecutingAssembly().GetName().Name} 更新", MessageBoxButton.OK, MessageBoxImage.Information);
	_ = Process.Start(Process.GetCurrentProcess().MainModule.FileName, $"/up {Environment.ProcessId}");
	Application.Current.Shutdown();
}
例外が発生したら

例外が発生したら変更前に戻すのが無難でしょう。

拡張子が「.delete」の一覧と「.delete」を除いた一覧を作成しファイル名を変更し、拡張子を戻す前に更新ファイルは削除し、その後元のファイルを復旧します。

***** 異常が発生したら.deleteファイルを復旧させる *****/
// 拡張子が.deleteの一覧取得
List<string> restoreFiles = new(Directory.GetFiles(Environment.CurrentDirectory, "*.delete"));
// 上記一覧から復旧させるファイル名を生成(「.delete」を除いたファイル名)
List<string> recoveryFiles = restoreFiles.Select(r => r.Replace(".delete", "")).ToList();
// ファイルを復旧させる
for (int i = 0; i < restoreFiles.Count; i++)
{
	// 更新したファイルは削除
	File.Delete(recoveryFiles[i]);
	// 拡張子を「.delete」としたファイルは元の名称へ
	File.Move(restoreFiles[i], recoveryFiles[i]);
}
result = false;
_ = MessageBox.Show("アップデートで問題が発生しました。\r\n以前のバージョンに戻します。", $"{Assembly.GetExecutingAssembly().GetName().Name} アップデート", MessageBoxButton.OK, MessageBoxImage.Error);
運用

サンプルではウインドウが最初に表示された時に処理を行います。

ウインドウが表示される前に処理を行うと一時的に何も表示されず使用者が「???」な状態になるからです。

private void Update()
{
	if (args.Any(a => a == "/up") == false)
	{
		// 今回は実験なので拡張子がtxtのだけ更新
		List<string> extension = new() { "txt" };
		ApplicationUpdate update = new();
		if (update.Update(@"D:\Files", extension) == true)
		{
			Title.Value += " 更新チェック完了 更新なし";
		}
	}
	else
	{
		// コマンドライン引数に「/up」があれば更新処理があったので拡張子が「delete」の古いファイルを取得し削除
		foreach (var f in Directory.GetFiles(Environment.CurrentDirectory, "*.delete"))
		{
			File.Delete(f);
		}
	}
}
さいごに

今回はクラスにしてインスタンス生成して更新処理を行っていますが、コピペでメソッドを作成して運用しても何ら問題ありません。

また、不要なファイルをどうするかは設計者の思想次第になりますので、これが正解とは限らないです。