しばやん雑記

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

Build 2023 で発表されて Windows 11 Insider Preview で利用可能になった Dev Drive を試した

先月の時点で Windows 11 Insider Preview の Dev Channel では利用可能になっていて、パフォーマンス面で気になっていた点を検証していたのですが、ようやく Dev Drive について簡単にまとめる気になりました。

Build 2023 ではオフライン限定の Dev Drive ディスカッション部屋もあったので覗いてきましたが、意外に注目されていない気配を感じました。Dev Drive については記事とドキュメントが地味に充実しているので、こちらを読んでおくと大体は理解できます。

記事とドキュメントは充実していますが、とりあえず試せる環境を作らないことには始まらないので、適当な Windows 11 マシンを Dev Channel に変更すると Dev Drive が作成可能になります。セットアップ方法は以下のドキュメントが分かりやすいです。

Dev Channel っぽく日本語と英語が混在した、ローカライズが不完全な画面が多いですが利用には問題ありません。VHD を利用するか、パーティションを作成するかを選べるようになっていますが、今回はパーティションを作成することにします。

パーティションを作成する方を選ぶと、既存パーティションのリサイズが出来るので、それを使って Dev Drive 用のパーティションを新しく確保します。この後に既存パーティションのサイズをいくつにリサイズするか入力する流れです。

リサイズしてしまえば、確保した領域を Dev Drive としてフォーマットしていきます。とりあえず D ドライブとしてマウントするのが一般的になる気がしています。

Dev Drive の作成が完了すると ReFS かつ Dev Drive として作成されたことがストレージの一覧から確認できるようになります。今回は 200GB を割り当ててあるはずです。

これで Dev Drive の作成が完了したので、Git のリポジトリなど一通りコピーすれば最低限の Dev Drive の恩恵に預かれます。更に Dev Drive を活用するためには、各言語のパッケージマネージャのキャッシュディレクトリを Dev Drive に移すことが推奨されています。

今回は NuGet と npm だけで試したので、以下の 2 つのコマンドを叩きこんでキャッシュの場所を Dev Drive に変更しておきました。事前にディレクトリを作っておいた方が安全です。

setx /M NUGET_PACKAGES D:\packages\nuget
setx /M npm_config_cache D:\packages\npm

これで Dev Drive を利用する準備も出来たので、適当に Nuxt 3 のプロジェクトを作成して npm ci の実行時間を確認してみました。npm i だとネットワーク周りに依存する部分が大きくなるので、キャッシュのある状態で npm ci を実行することでストレージ周りの違いを確認したいという目論見です。

以下が試してみた結果ですが、Dev Drive 上で実行した方が 2 倍近く早く完了しています。ちなみに Dev Drive ではない方では、キャッシュのディレクトリもデフォルトのままにしています。

Dev Drive は通常のドライブよりもパフォーマンスは改善されるのは確実のようですが、WSL 2 と比べるとまだ少し遅いようです。Node.js のアプリケーション開発は Dev Drive でも頑張れるレベルになってそうですが、WSL 2 の方が未だ優勢なのは変わらないようです。

次は NuGet 周りでの確認を行った結果ですが、正直なところ大きな違いは得られませんでした。用意できたプロジェクトがそこまで大きくないこともありますが、恐らく .NET Runtime などかなり規模の大きいプロジェクトぐらいで実感できる気がします。

ただし Dev Drive で利用可能な Copy-on-Write を MSBuild や .NET CLI で有効化すると、ビルド時にコピーされるアセンブリといった中間生成物などに効果的に作用するので、トータルでのストレージ使用量を大幅に改善できるようでした。有効化するには以下のドキュメントにあるように、専用の NuGet パッケージを追加する必要があります。

簡単な適用方法はドキュメントにもあるように Directory.Packages.Props を作成して CPM を有効化し、GlobalPackageReference として参照を追加する方法です。以下のような内容のファイルを作成すれば、ビルド時に Copy-on-Write が有効になります。

<Project>
  <ItemGroup>
    <GlobalPackageReference Include="Microsoft.Build.CopyOnWrite" Version="1.0.263" />
  </ItemGroup>
</Project>

そもそも CPM が気になる方は、以下のエントリで紹介しているのでこちらを参照してください。

パッケージを追加後、C# プロジェクトのビルドで Copy-on-Write を有効にするには、MSBuild や .NET CLI のパラメータとして /p:EnableCopyOnWriteWin=true を追加するだけです。プロパティの有無によってビルド後の消費ストレージサイズが 約 1GB から 20MB にまで大幅に削減されました。

内部では以下のリポジトリの実装が使われているようですが、今年リリース予定の .NET 8 には Linux と macOS 向けには組み込みの IO に Copy-on-Write の実装が入っているようです。

Windows 向けの Copy-on-Write 実装は含まれていないので、必要な場合はこのライブラリを利用できそうですが、やはり理想的には .NET の実装として透過的に利用できるようになっていて欲しいですね。

Windows App SDK 1.3 で System Backdrop の設定と AppWindow の利用が簡単になった

先日 Windows App SDK 1.3 がリリースされて、個人的に良いアップデートだと思ったのでブログに書くことにしました。アップデート内容としてはタイトルの通りで少ないですが、Windows App SDK に対する不満の一部が解消されて嬉しいです。

ぶっちゃけてしまうと最初から実装しておいてほしいレベルなのですが、贅沢は言わないことにします。

モダンな Windows 10/11 アプリケーションには Desktop Acrylic や Mica といったエフェクトが欲しいのですが、1.3 以前のバージョンでは SDK 側のサポートが非常に薄く、関係あるのかないのかわからない謎の Interop コードを追加する必要もあり不満の残るものでした。

その当時のコードが気になる方は以下のエントリを参照してください。意味不明なコードも出てきます。

Windows App SDK 1.3 ではそのあたりが SystemBackdrop というクラスで綺麗にラップされるようになったので、シンプルに Window クラスのプロパティで設定できるようになりました。

これからは以下のようなシンプルなコードで Desktop Acrylic や Mica を有効化できます。

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        SystemBackdrop = new DesktopAcrylicBackdrop();
    }

    private void myButton_Click(object sender, RoutedEventArgs e)
    {
        myButton.Content = "Clicked";
    }
}

実行してみると Desktop Acrylic が有効化されていることが分かります。Mica の場合ぱっと見では分かりにくいので Desktop Acrylic を使っていますが、設定方法はほぼ同じです。

Windows App SDK は Windows 10 と 11 の両方に対応しているので、実際に利用する場合には以下のように Mica や Desktop Acrylic に対応しているか調べるようにした方が良さそうです。

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        if (MicaController.IsSupported())
        {
            SystemBackdrop = new MicaBackdrop();
        }
        else if (DesktopAcrylicController.IsSupported())
        {
            SystemBackdrop = new DesktopAcrylicBackdrop();
        }
    }

    private void myButton_Click(object sender, RoutedEventArgs e)
    {
        myButton.Content = "Clicked";
    }
}

このように書いておけば Mica に非対応な Windows 10 の場合は Desktop Acrylic で表示されるはずです。これまでと比較すると劇的にシンプルなコードで実現できるようになりました。

Windows App SDK を使う上で不満が大きかったのが AppWindow 周りでした。Window クラスには有用なプロパティがほぼ存在しないので、実際にウィンドウの操作を行うには AppWindow を取得して触る必要があるのですが、その AppWindow のインスタンスを取得するのに手間がかかっていました。

これまでは以下の公式ドキュメントにもある通り、わざわざ Interop コードを書いて HWND から取得する必要があるという、本気で意味が分からないコードを強要されていました。

実際に自分がイラっとした時の例を挙げると、タイトルバーに表示されているアイコンを非表示にしたい場合には、以下のように AppWindow を取得するための謎のコードを書く必要がありました。

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        // Retrieve the window handle (HWND) of the current (XAML) WinUI 3 window.
        var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this);

        // Retrieve the WindowId that corresponds to hWnd.
        var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hWnd);

        // Lastly, retrieve the AppWindow for the current (XAML) WinUI 3 window.
        var appWindow = AppWindow.GetFromWindowId(windowId);

        if (appWindow != null)
        {
            appWindow.TitleBar.IconShowOptions = IconShowOptions.HideIconAndSystemMenu;
        }
    }

    private void myButton_Click(object sender, RoutedEventArgs e)
    {
        myButton.Content = "Clicked";
    }
}

