redwarrior’s diary

C#, ASP.NET, WEB, G*などの雑多な情報

MSIX形式で配布すると、プロジェクトに含めたファイルが見つからない場合の対処方法

WPFプロジェクトに画像ファイルや、実行ファイル等を含めて、プログラムの中から参照したり、実行したりするのは珍しいことではないと思います。

Visual Studio上のソリューションエクスプローラーで対象のファイルをクリックして、プロパティから「出力ディレクトリにコピー」で、「新しい場合はコピーする」や「常にコピーする」を選択してビルドすれば、出力フォルダ(binディレクトリ配下の所定の場所)にコピーされます。

ファイル名(ディレクトリを作成している場合は、ディレクトリ名\ファイル名)を指定するだけで、プログラム上から参照したり、実行したりする事ができます。

Visual Studio上でのデバッグ実行や、出力フォルダにあるアプリケーションを実行した場合は問題ありませんでした。

しかし、それをMSIX形式にパッケージングして配布したら、ファイルが見つかりませんという例外が発生してしまったので、対処方法を調べました。

調査開始

まず、MS公式サイトを確認しましたが、よくわからなかったです。

docs.microsoft.com

次に、しばやん(id:shiba-yan)さんが、実行ファイルのパスについて書いていたので、試すことにしました。

blog.shibayan.jp

加えて、以下のサイトも参考にしながら、取得するパスを追加しました。

techinfoofmicrosofttech.osscons.jp

実際には、以下のコードで試しました。

Logger.Debug($"Assembly.GetExecutingAssembly().Location\n  -> {Assembly.GetExecutingAssembly().Location}");
Logger.Debug($"Environment.GetCommandLineArgs()[0]\n  -> {Environment.GetCommandLineArgs()[0]}");
Logger.Debug($"Environment.CurrentDirectory\n  -> {Environment.CurrentDirectory}");
Logger.Debug($"AppContext.BaseDirectory\n  -> {AppContext.BaseDirectory}");
Logger.Debug($"AppDomain.CurrentDomain.BaseDirectory\n  -> {AppDomain.CurrentDomain.BaseDirectory}");
Logger.Debug($"MainModule.FileName\n  -> {Process.GetCurrentProcess().MainModule?.FileName}");

//階層を下ってディレクトリ、ファイルを全て表示
Logger.Debug("AppDomain.CurrentDomain.BaseDirectory All Entries");
foreach (var entry in Directory.EnumerateFileSystemEntries(AppDomain.CurrentDomain.BaseDirectory,
    "*.*",
    SearchOption.AllDirectories))
{
    Logger.Debug(entry);
}
調査結果

Environment.CurrentDirectoryがC:\WINDOWS\System32を返していて、他はC:\Program Files\WindowsApps配下のアプリケーション個別のパスを返していました。

AppDomain.CurrentDomain.BaseDirectoryで得られたパスの階層を下っていくと、プロジェクトに含めたファイルがありました。

対処方法

ファイル名だけを指定した場合は、Environment.CurrentDirectoryのパス配下していると想定できるので、
AppDomain.CurrentDomain.BaseDirectoryで得られたパスを使用して、絶対パスを作成することで、プロジェクトに含まれたファイルを参照・実行する事が出来ました。

以上

(小ネタ).NET CoreのWPFアプリケーションから、.NET Frameworkのコンソールアプリケーションを実行する方法

タイトルの内容をやりたい人はあまりいないと思いますが、必要になったので調べました。

以下が作業手順になります。

  1. .NET CoreのWPFアプリケーション、.NET Frameworkのコンソールアプリケーションプロジェクトをソリューション内にそれぞれ作成します。
  2. WPFアプリケーションプロジェクトを右クリックし、追加→プロジェクト参照と選択し、コンソールアプリケーションプロジェクトを追加します。
  3. WPFアプリケーションで、ProcessクラスのStartメソッドを引数に「コンソールアプリケーションをビルドして作成されたexeファイル名」を渡して、呼び出します。

