はじめに
インストーラーに関して、本来入れたいアプリとは別に、依存パッケージの確認や事前インストールを行いたい場合がある。
また、Orcaのような、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
Msi.hが提供するAPIとデータ構造を大体カバーしている。
大抵の場合はとりあえずこれを参照に追加する。
このパッケージのみで事足りる場合は多いが、元のMSI APIに対してプリミティブな部分も多いので、必要に応じて補助パッケージを導入する。
リファレンス: https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller/
WixToolset.Dtf.WindowsInstaller.Package
既存のMSIファイルに対して、ファイルの追加や更新、テーブルの操作等を行いたい場合に便利なユーティリティクラスが入っている。
WixToolset.Dtf.WindowsInstaller.Linq
WixToolset.Dtf.WindowsInstallerにもDatabaseというクラスはあるが、そのままだと情報の取り出し等が面倒になるため、良く知られたデータ構造からデータを取り出しやすくするためのもの。
AsQuerableでQDatabaseを生成して使う。
リファレンス: https://wixtoolset.org/docs/api/wixtoolset.dtf.windowsinstaller.linq/
WixToolset.Dtf.Compression
MSIに付随する、インストールするファイル本体が入っているcab等の圧縮データを扱うための機能をカバーしている。
cabが別になっているMSIを扱う時は、これを使って直接ファイルを展開できる。
更に個別の圧縮形式に対応したサブパッケージとして、WixToolset.Dtf.Compression.Zipと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
あるパッケージが依存するパッケージの検査やインストールを事前に行いたい、あるいは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をnewする。
msp等もこれで開くことが可能。
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公式ドキュメントのクエリ書式に準拠する。
戻り値として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
に対応する数値
-
プロパティの場合は特別にstring Database.ExecutePropertyQuery(string propertyName)
が用意されている。
追加と更新はvoid Database.Execute(string query, params object[] args)
で行う。
Transactモードの場合はCommitを忘れずに。
修復インストールあるいは削除する時は、パッケージコードまで一致していなければならないが、
Databaseインスタンスでこの情報を確認するにはDatabase.SummryInfo
を使う。
インストール済みパッケージの情報を取得する
MSIを使ってインストールされている場合、APIからパッケージの情報を取得することができる。
プロダクトコードから得る方法
既にインストールされているパッケージの情報を得たい場合、WixToolset.Dtf.WindowsInstaller.ProductInstallation
を使用する。
パッケージのプロダクトコードが分かっている場合は、static ProductInstallation.GetProducts(..)
を使用する。
UserContexts.Machineだけを指定した時はuserSidはnullにすること。
メンテナンスあるいは削除時に完全にパッケージコードまで一致するかどうかを見る必要があるが、パッケージコードはProductInstallation.AdvertisedPackageCodeで把握可能。
GetProductsでnullを指定したときは、全てのパッケージ情報が取得できる。ただし、列挙自体に時間がかかる可能性があるので注意する事。
単純に全パッケージの情報を列挙したい場合は、ProductInstallation.AllProducts
プロパティで取得した方が簡単。
内部的にはMsiEnumProductsExを呼び出しているので、細かい挙動が知りたい場合はそちらも参照。
アップグレードコードから得る方法
メジャーアップグレードの判定を行いたい場合、アップグレードコードからインストールされているパッケージを抽出する必要がある。
そのような場合には、ProductInstallation.GetRelatedProducts(string upgradeCode)
で情報を取得する
取得できる情報はGetProductsと一緒だが、複数返ってくる可能性が高いことに注意。
内部的にはMsiEnumRelatedProductsExを呼び出しているので、細かい挙動が知りたい場合はそちらも参照。
MSIのインストール/修復インストール/アップデート/削除を行う
主にWixToolset.Dtf.WindowsInstaller.Installer
クラスを使う。
前準備
実際に処理を開始する前に、以下のことを決めておく
- インストーラーのUIを出すか
- MSIインストール処理のモニタリングを行うか
- デバッグログをファイルに出力するか
インストーラーのUIを出すかどうか
インストーラーのUIを出すかどうかは、Installer.SetInternalUI
を使う。
UIオプションは、途中の画面と結果を出したい場合は"Full"で、画面無しの場合は"Silent"といった具合になる。
削除の時はBasicあるいはProgressOnlyになる場合もあるかもしれない。
これはプロセス単位で有効になるので、インストーラーを実際に処理開始するまえに予め実行しておくこと。
これは内部でMsiSetInternalUIを呼び出しているので、細かい挙動が知りたくなったらそちらを参照すること。
インストーラーのイベントをフックするかどうか
インストーラーの状態遷移(どのアクションを実行した等)を監視したい場合、インストール開始前にInstaller.SetExternalUI
を実行して、ハンドラを登録する必要がある。
この時にInstallLogModesに通知を送るイベントのフィルタリングを行う。
知らない値は無視されるため、全て取得したい場合は"0xffff"と設定しておけばOK
コールバックとして指定されるメソッドの型は
MessageResult ExternalUIRecordHandler(InstallMessage messageType, Record? messageRecord, MessageButtons buttons, MessageIcon icon, MessageDefaultButton defaultButton)
となる。
基本的にmessageType、messageRecord以外は気にしなくても問題は無い。
戻り値がMessageResultになっているが、基本的に何も影響を与えたくない場合はMessageResult.None、中断したい場合はMessageResult.CancelあるいはMessageResult.Abortを選択すればOK。
SetExternalUIの戻り値として、それまで使用していたハンドラを返すという仕様になっているので、後始末をきちんとしたい場合は戻り値を取っておいて、処理終了後に元に戻す処理を入れるのもいいかもしれない。
一応、SetExternalUIの設定が及ぶ範囲はそのプロセスの中なので、実行して即終了する場合は厳密に元に戻さなくても問題は無い。
その他細かい挙動に関しては、MsiSetExternalUIに準ずるのでそちらを参照すると良い。
Recordの取り扱い
ExternalUIRecordHandlerのRecordにメッセージ内容とパラメータが入ってくることになる。
このRecordについては、以下のようなデータとなる
- nullの場合もある
-
ToString()
すると、整形済みメッセージが取得できる -
Get*(index i)
で各要素から値を取り出す- フィールド数は
Record.FieldCount
で取得可能
- フィールド数は
- インデックスでも各要素にアクセスできる
-
Record[0]
に書式文字列が入ってくる-
A: [1], B: [2]
のような形式 -
[n]
の部分が、整形済み文字列では後続の要素で置き換えられる
-
デバッグログを出すかどうか
デバッグログをファイルに出したい場合は、Installer.EnableLogを使用する。
InstallLogModesは大雑把に全て有効にしたい場合は0xffffを渡せばOK
ここで指定できる出力先はファイルのみなので、Formsのテキストボックスに出す等、より柔軟に出力したい場合はSetExternalUIを使用する。
MSIファイルからのインストール/アップデート/削除の実行
インストール、更新、削除をmsiファイルから行う場合は、全てInstaller.InstallProduct
を基本として使う。
第二引数のcommandlineの方に操作によって渡す値を変えていく。
どの値を渡せばいいかというのはMSIの仕様に準拠する。
- インストールまたはメジャーアップデートの場合は特に無い
- メンテナンス画面を起動したい場合も特に指定しない
- ただし、パッケージコードが一致していないとエラーが出る
- アンインストールの場合は、"REMOVE=ALL"を追加する
- パッケージコードまで一致していないとエラーが出る
- 修復インストールまたはマイナーアップデートの場合は、"REINSTALL=ALL REINSTALLMODE=[フラグ]"を追加する
- Administrativeインストール(システムにインストールするのではなく、別の場所にMSIと含まれているファイル一式を展開だけする処理)を行う場合は、"ACTION=ADMIN"をcommandlineに追加する
Installer.InstallProductは最も柔軟に処理できるが、そこまで細かく設定しなくていい場合はInstaller.ReinstallProduct等が使える。
ただし、削除はInstallProductを使う必要がある。
InstallProduct実行は、完了まで制御が戻ってこないので、GUI経由で呼び出す場合等は注意する事。
InstallProductは内部でMsiInstallProductを呼び出しているので、細かい挙動が気になったらそちらも参照すると良い。
終了判定
InstallProduct実行時、何らかの原因でエラーが起きた時、InstallProduct内でInstallerException
が発生する。
エラーコードはこのInstallerExceptionで取得する事。
また、MSIの方で再起動処理が必要等の判断が出た場合はInstaller.RebootRequired、再起動処理の準備ができた場合は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
を実行する。
InstallProductとは異なり、第一引数にProductCodeを指定することに注意。
installLevel基本的に0で問題ない。
installStateは、削除する時はAbsent、修復インストールの場合はDefaultとなる。
その他細かい部分に関しては、MsiConfigureProductExを内部で呼び出しているので、よくわからなくなったらそちらの挙動を参照すると良い。
終了判定等はInstaller.InstallProductと同じ。
ファイルを取り出す
ファイルを取り出したい場合は、WixToolset.Dtf.WindowsInstaller.Package.InstallPackageクラスを使用する。
これはWixToolset.Dtf.WindowsInstaller.Packageが必要なので、パッケージ参照で追加しておくこと。
InstallPackageのソースを見ると、InstallPackageでなくても自力でできる方法が無くは無いが、実際大変なので、大人しくInstallPackageを使った方が良い。
やり方としては、
-
new InstallPackage(filePath)
でインスタンス生成 -
InstallPackage.WorkingDirectory
で展開先を設定- インストールファイルがMSI埋め込みではない場合は、InstallPackage.SourceDirectoryプロパティも設定する
-
InstallPackage.ExtractFiles()
で展開-
InstallPackage.ExtractFiles(IList<string> keys)
とすると、FileテーブルのFileをキーにして選択的にファイルを展開できる(完全一致)
-
展開すると、WorkingDirectoryに設定したディレクトリに、cabファイルとインストールファイルが展開される。
終わりに
DTFには他にも細かい部分のAPIがカバーされているが、インストーラーの基本的な操作に関してはこの記事で書かれている範囲で十分だと思う。
他にもDTFには直接ファイルを個別に取り出す機能もあったりするが、長くなるので今回は紹介しない。
ブートストラッパーに使うとなった場合、現状のWinFormsがpublishすると非常に大きなサイズ(+100MBオーバー)になるので使いにくいが、コンソールアプリ(OutputType=WinEXEにするとコンソールを出さなくなる)でトリミングするならばある程度軽減できると思う。
WinFormsでもトリミングができるようになることを願うばかり。