それぞれのクラスの名前空間がすべて異なるというのも、一貫性が皆無でイラっとするポイントです。

しかし Windows App SDK 1.3 からは Window クラスに AppWindow プロパティが追加されたので、先ほどと同じコードは以下のように 1 行で完結出来るようになります。

public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        AppWindow.TitleBar.IconShowOptions = IconShowOptions.HideIconAndSystemMenu;
    }

    private void myButton_Click(object sender, RoutedEventArgs e)
    {
        myButton.Content = "Clicked";
    }
}

ウィンドウの表示や非表示といった最低限必要なメソッドは全て AppWindow 側に存在しているので、これまで面倒だった処理も比較的簡単に実現できるようになりました。

正直なところ Windows App SDK 1.0 の時点で対応しておいて欲しい改善しかなかったのですが、最近の Windows App SDK のアップデートの中では一番嬉しい更新でした。

Project Volterra 改め Windows 開発キット 2023 を購入した

Build 2022 で発表されてから音沙汰がなかった Windows on ARM 開発 PC である Project Volterra ですが、突然 Windows 開発キット 2023 として発表されて、なんと日本でも発売が開始されたので購入しました。

こういった開発者向けデバイスが最初から日本でも発売されるのは珍しい予感です。

日本の Microsoft Store では価格を巡って混乱がありましたが、どうせキャンセルされると分かり切っていたので、正規の料金になったタイミングで購入したところあっという間に届きました。

あくまでも開発者向けという扱いにいるので外箱は段ボールそのままでしたが、本体は Surface と同様に質感が高く、コンシューマ向けにもそのまま発売して問題ないレベルだと感じています。

スペックについては以下の公式ドキュメントに一通りまとまっていました。最新の Windows 向け Snapdragon 8cx Gen 3 に 32GB LPDDR4x、そして 512GB NVMe SSD からは開発者向けなのを感じます。

Mini DisplayPort よりも USB-C 経由のディスプレイ出力の方が、HBR3 をサポートしているので高解像度に対応していますが、UEFI 周りで少し挙動が異なっているので注意が必要だと感じます。

Build 2021 で発表された Snapdragon Developer Kit for Windows は CPU とストレージは遅く、メモリは少ないという代物で C++ コードのビルドは無理という状態でしたが、Windows 開発キット 2023 では余裕です。

自称 Windows on ARM エンスージアストとしての活動として、メジャーな OSS ライブラリに Windows on ARM のサポートを追加する PR をちょいちょい投げているので、C++ コードのビルド速度は重要です。

完全に余談ですが、直近では OpenCV に対して Windows on ARM のビルドと ARM64 NEON をサポートするパッチを投げたところ、取り込まれて 4.6.0 としてリリースされています。

今後も Vcpkg 周りで Windows on ARM のビルドをサポートするパッチを投げていければと思っています。

初期セットアップ中に比較的負荷の高い作業として Windows Update と Firmware Update を行いましたが、本体はほんのり温かくなる程度で ARM っぽさを感じました。音がしなかったのでファンレスかと思いましたが、以下の動画で分解されたパーツが紹介されていたので確認したところ、一応ファンは付いていました。

今回はベンチマークを行いませんが、目的が実用的な速度で Windows on ARM 向けアプリの開発とデバッグが行えることなので、そこまでのパフォーマンスを求めていないという事情があります。

しかしデバイスマネージャーは面白かったので簡単に紹介しておきます。特にシステムファームウェアは Surface と名前が付いているので、実質 Surface Pro 9 5G から流用されているのではないかと思います。

Wi-Fi はデバイス名から確認出来るように Wi-Fi 6E に対応しているようですが、例によって日本固有の技適問題によって 6GHz 帯は無効化された状態のようです。技適マークは本体に印字されているので、6GHz 帯の追加対応はほぼ無いと考えておいて良さそうです。

話を戻しますが、開発キットとしてのセットアップを一通り行いました。既に Intel NUC をサンドボックス環境としてリモートデスクトップで使っていますが、今回もリモートデスクトップで画面無しで使います。

Windows on ARM 向けの開発を行うために Visual Studio 2022 Preview をインストールして、ARM64 対応のビルドツールや .NET 7 SDK など一通りセットアップしました。

ARM64 向けで選択できるワークロードはかなり少ないですが、基本はデスクトップアプリケーションの開発が目的なので問題ありませんでした。バージョンを確認すると ARM64 版であることが分かります。

早速ですが GitHub 上で開発している WinQuickLook の次期バージョンを、Windows 開発キット 2023 と Visual Studio 2022 Preview を使って、ARM64 環境下でデバッグ実行を試しておきました。

これまで Visual Studio から ARM64 向けのアプリケーションをデバッグ実行するには、面倒な設定を行ってリモートデバッグを使う必要がありましたが、開発キットのおかげで圧倒的に簡単になりました。

.NET 6 向けに開発しているので x64 環境とそこまで互換性で問題は発生しないのですが、COM Interop などネイティブ連携を利用している場合には、地味に問題となるので実機での動作確認は重要になります。*1

今回 Windows 開発キット 2023 のおかげで ARM64 向け開発ドキュメントを再確認したのですが、エミュレーション向けの ARM64EC と ARM64 を Fat binary としてビルドする ARM64X というものを知りました。

3D 眼鏡での例えは若い人には通じないのではと思いますが、x64 エミュレーションと ARM64EC、そして ARM64X が Windows on ARM の初期にあるとイメージは大きく変わっていたかも知れません。

*1:Microsoft Store の審査プロセスでは ARM64 向け確認をしていない疑惑もある

Windows App SDK 1.1 に移行したアプリを Microsoft Store で公開したらクラッシュが多発して困った話

以下のエントリで書いたように、UWP アプリを Windows App SDK 1.1 に移行したので Microsoft Store で公開したのですが、公開直後からクラッシュレポートが大量に発生してしまいました。

今は一通り対応が完了していて、大きな問題は発生していない状態なのですが、今回アプリを襲ったクラッシュ地獄について簡単にまとめたので、今後犠牲者が出ないように共有しておきます。

Windows 10 では使えない機能がある

Microsoft Store で Windows App SDK 版をリリースしたら、すぐにクラッシュが大量に発生していることがパートナーセンターから確認されました。起動時にクラッシュしていたのでかなり深刻でした。

アプリのウィンドウからアイコンを削除したかったので、IconShowOptions を使ってアイコンとシステムメニューを消す設定を追加したのですが、これが原因で Windows 10 で常に起動時にクラッシュしていました。

Windows App SDK は Windows 10 1809 以降に対応しているはずなので疑問に思っていたのですが、ドキュメントを確認すると TitleBar 周りのカスタマイズは Windows 11 限定となっていました。

Title bar customization APIs are currently supported on Windows 11 only. We recommend that you check AppWindowTitleBar.IsCustomizationSupported in your code before you call these APIs to ensure your app doesn't crash on other versions of Windows.

Title bar customization - Windows apps | Microsoft Docs

全て Windows 11 にアップグレード済みだったので、当然ながら手元では全く再現しませんでした。

ぶっちゃけ騙された気分ですし、Windows 11 でしか使えない API はコンパイル時に警告ぐらい出してほしいのですが、仕方ないので IsCustomizationSupported をチェックするコードを追加して対応しました。

if (AppWindowTitleBar.IsCustomizationSupported())
{
    appWindow.TitleBar.IconShowOptions = IconShowOptions.HideIconAndSystemMenu;
}

これが意味するのは、Windows 10 ではウィンドウからアイコンを削除する API が存在しないことです。

ドキュメントには Windows のバージョン間で一貫性があることを売りにしていますが、このように Feature Detection を書く必要があるのに一貫性があると言えるのか謎です。

後述する WebView2 でも思いましたが、基本的に新しく開発するアプリケーションは Windows 11 以降をターゲットにした方が良いと感じました。

アプリ内購入時に必ずクラッシュする

