NSIS 是流行的 Windows 程序打包程序。使用其脚本配置快速生成。该工具支持在多个平台下工作。
本文在 MacOS 下安装该工具,并打包使用 golang 的 Wails 编写的客户端程序。

安装 NSIS

该软件支持 Windows,Linux 和 MacOS。根据 Wails 官网部分 指引安装即可。
在 MacOS 系统中,本文使用 homebrew 安装。

brew install nsis

实际安装的二进制命令为 makensis

脚本编写

Wails 创建的项目自带了 nsis 的配置脚本。但是对于初次接触的人不好理解里面各种命令、指令的作用 。本人从空白到打包完成给出基础的命令。细节部分还不到位,还需要查询资料后处理。

⚠️注意:NSIS 脚本的执行是自上而下执行。所以命令的执行是有先后顺序的,必须按照以下顺序依次配置编写。

首先创建一个文件,结尾使用 .nsi 结尾,该文件就是我们的打包配置脚本了。

NSIS 的安装界面我们使用现代的 UI 设计,否则安装界面的样式就跟上个世纪的一样。在文件开头写入

# 引入现代化用户界面
!include "MUI2.nsh"

定义常用的一些变量,包含桌面快捷方式名称,主程序文件名,安装程序欢迎界面的程序名称。常量使用如下方式定义,在使用时使用 ${常量名称} 来引用。

# 常量定义
!define DESKTOP_SHORTCUT_NAME "TestProject.lnk"
!define MAIN_BIN_NAME "test_project.exe"
!define SERVER_JAR_NAME "demo-0.0.1-SNAPSHOT.jar"
!define INSTALLER_TITLE_NAME "测试程序"

定义我们的安装程序生成后放的位置,注意该位置是相对于你 nsi 脚本存放的位置。当然你也可以使用绝对路径或者仅写一个文件名,如果仅有文件名,那么打包输出的位置就是本脚本所在的位置同级。

Outfile "../../DemoInstaller.exe"  # NSIS 编译打包输出位置,相对于本脚本的位置

定义安装界面的一些名字和提示语

Caption "${INSTALLER_TITLE_NAME} Installer"  # 设置安装窗口标题
Name "${INSTALLER_TITLE_NAME}"  # 安装界面欢迎标题中的程序名称
BrandingText "欢迎使用 ${INSTALLER_TITLE_NAME}"  # 设置左下角灰色标语

配置关闭警告和安装程序的图标以及卸载程序的图标,这里的文件路径也是相对于本脚本的路径,也可以写绝对路径。图标必须是 Windows 使用的 .ico 文件,否则无效。

!define MUI_ABORTWARNING   # 关闭安装程序弹窗确认
!define MUI_ICON "..\icon.ico"  # 设置安装程序图标
!define MUI_UNICON "..\icon.ico"  # 设置卸载程序图标

设置程序的默认安装位置。这里的 $PROGRAMFILES 就是最常见的 C:\Program XXXX 的那个,后面可以自行拼接路径。和大多数安装程序一样,安装时会自动创建安装目录,如果目录不存在的话。

# 安装目录默认路径
InstallDir "$PROGRAMFILES\MyApp"

请求管理员权限,因为安装程序可以让用户自行选择安装位置,为了防止某些文件权限问题,请求管理员权限以正常写入磁盘文件。

# 请求管理员权限
RequestExecutionLevel admin

界面设置,NSIS 新版的安装界面都是模块化的,需要什么界面就插入什么界面。这里的 license 文件路径同上,都是相对于本脚本的位置。license 文件是必须的,需要事先准备好。

# 安装器界面设置
!define MUI_PRODUCT_NAME "${APP_NAME}"  # 设置产品名称
!insertmacro MUI_PAGE_WELCOME          # 欢迎界面
!insertmacro MUI_PAGE_LICENSE "../../bin/license.txt"   # 许可证界面,license.txt为你的许可证文件
!insertmacro MUI_PAGE_DIRECTORY        # 选择安装目录界面
!insertmacro MUI_PAGE_INSTFILES        # 安装进度界面
!insertmacro MUI_PAGE_FINISH           # 完成界面

# 卸载界面设置
!insertmacro MUI_UNPAGE_WELCOME        # 卸载欢迎界面
!insertmacro MUI_UNPAGE_CONFIRM        # 卸载确认界面
!insertmacro MUI_UNPAGE_INSTFILES      # 卸载进度界面
!insertmacro MUI_UNPAGE_FINISH         # 卸载完成界面

# 设置语言为简体中文
!insertmacro MUI_LANGUAGE "SimpChinese"

主要部分,安装逻辑和卸载逻辑。本质上我们的程序二进制都是由编译器生成。附属的库文件什么的都是事先准备好的。NSIS 的打包动作等同于创建压缩文件。配置参考如下内容注释

