利用 Wix 建立 Windows .msi 安裝檔

最近兩週花了蠻多時間研究該如何使用 Wix (Windows Installer XML) 來打包 source file 成一個 .msi 檔,因為沒有打包成 .exe 的經驗,無法比較兩者的差異。中文資料真的少之又少,有很多又很舊了,雖然我覺得以後自己也不太會用到,但還是寫一下希望可以幫助到需要的人。這篇文章是以 Wix 上的教學 為基礎,再加上許多自一些的經驗,雖然只是略懂皮毛,不過至少可以成功建立 .msi 檔。

簡單指令介紹

Wix 是一套利用 XML 來編寫一些設定,並且利用內建的一些工具來建立完整的 .msi。我們寫的 XML 需要用副檔名 .wxs,並且使用內建的工具 candle.exe 來產生一個編譯過後的檔案,假設我們已經有一個 example.wxs,使用下面指令可以產生編譯過後的檔案。

candle.exe example.wxs

如果沒有特別指名輸出的檔名,檔名會以 xml 的檔名命名,副檔名為 .wixobj,之後在使用一次內建的工具 light.exe 產生 .msi,可以使用下面指令。

light.exe example.wixobj

更詳細內容可以參考: Getting Start


The Software Package

Windows Installer 利用 GUID 來追蹤我們的程式,GUID 是一組保證為一個十六進位的字串,原理可以看一下 Wiki GUID 介紹,因為每一台電腦每一次所產生的 GUID 都不一樣,所以下面的所有程式碼,如果有遇到需要寫 GUID,我都會用 YOUR_GUID,請自己換成另外一組你自己的 GUID。使用下面指令可以在 powershell 中產生,根據不同的 powershell 版本,產生方式可能有所不同。

## Win 9x
[guid]::NewGuid().GUID.ToUpper()

## Win 2008 or higher
New-Guid.GUID.ToUpper()

產生完之後就來建立我們的 xml 吧,先假設我們的檔名叫做 example.wxs,下面為第一階段的檔案內容,可以看到有兩個 YOUR_GUID,用上面的指令產生兩組,並且填進去即可。

<?xml version='1.0' encoding='windows-1252'?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
    <Product Name='PRODUCT_NAME' 
             Manufacturer='COMPANY_NAME' 
             Id='YOUR_GUID' 
             UpgradeCode='YOUR_GUID' 
             Language='1033' 
             Codepage='1252' 
             Version='1.0.0'>

        <Package Id='*' 
                 Keywords='Installer'  
                 Manufacturer='COMPANY_NAME'
                 InstallerVersion='100' 
                 Languages='1033' 
                 Compressed='yes' 
                 SummaryCodepage='1252' />
    </Product>
</Wix>

我覺得這段蠻直覺的,就是填上一些 Product 以及 Package 的資訊,並且把語言都設定。這邊比較要注意的是 Package 裡面的 Compressed 一定要設為 yes,如果沒有這個 Attribute,最後產生的 .msi 不會把 source files 都包進去,就只有殼,這種情況下,如果把 msi 檔案換到其他資料夾,就無法安裝了。至於 Product 和 Package 當中有哪些特徵可以用,可以參考 Product Elements 以及 Packge Elements

更詳細的內容可以參考:The Software Package


把檔案放進去

這邊就是這個工具的重點了,我們要把 source file 放進 xml,讓 Wix 看得懂。直接看整份的 xml,再一塊一塊看裡面的內容:

Directory

這個地方就是放要把 source file 安裝到電腦的哪邊,比如說安裝到 Program Files 還是安裝到 Program Files (x86);如果想要新增一個捷徑在開始選單,那要安裝到哪邊;如果想要新增一個捷徑到桌面,那要安裝到那邊,全部都是在這一個 Directory 當中設定。上面的例子是安裝到 Program Files 以及新增一個捷徑到開始選單。