Windows 10 でのクラッシュは直りましたが、次はアプリ内購入で必ずクラッシュしていることが分かりました。マネタイズとして重要な要素となるので、ここでクラッシュするのは致命傷です。

Windows App SDK は実体が .NET 6 と WinRT Interop のキメラなので、ダイアログを表示する API は InitializeWithWindow を使って初期化する必要があるのですが、IAP に関しても同じ処理が必要でした。

ドキュメントにも記載があるのですが、Windows App Development 側にあったので気が付きませんでした。Desktop Bridge 向けなので .NET 6 という扱いですが、Windows App SDK でも基本は同じです。

ダイアログを表示するだけなのに、Windows App SDK は UWP のように簡単ではなく、Desktop Bridge と同じ追加コードが必要になるのが本当に納得出来ない部分です。もっと API を使いやすいようにラップしてくれという気持ちしか湧いてきません。

WebView2 ランタイムが存在しないケースがある

Windows App SDK では Chromium Edge ベースの WebView2 が標準で組み込まれているので、Web ページの表示は WebView2 を使う形になるのですが、WebView に比べると使い勝手が悪いです。

基本的な開発方法については公式ドキュメントを参照してください。表示だけなら簡単に使えます。

WebView2 は Chromium Edge ベースなので WebView よりも飛躍的に互換性が高まっているので便利ですが、WebView2 Runtime が別途必須になるのと初期化が非同期で行われるので実装で若干はまります。

特に開発初期には WebView2 クラスには大したメソッドやプロパティが無いことに気が付くはずです。まともなアプリを作るためには CoreWebView2 プロパティを直接触る必要が出てきますが、初期化が非同期で行われるので CoreWebView2 プロパティは null を返してくることがあります。

実際に操作を行う前に CoreWebView2 の初期化が終わっていないと面倒なので、明示的に初期化を実行するメソッドを呼び出します。これで CoreWebView2 の作成を強制できます。

これを使えば CoreWebView2not null として扱えるかと思いきや、WebView2 Runtime が存在しない場合を考慮する必要があるので null チェックは必要になります。

Windows 11 では WebView2 が Windows Update 経由で配布されているので気が付かないのですが、Windows 10 には存在しないケースがあるためチェックする必要があります。

await webView2.EnsureCoreWebView2Async();

if (webView2.CoreWebView2 is null)
{
    // WebView2 Runtime がインストールされていないとして扱う
}

初期化後に呼び出されるイベントもあるので、コンストラクタ内など await 出来ない場面で使えます。

WebView2 がインストールされていない場合などで初期化に失敗した時には、イベントの情報として例外が渡されるので成功したかどうかが判定できます。

最近になって Windows 10 にも WebView2 Runtime の配布が始まったようなので、今後は WebView2 Runtime は存在する前提で良くなりそうなのが救いです。

Microsoft の対応を見ていてわかるように Windows 11 への対応が優先されるようなので、Windows 10 のサポートを早々に切りたくなる理由がまた一つ増えました。

App Center Analytics が動作しない

最後はサービス自体の継続性が若干怪しい App Center ですが、最新版の SDK をインストールしても Analytics のデータが確認出来なくなりました。

App Cener SDK のログを出力させるとテレメトリ自体は送信成功していますが、何故か App Center から確認出来ないという謎の状況が発生しています。GitHub の Issue も上がっていますがあまり進展はないです。

Analytics の確認は出来ないですが、何故か Crashes は確認出来ているのでエラーの特定は可能です。実際に TitleBar や WebView2 のエラーの解決には役に立ってくれました。

GitHub Actions を使って Windows Containers のビルドと Web App for Containers へのデプロイを自動化する

暫く触っていなかったのですが、若干 Windows Containers が必要になりそうな気配を感じ取ったので、最新の Windows Containers 事情と Azure / GitHub での扱いについて再度確認しました。

Windows Containers を使って動かしたいアプリケーションは Classic ASP か ASP.NET のどちらかだと思うので、今回は ASP.NET をベースに話を進めていきます。Web App for Containers への ASP.NET アプリケーション移行はいくつかドキュメントが存在しています。

App Service を使えば Classic ASP と ASP.NET アプリケーションを実行できますが、特殊なライブラリやカスタムフォントのインストールが必要な場合には Windows Containers が選択肢に上がってきます。

Docker Image のカスタマイズには今回触れずに、GitHub Actions を使ったビルドとデプロイに特化して確認しています。Web App for Containers は暫く触っていない間に割と進化していました。

ここから先は ASP.NET アプリケーションを Web App for Containers にデプロイするまでの流れとなります。

フォルダー用 Publish Profile を作成

ASP.NET アプリケーションの Docker Image をビルドするためには、MSBuild を使ってアプリケーションを Release 向けにビルドする必要がありますが、全てのパラメータをコマンドライン引数で渡すのは面倒なので、Visual Studio から Publish Profile を作成しておくと楽が出来ます。

Publish Profile を作成する際に Docker Container Registry がありますが、こちらは使わずにフォルダーを選択してファイルシステムにビルド結果を出力するようにします。

ASP.NET アプリケーションの Docker Image 向けビルド時には、プリコンパイルを更新不可能な形で実施することを強くお勧めしています。これだけで Web Forms や Razor を使ったページのコールドスタートが大幅に改善するのと、Docker Image はイミュータブルなので更新可能なプリコンパイルの必要がありません。

プリコンパイルの設定は Publish Profile を作成後に設定から有効化出来ます。

ビルド時間は多少伸びますが、その分コールドスタートがかなり改善するのでメリットは大きいです。

MSBuild を使って ASP.NET アプリケーションをビルド

Publish Profile を作成後は MSBuild を使って ASP.NET アプリケーションのビルドを行いますが、今回は Multi-stage build を使う予定なので、ビルド周りの処理全てを Dockerfile 内で完結させる必要があります。

ASP.NET Core の dotnet コマンドは NuGet パッケージの復元を自動で行ってくれますが、MSBuild では明示的に指定する必要があります。更に ASP.NET では依存する NuGet パッケージの保存に packages.config が使われているので -p:RestorePackagesConfig=true という追加のオプションが必要です。

あまり情報が出てこない部分でもあるので、公式ドキュメントを紹介しておきます。

実際には .NET Framework SDK の Docker Image には NuGet CLI がインストールされているので、これまで通り nuget restore で復元を実行できますが、稀にビルド時と一致した構成での復元が必要なケースがあるので覚えておいて損はありません。*1

NuGet パッケージの復元が完了すれば、後は DeployOnBuildPublishProfile を指定すれば MSBuild が Publish Profile の設定に従ってビルドを行ってくれます。

フォルダー向けの Publish Profile を利用するとファイルシステム上に書き出されるので、Dockerfile ではその出力されたパスのファイルを全てコピーするだけで完了です。

ビルド・実行用の Dockerfile を作成

ここまでの手順で Dockerfile で必要となる設定とコマンドが確認出来ているので、後は Multi-stage build 用の Dockerfile を作成していきます。.NET Framework 向けの Dockerfile は以下のリポジトリで公開されているので、必要に応じて参照してください。

今回は .NET Framework 4.8 向けのアプリケーションなので、以下の 2 つの Docker Image を利用します。

  • mcr.microsoft.com/dotnet/framework/aspnet:4.8
    • 実行用
  • mcr.microsoft.com/dotnet/framework/sdk:4.8
    • ビルド用

ASP.NET 向けの Docker Image では IIS と関連機能の有効化と Roslyn 周りの追加が行われているだけですが、SDK では .NET Framework アプリケーションのビルドに必要な各種コンポーネントがインストールされているので、Docker Image のサイズは大きめです。

OS バージョンは指定していませんが、省略した際には GitHub Actions の Windows Hosted runner の OS バージョンが使われるので Windows Server 2022 となります。利用するコンポーネントによって OS バージョンを指定する必要がある場合は、タグ名に 4.8-windowsservercore-ltsc2022 のように OS バージョンを追加して対応します。現状では ltsc2022 / ltsc2019 / ltsc2016 のどれかになると思います。

ASP.NET Core 向けで使われる Dockerfile をベースに ASP.NET 向けに修正したものが以下のようになります。Multi-stage build を使って必要なファイルのみ実行用の Docker Image に含めるようにしています。