# 定义安装的 Section
Section "Install"
  # 创建目录 $INSTDIR 就是用户选择的安装目录。
  # 注意 Windows 上的路径必须是 \ 间隔。
  CreateDirectory "$INSTDIR\Resources"
  CreateDirectory "$INSTDIR\Resources\jre"

  # 设置文件输出路径,可以理解为切换工作目录。
  # 以下两行意思是:安装时复制本机本脚本相对路径的 ../../bin/${MAIN_BIN_NAME} 文件到用户指定的安装目录
  SetOutPath "$INSTDIR"
  File "../../bin/${MAIN_BIN_NAME}"  # 这里的路径为相对本脚本的路径

  # 更换输出路径
  SetOutPath "$INSTDIR\Resources"
  File "../../bin/${SERVER_JAR_NAME}"

  # 更换输出路径
  # 复制 jre 整个文件夹,注意这里的复制是文件夹下的所有内容,并且是递归复制。
  SetOutPath "$INSTDIR\Resources\jre"
  File /r "../../bin/windows/jre/*.*"

  # 创建桌面快捷方式,两个参数,第一个是跨界方式名称,第二个是你主程序的文件名。路径是 Windows 上的路径。
  CreateShortcut "$DESKTOP\${DESKTOP_SHORTCUT_NAME}" "$INSTDIR\${MAIN_BIN_NAME}"

  # 添加卸载程序,卸载程序名也是自己写的
  WriteUninstaller "$INSTDIR\uninstaller.exe"

SectionEnd

# 卸载 Section
Section "Uninstall"

  # 删除安装目录及其所有内容
  RMDir /r "$INSTDIR"

  # 删除桌面快捷方式
  Delete "$DESKTOP\${DESKTOP_SHORTCUT_NAME}"

SectionEnd

开始编译

上述脚本准备好之后,执行命令。

makensis build/windows/installer/installer.nsi

注册表问题

通常来讲 Windows 上面安装的程序都可以在 控制面板 中卸载。这个功能需要在注册表中写入部分内容,且是约定的规范。本文创建的脚本,没有这块操作。所以安装完成后就是一个"绿色软件"。

Wails HTTP 访问安全问题设置

该文件出现在 MacOS 的 APP 打包上。使用 wails build 在 macOS 上打包 app 时,正常打包。但是如果前端使用了 http 方式访问服务器数据。就会被阻止。

在 wails 创建的项目中,找到 build/darwin/info.plist 以及 info.dev.plist 两个文件,在第一层 dict 标签末尾追加以下配置即可。

<key>NSAppTransportSecurity</key>
<dict>
	<key>NSAllowsLocalNetworking</key>
	<true/>
</dict>

完整示例

# info.dev.plist
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>CFBundlePackageType</key>
        <string>APPL</string>
        <key>CFBundleName</key>
        <string>{{.Info.ProductName}}</string>
        <key>CFBundleExecutable</key>
        <string>{{.Name}}</string>
        <key>CFBundleIdentifier</key>
        <string>com.wails.{{.Name}}</string>
        <key>CFBundleVersion</key>
        <string>{{.Info.ProductVersion}}</string>
        <key>CFBundleGetInfoString</key>
        <string>{{.Info.Comments}}</string>
        <key>CFBundleShortVersionString</key>
        <string>{{.Info.ProductVersion}}</string>
        <key>CFBundleIconFile</key>
        <string>iconfile</string>
        <key>LSMinimumSystemVersion</key>
        <string>10.13.0</string>
        <key>NSHighResolutionCapable</key>
        <string>true</string>
        <key>NSHumanReadableCopyright</key>
        <string>{{.Info.Copyright}}</string>
        {{if .Info.FileAssociations}}
        <key>CFBundleDocumentTypes</key>
        <array>
          {{range .Info.FileAssociations}}
          <dict>
            <key>CFBundleTypeExtensions</key>
            <array>
              <string>{{.Ext}}</string>
            </array>
            <key>CFBundleTypeName</key>
            <string>{{.Name}}</string>
            <key>CFBundleTypeRole</key>
            <string>{{.Role}}</string>
            <key>CFBundleTypeIconFile</key>
            <string>{{.IconName}}</string>
          </dict>
          {{end}}
        </array>
        {{end}}
        {{if .Info.Protocols}}
        <key>CFBundleURLTypes</key>
        <array>
          {{range .Info.Protocols}}
            <dict>
                <key>CFBundleURLName</key>
                <string>com.wails.{{.Scheme}}</string>
                <key>CFBundleURLSchemes</key>
                <array>
                    <string>{{.Scheme}}</string>
                </array>
                <key>CFBundleTypeRole</key>
                <string>{{.Role}}</string>
            </dict>
          {{end}}
        </array>
        {{end}}
        <key>NSAppTransportSecurity</key>
        <dict>
            <key>NSAllowsLocalNetworking</key>
            <true/>
        </dict>
        <key>NSAppTransportSecurity</key>
        <dict>
          <key>NSAllowsArbitraryLoads</key>
          <true/>
        </dict>
    </dict>
</plist>