[WiX]C#アプリケーションのインストーラーを作る

先日から参加しているプロジェクトでは、Visual Studio Installer Projects(vdproj) という古いものを使っていて、Visual Studio 2022だと標準では対応しておらず、プラグインを入れてなんとかするしかなく、また、なんかアプリが参照しているライブラリの検出がおかしかったり、設定の仕方がいまいちよくわからないため、どうにも腫れ物を触るような状態なので、この3連休を使ってWiX toolsetで作り直せないか調べてみました。

インストーラーはClickOnceやサードパーティーのものなど選択肢はありますが、なんとなく仕様面、費用面でなにかと制約が出そうなイメージがあり、WiX toolsetであれば基本的にWindows Installerに関してはなんでも対応できそうなので選択したのはいいものの、ググって得られる情報も古いのから新しいものから混在していたり、バージョンの違いが結構ありそうだったりと、情報の密度にムラがあり、とにかくとっつきづらいのがつらい感じがします。

とはいえ、腹くくって取りかかってみたら、なんとなく単純なものであればなんとかなったのでメモしておきます。

ミッション

  1. C#で作ったアプリケーションをインストールする
  2. ProgramDataに関連するディレクトリを作る
  3. デスクトップにショートカットを作る
  4. スタートメニューにショートカットを作る
  5. エクスプローラーのコンテキストメニューを登録する

準備

1. HeatWave for VS2022 をインストールする

これがあると、Visual Studioでプロジェクトとして扱うのが便利になります。

1

プロジェクト作成メニューに追加されます。

2

2. インストールしたいアプリを作る

とりあえずわかりやすく、WindowsFormsプロジェクトを作ります。 そのままだとインストールされるものが寂しいので、なにかライブラリを追加しておきます。

3. アイコンファイルを用意しとく

なんか適当なアイコンファイル(ico)を用意しておきます。

4. エクスプローラのコンテキストメニュー用のプロジェクトを作る

これは、今対応中のプロジェクトで扱っている機能で、.NET Framework 4.8 + SharpShell でエクスプローラのコンテキストメニューを追加する必要があります。 クラスライブラリプロジェクトでSharpShellを追加して、コンテキストメニューを追加するクラスを作ります。

PM> Install-Package SharpShell
using System.Runtime.InteropServices;
using System.Windows.Forms;
using SharpShell.Attributes;
using SharpShell.SharpContextMenu;

namespace WinFormsApp1ContextMenu
{
    [ComVisible(true)]
    [COMServerAssociation(AssociationType.AllFiles)]
    public class SampleMenu : SharpContextMenu
    {
        protected override bool CanShowMenu() => true;

        protected override ContextMenuStrip CreateMenu()
        {
            var menu = new ContextMenuStrip();
            menu.Items.Add(new ToolStripSeparator());
            menu.Items.Add(new ToolStripMenuItem("さんぷる"));
            menu.Items.Add(new ToolStripSeparator());
            return menu;
        }
    }
}