<Directory Id="TARGETDIR" Name="SourceDir">
    <Directory Id="ProgramFiles64Folder" Name="PFiles">
        <Directory Id="ProgramFilesFolderCompany" Name="COMPANY_NAME">
            <Directory Id="INSTALLDIR" Name="PRODUCT_NAME" />
        </Directory>
    </Directory>
    <Directory Id="ProgramMenuFolder">
        <Directory Id="ProgramMenuDirCompany" Name="COMPANY_NAME">
            <Directory Id="ProgramMenuDirProduct" Name="PRODUCT_NAME" />
        </Directory>
    </Directory>
</Directory>

可以看到這邊我們不用特別寫清楚到底 Program Files 的絕對路徑在哪裡,只要在 Id 寫上 ProgramFilesFolder,Wix 就會自動幫我們轉換成相對應的資料夾。像我們常會用到的保留字包括:ProgramFilesFolderProgramMenuFolderDesktopFolder這邊 可以查詢到其他的保留字。而名稱(Name) 就是你要資料夾顯示的名稱,蠻直覺的。這邊要特別注意的是在每一個 xml Element 中,都會有 Id 這個 Attribute,Wix 利用這個 Id 來辨認不同的 Element,所以 Id 一定要是唯一的。在我們的例子當中最後安裝的資料夾叫做 INSTALLDIR,之後也會在其他地方看到他。可以看到在開始選單的部分,大架構都一樣,就只有差在第一層的 Id 改成了 ProgramMenuFolder

更詳細的內容可以參考:Directory Element

DirectoryRef

在官網的教學上,並沒有特別提到這一個 Element,但是我東查西查之後發現利用 DirectoryRef 把內容跟資料夾分開,比較清楚,也更有益於了解整個架構。先看簡單的例子:

<DirectoryRef Id="INSTALLDIR">
    <Component Id="myapplication.exe" Guid="PUT-GUID-HERE">
        <File Id="myapplication.exe" 
              Source="MySourceFiles\MyApplication.exe" 
              KeyPath="yes" 
              Checksum="yes"/>
    </Component>
    <Component Id="documentation.html" Guid="PUT-GUID-HERE">
        <File Id="documentation.html" 
              Source="MySourceFiles\documentation.html" 
              KeyPath="yes"/>
    </Component>
</DirectoryRef>

看到在 DirectoryRef 中的 Id 的值指向了剛剛在 Directory 中我們最後希望安裝進去的資料夾的 INSTALLDIR,這就代表在這個 DirectoryRef 下面的所有的檔案,都要被安裝進 INSTALLDIR 中。接下來看到 Component,Component 當中可以包含多個檔案,可以想像在同一個 Component 中的所有檔案都是一起的,一個壞掉這個 Component 就壞掉了,雖然這樣說,但是官方強烈建議我們一個檔案一個 Component。Component 的 Id 必須是唯一的 (之後會用到),另外還要多產生一組的 GUID 給每一個 Component。這時候就出現了兩個問題:

  1. 要是我有上千個檔案甚至上萬個檔案,要自己產生這些 Component 和 File 嗎?
  2. 如果我的資料夾結構很複雜,很多層,那不就要自己產生一堆 Directory 以及一堆的 DirectoryRef 嗎?

理論上是這樣沒錯,所以官方建議我們在開發的時候不應該把 Build msi 這件事當成開發完畢之後才做的事情,而應該把 Build msi 視為開發中的一部分,在 Build 專案的過程當中,也要考慮到 Build msi 這件事情,一併處理。

這不是風涼話嗎?

是!不過好在 Wix 還是提供了一個 方法 可以自動幫我們產生資料夾的結構。這邊要使用另外一個內建的工具 heat.exe。使用指令如下:

heat dir SOURCE_FILE_DIRECTORY -t HeatTransform.xslt -cg HarvestedComponentId -out heat_harvested_results.wxs -dr INSTALLDIR -var var.Dist -scom -frag -srd -sreg -gg