FROM mcr.microsoft.com/dotnet/framework/aspnet:4.8 AS base

FROM mcr.microsoft.com/dotnet/framework/sdk:4.8 AS build
WORKDIR /src
COPY . .
RUN msbuild WebApplication1.sln /Restore /p:RestorePackagesConfig=true /p:Configuration=Release /verbosity:minimal

FROM build AS publish
RUN msbuild WebApplication1.sln /p:DeployOnBuild=true /p:PublishProfile=FolderProfile /verbosity:minimal

FROM base AS final
COPY --from=publish /src/bin/publish /inetpub/wwwroot

IIS なのでビルドの最終成果物は /inetpub/wwwroot にコピーして終わりになっています。実行用の Docker Image に修正が必要な場合は、最初の FROM の後に処理を追加すれば良いです。

GitHub Actions で Docker Image のビルドとデプロイを行う

アプリケーションと Dockerfile の準備が完了したので、最後は GitHub Actions で Docker Image のビルドと Web App for Containers へのデプロイを行えば完了です。

GitHub Actions を使って Docker Image のビルドを行う場合は Buildx を使うのが一般的だと思いますが、残念ながら Windows 環境では "buildx failed with: error: Error response from daemon: Windows does not support privileged mode" というエラーで怒られてしまうので、これまで通り docker build を利用します。

name: Build and Deploy

on:
  push:
    branches:
      - master
    paths:
      - 'app/**'
      - '.github/workflows/build.yml'

  workflow_dispatch:

jobs:
  build:
    runs-on: windows-latest
    steps:
    - uses: actions/checkout@v3

    - name: Log in to container registry
      uses: docker/login-action@v1
      with:
        registry: xxxx.azurecr.io
        username: ${{ secrets.REGISTRY_USERNAME }}
        password: ${{ secrets.REGISTRY_PASSWORD }}

    - name: Build and push container image to registry
      run: |
        docker build -t xxxx.azurecr.io/wincontainer-test:${{ github.sha }} -f ./app/Dockerfile ./app/
        docker push xxxx.azurecr.io/wincontainer-test:${{ github.sha }}

  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
    - name: Deploy to Web App for Container
      uses: azure/webapps-deploy@v2
      with:
        app-name: xxxx
        slot-name: production
        publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
        images: xxxx.azurecr.io/wincontainer-test:${{ github.sha }}

基本的な Workflow の流れは Container Apps と同じように実装しました。Buildx を使っていない点と Docker Image のビルド時のみに windows-latest を使っているのが特徴的な部分です。Container Apps の Workflow については以下のエントリを参照してください。

GitHub Actions では windows-latestubuntu-latest の 2 倍時間を消費するのと、スピンアップまでの時間がかかるのでデプロイ部分は ubuntu-latest を使うようにしています。azure/webapps-deploy@v2 を使ったデプロイは OS に依存しない処理なので問題ありません。

作成した Workflow を実行すると build と deploy が実行されて、両方とも成功していることが分かります。

それなりの頻度で GitHub Actions と Azure Pipelines を使った ASP.NET / ASP.NET Core 向けの Workflow 全てで windows-latest を使っているケースを見ますが、OS に依存しない処理の場合は必ず ubuntu-latest を利用するようにしましょう。

App Service にデプロイされた結果を確認

デプロイ先の Web App for Containers の URL をブラウザで開くと、懐かしい ASP.NET MVC のテンプレートサイトが表示されます。Windows Containers は Linux ベースの Docker Image に比べると格段にサイズが大きいので起動までにはそれなりに時間がかかっていたのですが、Windows Server 2022 へのアップデートが行われたからか思ったよりも素早く起動しました。

実際の本番利用時には Deployment Slot と組み合わせる必要はあると思いますが、それでもアプリケーションの起動が早かったので割と実用的だなと感じました。

本番利用時には気を付けておきたい設定として WEBSITE_MEMORY_LIMIT_MBWEBSITE_CPU_CORES_LIMIT の 2 つがあります。ぶっちゃけ名前から想像つくと思いますが、詳細はドキュメントにまとめられているので目を通しておくと良いです。

Web App for Container (Windows) は Premium V3 のどのサイズを選んでいても、デフォルトでは 1GB のメモリしかコンテナに割り当てられないようなので、処理の内容次第ではメモリ不足となる可能性があります。

1 つの App Service Plan に 1 つの Web App for Container だけ載せるような場合には、明示的に WEBSITE_MEMORY_LIMIT_MB を指定してサイズを大きくしておかないと無駄になります。

補足 : Web App for Containers (Windows) のアップデートについて

最後に Web App for Containers (Windows) で気になった点について少しだけ書きます。まずは Windows Server 2022 に恐らくサイレントアップデートされている件です。

Hyper-V ベースの Windows Containers は下位互換性があるので、プラットフォームが Windows Server 2022 にアップデートされても 2019 や 2016 の Docker Image は正常に動作しますし、サポートもされています。

このような事情もあるので App Service チームは結構アグレッシブにプラットフォーム側のアップデートが行えるのかもしれません。次は通常の App Service も Windows Server 2022 にアップデートして欲しいです。

後はこれまで稼働中のコンテナーへのアクセスは WinRM を使う必要がありましたが、気が付いたら Azure Portal や Kudu から簡単にコンソールでの接続が出来るようになっていました。

WinRM はクライアント側に追加の設定が必要でかなり面倒でしたが、他のプラットフォームと同様にブラウザから簡単に接続できるようになったのでトラブルシューティングが容易になっています。

Windows App SDK 1.1 がリリースされたので UWP アプリを移行してビルド自動化まで対応した

Build 2022 合わせで Windows App SDK 1.1 の正式版がリリースされていたようです。あまりリリース自体が話題になっていない気がしますが、UWP アプリケーションからの移行先なので 1.1 対応を行いました。

リリースノートを見る限り、割と地味なアップデートという感があります。ぶっちゃけ 1.1 の機能の大半は 1.0 の時にやっておいて欲しかったものです。

公式ブログと窓の杜で紹介されているので共有しておきます。やはり 1.1 が実質 1.0 という感じです。

残念ながら 1.1 になっても、おまじないのような WinRT Interop 向けコードを書く必要が多く、Window の設定は XAML で書くことが出来ず AppWindow を HWND から引っ張り出してくる必要があり、これが公式の UI Framework なのかという感想を持たざるを得ないです。

Windows App SDK 2.0 や 3.0 で大化けすることを期待したいですが、途中で捨てられそうな予感があります。

Windows App SDK 拡張のインストール

気を取り直して実際に Windows App SDK 1.1 への移行を行った件について進めます。Visual Studio 2022 のアップデートと同時に最新の SDK が入っていなかったので、以下のページから C# 2022 用の拡張をダウンロードしてきてインストールしました。

普通は Windows App SDK 1.1 と同時にインストールされるっぽいですが、Single Project MSIX Packaging Tools も最新版が公開されているので、バージョンを確認しておいた方が良いです。

これで Windows App SDK 1.1 のテンプレートとパッケージングの拡張が入りました。

UWP から Windows App SDK への移行

Windows App SDK は .NET 6 ベースになっているので、新しくプロジェクトを作成してファイル一式をコピーする方法が比較的楽でした。いくつかプロジェクトがありますが UWP は元から単一プロジェクトなので、Windows App SDK でも単一プロジェクトを選んだほうが良いです。

実際にプロジェクトを作成してファイル一式をコピーするだけでは当然ながら大量にエラーが出ますが、名前空間周りは機械的に対応できるので ReSharper に大体任せて対応しました。

残りのエラーは地道に対応していくしかありませんでしたが、UWP から Windows App SDK へのマイグレーション方法は公式ドキュメントにまとめられているので、大体ここを読めば解決しました。

大体はまるケースとしては GetForCurrentView が存在しないか、正常に動作しないようになっていることかと思います。マイグレーション方法はクラス毎に用意されていたので、ドキュメント通りに WinRT Interop を使って対応することも多かったです。

WebView2 対応も別途必要になったのが割と面倒でしたが、この辺りは気合で何とかするしかないです。

Desktop Acrylic / Mica 対応

