しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

.NET Core 3.0 で WinForms / WPF を使う場合は実行ファイルのパスに注意

.NET Core 3.0 では WPF や WinForms が使えるようになっていて、配布時には大体 Self-contained かつ Single-file executables としてパッケージングするのが一般的になるはずです。

Desktop Bridge を使って APPX / MSIX を作るのに近い形ですが、よりカジュアルに扱えます。

単体の exe ファイルだけ配布すれば、実行環境に .NET Core がインストールされていなくても動作するので便利ですが、以下のように自分自身のパスを取ると挙動が .NET Framework の時と異なっています。

using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            MessageBox.Show(Application.ExecutablePath, "WinForms", MessageBoxButtons.OK, MessageBoxIcon.Information);
        }
    }
}

このコードで使っている Application.ExecutablePath は自分自身の exe のパスを返すので、ショートカットを作ったりレジストリに登録してシェル連携などで使っているケースがあると思います。

上のコードをコマンドを叩いて Self-contained かつ Single-file executables としてパッケージングします。

dotnet publish -c Release -p:PublishSingleFile=true -r win10-x64

作成された exe を実行するとダイアログが表示されますが、パスは Temp 以下を指していますし、そもそも exe ではなくて dll が返ってきているので多少混乱しそうな挙動です。

とはいえ、これは Single-file executables の設計によるものです。実体は 1 つの exe ですが、初回実行時に Temp 以下に必要なファイルが展開されて、そこから実行されるようになっています。

GitHub にドキュメントが公開されているので、読んでおくと良いと思います。

https://github.com/dotnet/designs/blob/master/accepted/single-file/design.md

Single-file executables 以外の場合でも生成される exe はランタイムホストなので、アプリケーションの実体は dll というのは変わらないですし、Application.ExecutablePath は dll のパスを返してきます。

この挙動は理解できても、先ほど挙げたような使い方をする場合に困るので調べてみると、Process.GetCurrentProcess を使うと exe 自体のパスが取れるというコメントを見つけました。

実行されている Win32 のプロセスとしてみると、.NET Core のアセンブリとか Single-file executables などに依存しないので上手い方法ですね。

とりあえず思いついただけ、実行ファイルのパスを取る方法をコンソールアプリケーションで試しました。

using System;
using System.Diagnostics;
using System.Reflection;

namespace ConsoleApp4
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine($"Assembly.Location\n  -> {typeof(Program).Assembly.Location}");
            Console.WriteLine($"Environment.GetCommandLineArgs()[0]\n  -> {Environment.GetCommandLineArgs()[0]}");
            Console.WriteLine($"AppContext.BaseDirectory\n  -> {AppContext.BaseDirectory}");
            Console.WriteLine($"AppDomain.CurrentDomain.BaseDirectory\n  -> {AppDomain.CurrentDomain.BaseDirectory}");

            Console.WriteLine($"MainModule.FileName\n  -> {Process.GetCurrentProcess().MainModule.FileName}");
        }
    }
}

Assembly.GetEntryAssembly などを使ってる例が多かったですが、何を表しているのか結構分かりにくいので使いませんでした。

まずは Single-file executables にはせず、普通にビルドして実行してみました。exe は生成されていますが、先述した通りこれはランタイムホストなので実体は dll です。

Process.GetCurrentProcess を使うと、欲しかった exe のパスがちゃんと取れています。

Single-file executables を使った場合も、結果としてはベースとなるディレクトリが違うだけでほぼ同じです。

こちらの例でも Process.GetCurrentProcess を使うと、問題なくパスが取れています。

なので、最初の WinForms の例でも同様に Process からパスを取るようにすると解決します。

using System.Diagnostics;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            MessageBox.Show(Process.GetCurrentProcess().MainModule.FileName, "WinForms", MessageBoxButtons.OK, MessageBoxIcon.Information);
        }
    }
}

ビルドして実行すると、ちゃんと exe のパスが取れていることが確認できるはずです。

そもそも、プロパティが返す値が変わっているのがおかしいという話でもありそうですが、Issue を見ている限りでは GA までに対応されるということは無さそうですし、今後もこのままな気がしました。

おまけ : CoreRT でビルドした場合

激しく現実的ではないですが、CoreRT を使ってビルドするとパッケージングではなく純粋な exe になるので、これまでの .NET Framework 上での動作に近くなります。

ネイティブコードを吐き出しているので、リフレクションを使った取り方は動作しません。当然ながら Process.GetCurrentProcess を使った方法は CoreRT でも問題なく値が取れます。