プロジェクト参照を追加することで、ビルド時にコンソールアプリケーションをビルドしたファイルを、WPFアプリケーションの出力フォルダにまとめて配置してくれるので、exeファイル名を指定だけで実行できるのがとても楽です。

補足 コマンドラインでのビルドについて

Visual Studio上で実行する場合は良いのですが、CI/CDのためにコマンドラインでビルドしたい場合、dotnet buildコマンドを実行すると、.NET Framewrokプロジェクトのビルドで失敗してしまいます。
msbuildコマンドを実行すると、ビルドが成功したため、こちらを使用しました。

動作環境

以上

Prismを使用してサブウィンドウでRegionを使用した場合に発生したエラーと対処方法・その2

一つ前の記事で、サブウィンドウでもMainWindow(親ウィンドウ)と同一のRegionManagerを使用するように設定した。そうしたら、新たな問題が発生した。

サブウィンドウを閉じて、また開くとエラーが発生する

サブウィンドウでもMainWindow(親ウィンドウ)と同一のRegionManagerを使用するようにした状態で、サブウィンドウを一度閉じて、再び親ウィンドウからサブウィンドウを開くと例外が発生した。

例外ログ

Prism.Regions.Behaviors.RegionCreationException: An exception occurred while creating a region with name 'ContentRegion'. The exception was: System.ArgumentException: Region with the given name is already registered: ContentRegion
   at Prism.Regions.RegionManager.RegionCollection.Add(IRegion region)
   at Prism.Regions.Behaviors.RegionManagerRegistrationBehavior.TryRegisterRegion()
   at Prism.Regions.Behaviors.RegionManagerRegistrationBehavior.StartMonitoringRegionManager()
   at Prism.Regions.Behaviors.RegionManagerRegistrationBehavior.OnAttach()
   at Prism.Regions.RegionBehavior.Attach()
   at Prism.Regions.RegionBehaviorCollection.Add(String key, IRegionBehavior regionBehavior)
   at Prism.Regions.RegionAdapterBase`1.AttachDefaultBehaviors(IRegion region, T regionTarget)
   at Prism.Regions.RegionAdapterBase`1.Initialize(T regionTarget, String regionName)
   at Prism.Regions.RegionAdapterBase`1.Prism.Regions.IRegionAdapter.Initialize(Object regionTarget, String regionName)
   at Prism.Regions.Behaviors.DelayedRegionCreationBehavior.CreateRegion(DependencyObject targetElement, String regionName).

どうやら、サブウィンドウのRegionを2重に設定しようとして発生しているようだ。

それならば、サブウィンドウのClosingイベントで何らかの処理をすれば良さそうだ。

試しに、Regionに設定しているViewを削除する処理を入れてみたが、エラーは変わらず。

次に、RegionManagerに登録されているRegion自体を削除する処理を入れたところ、サブウィンドウを閉じたり開いたりしても例外が発生しなくなった。

public IRegionManager MainRegionManager { get; }

public DelegateCommand ClosingCommand { get; set; }

public SubWindowViewModel(IRegionManager regionManager)
{
    MainRegionManager = regionManager;

    ClosingCommand = new DelegateCommand(ExecuteClosing);
}

private void ExecuteClosing()
{
    MainRegionManager.Regions.Remove("ContentRegion");
}

動作環境

  • .NET Core 3.1
  • Prism 7.2

以上

Prismを使用してサブウィンドウでRegionを使用した場合に発生したエラーと対処方法・その1

Prismを使用したWPFアプリケーションを開発していて、サブウィンドウでRegionを使用した場合に発生したエラーと対処方法

次の画面に遷移できない

現象

Regionを使用した画面遷移

ウィンドウのXAMLにRegionを設定し、コードビハインドで、IRegionManagerインターフェースのRegisterViewWithRegionメソッドを呼び出して、ユーザーコントロールとRegionを紐づける。

XAML

<Window x:Class="Regions.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        Title="Shell" Height="350" Width="525">
    <Grid>
        <ContentControl prism:RegionManager.RegionName="ContentRegion" />
    </Grid>
</Window>

コードビハインド

using System.Windows;

namespace Regions.Views
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            regionManager.RegisterViewWithRegion("ContentRegion", typeof(ViewA));
        }
    }
}

実行すると、ウィンドウにはユーザーコントロールの内容が表示される。

ユーザーコントロール上にあるボタンをクリックして、次の画面に遷移をしたいとする。Regionに設定されているユーザーコントロールを別のものに置き換えることで、画面遷移したように見せることが出来る。

具体的には、DIの設定時にRegisterForNavigationメソッドを使用してユーザーコントロールを登録しておき、ViewModelでIRegionManagerのRequestNavigateメソッドにRegionNameと表示したいユーザーコントロールを指定することで置き換えることができる。

App.xaml.cs(DI部分)

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterForNavigation<ViewB>();
}

XAML(遷移ボタン)

<StackPanel Orientation="Horizontal" DockPanel.Dock="Top" Margin="5" >
    <Button Command="{Binding NavigateCommand}" CommandParameter="ViewB" Margin="5">Navigate to View B</Button>
</StackPanel>

ViewModel(遷移処理)

public DelegateCommand<string> NavigateCommand { get; private set; }

public MainWindowViewModel(IRegionManager regionManager)
{
    _regionManager = regionManager;

    NavigateCommand = new DelegateCommand<string>(Navigate);
}

private void Navigate(string navigatePath)
{
    if (navigatePath != null)
        _regionManager.RequestNavigate("ContentRegion", navigatePath);
}

これはMainWindowでやると問題なく成功する(上記サンプルの詳細はこちらを参照)。

サブウィンドウでRegionを使用する

ある画面をサブウィンドウとして開くために、親ウィンドウから、サブウィンドウのShowメソッドもしくは、ShowDialogメソッドを呼び出した。

var subWindow = _container.Resolve<SubWindow>();
subWindow.ShowDialog();

サブウィンドウでも画面の切り替えをしたいので、サブウィンドウのXAMLにRegionを設定し、同様のコードを書いたが、実行すると次の画面に遷移できない(ユーザーコントロールが切り替わらない)。

対処方法

デバッグ実行でRegionManagerに登録されているRegionを調べてみると、サブウィンドウで指定したRegionNameが存在しない。 MainWindow(親ウィンドウ)でもRegionを設定して試してみると、MainWindowのRegionはあった。

ここから、ViewModelのDIで取得できるのは、MainWindowと関連付けられたRegionManagerであることがわかった。

サブウィンドウのRegionが、MainWindowのRegionManagerに登録できていない。登録がないため、RegionNameを指定しても画面遷移ができない。

調べてみると、サブウィンドウのXAMLで、RegionNameだけではなく、RegionManagerも指定出来ることがわかった。

ViewModelでDIした(MainWindowの)RegionManagerを、サブウィンドウのXAMLでRegionManagerに指定することで、 サブウィンドウでもMainWindowのRegionManagerを使用する事ができた。*1

XAML

<ContentControl prism:RegionManager.RegionName="SubContentRegion" prism:RegionManager.RegionManager="{Binding MainRegionManager}"/>

ViewModel

public IRegionManager MainRegionManager { get; }

public DelegateCommand<string> NavigateCommand { get; private set; }

public SubWindowViewModel(IRegionManager regionManager)
{
    MainRegionManager = regionManager;
    
    NavigateCommand = new DelegateCommand<string>(Navigate);
}

private void Navigate(string navigatePath)
{
    if (navigatePath != null)
        MainRegionManager.RequestNavigate("SubContentRegion", navigatePath);
}

これでRegionNameを分けることで、サブウィンドウでもRegionを使用した画面遷移が出来るようになった。

動作環境

  • .NET Core 3.1
  • Prism 7.2

以上

*1:IRegionManagerインターフェースのCreateRegionManagerメソッドを呼び出すことで、新しいRegionManagerを取得する事もできる

PasswordBoxのバリデーションをMVVMアーキテクチャで実装する

前提のはなし

WPFでマスクのついたテキストボックスを作成するには、PasswordBoxというコントロールが用意されている。

PasswordBoxに入力した値は、string型のPasswordプロパティから取得できるが、セキュリティ上はSecureString型のSecurePasswordプロパティから取得した方が良いという話になっていた。

.NET Coreでは方針が変更されて、SecurePasswordプロパティは推奨されなくなった。それならばと、Passwordプロパティを使うことにした。

Passwordプロパティは、コードビハインドで使用するならば特に障害は無いが、MVVMアーキテクチャバインディングを利用しようとすると、バインディングできるように設計されていないので、単純にバインディングする事は出来ない。

バインディングできるようにヘルパークラスを作って公開している人がいるため、それを利用させてもらう。詳しくは、以下で説明されている。

mseeeen.msen.jp

garafu.blogspot.com

本題

PasswordBoxに入力した値をViewModelのプロパティにバインディング出来るようになったので、ViewModelにバリデーションを実装した。

上記サイトとあまり変わらないが、以下に作成したヘルパークラス(PasswordBoxHelper.cs)、XAMLの使用箇所と、バリデーションを実装したViewModel(CreateUserViewModel.cs)をのせておく。

PasswordBoxHelper.cs

internal class PasswordBoxHelper : DependencyObject
{
    public static readonly DependencyProperty AttachmentProperty
        = DependencyProperty.RegisterAttached(
            "Attachment",
            typeof(bool),
            typeof(PasswordBoxHelper),
            new FrameworkPropertyMetadata(false, AttachmentProperty_Changed));

    public static readonly DependencyProperty PasswordProperty
        = DependencyProperty.RegisterAttached(
            "Password",
            typeof(string),
            typeof(PasswordBoxHelper),
            new FrameworkPropertyMetadata(default(string), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static bool GetAttachment(DependencyObject dp)
    {
        return (bool)dp.GetValue(AttachmentProperty);
    }

    public static void SetAttachment(DependencyObject dp, bool value)
    {
        dp.SetValue(AttachmentProperty, value);
    }

    public static string GetPassword(DependencyObject dp)
    {
        return (string)dp.GetValue(PasswordProperty);
    }

    public static void SetPassword(DependencyObject dp, string value)
    {
        dp.SetValue(PasswordProperty, value);
    }

    private static void AttachmentProperty_Changed(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        if (!(sender is PasswordBox passwordBox)) return;

        if ((bool)e.NewValue)
        {
            passwordBox.PasswordChanged += PasswordBox_PasswordChanged;
        }
    }

    private static void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
    {
        var passwordBox = (PasswordBox)sender;
        SetPassword(passwordBox, passwordBox.Password);
    }
}

XAML抜粋

<UserControl x:Class="MainMenu.Views.CreateUser"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:helper="clr-namespace:MainMenu.Utilities"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="500">
--省略
<PasswordBox Grid.Row="4" Grid.Column="1" VerticalAlignment="Top" HorizontalAlignment="Left"
         Width="200" Height="24"
         Validation.ErrorTemplate="{StaticResource ErrorTemplate}"
         helper:PasswordBoxHelper.Attachment="True"
         helper:PasswordBoxHelper.Password="{Binding Password, UpdateSourceTrigger=PropertyChanged}" />
--省略

CreateUserViewModel.cs

using Prism.Mvvm;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Runtime.CompilerServices;

namespace MainMenu.ViewModels
{
    public class CreateUserViewModel : BindableBase, INotifyDataErrorInfo
    {
        private readonly ErrorsContainer<string> _errors;

        private string _password;

        [Required(ErrorMessage = "パスワードを入力してください")]
        public string Password
        {
            get { return _password; }
            set
            {
                if (SetProperty(ref _password, value))
                {
                    ValidateProperty(value);
                }
            }
        }

        public CreateUserViewModel()
        {
            _errors = new ErrorsContainer<string>(RaiseErrorsChanged);
        }

        protected void ValidateProperty(object value, [CallerMemberName] string propertyName = null)
        {
            var context = new ValidationContext(this) { MemberName = propertyName };
            var validationErrors = new List<ValidationResult>();
            if (!Validator.TryValidateProperty(value, context, validationErrors))
            {
                _errors.SetErrors(propertyName, validationErrors.Select(error => error.ErrorMessage));
            }
            else
            {
                _errors.ClearErrors(propertyName);
            }
        }

        private void RaiseErrorsChanged([CallerMemberName] string propertyName = null)
        {
            ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
        }

        public IEnumerable GetErrors(string propertyName) => _errors.GetErrors(propertyName);

        public bool HasErrors => _errors.HasErrors;

        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    }
}

解説

XAML)PasswordBoxコントロールのPasswordプロパティは依存関係プロパティではないので、ヘルパークラスで添付プロパティ(Password)を作成して、VIewModelのプロパティ(Password)とバインディングする。

(PasswordBoxHelper.cs)PasswordBoxのPasswordChangedイベントに処理を追加して、PasswordBoxの値が変わったら、ヘルパークラスの添付プロパティに同じ値を入力する。

XAML)ヘルパークラスの添付プロパティと、VIewModelのプロパティがバインディングされているので、ViewModelのプロパティ(Password)が更新される。

バリデーションをしない場合は、PasswordBoxの値がバインディングしたViewModelのプロパティから取得できれば良い、つまり画面からソースへの反映ができれば良く、ViewModelのプロパティをPasswordBoxに反映する、つまりソースから画面への反映は必要無いので、バインドモードはOneWayToSourceでも良いはず。

バリデーションはTextBoxコントロールみたいに、Validation.ErrorTemplateを使用した。

バリデーションを使用した場合は、ViewModelのバリデーション結果によって、画面を更新する(メッセージの表示等)ため、バインドモードはTwoWayを指定する必要があった。指定しないとバリデーションを設定しても行われなかった。

動作環境

  • .NET Core 3.1
  • Prism 7.2

以上

TFS 2018 で MSIXの発行を行う

Visual Studio 2019からのMSIX形式のアプリパッケージの作成は、他サイトを参考にして出来るようになったので、TFS上でビルドする方法を調査しました。

一応、今回は以下の状態で始めました。

  • .NET Core 3.1の32bitのWPFアプリケーション
  • Visual Studio 2019の画面操作で、アプリパッケージの作成が一度成功している
  • 配布方法は、サイドローディング
  • コード署名証明書は、証明書ストアから選択している
  • アプリケーション バンドルの生成は、常に行う

まずは、コマンドラインでビルドをしてみましたが、さっそく以下のエラーが発生。

error NETSDK1047:
資産ファイル 'C:\Users\(省略)\obj\project.assets.json'  に '.NETCoreApp,Version=v3.1/win-x86' のターゲットがありません。
復元が実行されたこと、および 'netcoreapp3.1' がプロジェクト の TargetFrameworks に含まれていることを確認してください。
プロジェクトの RuntimeIdentifiers に 'win-x86' を組み込む必要が生じる可能性もあります。 [C:\Users\(省略).csproj]

MSIXパッケージでは、自己完結型デプロイメントをするため、RuntimeIdentifiersの設定が必要らしいです。

techcommunity.microsoft.com

csprojファイルに以下を追加します。

<RuntimeIdentifiers>win-x86</RuntimeIdentifiers>

以下のコマンドで、コマンドラインからの作成が出来ました。(Developer PowerShell for VS 2019で実行しました)

> msbuild (プロジェクト名).wapproj /p:Configuration=Release /p:AppxBundlePlatforms=x86

プロパティなしで実行することも可能で、その場合はDebug構成のx86プラットフォームが選択されていました。指定しなかったプロパティは、wapprojファイルやappxmanifestファイルの値が使用されるようです。パッケージの作成とコード署名証明書による署名も行われていました。

TFSでのビルド、発行

コマンドラインからのビルドが出来たので、いよいよTFSでのビルドに入ります。

このサイトが参考になりました。対象がTFSではなく、後継のAzure DevOps(Azure Pipelines)なので、設定ファイルがYAMLで書かれているため、読むのがちょっと大変でした。

docs.microsoft.com

この記事は、MSDN Magagine 2019 の記事の一部ですが、PDF版もダウンロード可能です。PDF版はWeb版で省略された図や画面も含まれるので、PDF版を見た方が理解しやすいです。

docs.microsoft.com

コマンドラインでのビルドが出来ているので、それをタスクに置き換えます。

ただし、TFS 2018 だと「Visual Studioのビルド」タスクが、VS 2017までしか対応していないため、MSBuildタスクを使用し、ビルドを実行するPCのMSBuild へのパスを設定する必要があったので、以下のように設定しました。

MSBuildタスク
プロジェクト
**/*.wapproj

MSBuild へのパス
C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe

プラットフォーム
x86

構成
Release

MSBuild 引数
/p:AppxPackageDir=$(build.artifactstagingdirectory)\AppxPackages /p:AppxBundlePlatforms=x86

最後に、AppxPackagesフォルダを、配布用フォルダにコピーします。発行場所は「TFS」ではなく「A file share」を指定しました。ここはClickOnceの発行と同じですね。

成果物の公開タスク
発行するためのパス
$(build.artifactstagingdirectory)\AppxPackages

成果物名
AppxPackagesの中身が格納されるフォルダ名

ファイル共有パス
成果物が格納されるフォルダの親フォルダ

動作環境

  • TFS 2018 Update 3
  • .NET Core 3.1
  • ビルドマシン:Visual Studio 2019 Professional

以上

TFS 2018 のビルドが NuGet restore タスクで失敗したので調査する

少し前にソースコードの修正や、ビルド定義の編集をしていないのに、TFSのビルドが失敗するようになっていたので調査しました。

エラーメッセージ

MSBuild auto-detection: using msbuild version '16.5.0.12403' from 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\bin'.
Use option -MSBuildVersion to force nuget to use a specific version of MSBuild.
NuGet.CommandLine.CommandLineException:
Error parsing solution file at C:\TfsBuildsAgents\VstsAgent2\_work\1\s\XXXProduct.sln:
呼び出しのターゲットが例外をスローしました。
The project file could not be loaded.
ファイルまたはアセンブリ 'Microsoft.Build.Framework, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'、またはその依存関係の 1 つが読み込めませんでした。
指定されたファイルが見つかりません。C:\TfsBuildsAgents\VstsAgent2\_work\1\s\XXXProduct.sln

エラーメッセージで調べてみると、MSBuildのバージョンに対して、指定しているNuGetのバージョンが古いらしい。使用しているのは、NuGet 4.4.1でした。

stackoverflow.com

ビルドエージェントを動かしているPCには、Visual Studio 2019がインストールされていて、エラーになる直前にアップデートした事を思い出しました。

サイトを参考にビルド定義を編集して「NuGet Tool インストーラー」で4.4.1としていたところを5.5.0に変更したところ、ビルドが成功するようになりました。

インストールされているNuGetのバージョンを確認するには、Microsoft Visual Studio のバージョン情報を見れば良いようです。

blog.house-soft.info

以上

追伸

つい最近、Visual Studio 2019 を 16.8以降にアップデートした後も、ビルドが失敗しました。その時のエラーメッセージは、Error NETSDK1005 netcoreapp3.1と出ていました。

エラーメッセージで調べると以下のサイトがヒットしました。

https://developercommunity.visualstudio.com/content/problem/1248649/error-netsdk1005-assets-file-projectassetsjson-doe.html

.NET 5対応のために、修正が入ったのかなと思い、こちらもビルド定義の「NuGet Tool インストーラー」のバージョンを5.5.0から5.8.0にしたところビルドが成功するようになりました。