.NETでMSIインストーラーを操作する(wixtoolset v4)

# はじめに

インストーラーに関して、本来入れたいアプリとは別に、依存パッケージの確認や事前インストールを行いたい場合がある。
また、[Orca](https://learn.microsoft.com/ja-jp/windows/win32/msi/orca-exe)のような、MSIに対してプロダクトコードや内部に設定されているプロパティ等を確認するツールが欲しい場合もある。
そのような場合に使えるものとして、WixToolsetにはWindowsのMSIに関するAPIを.NETで使えるようにしたDTF(Deployment Tool Foundation)を提供している。
今回は、DTFを使って作成済みのMSIを操作して、.NETのコードからインストーラーからのファイル取り出しや、インストールを実行するやり方を紹介する。

WixToolset v4に関する概要は https://qiita.com/skitoy4321/items/194888be042e5c4c32ad を参照

# 準備

まずパッケージを参照する。wix3の時は、SDKの中に含まれる `Microsoft.Deployment.*.dll` を取り出して参照する必要があったが、wix4からは、nugetで提供されるようになった。
MSIを操作したい場合によく使われるパッケージは以下。なお、nugetパッケージ的には限定されていないが、
MSIのAPIを内部で使っているためWindows専用となる。

## [WixToolset.Dtf.WindowsInstaller](https://www.nuget.org/packages/WixToolset.Dtf.WindowsInstaller)

[Msi.h](https://learn.microsoft.com/en-us/windows/win32/api/msi/)が提供するAPIとデータ構造を大体カバーしている。
大抵の場合はとりあえずこれを参照に追加する。
このパッケージのみで事足りる場合は多いが、元のMSI APIに対してプリミティブな部分も多いので、必要に応じて補助パッケージを導入する。

リファレンス: https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/

## [WixToolset.Dtf.WindowsInstaller.Package](https://www.nuget.org/packages/WixToolset.Dtf.WindowsInstaller.Package)

既存のMSIファイルに対して、ファイルの追加や更新、テーブルの操作等を行いたい場合に便利なユーティリティクラスが入っている。

## [WixToolset.Dtf.WindowsInstaller.Linq](https://www.nuget.org/packages/WixToolset.Dtf.WindowsInstaller.Linq)

WixToolset.Dtf.WindowsInstallerにも[Database](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/database/)というクラスはあるが、そのままだと情報の取り出し等が面倒になるため、良く知られたデータ構造からデータを取り出しやすくするためのもの。
[AsQuerable](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller.linq/queryable/#asqueryable_db)で[QDatabase](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller.linq/qdatabase/)を生成して使う。

リファレンス: https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller.linq/

## [WixToolset.Dtf.Compression](https://www.nuget.org/packages/WixToolset.Dtf.Compression)

MSIに付随する、インストールするファイル本体が入っているcab等の圧縮データを扱うための機能をカバーしている。
cabが別になっているMSIを扱う時は、これを使って直接ファイルを展開できる。
更に個別の圧縮形式に対応したサブパッケージとして、[WixToolset.Dtf.Compression.Zip](https://www.nuget.org/packages/WixToolset.Dtf.Compression.Zip)と[WixToolset.Dtf.Compression.Cab](https://www.nuget.org/packages/WixToolset.Dtf.Compression.Cab)が存在する

リファレンス: https://wixtoolset.org/docs/api/wixtoolset.dtf.compression/
cabファイルについては https://learn.microsoft.com/en-us/windows/win32/msi/cabinet-files を参照

## [WixToolset.Dtf.Resources](https://www.nuget.org/packages/WixToolset.Dtf.Resources)

あるパッケージが依存するパッケージの検査やインストールを事前に行いたい、あるいはVSのように複数のMSIをまとめてインストールしたいというのはよくある要件で、その際EXE(より正確にはPEファイル)のリソース領域に埋め込んで、
setup.exeがそれを展開して実行するという形で要件を満たす事が多い。
そのようなとき、このパッケージを参照すれば、PEのリソースデータを取り出したりすることができるようになる。

リファレンス: https://wixtoolset.org/docs/api/wixtoolset.dtf.resources/

# MSIデータベースファイルを操作する

Windows標準のインストーラー形式であるMSIファイルの中には、各種テーブルがあってその中にレコードが存在するので、一種のRDBとみなすことができる。

## ファイルを直接指定してオープン

MSIファイルを直接指定してオープンする場合は、[WixToolset.Dtf.Windows.Installer.Database](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/database/)をnewする。
msp等もこれで開くことが可能。

```csharp
using var db = new Database(filePath, DatabaseOpenMode.ReadOnly);
```

MSIファイルの更新等を行わず、参照のみ行う場合はReadOnlyを指定する。
ReadOnlyを指定しておけば、インスタンスをDisposeしなくてもインストール実行は可能。
MSIデータベースの中を操作したい場合はTransactかDirectを使用する。
Transactを選んだ場合は、Dispose前にCommitしないと変更が破棄される。
Directの場合はCommitは必要ない。
どちらがいいかは要件に依ってくるだろう。

## テーブル情報を追加、更新、取得する

生のDatabaseインスタンスを用いて情報を取得したい場合、`IList Database.ExecuteQuery(string sql, params object[])`を実行する。
この時のSQLのフォーマットは[MSI公式ドキュメントのクエリ書式](https://learn.microsoft.com/en-us/windows/win32/msi/sql-syntax)に準拠する。
戻り値として`IList`が戻ってくるが、これは行列が全て直列化されたオブジェクト配列となり、個別の型はMSIテーブルの型に準ずる。
例えば、クエリの結果が列数:行数=n:mとなる場合、x行目y列目(0始まり)の値の場所は`result[x * n + y](x < m, y < n)`となる。

クエリで明示的に文字列を選択する場合は順番は分かっているので問題はないが、例えば全テーブルのデータを雑に取得したい場合は、`TableCollection Database.Tables`プロパティに情報があるのでそれを参考にしよう。
TableCollectionの各要素`TableInfo`の中でも特に使う要素は以下

* `string Name`: テーブル名
* `ColumnCollection Columns`: 列情報、列情報の各要素`ColumnInfo`は下記のような情報を持っている
    * `string Name`: 名前
    * `int Size`: (もしあれば)列のサイズ、無限と未定義は0
    * `int DBType`: 列の型、[`System.Data.DbType`](https://learn.microsoft.com/en-us/dotnet/api/system.data.dbtype?view=net-7.0)に対応する数値

プロパティの場合は特別に`string Database.ExecutePropertyQuery(string propertyName)`が用意されている。

追加と更新は`void Database.Execute(string query, params object[] args)`で行う。
Transactモードの場合はCommitを忘れずに。

修復インストールあるいは削除する時は、パッケージコードまで一致していなければならないが、
Databaseインスタンスでこの情報を確認するには[`Database.SummryInfo`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/summaryinfo/)を使う。

# インストール済みパッケージの情報を取得する

MSIを使ってインストールされている場合、APIからパッケージの情報を取得することができる。

## プロダクトコードから得る方法

既にインストールされているパッケージの情報を得たい場合、[`WixToolset.Dtf.WindowsInstaller.ProductInstallation`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/productinstallation/)を使用する。
パッケージのプロダクトコードが分かっている場合は、[`static ProductInstallation.GetProducts(..)`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/productinstallation/)を使用する。
[UserContexts.Machine](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/usercontexts/)だけを指定した時はuserSidはnullにすること。
メンテナンスあるいは削除時に完全にパッケージコードまで一致するかどうかを見る必要があるが、パッケージコードは[ProductInstallation.AdvertisedPackageCode](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/productinstallation/#advertisedpackagecode)で把握可能。
GetProductsでnullを指定したときは、全てのパッケージ情報が取得できる。ただし、列挙自体に時間がかかる可能性があるので注意する事。

単純に全パッケージの情報を列挙したい場合は、[`ProductInstallation.AllProducts`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/productinstallation/#allproducts)プロパティで取得した方が簡単。

内部的には[MsiEnumProductsEx](https://learn.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msienumproductsexw)を呼び出しているので、細かい挙動が知りたい場合はそちらも参照。

## アップグレードコードから得る方法

メジャーアップグレードの判定を行いたい場合、アップグレードコードからインストールされているパッケージを抽出する必要がある。
そのような場合には、[`ProductInstallation.GetRelatedProducts(string upgradeCode)`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/productinstallation/#getrelatedproducts_upgradecode)で情報を取得する

取得できる情報はGetProductsと一緒だが、複数返ってくる可能性が高いことに注意。
内部的には[MsiEnumRelatedProductsEx](https://learn.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msienumrelatedproductsw)を呼び出しているので、細かい挙動が知りたい場合はそちらも参照。

# MSIのインストール/修復インストール/アップデート/削除を行う

主に[`WixToolset.Dtf.WindowsInstaller.Installer`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/)クラスを使う。

## 前準備

実際に処理を開始する前に、以下のことを決めておく

* インストーラーのUIを出すか
* MSIインストール処理のモニタリングを行うか
* デバッグログをファイルに出力するか

### インストーラーのUIを出すかどうか

インストーラーのUIを出すかどうかは、[`Installer.SetInternalUI`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#setinternalui_uioptions)を使う。
UIオプションは、途中の画面と結果を出したい場合は"Full"で、画面無しの場合は"Silent"といった具合になる。
削除の時はBasicあるいはProgressOnlyになる場合もあるかもしれない。

これはプロセス単位で有効になるので、インストーラーを実際に処理開始するまえに予め実行しておくこと。
これは内部で[MsiSetInternalUI](https://learn.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msisetinternalui)を呼び出しているので、細かい挙動が知りたくなったらそちらを参照すること。

### インストーラーのイベントをフックするかどうか

インストーラーの状態遷移(どのアクションを実行した等)を監視したい場合、インストール開始前に[`Installer.SetExternalUI`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#setexternalui_uihandler_messagefilter)を実行して、ハンドラを登録する必要がある。
この時にInstallLogModesに通知を送るイベントのフィルタリングを行う。
知らない値は無視されるため、全て取得したい場合は"0xffff"と設定しておけばOK
コールバックとして指定されるメソッドの型は
```csharp
MessageResult ExternalUIRecordHandler(InstallMessage messageType, Record? messageRecord, MessageButtons buttons, MessageIcon icon, MessageDefaultButton defaultButton)
```
となる。
基本的にmessageType、messageRecord以外は気にしなくても問題は無い。

戻り値が[MessageResult](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/messageresult/)になっているが、基本的に何も影響を与えたくない場合はMessageResult.None、中断したい場合はMessageResult.CancelあるいはMessageResult.Abortを選択すればOK。

SetExternalUIの戻り値として、それまで使用していたハンドラを返すという仕様になっているので、後始末をきちんとしたい場合は戻り値を取っておいて、処理終了後に元に戻す処理を入れるのもいいかもしれない。
一応、SetExternalUIの設定が及ぶ範囲はそのプロセスの中なので、実行して即終了する場合は厳密に元に戻さなくても問題は無い。



その他細かい挙動に関しては、[MsiSetExternalUI](https://learn.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msisetexternaluiw)に準ずるのでそちらを参照すると良い。

#### Recordの取り扱い

ExternalUIRecordHandlerのRecordにメッセージ内容とパラメータが入ってくることになる。
このRecordについては、以下のようなデータとなる

* nullの場合もある
* `ToString()`すると、整形済みメッセージが取得できる
* `Get*(index i)`で各要素から値を取り出す
    * フィールド数は`Record.FieldCount`で取得可能
* インデックスでも各要素にアクセスできる
* `Record[0]`に書式文字列が入ってくる
    * `A: [1], B: [2]`のような形式
    * `[n]`の部分が、整形済み文字列では後続の要素で置き換えられる

### デバッグログを出すかどうか

デバッグログをファイルに出したい場合は、[Installer.EnableLog](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installstate/)を使用する。
[InstallLogModes](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installmode/)は大雑把に全て有効にしたい場合は0xffffを渡せばOK
ここで指定できる出力先はファイルのみなので、Formsのテキストボックスに出す等、より柔軟に出力したい場合はSetExternalUIを使用する。

## MSIファイルからのインストール/アップデート/削除の実行

インストール、更新、削除をmsiファイルから行う場合は、全て[`Installer.InstallProduct`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#installproduct_packagepath_commandline)を基本として使う。
第二引数のcommandlineの方に操作によって渡す値を変えていく。
どの値を渡せばいいかというのはMSIの仕様に準拠する。

* インストールまたはメジャーアップデートの場合は特に無い
* メンテナンス画面を起動したい場合も特に指定しない
    * ただし、パッケージコードが一致していないとエラーが出る
* アンインストールの場合は、"REMOVE=ALL"を追加する
    * パッケージコードまで一致していないとエラーが出る
* 修復インストールまたはマイナーアップデートの場合は、"REINSTALL=ALL REINSTALLMODE=[フラグ]"を追加する
    * [REINSTALLMODEについてはMSI公式サイトを参照](https://learn.microsoft.com/ja-jp/windows/win32/msi/reinstallmode)
* Administrativeインストール(システムにインストールするのではなく、別の場所にMSIと含まれているファイル一式を展開だけする処理)を行う場合は、"ACTION=ADMIN"をcommandlineに追加する

Installer.InstallProductは最も柔軟に処理できるが、そこまで細かく設定しなくていい場合は[Installer.ReinstallProduct](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#reinstallproduct_product_reinstallmodes)等が使える。
ただし、削除はInstallProductを使う必要がある。

**InstallProduct実行は、完了まで制御が戻ってこない**ので、GUI経由で呼び出す場合等は注意する事。

InstallProductは内部で[MsiInstallProduct](https://learn.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msiinstallproductw)を呼び出しているので、細かい挙動が気になったらそちらも参照すると良い。

### 終了判定

InstallProduct実行時、何らかの原因でエラーが起きた時、InstallProduct内で[`InstallerException`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installerexception/)が発生する。
エラーコードはこのInstallerExceptionで取得する事。
また、MSIの方で再起動処理が必要等の判断が出た場合は[Installer.RebootRequired](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#rebootrequired)、再起動処理の準備ができた場合は[Installer.RebootInitiated](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#rebootinitiated)がtrueになる。
RebootRequiredは下記URLで言う所のERROR_SUCCESS_REBOOT_REQUIRED、RebootInitiatedはERROR_SUCCESS_REBOOT_INITIATEDに対応する。
https://learn.microsoft.com/en-us/windows/win32/msi/error-codes

## プロダクトコードを指定した削除、修復インストールの実行

既にインストールされているパッケージを、元のMSIファイル無しで修復インストールや削除する場合は、プロダクトコードを指定して処理を行うことができる。

実行する時は、InstallProductと同様の前準備を行った上で、[`Installer.ConfigureProduct`](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installer/#configureproduct_productcode_installlevel_installstate_commandline)を実行する。
InstallProductとは異なり、第一引数にProductCodeを指定することに注意。
installLevel基本的に0で問題ない。
[installState](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/installstate/)は、削除する時はAbsent、修復インストールの場合はDefaultとなる。
その他細かい部分に関しては、[MsiConfigureProductEx](https://learn.microsoft.com/en-us/windows/win32/api/msi/nf-msi-msiconfigureproductexa)を内部で呼び出しているので、よくわからなくなったらそちらの挙動を参照すると良い。

終了判定等はInstaller.InstallProductと同じ。

# ファイルを取り出す

ファイルを取り出したい場合は、[WixToolset.Dtf.WindowsInstaller.Package.InstallPackage](https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller.package/installpackage/)クラスを使用する。
これはWixToolset.Dtf.WindowsInstaller.Packageが必要なので、パッケージ参照で追加しておくこと。
[InstallPackageのソースを見ると、InstallPackageでなくても自力でできる方法が無くは無い](https://github.com/wixtoolset/wix/blob/e53c658973fa0ece0b64e7be7ca9ebcb7e05044a/src/dtf/WixToolset.Dtf.WindowsInstaller.Package/InstallPackage.cs#L312-L382)が、実際大変なので、大人しくInstallPackageを使った方が良い。

やり方としては、

1. `new InstallPackage(filePath)`でインスタンス生成
2. `InstallPackage.WorkingDirectory`で展開先を設定
    * インストールファイルがMSI埋め込みではない場合は、InstallPackage.SourceDirectoryプロパティも設定する
3. `InstallPackage.ExtractFiles()`で展開
    * `InstallPackage.ExtractFiles(IList<string> keys)`とすると、[Fileテーブル](https://learn.microsoft.com/en-us/windows/win32/msi/file-table)のFileをキーにして選択的にファイルを展開できる(完全一致)

展開すると、WorkingDirectoryに設定したディレクトリに、cabファイルとインストールファイルが展開される。

# 終わりに

DTFには他にも細かい部分のAPIがカバーされているが、インストーラーの基本的な操作に関してはこの記事で書かれている範囲で十分だと思う。
他にもDTFには直接ファイルを個別に取り出す機能もあったりするが、長くなるので今回は紹介しない。

ブートストラッパーに使うとなった場合、現状のWinFormsがpublishすると非常に大きなサイズ(+100MBオーバー)になるので使いにくいが、コンソールアプリ(OutputType=WinEXEにするとコンソールを出さなくなる)でトリミングするならばある程度軽減できると思う。
WinFormsでもトリミングができるようになることを願うばかり。