Windows App SDK 1.1 を使う最大のメリットは Desktop Acrylic と Mica のサポートなので、必ずアプリケーションに組み込んでおきたい部分です。対応方法は以前に書いたので、以下のエントリを参照してください。

1.1 の正式版になっても WinRT Interop 周りの謎コードを書く必要はあるみたいです。使いたい機能なので Window のプロパティで気軽に設定させてほしかったです。

ちなみに Windows 11 22H2 では DWM レベルで Desktop Acrylic と Mica サポートが公式に入るようです。

サンプルコードを見る限りでは Windows App SDK 1.1 で有効化するより簡単な気がしました。本当にこういうところだぞという感想しか持てないです。

Store 用パッケージを GitHub Actions でビルド

何とか Windows App SDK 1.1 への移行を終わらせた後は、GitHub Actions を使って MSIX のビルド自動化を行っておきます。コマンドベースでの MSIX 作成方法は以下のドキュメントで紹介されています。

要するに GenerateAppxPackageOnBuildtrue に設定してビルドすれば生成されます。

MSBuild を使う必要がありますが x64 向けに署名無しの MSIX を作成する場合は、以下のようなコマンドを実行すると生成されます。Store で公開する場合は署名無しで問題ありません。

UWP の時は AppxBundlePlatforms に複数アーキテクチャを指定できましたが、Windows App SDK では未対応なので単一アーキテクチャを指定してビルドする必要がありました。

msbuild .\QuickCapture.sln -p:Configuration=Release -p:Version=1.0.0.0 -p:Platform=x64 \
        -p:UapAppxPackageBuildMode=StoreUpload -p:AppxBundlePlatforms=x64 \
        -p:AppxPackageDir=..\packed\ -p:AppxBundle=Never -p:AppxPackageSigningEnabled=false \
        -p:GenerateAppxPackageOnBuild=true -p:PackageCertificateThumbprint="" \
        -verbosity:minimal

必要に応じて ReadyToRun を行っておくと良いです。.NET 6 から ReadyToRun に必要なパッケージは、明示的に PublishReadyToRun を設定して復元しないとインストールされなくなったので注意が必要です。

詳しくは以前書いた以下のエントリを参照してください。MSBuild だと復元が分かり難かったです。

MSBuild を使ってビルドを行うと UWP の時のように自動的に msixbundle や msixupload を作ってはくれず、単一のアーキテクチャ向け msix が生成されるだけなので、Store で公開する前には msixbundle を自前で作っておく必要があります。具体的には MakeAppx を使います。

GitHub Actions では MakeAppx へのパスを通すのが若干面倒なので、専用の Action を使った方が楽です。

MakeAppx は msix が入っているディレクトリを指定する必要があるので、以下のように GitHub Actions では先に msix だけコピーしてきてから、Action を使って msixbundle を作成しました。