(.NET Framework 4.8だとC#7.3で、namespaceのせいでもっさりして見える…)

大体こんな感じで配布するアプリができたとしましょう。

WiXプロジェクトの追加

ソリューションに、WiXの MSI Package プロジェクトを追加します。

4

こんな感じになりました。

5

あと、下記のパッケージを追加します。

PM> Install-Package WixToolset.UI.wixext
PM> Install-Package WixToolset.Heat

UI.wixextはインストーラーのUIを表示する基本セット、Heatは他のプロジェクトなどから必要なファイルを収集してくれるライブラリです。

WiXプロジェクト編集

Package.wxs 以外はバッサリ削除します。 他のwxsファイルは内容を分割して管理しているだけで Package.wxs 1個に書けば済みます(なんか巨大で複雑になってきたら分割を考えればいいでしょう)。wxlも言語リソースファイルなので、今回は使わないので削除します。

では、諸々編集していきます。

WinFormsApp1Installer.wixproj

いきなりプロジェクトファイルですが、下記のようになります。

ポイントは下記の通り。

<Project Sdk="WixToolset.Sdk/5.0.0">
	<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
		<Cultures>ja-JP</Cultures>
	</PropertyGroup>
	<ItemGroup>
		<Content Include="icon.ico" />
	</ItemGroup>
	<ItemGroup>
		<PackageReference Include="WixToolset.Heat" Version="5.0.0" />
		<PackageReference Include="WixToolset.UI.wixext" Version="5.0.0" />
	</ItemGroup>
	<ItemGroup>
		<ProjectReference Include="..\WinFormsApp1ContextMenu\WinFormsApp1ContextMenu.csproj" />
		<ProjectReference Include="..\WinFormsApp1\WinFormsApp1.csproj" />
	</ItemGroup>
	<ItemGroup>
		<HarvestDirectory Include="..\WinFormsApp1\bin\Debug\net8.0-windows">
			<ComponentGroupName>WinFormsApp1.Components</ComponentGroupName>
			<DirectoryRefId>ProductFolder</DirectoryRefId>
			<SuppressRootDirectory>true</SuppressRootDirectory>
		</HarvestDirectory>
	</ItemGroup>
	<ItemGroup>
		<HarvestDirectory Include="..\WinFormsApp1ContextMenu\bin\Debug">
			<ComponentGroupName>WinFormsApp1ContextMenu.Components</ComponentGroupName>
			<DirectoryRefId>ProductFolder</DirectoryRefId>
			<SuppressRootDirectory>true</SuppressRootDirectory>
		</HarvestDirectory>
	</ItemGroup>
</Project>

Package.wxs

なんと、コンテキストメニューのCOM登録も自動でやってくれるのですねー、これはびっくりしました。

<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
     xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
	<Package
		Name="WinFormsApp1"
		Manufacturer="Spacekey Inc."
		Version="1.0.0"
		UpgradeCode="(GUIDを生成して書く)"
		Language="1041"
		Scope="perMachine"
		ProductCode="(GUIDを生成して書く)">

		<SummaryInformation Keywords="Installer" />

		<!-- インストーラー生成時に、cabファイルをmsiファイルに埋め込む設定 -->
		<Media Id="1" EmbedCab="yes" Cabinet="cab1.cab" />

		<!--
			アンインストール時にコンテキストメニューのせいでエクスプローラーを再起動する必要があるが
			その辺を無視する設定
		-->
		<Property Id="MSIRESTARTMANAGERCONTROL" Value="Disable"/>

		<!--
            インストール時に行われる内容
            それぞれ後続でIdとして定義されている
        -->
		<Feature Id="Main">
			<ComponentRef Id="Products" />
			<ComponentRef Id="DesktopShortcut" />
			<ComponentRef Id="ProductProgramMenu" />
			<ComponentRef Id="CreateLogsFolder" />
			<ComponentRef Id="CreateTempFolder" />
			<ComponentGroupRef Id="WinFormsApp1.Components"/>
			<ComponentGroupRef Id="WinFormsApp1ContextMenu.Components"/>
		</Feature>

		<!-- アイコンファイルの定義 -->
		<Icon Id="icon.ico" SourceFile="icon.ico" />

		<!--
            Program Files ディレクトリの設定
            StandardDirectoryは、Idでどこのディレクトリを指すか決まる。
            `ProgramFilesFolder`とかすると、(x86)の方になったりする。
        -->
		<StandardDirectory Id="ProgramFiles64Folder">
			<Directory Id="ProductFolder" Name="!(bind.Property.ProductName)">
				<Component Id="Products" Guid="(GUIDを生成して書く)"/>
			</Directory>
		</StandardDirectory>

		<!--
			ProgramData ディレクトリの設定
            IdがCommonAppDataFolderは、c:\ProgramDataになる
			LogsとTempディレクトリを作成
		-->
		<StandardDirectory Id="CommonAppDataFolder">
			<Directory Id="ProductDataFolder" Name="!(bind.Property.ProductName)">
				<Directory Id="Logs" Name="Logs"/>
				<Directory Id="Temp" Name="Temp"/>
			</Directory>
			<Component Id="CreateLogsFolder" Directory="Logs" Guid="(GUIDを生成して書く)">
				<CreateFolder />
			</Component>
			<Component Id="CreateTempFolder" Directory="Temp" Guid="(GUIDを生成して書く)">
				<CreateFolder />
			</Component>
		</StandardDirectory>

		<!-- Desktop shortcut -->
		<StandardDirectory Id="DesktopFolder">
			<Component Id="DesktopShortcut" Guid="(GUIDを生成して書く)">
				<RegistryValue Root="HKCU" Key="Software\[Manufacturer]\[ProductName]" Name="Installed" Type="integer" Value="1" KeyPath="yes" />
				<Shortcut Id="WinFormsApp1.lnk" Directory="DesktopFolder" Name="WinFormsApp1" WorkingDirectory="ProductFolder" Target="[ProductFolder]\\WinFormsApp1.exe" Icon="icon.ico" />
				<RemoveFolder Id="DesktopShortcut" On="uninstall" />
				<RegistryValue Root="HKCU" Key="Software\[Manufacturer]\[ProductName]" Name="desktopShortcut" Type="string" Value="1" />
			</Component>
		</StandardDirectory>

		<!-- Program menu shortcut -->
		<StandardDirectory Id="ProgramMenuFolder">
			<Component Id="ProductProgramMenu" Guid="(GUIDを生成して書く)">
				<RegistryValue Root="HKCU" Key="Software\[Manufacturer]\[ProductName]" Name="Installed" Type="integer" Value="1" KeyPath="yes" />
				<Shortcut Id="ExeFileMenu" Directory="ProgramMenuFolder" Name="WinFormsApp1" WorkingDirectory="ProductFolder" Target="[ProductFolder]\\WinFormsApp1.exe" Icon="icon.ico" />
				<RemoveFolder Id="ProductProgramMenu" On="uninstall" />
				<RegistryValue Root="HKCU" Key="Software\[Manufacturer]\[ProductName]" Name="programMenuShortcut" Type="string" Value="1" />
			</Component>
		</StandardDirectory>

		<!-- 使うUIセットの指定 -->
		<UIRef Id="WixUI_More_Minimal"/>
	</Package>
</Wix>

MoreMinimal.wxs

新しいwxsファイルを追加します。 これは、UIセットの WixUI_Minimal.wxs をベースにして、使用許諾の表示部分を単純に削除して、2段階のステップになっているのを1段階にしています。

https://github.com/wixtoolset/UI.wixext/blob/master/src/wixlib/WixUI_Minimal.wxs

元の状態を見たい場合は、<UIRef Id="WixUI_Common" /><UIRef Id="WixUI_Minimal" /> に変更してください。

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
	<Fragment>
		<UI Id="WixUI_More_Minimal">
			<TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
			<TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
			<TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />

			<Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
			<Property Id="WixUI_Mode" Value="Minimal" />

			<DialogRef Id="ErrorDlg" />
			<DialogRef Id="FatalError" />
			<DialogRef Id="FilesInUse" />
			<DialogRef Id="MsiRMFilesInUse" />
			<DialogRef Id="PrepareDlg" />
			<DialogRef Id="ProgressDlg" />
			<DialogRef Id="ResumeDlg" />
			<DialogRef Id="UserExit" />
			<DialogRef Id="WelcomeDlg" />

			<Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999" />

			<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" />

			<Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg" />

			<Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg" />
			<Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg" />
			<Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg" />

			<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="PrepareDlg" />

			<Property Id="ARPNOMODIFY" Value="1" />
		</UI>

		<UIRef Id="WixUI_Common" />
	</Fragment>
</Wix>

最終的にプロジェクトはこうなります。

6

あと、Heatによってそれぞれのプロジェクトからどういうファイルが収集されるのかは、obj\x64\Debugフォルダに生成されたファイルを見るとわかります。

_WinFormsApp1.Components_dir.wxs

<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
    <Fragment>
        <DirectoryRef Id="ProductFolder">
            <Component Id="cmpE4q2wSdhOwHUUnSq0HZ3zrRSHL4" Guid="*">
                <File Id="filpPboPxJN50efoYvKpEZoPZ3QekA" KeyPath="yes" Source="SourceDir\Serilog.dll" />
            </Component>
            <Component Id="cmpHvdz4cj7ljJ..0P5oFvVCteG4cU" Guid="*">
                <File Id="filqg4Bwj2Nr0hV7_wcu0e4RsDhGBk" KeyPath="yes" Source="SourceDir\WinFormsApp1.deps.json" />
            </Component>
            <Component Id="cmpKSP.q5aAgF.arQitdRlaNDM7c2Q" Guid="*">
                <File Id="filtR672Y.0eqn3EFoWK6s0VY4pzAk" KeyPath="yes" Source="SourceDir\WinFormsApp1.dll" />
            </Component>
            <Component Id="cmpMjHvdI7LzQxMEUpRST9X_HLnhgY" Guid="*">
                <File Id="filSmC8QPn0MAm0WoM8A4.GYMYI7c4" KeyPath="yes" Source="SourceDir\WinFormsApp1.exe" />
            </Component>
            <Component Id="cmp.Jxsdd9eyfcYAaMiarwwxq5DwXQ" Guid="*">
                <File Id="filB.ytNPeHasxwqEbaBk8SztyOKrE" KeyPath="yes" Source="SourceDir\WinFormsApp1.pdb" />
            </Component>
            <Component Id="cmpWXqeBwPTQEedGfU7g07j4u8qdR0" Guid="*">
                <File Id="filyHsaryB4O_WqUptcEfTLxqg0yKs" KeyPath="yes" Source="SourceDir\WinFormsApp1.runtimeconfig.json" />
            </Component>
        </DirectoryRef>
    </Fragment>
    <Fragment>
        <ComponentGroup Id="WinFormsApp1.Components">
            <ComponentRef Id="cmpE4q2wSdhOwHUUnSq0HZ3zrRSHL4" />
            <ComponentRef Id="cmpHvdz4cj7ljJ..0P5oFvVCteG4cU" />
            <ComponentRef Id="cmpKSP.q5aAgF.arQitdRlaNDM7c2Q" />
            <ComponentRef Id="cmpMjHvdI7LzQxMEUpRST9X_HLnhgY" />
            <ComponentRef Id="cmp.Jxsdd9eyfcYAaMiarwwxq5DwXQ" />
            <ComponentRef Id="cmpWXqeBwPTQEedGfU7g07j4u8qdR0" />
        </ComponentGroup>
    </Fragment>
</Wix>

_WinFormsApp1ContextMenu.Components_dir.wxs

<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
    <Fragment>
        <DirectoryRef Id="ProductFolder">
            <Component Id="cmpdVd8dqK53R.1wbV8z_W5e_R7018" Guid="*">
                <File Id="filPa_0ijirJnLSELQt1Ypa7eKMG88" KeyPath="yes" Source="SourceDir\SharpShell.dll" />
            </Component>
            <Component Id="cmpLyPX6gl7Q_iQYFNv9cYpXWAnBpQ" Guid="*">
                <File Id="filGTM3UlMsU_.rvMtYGehOdc3bqlc" KeyPath="yes" Source="SourceDir\SharpShell.xml" />
            </Component>
            <Component Id="cmpzVQivpnWXcZTH7ujaPYz0TT9rn8" Guid="*">
                <Class Id="{AF9A1D22-2918-3FE6-92E6-022F6013FA27}" Context="InprocServer32" Description="WinFormsApp1ContextMenu.SampleMenu" ThreadingModel="both" ForeignServer="mscoree.dll">
                    <ProgId Id="WinFormsApp1ContextMenu.SampleMenu" Description="WinFormsApp1ContextMenu.SampleMenu" />
                </Class>
                <File Id="fil.yDMki2k5DPmtv6rq38LvwZBaCA" KeyPath="yes" Source="SourceDir\WinFormsApp1ContextMenu.dll" />
                <RegistryValue Root="HKCR" Key="*\ShellEx\ContextMenuHandlers\SampleMenu" Value="{af9a1d22-2918-3fe6-92e6-022f6013fa27}" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\Implemented Categories\{62C8FE65-4EBB-45e7-B440-6E39B2CDBF29}" Value="" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32\1.0.0.0" Name="Class" Value="WinFormsApp1ContextMenu.SampleMenu" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32\1.0.0.0" Name="Assembly" Value="WinFormsApp1ContextMenu, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32\1.0.0.0" Name="RuntimeVersion" Value="v4.0.30319" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32\1.0.0.0" Name="CodeBase" Value="file:///[#fil.yDMki2k5DPmtv6rq38LvwZBaCA]" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32" Name="Class" Value="WinFormsApp1ContextMenu.SampleMenu" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32" Name="Assembly" Value="WinFormsApp1ContextMenu, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32" Name="RuntimeVersion" Value="v4.0.30319" Type="string" Action="write" />
                <RegistryValue Root="HKCR" Key="CLSID\{AF9A1D22-2918-3FE6-92E6-022F6013FA27}\InprocServer32" Name="CodeBase" Value="file:///[#fil.yDMki2k5DPmtv6rq38LvwZBaCA]" Type="string" Action="write" />
                <RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Explorer" Name="GlobalAssocChangedCounter" Value="1" Type="integer" Action="write" />
            </Component>
            <Component Id="cmp1WMCvZy.Xr5NvCJMR3b45YjatKs" Guid="*">
                <File Id="filRiSz.Fz0UiUR3aKdZUPOMosYltM" KeyPath="yes" Source="SourceDir\WinFormsApp1ContextMenu.pdb" />
            </Component>
        </DirectoryRef>
    </Fragment>
    <Fragment>
        <ComponentGroup Id="WinFormsApp1ContextMenu.Components">
            <ComponentRef Id="cmpdVd8dqK53R.1wbV8z_W5e_R7018" />
            <ComponentRef Id="cmpLyPX6gl7Q_iQYFNv9cYpXWAnBpQ" />
            <ComponentRef Id="cmpzVQivpnWXcZTH7ujaPYz0TT9rn8" />
            <ComponentRef Id="cmp1WMCvZy.Xr5NvCJMR3b45YjatKs" />
        </ComponentGroup>
    </Fragment>
</Wix>

レジストリ登録の部分は、「自分でレジストリ登録を書かねばならんのかー?」と思っていたのですが、Heatがちゃんとやってくれるのですね。スバラシイ。

ビルド

WinFormsApp1WinFormsApp1ContextMenu をビルドしておいてから、WinFormsApp1Installer をビルドします。特に書き間違いとかがなければビルドが通るはずです。

7

もし何か間違いがあっても、エラーや警告などがちゃんと出ます。 (アイコンファイルの定義を忘れていたときの図)

8

インストール

bin\x64\Debugあたりにmsiファイルができているはずなので、実行します。

9

10

インストールが完了しました。

11

12

13

14

15

アンインストール

普通にインストールされているアプリからアンインストールできます。ProgramDataとかもちゃんと削除してくれます。

16

※エクスプローラのコンテキストメニューのせいで、アンインストールに時間がかかったり、なんか止まってしまったりすることがありますが、この辺はよくわかりません…

まとめ

今回のプロジェクトは、GitHubにアップしておきました。

https://github.com/dalian-spacekey/WinFormsApp1

とっつきづらさがあって避けてたのですが、この程度であれば思ったより簡単でした。 UIをカスタマイズしたりするのはもうちょっと研究が必要ですが、ファイルを配置したりフォルダを作ったりとかいう基本的な操作は今回の書き方で十分使えますし、何らか特定のファイルを配置したいなどは <File> タグを使って指定すればコピーされるので困ることはなさそうです。

ただなんというか、ひたすらxmlタグなので、何が行われるのかいまいちイメージつきづらいというのはなかなか払拭できないのがこれの困ったところですかね…(なんかWixSharpというC#で記述するものもあるみたいですが)。