這邊需要把 SOURCE_FILE_DIRECTORY 改成你放那些 source file 的資料夾

  • -cg 的意思是要把 ComponentGroup 叫什麼名字 (在 example.wxs 中會用到),這邊我們先命名為 HarvestedComponentId
  • -o 指定輸出檔案名稱,假設為 heat_harvested_results.wxs
  • -dr 可以指定資料夾結構從哪邊開始參照,因為有可能檔案階層多層,heat 也會自動幫我們產生 DirectoryRef
  • -var 可以在之後 Build 的時候用較方便的方式把檔案的路徑都指定到正確的路徑
  • -t 使用 template 來修改產生之後的 wxs。因為 heat 內建的設定有限,利用 template 更方便,比如說要為所有的檔案增加 win64='yes' 的 attribute。

使用了 heat 這個工具,可以讓 DirectoryRef 更加簡化,看一下我們的真實的 DirectoryRef 吧

<DirectoryRef Id="INSTALLDIR">
    <Component Id="componentINSTALLDIR" Guid="SOME_GUID" Win64="yes">
        <RemoveFolder Directory="INSTALLDIR" 
                      Id="componentINSTALLDIR" 
                      On="uninstall" />
    </Component>
</DirectoryRef>
<DirectoryRef Id="ProgramMenuDirHpProduct">
    <Component Id="componentProgramMenuDirHpProduct" 
               Guid="SOME_GUID" 
               Win64="yes">
        <Shortcut Name="PRODUCT_NAME" 
                  Id="PRODUCTSHORTCUT" 
                  Target="[INSTALLDIR]PRODUCT_NAME.exe" 
                  WorkingDirectory="INSTALLDIR" />
        <RemoveFolder Directory="ProgramMenuDirCompany" 
                      Id="removeProgramMenuDirCompany" 
                      On="uninstall" />
        <RemoveFolder Directory="ProgramMenuDirProduct" 
                      Id="removeProgramMenuDirProduct" 
                      On="uninstall" />
        <RegistryValue Value="1" 
                       Name="installed" 
                       Root="HKCU" 
                       KeyPath="yes" 
                       Type="integer" 
                       Key="Software\COMPANY_NAME\PRODUCT_NAME" />
    </Component>
</DirectoryRef>

這邊我們定義了兩個 DirectoryRef。第一個我們給了 Id="INSTALLDIR",讓 Wix 知道這個 DirectoryRef 對應到 Directory 中 Id 為 INSTALLDIR 的那一個資料夾;在這個 DirectoryRef 中,只有一個 Component,代表我們要包含哪些東西,在這個 Component 中只有一個 RemoveFolder 的 Element,意思說如果今天要移除這個軟體,我們也要同時移除這個資料夾。再來看第二個比較複雜的 DirectoryRef,這個 DirectoryRef 指向 Id 為 ProgramMenuDirHpProduct 的資料夾,就是開始功能捷徑的資料夾,一樣是一個 Component,不一樣的是在這個 Component 下面有四個 Element,分別是 Shortcut、RemoveFolder 以及 RegistryValue。都蠻直覺的,Shortcut 代表這個捷徑的目標執行檔是哪一個,在哪一個資料夾中執行:第二個 Remove 之前說過了就不多作介紹,最後一個 RegistryValue,表示在安裝的同時要新增一筆資料到 Registry 中,這樣就完成了我們的 DirectoryRef 了。前面有提到 Component 的 Id 必須要是唯一的,在下面的 Feature 當中會使用到這些 Id。

那我們的檔案呢?因為前面我們使用了 heat 來產生檔案相關的 wxs 了,在 DirectoryRef 裡面就不用重複寫上,只需要在下面的 Feature 加上即可。

更詳細的內容可以參考:DirectoryRef Element, Component Element, File Element, Heat Tool

Feature