- name: Collect msix files
  run: |
    mkdir msix
    cp ./packed/QuickCapture_${{ steps.setup_version.outputs.VERSION }}_arm64_Test/*.msix ./msix
    cp ./packed/QuickCapture_${{ steps.setup_version.outputs.VERSION }}_x64_Test/*.msix ./msix

- name: Make msixbundle
  uses: LanceMcCarthy/Action-MsixBundler@v1.0.1
  with:
    msix-folder: msix
    msixbundle-filepath: QuickCapture_${{ steps.setup_version.outputs.VERSION }}.msixbundle
    msixbundle-version: ${{ steps.setup_version.outputs.VERSION }}

- name: Upload msixbundle
  uses: actions/upload-artifact@v2
  with:
    name: msixbundle
    path: QuickCapture*.msixbundle

msixbundle にもバージョンがあるので、Action のパラメータで渡しておきます。フォーマットが x.x.x.x のように 4 つの数字で構成されるバージョンじゃないとエラーになるので注意しましょう。

以下は実際に GitHub Actions を使って msixbundle まで自動で生成している例です。半分ぐらいは Windows App SDK が msixbundle の作成と複数アーキテクチャ向けビルドに対応すれば必要なくなるものです。

既に Windows App SDK 1.1 に移行したアプリは Microsoft Store で公開されていますが、手元で再現できない謎のクラッシュが発生しているので別途対応しているところです。

UWP よりは Stack Trace はマシですが AppCenter が何故か正しく動いていないので調査しています。

Windows App SDK 1.1 Preview 3 で追加された Desktop Acrylic と Mica のサポートを試した

何故か Windows App SDK を使っても、Windows 10 や 11 で追加された Desktop Acrylic や Mica といったウィンドウ効果は適用できませんでしたが、先日リリースされた 1.1 Preview 3 でようやく対応したようです。

Windows App SDK の開発はぶっちゃけ想像以上に遅いですが、進捗はあるようなので長い目で見ないとダメのようです。以下のドキュメントに Acrylic と Mica について説明はありますが、ポップアップメニューについては Acrylic は未対応な気がしています。

実際に Windows App SDK 1.1 Preview 3 を使って Desktop Acrylic と Mica のサポートを組み込んでみましたが、ドキュメントにあるサンプルが分かり難かったので、以下の PR を見るのをお勧めします。

アプリケーションに 1.1 Preview 3 を使って Desktop Acrylic と Mica のサポートを組み込むには以下の手順が必要になります。WindowAppWindow のプロパティを変更すれば良いといったレベルではないのが残念なポイントです。

  1. 現在のスレッドに DispatcherQueueController を作成
    • Windows.System.DispatcherQueue 向けなので名前空間には注意
  2. SystemBackdropController を作成
  3. 現在の Window に対して SystemBackdropController を適用
  4. Window がアクティブ化された時に SystemBackdropController に状態を通知する

正直なところ DispatcherQueueController の作成なんてユーザーコードでやることじゃないと思うのですが、こういうところで Windows App SDK の出来の悪さが露呈している感があります。

Desktop Acrylic と Mica を有効化するコードを追加

将来的には WindowAppWindow に組み込まれると信じていますが、現状は謎のコードを書く必要があるので抽象クラスを作って隠ぺいしてしまうのが正解でしょう。

適当に実装した抽象クラスが以下のようになりました。ここには WindowsSystemDispatcherQueueHelper の実装は含まれていませんが、特に変更点はないのでサンプルからコピペすれば問題ありません。

public abstract class SystemBackdropWindow : Window
{
    protected SystemBackdropWindow()
    {
        Activated += Window_Activated;
        Closed += Window_Closed;
    }

    private readonly WindowsSystemDispatcherQueueHelper _wsdqHelper = new();

    private SystemBackdropConfiguration _configurationSource;
    private ISystemBackdropControllerWithTargets _systemBackdropController;

    protected bool TrySetSystemBackdrop()
    {
        if (MicaController.IsSupported())
        {
            SetSystemBackdropController<MicaController>();

            return true;
        }

        if (DesktopAcrylicController.IsSupported())
        {
            SetSystemBackdropController<DesktopAcrylicController>();

            return true;
        }

        return false;
    }

    private void SetSystemBackdropController<TSystemBackdropController>() where TSystemBackdropController : ISystemBackdropControllerWithTargets, new()
    {
        _wsdqHelper.EnsureWindowsSystemDispatcherQueueController();

        _configurationSource = new SystemBackdropConfiguration
        {
            IsInputActive = true,
            Theme = ((FrameworkElement)Content).ActualTheme switch
            {
                ElementTheme.Dark => SystemBackdropTheme.Dark,
                ElementTheme.Light => SystemBackdropTheme.Light,
                ElementTheme.Default => SystemBackdropTheme.Default,
                _ => throw new ArgumentOutOfRangeException()
            }
        };

        _systemBackdropController = new TSystemBackdropController();

        _systemBackdropController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
        _systemBackdropController.SetSystemBackdropConfiguration(_configurationSource);
    }

    private void Window_Activated(object sender, WindowActivatedEventArgs args)
    {
        if (_configurationSource is not null)
        {
            _configurationSource.IsInputActive = args.WindowActivationState != WindowActivationState.Deactivated;
        }
    }

    private void Window_Closed(object sender, WindowEventArgs args)
    {
        if (_systemBackdropController is not null)
        {
            _systemBackdropController.Dispose();
            _systemBackdropController = null;
        }

        Activated -= Window_Activated;

        _configurationSource = null;
    }
}

Windows App SDK のテンプレートで生成された MainWindow の基底クラスを SystemBackdropWindow に変更して、コンストラクタで TrySetSystemBackdrop を呼び出せば Mica => Desktop Acrylic の優先度で有効化されます。これで Windows 11 では Mica が、Windows 10 では Desktop Acrylic が有効になるはずです。

public sealed partial class MainWindow : SystemBackdropWindow
{
    public MainWindow()
    {
        InitializeComponent();

        if (!TrySetSystemBackdrop())
        {
            // Cannot support for system backdrop
        }
    }

    private void myButton_Click(object sender, RoutedEventArgs e)
    {
        myButton.Content = "Clicked";
    }
}

この実装では Windows 11 の場合は Mica が優先的に有効化されますが、パッと見で違いが分かり難いので Desktop Acrylic を有効化するように変更して実行したのが以下のスクリーンショットです。

ウィンドウがアクティブな場合のみ Acrylic が有効になります。非アクティブ化されるとグレーになります。

この状態で ExtendsContentIntoTitleBar を設定すると、以下のようにタイトルバー部分も Desktop Acrylic や Mica が適用されるようになります。

これで Terminal のようなタイトルバー部分も Acrylic なアプリケーションを簡単に作れるようになりました。

1.1 Preview 2 からマルチモニター環境での不具合あり

自宅のマルチモニター環境で Desktop Acrylic と Mica サポートを試していると、ウィンドウを別のモニターに移動した時に 100% クラッシュすることに気が付きました。

Windows App SDK の Issue を確認すると Preview 2 からの不具合らしく、モニターが縦置きになっている場合のみ発生するようです。今のところ修正時期は未定のようです。

現在の感触としては Windows App SDK 1.1 の正式版が出たタイミングなら、移行を検討しても良いかなと思っています。開発のペースが上がることを祈るばかりです。

Windows Property System を使って C# から曲情報を取得する

GW なので趣味アプリの開発を行っていると、音楽ファイルに保存された曲情報を取得する必要が出てきたので、Windows Property System を使って実現したという話です。

曲情報というのは以下のようにファイルのプロパティから確認出来るメタデータのことです。

単純にファイルからメタデータを読み込むだけなら TagLib# というライブラリを使うと、特に難しい処理が必要なく読み取ることが出来ます。しかし最近はアップデートがほぼ止まっていて、偶に PR はマージされていますがリリースがされていない状態が続いています。

そういう経緯もあり、別の方法を検討した結果 Windows Property System に辿り着きました。

Windows Explorer やファイルのプロパティから確認出来る各種メタデータは、Windows Property System という統一されたインターフェースで任意のアプリケーションから利用できるようになっています。

特定のファイル形式に依存しないインターフェースになっていて、Windows で対応しているファイルなら扱えるというのがポイントです。当然ながら COM で実装されているので、TagLib# 程の手軽はありません。

とはいえ必要なインターフェースは少なく、最近では CsWin32 が使えるので楽勝かと思ったのですが、必要となる PROPVARIANT 構造体が複雑すぎて少しはまりました。

メモリの利用効率を高めるために巨大な共用体として実装されているのですが、これが C# から利用する上で問題となりました。この構造体を何とかするのが Windows Property System を使う上でのメイン課題です。

CsWin32 を使ってマーシャリング無しで頑張る

令和にもなって P/Invoke 定義を全て書きたくはないので、今回も CsWin32 を使って定義を自動生成させてみます。以前に CsWin32 を使う方法は書いたので、詳細は以下のエントリを参照してください。

必要となる関数・インターフェースは SHGetPropertyStoreFromParsingNameIPropertyStore ぐらいなので、この 2 つを CsWin32 を使って自動生成させます。

生成された P/Invoke 定義を使って、曲名を取得するコードとして以下のような例を用意しました。

var filePath = @"...";

PInvoke.SHGetPropertyStoreFromParsingName(filePath, null, GETPROPERTYSTOREFLAGS.GPS_DEFAULT, typeof(IPropertyStore).GUID, out var ppv);

var propertyStore = (IPropertyStore)Marshal.GetUniqueObjectForIUnknown(new IntPtr(ppv));

propertyStore.GetValue(PInvoke.PKEY_Title, out var pvTitle);
var title = new string(pvTitle.Anonymous.Anonymous.Anonymous.pwszVal);
PInvoke.PropVariantClear(ref pvTitle);

Marshal.ReleaseComObject(propertyStore);

パッと見は問題なさそうなコードですが、実行時に TypeLoadException が発生して上手く動きません。

型名がアレなので何のことかわからなくなりそうですが、CsWin32 で自動生成された PROPVARIANT 型の読み込みに失敗しています。具体的にはマーシャリングがデフォルトで有効になっているので、値型と参照型が同じオフセットに配置されてしまうことでエラーになっています。

構造体のレイアウトの制約に関しては、以下のにぃにの記事を参照してください。公式ドキュメントでの言及は見つからなかったのですが、言われてみるとそうだなという感じです。

この PROPVARIANT 型は結構いろいろな場所で出てくるらしく、CsWin32 のリポジトリにも既にいくつか Issue が作成されていました。結論としてはマーシャリングを有効にしていると回避は不可能のようです。

仕方ないので Issue にあるようにマーシャリングを無効にし、同様のコードを書いて確認します。

CsWin32 でマーシャリングを無効にするとポインターが必須になるので unsafe な文脈でコードを書く必要があります。以下の例が動作を確認出来たコードとなります。

var filePath = @"...";

// 指定したファイルの IPropertyStore を作成
PInvoke.SHGetPropertyStoreFromParsingName(filePath, null, GETPROPERTYSTOREFLAGS.GPS_DEFAULT, typeof(IPropertyStore).GUID, out var ppv);

// 返ってきたポインタの型は void* なので IPropertyStore* にキャストする
var propertyStore = (IPropertyStore*)ppv;

// 曲名を取得
propertyStore->GetValue(PInvoke.PKEY_Title, out var pvTitle);
var title = new string(pvTitle.Anonymous.Anonymous.Anonymous.pwszVal);
PInvoke.PropVariantClear(ref pvTitle);

// アーティスト名を取得
propertyStore->GetValue(PInvoke.PKEY_Music_Artist, out var pvArtist);
var artist = new string(pvArtist.Anonymous.Anonymous.Anonymous.calpwstr.pElems[0]);
PInvoke.PropVariantClear(ref pvArtist);

// 参照カウンタを減らす
propertyStore->Release();

正直なところ C++ で書いた方が楽な気がしてくるコードです。C# で -> 演算子を使ってメソッド呼び出しを書いたのは、何年振りかも分からないぐらいです。COM も RCW が使えないので、昔ながらの Release メソッドを明示的呼び出して、参照カウンタを減らす必要があります。

このコードを実行すると、以下のように曲情報が正しく取得できていることは確認できます。

これで動くことは確認出来ましたが、CsWin32 でマーシャリングを無効にするのは影響範囲が大きすぎるのと、出来るだけ unsafe なコードと生ポインターは使いたくないので、別の方法を試すことにしました。

P/Invote 定義を手書きして unsafe コードを避ける

色々と検討した結果として、Property System に必要な P/Invoke 定義だけを仕方なく手書きすることで、全体として unsafe コードを避けるという方法を選びました。

一番の問題となる PROPVARIANT 構造体は必要な分だけ定義することでシンプルにしました。FieldOffset を使った共用体の再現は値型に統一すれば問題ないので、以下のような定義にしています。

[StructLayout(LayoutKind.Explicit)]
public struct PROPVARIANT
{
    [FieldOffset(0)]
    public ushort vt;

    [FieldOffset(2)]
    public ushort wReserved1;

    [FieldOffset(4)]
    public ushort wReserved2;

    [FieldOffset(6)]
    public ushort wReserved3;

    [FieldOffset(8)]
    public IntPtr pwszVal;

    [FieldOffset(8)]
    public CALPWSTR calpwstr;
}

[StructLayout(LayoutKind.Explicit)]
public struct CALPWSTR
{
    [FieldOffset(0)]
    public uint cElems;

    [FieldOffset(8)]
    public IntPtr pElems;
}

文字列へのポインターは Marshal.PtrToStringUni を使うことで IntPtr から string に変換します。COM は基本的に LPWSTR なので Unicode として扱うメソッドを使えばよいです。

これで単純な文字列であればサクッと C# から扱えるように出来ます。

少し厄介なのが CALPWSTR 型の pElems です。名前からも想像がつくように文字列へのポインターの配列なので、実際には cElems 個のポインターが含まれています。C++ なら pElems[0] のように参照すれば良いですが、unsafe 以外ではそのように書けないので Marshal.ReadIntPtr を使って実装します。

これで実際の文字列を指すポインターを取得できるので、後は string に変換してあげればよいです。

同様の処理を CsWin32 のマーシャリングが有効な環境で実現したコードが以下の通りです。まだこっちの方が C# っぽさが残っていると思います。

var filePath = @"...";

// 指定したファイルの IPropertyStore を作成
PInvoke.SHGetPropertyStoreFromParsingName(filePath, null, GETPROPERTYSTOREFLAGS.GPS_DEFAULT, out IPropertyStore propertyStore);

// 曲名を取得
propertyStore.GetValue(PInvoke.PKEY_Title, out var pvTitle);
var title = Marshal.PtrToStringUni(pvTitle.pwszVal);
PInvoke.PropVariantClear(ref pvTitle);

// アーティスト名を取得
propertyStore.GetValue(PInvoke.PKEY_Music_Artist, out var pvArtist);
var artist = Marshal.PtrToStringUni(Marshal.ReadIntPtr(pvArtist.calpwstr.pElems));
PInvoke.PropVariantClear(ref pvArtist);

// RCW のカウントを減らす
Marshal.ReleaseComObject(propertyStore);

このコードを実行してみると、ファイルから正しく曲情報が取得できていることが確認出来ます。

今回は出来るだけ unsafe を使いたくなかったので Marshal クラスのメソッドを使って実現しましたが、大人しくマーシャリング無しでポインターを触った方が分かりやすいと思う人もいると思います。

この辺りは好みに寄りそうな気がするので、それぞれの使い方を知っておいて損はなさそうです。

CsWin32 で Win32 API や COM を使ったアプリケーション開発を効率化する

これまで P/Invoke 定義はドキュメントやヘッダーファイルから手書きすることが多かったのですが、最近は必要な Win32 API や COM インターフェースが多くなってきたので CsWin32 を使って自動生成しています。

CsWin32 自体は win32metadata というプロジェクトの成果物となる winmd ファイルから、必要なものだけを Source Generator で生成するツールになっています。

生成してほしい Win32 API と COM インターフェースに関係する構造体と定数も自動生成されるので、依存関係を自分でチェックする必要もないのが最高です。

詳しい説明やセットアップ方法は公式の GitHub リポジトリに任せて、ここからは実際に CsWin32 を使ってアプリケーションを開発する際の注意点と効率化のポイントを書いていきます。

CsWin32 用のプロジェクトを別途作る

手書きで P/Invoke 定義を書いていた時は出来るだけポインターを使わないように書いてきたと思いますが、CsWin32 ではパフォーマンスと効率が重視されているので結構ポインターが出てくることが多いです。

ポインターを含んだコードをビルドするには unsafe を追加する必要があるので、アプリケーションから使う場合には意識したくない部分です。なので以下のように CsWin32 と unsafe なコードはクラスライブラリから切り出してしまうとスッキリします。

プロジェクトの RootNamespaceWindows.Win32 にしておけば追加のコードも同じ名前空間に定義できるので、自動生成される partial クラスにメソッドを追加する際にも便利です。

最大のメリットはメインプロジェクトでビルドエラーが発生しても、CsWin32 と Source Generator はクラスライブラリに閉じているので P/Invoke 周りの定義が巻き込まれずに済むことです。ビルドエラーになると Source Generator で生成されたコードもエラー扱いになるのがストレスフルでした。

PWSTR な引数は Span<char> に置き換える

Win32 API を使っていると頻発するパターンとして、呼び出し側が必要サイズのメモリを確保して、そのポインターを API に渡すことで結果を受け取るという挙動があります。

特に文字列を受け取る場合には StringBuilder を使うことが多かったのですが、最近では非推奨になっているので別の方法を使う必要があります。

ドキュメントにはポインターや char の配列を使う例がありますが、今の時代では Span<T> を使って解決するのがベストです。P/Invoke 定義に直接渡すことは出来ませんが、fixed を使ってポインターを取得できるので CsWin32 との相性が良いです。

例として AssocQueryString に対して Span<T> を使うオーバーロードを追加します。

引数にある pszOut が結果が書き込まれるポインターになるので、これを Span<T> に書き換えたオーバーロードとして以下のような定義を追加します。

public static unsafe HRESULT AssocQueryString(uint flags, ASSOCSTR str, string pszAssoc, string pszExtra, Span<char> pszOut, ref uint pcchOut)
{
    fixed (char* pszOutLocal = pszOut)
    {
        return AssocQueryString(flags, str, pszAssoc, pszExtra, new PWSTR(pszOutLocal), ref pcchOut);
    }
}

やっていることは難しくなく、単純に Span<T> のポインターを固定して渡しているだけです。

定義したメソッドを呼び出すサンプルは以下のようになります。この例では C# 拡張子に関連づいたアプリケーションのパスを取得しています。

// stackalloc で書き込み先のメモリを確保して Span<T> に入れる
Span<char> pszOut = stackalloc char[260];

// 確保したメモリのサイズ
uint pcchOut = 260;

PInvoke.AssocQueryString(ASSOCF.ASSOCF_NOTRUNCATE, ASSOCSTR.ASSOCSTR_EXECUTABLE, ".cs", null, pszOut, ref pcchOut);

// string は \0 があっても無視してサイズ分確保してしまうので \0 で切る
var result = new string(pszOut.TrimEnd('\0'));

最後に文字列化する必要がある分 StringBuilder の時よりパッと見は冗長に見えますが、こちらの方が圧倒的にパフォーマンスが優れています。メモリ確保に stackalloc が使えるので効率的でもあります。

ちなみに PWSTR には AsSpan というメソッドが用意されているので、逆方向の場合は unsafe コードを使うことなく安全に扱えるようになっています。

COM オブジェクトは特定の型にキャストして返す

CsWin32 はデフォルトで COM オブジェクトのマーシャリングが有効化されているので、COM オブジェクトを返す関数はマーシャリング対応のオーバーロードが同時に生成されますが、型が object 固定になってしまうので使い勝手がよくありません。

そこで以下のようなオーバーロードを追加すると、COM インターフェースを指定するだけで IID の解決と、適切な型へのキャスト済みオブジェクトを受け取れるようになります

public static HRESULT SHCreateItemFromParsingName<T>(string pszPath, System.Com.IBindCtx pbc, out T ppv)
{
    var hr = SHCreateItemFromParsingName(pszPath, pbc, typeof(T).GUID, out var o);
    ppv = (T)o;
    return hr;
}

メソッドを呼び出す際に out 引数で型を明示的に指定すれば、キャスト不要でとてもスッキリします。

PInvoke.SHCreateItemFromParsingName("...", null, out IShellItem? shellItem);

殆どのケースでは自動的にマーシャリング用のオーバーロードが追加されますが、たまにマーシャリングされておらずポインタを直接返すメソッドしか生成されないことがあります。

最近だと SHGetPropertyStoreFromParsingName という関数は void**out void* のオーバーロードしか生成されませんでした。そういった場合は以下のようなオーバーロードを追加すると使いやすくなります。

public static unsafe HRESULT SHGetPropertyStoreFromParsingName<T>(string pszPath, System.Com.IBindCtx pbc, GETPROPERTYSTOREFLAGS flags, out T ppv)
{
    var hr = SHGetPropertyStoreFromParsingName(pszPath, pbc, flags, typeof(T).GUID, out var o);
    ppv = (T)Marshal.GetUniqueObjectForIUnknown(new IntPtr(o));
    return hr;
}

返ってきた COM オブジェクトへのポインタを Marshal.GetUniqueObjectForIUnknown を呼び出して RCW で包みます。あとはキャストするだけで欲しいオブジェクトを得ることが出来ます。

preserveSigMethods は指定した方が分かりやすい

CsWin32 のコード生成に関する設定は NativeMethods.json に書くことになっていて、デフォルトでも大体いい感じの設定になっているのですが preserveSigMethods だけは設定した方が扱いやすくなります。

具体的には HRESULT を自動的に COMException に変換するかどうかという設定なのですが、HRESULT のまま判定した方が例外のハンドリングを行うよりコードがスッキリしますし、例外が必要な場合は HRESULTThrowOnFailure メソッドが用意されているので同等の処理は簡単に書けます。

if (PInvoke.SHCreateItemFromParsingName("...", null, out IShellItem? shellItem).Succeeded)
{
    // IShellItem の作成に成功した場合はここにくる

    // RCW の解放もスコープが決まるのでわかりやすい
    Marshal.ReleaseComObject(shellItem);
}

// COMException が必要なら ThrowOnFailure を明示的に呼び出す
PInvoke.SHCreateItemFromParsingName("...", null, out IShellItem? shellItem).ThrowOnFailure();

1 つぐらいの呼び出しならともかく、これが複数の COM オブジェクトが必要な場合は例外ハンドリングがかなりカオスになるので、あえて HRESULT のまま使うのが簡単です。

可能な限りハンドル系は SafeHandle として扱う

Win32 API を使っている限り避けては通れないのが HWNDHBITMAP といったハンドル系です。あらゆる場面で必要になりますが CsWin32 は自動的に SafeHandle を実装したクラスを生成してくれます。

今回はメッセージフックを行うために SetWindowsHookEx を呼び出す例を挙げてみます。

通常なら HHOOK を返しているのを IntPtr に置き換えて P/Invoke 定義を用意するケースですが、CsWin32 では UnhookWindowsHookExSafeHandle というクラスを自動生成してくれるので、以下のようなシンプルなコードでフックの解放まで行えます。

// この場合は GetModuleHandle も SafeHandle を返している
var hook = PInvoke.SetWindowsHookEx(_idHook, HookProc, PInvoke.GetModuleHandle((string)null!), 0);

// Close メソッドが UnhookWindowsHookEx を呼び出してくれる
hook.Close();

ハンドルであれば自動的にオーバーロード含めて生成されるので Bitmap 周りを扱う場合にも便利です。ただし WPF の世界に持ち込む際には DangerousGetHandle を呼び出す必要があります。

ARM64 向けのビルド時に一部の定義が生成されない問題

時々ですが Win32 API には 32bit と 64bit で構造体のサイズが変わるというケースが存在します。

このようなケースに CsWin32 自体は問題なく対応されているのですが、ARM64 向けに関しては以下のような警告が出て、特定の定義が生成されない問題があります。

ちなみに x86 と x64 では問題ないので Issue を作成したところ、MSBuild 側の問題であることが発覚しました。ARM64 向けの場合のみ PlatformTarget が定義されずに正しくコード生成が行われないようです。

いつかは MSBuild 側で対応されるはずですが、それまでは CsWin32 のプロジェクトファイルに以下のような定義を追加することで、欠けた PlatformTarget を補って正しくコード生成出来るようになります。

  <PropertyGroup Condition=" '$(_PlatformWithoutConfigurationInference)' == 'ARM64' ">
    <PlatformTarget Condition=" '$(PlatformTarget)' == '' ">ARM64</PlatformTarget>
  </PropertyGroup>

最後に ARM64 向けにビルドしている人がほぼ居ないことが発覚してしまった感がありますが、CsWin32 を使うと P/Invoke での面倒な定義を自動化かつ整理された名前空間で利用できるようになるのでお勧めです。

Win32 API や COM 自体が膨大すぎるので、生成されたコードが正しく動作しないという問題も頻発するのですが、GitHub に Issue を立てれば解決出来るものも多いので改善に貢献していきたいですね。

Snapdragon Developer Kit for Windows こと ECS LIVA Mini Box QC710 Desktop を購入した

Build 2021 で夏に Microsoft Store 経由で発売と言っていた Snapdragon Developer Kit for Windows が 11 月中旬にようやく発売になりました。ずいぶん長い夏でしたね。

噂通り ECS のミニ PC だった訳ですが、Windows on ARM 周りに興味がありハワイまで Surface Pro X を買いに行ったぐらいの人間なので、これも購入しました。

200 ドルちょいで検証用に常時稼働させて、必要があればリモートデスクトップで入れるマシンがあるのは便利です。Windows 11 が降ってくれば、GitHub の Self hosted runner としても使えるはずです。

購入から到着までの流れ

例によって見事におま国を食らっているので、Microsoft Store から直接日本に発送は出来ません。日本の MSA でもカードに入れることは可能ですが、発送先は US に限定されていました。

コロナが無ければ US に遊びに行くついでに購入していましたが、流石に今回は転送サービスを使うことにしました。とりあえず昔にアカウントだけ作っておいた Planet Express を使いました。

これで発送先はクリア出来るはずですが、サービスによっては転送サービスの住所は NG を食らうことがあります。特にクレジットカードは US 発行のものが必要なケースが多いのですが、今回は奇跡的に転送サービス + 日本発行の VISA で決済が通りました。

Platnet Express は追加料金を払えば消費税の掛からないオレゴンの住所を使えるようになりますが、今回は気にせずにカリフォルニアの住所を使うことにしました。MS Store の配送センターが LA にあるらしく、転送サービスの倉庫までは 1 日で届きました。実に US っぽくない。

倉庫に届いてからは写真撮影とパッケージサイズが計測されるので、税関に必要な情報を埋めて配送方法を選ぶだけでした。$2 払って優先オプションを使ったので 1 日で DHL に渡りました。

f:id:shiba-yan:20211121020820p:plain

DHL に渡ってからは 1 週間近くかかりました。コロナになってから物流は常に不安定な感じです。日本に着いてからはあっという間なのは、これまでと変わらずという感じです。

f:id:shiba-yan:20211121021005p:plain

送料が地味に高くて $50 ぐらいしました。そして日本の関税と手数料が $20 ほどだったので結局はトータルで $300 以上かかりました。

それでも Surface Pro X より断然安いのと、ARM64 唯一の据え置き PC なので許せます。

本体・スペックについて

という流れで無事に手元まで届いたので、ARM64 用の検証機として使うために色々とセットアップを行いました。箱はシンプルで Developer 向けアピールがされています。そしてめっちゃ軽いです。

f:id:shiba-yan:20211127155106j:plain

中身はシンプルに本体と電源用の USB-C アダプターとケーブルぐらいでした。大きさは Intel NUC とほぼ同じでしたが、圧倒的に軽いです。中身何も入っていないのではと思うぐらい軽いです。

真ん中にあるのが電源ボタンです。感触はとても安っぽいですが、実際に安いので気にしません。

f:id:shiba-yan:20211127155229j:plain

ファンレスなので負荷を掛けても無音です。おそらく消費電力も相当に低いはずなので、常時稼働させておくのに最適です。実際にリモートデスクトップで自由に使うために常時稼働させています。

CPU 周りの情報は CPU-Z でもほぼ取れなかったので、適当にタスクマネージャで済ませます。事前情報通り Snapdragon 7c と 4GB メモリ、eMMC 64GB ストレージという安さを追求した構成です。

f:id:shiba-yan:20211128233431p:plain

eSIM が使えるという情報がありましたが、パッと見た感じ認識されていませんでした。同様に仮想化にも対応していないので WSL 2 は動かすことが出来ないはずです。

UEFI からいろいろ変更できる可能性はありますが、4GB のメモリでやることではないので調べていません。

Windows 11 に必要な要件は一応すべてクリアしています。最初から TPM 2.0 やセキュアブートは有効化されているので、特に何もすることなく Windows Update で降ってくるはずです。

f:id:shiba-yan:20211128223531p:plain

x64 エミュレーションが来ないと GitHub Actions の Self hosted runner として使うことが出来ないので、暫くは Visual Studio からリモートデバッグで ARM64EC のデバッグや、アプリケーションの ARM64 対応のために使う形になりそうです。