这段时间为开发的应用制作安装程序,选用了免费的Wix toolset来制作,花了大半个星期的时间。这篇文章记录一下制作过程中遇到的一些典型问题,希望对遭遇同样问题的同学能有所帮助。
如何批量生成component
Wix的基本逻辑是把每个要安装的文件作为一个component,类似以下结构
<Component Id="cmp0C572E8D2BFFC59B011F9362292F5902" Guid="{FF8E06CC-99CE-4818-9FAA-79B0DF282571}" Win64="yes" >
<File Id="filCB87CA23169B24DC40451965BA65034F" KeyPath="yes" Source="$(var.myapp.TargetDir)\Autofac.dll" ProcessorArchitecture="x64" />
</Component>
如果要安装的文件很多,手写的方式十分低效,Wix提供了一个工具来生成此内容,在Wix toolset的安装目录中,目录通常是C:\Program Files (x86)\WiX Toolset v3.11\bin,具体用法:
heat.exe dir "C:\project\myapp\bin\Release" -dr INSTALLFOLDER -cg ProductComponent -gg -scom -sreg -sfrag -out "C:\project\mySetupProject\myapp.wxs"
其中C:\project\myapp\bin\Release是你要安装的文件所在目录,heat会扫描目录下所有文件,包括子目录。生成的文件位置在C:\project\mySetupProject\myapp.wxs,也就是out参数指定的地方。这里mySetupProject是安装程序本身的工程。wix工程会扫描工程目录下所有wxs文件,一起进行编译链接。
注意生成后的File节点的Source属性可能不正确,需要替换成$(var.myapp.TargetDir),这里myapp是指你自己的应用程序的项目名。
如何生成64位安装程序
默认生成是32位的,如以下片段
<Directory Id="ProgramFilesFolder">
<Directory Id="COMPANYFOLDER" Name="!(bind.property.Manufacturer)">
<Directory Id="INSTALLFOLDER" Name="!(bind.property.ProductName)" />
</Directory>
</Directory>
ProgramFilesFolder会指向C:\program files(x86)目录, 为了改成指向C:\program files目录,需要修改几处地方
- Package节点添加Platform属性
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
- Component和File节点都标明64位属性
<Component Id="cmp0C572E8D2BFFC59B011F9362292F5902" Guid="{FF8E06CC-99CE-4818-9FAA-79B0DF282571}" Win64="yes" >
<File Id="filCB87CA23169B24DC40451965BA65034F" KeyPath="yes" Source="$(var.myApp.TargetDir)\Autofac.dll" ProcessorArchitecture="x64" />
</Component>
3.目录指明目标文件夹
<Directory Id="ProgramFiles64Folder">
<Directory Id="COMPANYFOLDER" Name="!(bind.property.Manufacturer)">
<Directory Id="INSTALLFOLDER" Name="!(bind.property.ProductName)" />
</Directory>
</Directory>
如何设定安装语言
首先创建语言文件strings_zh-cn.wxl,这个就是国际化对应的翻译文件,文件名后缀zh-cn对应不同语言,所有的界面文字都可以引用这个文件里的条目,引用方式为 !(loc.xxx),这里xxx就是文件中的Id
<?xml version="1.0" encoding="utf-8"?>
<WixLocalization Culture="zh-cn" Codepage="936" xmlns="http://schemas.microsoft.com/wix/2006/localization">
<String Id="Lang">2052</String>
<String Id="Code">936</String>
<String Id="WixUIBack" Overridable="yes">上一步(&B)</String>
<String Id="WixUINext" Overridable="yes">下一步(&N)</String>
<String Id="WixUICancel" Overridable="yes">取消</String>
<String Id="WixUIFinish" Overridable="yes">完成(&F)</String>
<String Id="WixUIRetry" Overridable="yes">重试(&R)</String>
<String Id="WixUIIgnore" Overridable="yes">忽略(&I)</String>
<String Id="WixUIYes" Overridable="yes">是(&Y)</String>
<String Id="WixUINo" Overridable="yes">否(&N)</String>
<String Id="WixUIOK" Overridable="yes">确定</String>
<String Id="WixUIPrint" Overridable="yes">打印(&P)</String>
</WixLocalization>
然后修改Package节点
<Package InstallerVersion="200" Compressed="yes" Languages="!(loc.Lang)" SummaryCodepage="!(loc.Code)" InstallScope="perMachine" Platform="x64" />
这里Languages和SummaryCodepage属性就引用的语言文件设定的值。
如何以管理员权限在安装过程中执行命令
我们的业务需求是安装完成后需要启动一个windows服务,虽然wix提供了服务安装,但由于我们的服务可以直接由命令安装,所以没使用wix的方式,而是在安装copy文件以后,以管理员权限执行一个命令。经过尝试与摸索,配置的方法如下:
- package节点指明权限
<Package InstallerVersion="200" Compressed="yes" Languages="!(loc.Lang)" SummaryCodepage="!(loc.Code)" InstallScope="perMachine" Platform="x64" InstallPrivileges="elevated" AdminImage="yes"/>
这里添加了InstallPrivileges和AdminImage属性
- 定义一个customaction
<CustomAction Id="regService" Execute="deferred" Return="check" Directory="INSTALLFOLDER" Impersonate="no" ExeCommand="cmd.exe /c "myapp.exe install -servicename:myAppService -username:[USERACCOUNT] -password:[PASSWD] -displayname:myAppService""/>
这里USERACCOUNT和PASSWD是用户在界面输入的参数,这个后面再介绍,这里的关键是Execute属性定义为deferred,Impersonate属性定义为no,然后用cmd去执行。
3.定义InstallExecuteSequence,指明执行顺序
<InstallExecuteSequence>
<Custom Action='regService' Before='InstallFinalize'>NOT Installed</Custom>
</InstallExecuteSequence>
注意这里执行时机必须是Before InstallFinalize
如何在卸载过程中执行命令
卸载和安装过程的执行命令类似,我们的需求是取消后台服务,同样定义一个CustomAction
<CustomAction Id="unRegService" Execute="deferred" Return="check" Directory="INSTALLFOLDER" Impersonate="no" ExeCommand="cmd.exe /c "myApp.exe uninstall -servicename:myAppService""/>
注意Execute和Impersonate属性,然后定义InstallExecuteSequence序列
<InstallExecuteSequence>
<Custom Action='regService' Before='InstallFinalize'>NOT Installed</Custom>
<Custom Action='unRegService' Before='RemoveFiles'>REMOVE</Custom>
</InstallExecuteSequence>
注意这里的触发时机是Before RemoveFiles,而且触发条件是REMOVE,也就是在卸载时才动作。
如何自定义界面并插入界面序列中
自定义界面的开发可以看看网络上的其他教程,这里说以下配置,我们的需求是在WixUI_FeatureTree模式下,license界面以后,跳到自定义的一个界面,用户输入一些环境信息,例如账号密码,点击下一步再进入组件选择的界面。也就是说将自定义界面插入原生的license和组件选择界面中间。
首先去github上找到wix toolset的源码,因为我们用的是WixUI_FeatureTree模式,就找到WixUI_FeatureTree.wxs这个文件,路径是src/ext/UIExtension/wixlib/WixUI_FeatureTree.wxs,然后将里面的<UI Id="WixUI_FeatureTree">节点拷贝到我们自己的工程中,放在Product节点下。
修改LicenseAgreementDlg界面的next按钮的指向
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="EnvInfoDialog">LicenseAccepted = "1"</Publish>
这里EnvInfoDialog是我们自己开发的界面
- 修改组件选择的界面的back按钮的指向
<Publish Dialog="CustomizeDlg" Control="Back" Event="NewDialog" Value="EnvInfoDialog" Order="2">NOT Installed</Publish>
4.修改我们自己界面的back和Next按钮的指向
<Publish Dialog="EnvInfoDialog" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
<Publish Dialog="EnvInfoDialog" Control="Next" Event="NewDialog" Value="CustomizeDlg">1</Publish>
这就像修改双向链表,插入一个节点。学过数据结构的都明白。
如何传递变量给自定义行为
开发过程中遇到的一个问题就是我们需要根据用户输入修改配置文件,这是由一个CustomAction来执行修改动作的,其 Execute是"deferred"模式,界面上采集的用户输入以会话变量的形式存在,但在deferred模式下又无法直接传递给CustomAction使用。在查阅很多资料后,才找到了正确途径:
<CustomAction Id="EnvInfoSaveAction.SetProperty" Return="check" Property="EnvInfoSaveAction" Value="USERACCOUNT=[USERACCOUNT];PASSWD=[PASSWD];ESHOST=[ESHOST]"/>
<CustomAction Id="EnvInfoSaveAction" BinaryKey="CustomActionBinary" DllEntry="saveConfigInfo" Execute="deferred" Impersonate="no"/>
这里的关键就是要再定义一个CustomAction,其 Property属性是修改配置的CustomAction 的Id,然后Value属性是要传递的变量,以key=value的形式,用分号分隔。最后在CustomAction的实际代码中,用session.CustomActionData去获取变量的值。
public class CustomActions
{
[CustomAction]
public static ActionResult saveConfigInfo(Session session)
{
string account = session.CustomActionData["USERACCOUNT"];
... Do Something
}
}
如何自定义License声明
这个比较简单,提供一个License文件,格式为rtf的,再覆盖定义Wix变量即可
<WixVariable Id="WixUILicenseRtf" Value="License.rtf" />
如何Debug安装程序和卸载过程
有时候安装和卸载过程中会出错,那么这时候能看到log会方便排查问题,可以用msiexec命令来执行你的安装程序,同时提供日志文件参数
msiexec MyProduct.msi /L*V "%TEMP%\MyProduct.log"
安装以后,windows系统会把安装程序保持在自己的c:\windows\installer目录一份,卸载的时候执行这个程序,如果卸载的时候出问题,可以在此目录找到自己的安装程序,虽然被重命名了,但是可以看控制面板卸载时要求权限的确认框,这里会有具体文件信息。要生成卸载日志,可以用msiexec去执行这个msi文件,指定卸载参数和日志参数
msiexec /x xxxx.msi /L*V "%TEMP%\MyProduct.log"
如何解决无法卸载干净的问题
这个问题通常是在开发安装程序的过程中出现,多次安装以后,突然卸载不干净了,控制面板中卸载成功以后,文件却还遗留在系统中。通过查看log,发现有这样的提示
Disallowing uninstallation of component: {5C517030-1AA5-4018-82F2-A2D6F784EC04} since another client exists
意思是这个组件还被其他程序使用,所以不允许删除。其实都是开发过程中偶尔的安装错误导致系统认为还有别的客户端在使用,这些信息被存储在注册表中。严格说起来,不建议在开发环境中直接测试安装程序,一般都是找个干净的环境专门用于测试和还原。回来说问题的解决,在实验了数款清理工具和软件后,找到一个有效的powershell脚本,执行这个脚本即可清理注册表中的遗留数据,需要这里面的productName替换成安装工程product节点的Name属性值。
$productName = "myApp" # this should basically match against your previous
# installation path. Make sure that you don't mess with other components used
# by any other MSI package
$components = Get-ChildItem -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Components\
$count = 0
foreach ($c in $components)
{
foreach($p in $c.Property)
{
$propValue = (Get-ItemProperty "Registry::$($c.Name)" -Name "$($p)")."$($p)"
if ($propValue -match $productName)
{
Write-Output $propValue
$count++
Remove-Item "Registry::$($c.Name)" -Recurse
}
}
}
Write-Host "$($count) key(s) removed"