再來介紹一下 Feature 這個 Element,我們前面定義了這麼多東西,倒底安裝的時候全部都要進去,還是我只要安裝某幾個 Component 就好了,這些設定都放在 Feature 裡面。所以看一下下面的 xml 也很直覺,只要簡單得把我們要安裝的 Component Id 寫下,並且放在 ComponentRef Element 中,在安裝的時候就會把這個 Component 一起安裝進去。這邊要特別注意就是 ComponentGroupRef Element,Id="HarvestedComponentId",這邊的 Id 就是前面使用 heat 指令時 -cg 的 attribute,利用這個 Element,把 heat 產生落落長的檔案全部都包含進來,一來可以讓原本的 xml 乾淨,可讀性又高,二來方便管理,如果新增或刪除了某些檔案,不用一個一個編輯 xml,方便許多。

<Feature Id="FEATUREPRODUCTNAME" 
         AllowAdvertise="no" 
         Display="expand" 
         Level="1" 
         InstallDefault="local"
         ConfigurableDirectory="INSTALLDIR">
    <ComponentRef Id="componentINSTALLDIR" />
    <ComponentRef Id="componentProgramMenuDirHpProduct" />
    <ComponentGroupRef Id="HarvestedComponentId" />
</Feature>

到這邊其實就已經告一段落了,產生出來的 msi 已經可以成功安裝,可惜的沒有漂亮的介面,使用者的體驗不是很好。Wix 也提供了一些方法,可以讓我們方便又快速的新增安裝的介面,提升使用者體驗,繼續看下去吧。

更詳細的內容可以參考:Feature Element

UI

我對 UI 比較沒有深入研究,在 Wix 的教學中看到,利用 Wix 可以製造出很漂亮的 UI,排版之類的也都可以很動態的設置,另外 Wix 還提供了懶人介面設計,利用內建的幾個樣板,可以製造出看起來還不錯的介面,我用了 WixUI_InstallDir 的樣板,可以幫我打造出兩頁,一頁歡迎頁,一頁讓使用者可以選擇要安裝在哪個資料夾,如果想要看更詳細的介面設計,可以看這邊

<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
<UIRef Id="WixUI_InstallDir" />
<UI>
    <Publish Control="Next" 
             Dialog="WelcomeDlg" 
             Value="InstallDirDlg" 
             Event="NewDialog">1</Publish>
    <Publish Control="Back" 
             Dialog="InstallDirDlg" 
             Value="WelcomeDlg" 
             Event="NewDialog" 
             Order="2">2</Publish>
</UI>

更詳細的內容可以參考:UI Element, Publish Element


產生 msi

好了,說了這麼多,也做了一堆事情,是應該把所有東西結合再一起產生 .msi 了。使用下面的指令就可以四行產生 msi

## Using heat to generate the harvested file from SOURCE_FILE_DIRECTORY
heat dir SOURCE_FILE_DIRECTORY -t HeatTransform.xslt -cg HarvestedComponentId -out heat_harvested_results.wxs -dr INSTALLDIR -var var.Dist -scom -frag -srd -sreg -gg

## Generate the wixobj file of heat result
candle.exe heat_harvested_results.wxs -dDist="SOURCE_FILE_DIRECTORY"

## Generate the wixjob file of the xml that you create, change the example.wxs to your file name
candle.exe example.wxs

## Generate the msi by the 2 wixobj file above
light.exe heat_harvested_results.wixobj example.wixobj -o "MSI_OUTPUT_FILENAME" -ext WixUIExtension

自動產生 wxs 的 script

因為擔心以後還會用到,我自己做了一個 powershell script,用來產生最基本可用的 .wxs,包含上面說的功能,另外也包含了 heat 要用到的 template。如果要使用的話,請自己修改每一個 Function 中定義的部分,就可以根據這個 script 產生對應的 .wxs 了,script 在這邊

Build 

comments powered by Disqus