// The Windows installer target. The generated NSIS script is intentionally
// repeatable so it can replace an existing installation.
///|
/// NSIS derives the silent-install switch (`/S`) itself.
let windows_installer_tool : String = "makensis"
///|
fn windows_installer_path(plan : PackageSpec) -> String {
resolve_path(plan.output, "\{executable_name(plan.product_name)}-setup.exe")
}
///|
fn windows_installer_staging_path(plan : PackageSpec) -> String {
resolve_path(
plan.output,
".\{executable_name(plan.product_name)}.staging-setup.exe",
)
}
///|
fn windows_installer_script_path(plan : PackageSpec) -> String {
resolve_path(
plan.output,
".\{executable_name(plan.product_name)}.installer.nsi",
)
}
///|
/// The registry key holding the install location. The updater does not read
/// it — it re-runs the installer, which resolves the location itself — but
/// Add/Remove Programs and a manual reinstall both do.
fn windows_installer_registry_key(plan : PackageSpec) -> String {
"Software\\\{plan.identifier}"
}
///|
fn windows_uninstall_registry_key(plan : PackageSpec) -> String {
"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\\{plan.identifier}"
}
///|
fn windows_protocol_command(executable : String) -> String {
"$\\\"$INSTDIR\\\{executable}$\\\" $\\\"%1$\\\""
}
///|
fn windows_protocol_install_script(
plan : PackageSpec,
executable : String,
) -> String {
let builder = StringBuilder()
let command = windows_protocol_command(executable)
for scheme in plan.url_schemes {
let scheme = windows_installer_quote(scheme)
builder <+
$| WriteRegStr SHCTX "Software\\Classes\\\{scheme}" "" "URL:\{scheme}"
builder.write_char('\n')
builder <+
$| WriteRegStr SHCTX "Software\\Classes\\\{scheme}" "URL Protocol" ""
builder.write_char('\n')
builder <+
$| WriteRegStr SHCTX "Software\\Classes\\\{scheme}\\shell\\open\\command" "" "\{command}"
builder.write_char('\n')
}
builder.to_string()
}
///|
fn windows_protocol_uninstall_script(
plan : PackageSpec,
executable : String,
) -> String {
let builder = StringBuilder()
let command = windows_protocol_command(executable)
for scheme in plan.url_schemes {
let scheme = windows_installer_quote(scheme)
builder <+
$| ReadRegStr $0 SHCTX "Software\\Classes\\\{scheme}\\shell\\open\\command" ""
builder.write_char('\n')
builder <+
$| ${If} $0 == "\{command}"
builder.write_char('\n')
builder <+
$| DeleteRegKey SHCTX "Software\\Classes\\\{scheme}"
builder.write_char('\n')
builder <+
#| ${EndIf}
builder.write_char('\n')
}
builder.to_string()
}
///|
/// `VIProductVersion` rejects anything that is not exactly four numeric
/// fields, so a semantic version has to be padded and any pre-release suffix
/// dropped. The human-readable version still ships in `FileVersion`.
fn windows_installer_version_quad(version : String) -> String {
let digits = []
let current = StringBuilder()
for char in version {
if char.is_ascii_digit() {
current.write_char(char)
} else {
if current.to_string() != "" {
digits.push(current.to_string())
current.reset()
}
if char != '.' {
break
}
}
}
if current.to_string() != "" {
digits.push(current.to_string())
}
while digits.length() < 4 {
digits.push("0")
}
let quad = digits[:4]
quad.to_owned().join(".")
}
///|
/// Escapes a value for an NSIS double-quoted string.
///
/// `$` opens a variable reference such as `$INSTDIR` and is doubled to stay
/// literal; a quote is written `$\"`. The `$` pass has to run first, or it
/// would go back over the `$` that the quote escape just introduced.
/// Backslashes are ordinary characters here and must be left alone, otherwise
/// every Windows path in the script breaks.
fn windows_installer_quote(value : String) -> String {
value.replace_all(old="$", new="$$").replace_all(old="\"", new="$\\\"")
}
///|
/// Renders the installer script.
///
/// Kept pure so the generated text is testable on any host; running
/// `makensis` needs Windows tooling that only the packaging step has.
fn windows_installer_script(plan : PackageSpec, source : String) -> String {
let product = windows_installer_quote(plan.product_name)
let executable = "\{executable_name(plan.product_name)}.exe"
let builder = StringBuilder()
builder <+
#|Unicode true
#|ManifestDPIAware true
#|SetCompressor /SOLID lzma
#|
#|!include "FileFunc.nsh"
#|!include "LogicLib.nsh"
#|!include "MUI2.nsh"
#|
#|Var ProtonUpdate
#|Var ProtonUpdatePid
#|
builder <+
$|Name "\{product}"
$|OutFile "\{windows_installer_quote(windows_installer_staging_path(plan))}"
$|
builder.write_string(windows_install_mode_header(plan))
builder <+
$|
$|VIProductVersion "\{windows_installer_version_quad(plan.version)}"
$|VIAddVersionKey "ProductName" "\{product}"
$|VIAddVersionKey "FileVersion" "\{windows_installer_quote(plan.version)}"
$|VIAddVersionKey "ProductVersion" "\{windows_installer_quote(plan.version)}"
$|VIAddVersionKey "FileDescription" "\{product} Setup"
$|
builder <+
#|Function .onInit
#|
builder.write_string(windows_install_mode_init(plan, false))
builder <+
#| ${GetParameters} $R0
#| ${GetOptions} $R0 "/PROTON-UPDATE=" $ProtonUpdate
#| ${GetOptions} $R0 "/PROTON-PID=" $ProtonUpdatePid
#| ${If} $ProtonUpdate == "1"
#| ${AndIf} $ProtonUpdatePid == ""
#| Abort "The update process id is missing."
#| ${EndIf}
#|
builder.write_string(windows_install_mode_update_init(plan))
builder <+
#|FunctionEnd
#|
#|Function un.onInit
#|
builder.write_string(windows_install_mode_init(plan, true))
builder <+
#|FunctionEnd
#|
#|!define MUI_ABORTWARNING
#|
if plan.nsis_install_mode == Both {
builder.write_string("!insertmacro MULTIUSER_PAGE_INSTALLMODE\n")
}
builder <+
#|!insertmacro MUI_PAGE_DIRECTORY
#|!insertmacro MUI_PAGE_INSTFILES
#|!insertmacro MUI_UNPAGE_CONFIRM
#|!insertmacro MUI_UNPAGE_INSTFILES
#|!insertmacro MUI_LANGUAGE "English"
#|
#|Section "Install"
#|
builder <+
$| ; Expand the complete release beside the installed tree before moving
$| ; either one. A failed extraction therefore leaves the old app intact.
$| RMDir /r "$INSTDIR.proton-next"
$| SetOutPath "$INSTDIR.proton-next"
$| ClearErrors
$| File /r "\{windows_installer_quote(source)}\\*.*"
$| IfErrors install_failed
$| ClearErrors
$| WriteUninstaller "$INSTDIR.proton-next\\Uninstall.exe"
$| IfErrors install_failed
$| ; SetOutPath also changes the working directory, which Windows locks.
$| SetOutPath "$TEMP"
$|
$| ; Reaching a later update proves the current tree started successfully,
$| ; so its predecessor is no longer needed as the recovery copy.
$| RMDir /r "$INSTDIR.proton-previous"
$| IfFileExists "$INSTDIR\\*.*" 0 promote_update
$| ; The updater names the exact process tree it is replacing. A manual
$| ; reinstall has no such token and falls back to the executable name.
$| ${If} $ProtonUpdate == "1"
$| nsExec::Exec 'taskkill /F /T /PID "$ProtonUpdatePid"'
$| ${Else}
$| nsExec::Exec 'taskkill /F /T /IM "\{executable}"'
$| ${EndIf}
$| Pop $0
$| Sleep 500
$| ClearErrors
$| Rename "$INSTDIR" "$INSTDIR.proton-previous"
$| IfErrors install_failed
$|promote_update:
$| ClearErrors
$| Rename "$INSTDIR.proton-next" "$INSTDIR"
$| IfErrors 0 install_complete
$| Rename "$INSTDIR.proton-previous" "$INSTDIR"
$| Abort "The new installation could not be moved into place."
$|install_failed:
$| SetOutPath "$TEMP"
$| RMDir /r "$INSTDIR.proton-next"
$| Abort "The update could not be installed."
$|install_complete:
$| SetOutPath "$INSTDIR"
$|
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_installer_registry_key(plan))}" "InstallDir" "$INSTDIR"
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_installer_registry_key(plan))}" "Version" "\{windows_installer_quote(plan.version)}"
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "DisplayName" "\{product}"
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "DisplayVersion" "\{windows_installer_quote(plan.version)}"
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "UninstallString" "$\\"$INSTDIR\\Uninstall.exe$\\"\{if plan.nsis_install_mode == Both { " /$MultiUser.InstallMode" } else { "" }}"
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "InstallLocation" "$INSTDIR"
$| WriteRegStr SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "DisplayIcon" "$INSTDIR\\\{executable}"
$| WriteRegDWORD SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "NoModify" 1
$| WriteRegDWORD SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "NoRepair" 1
$|
builder.write_char('\n')
builder.write_string(windows_protocol_install_script(plan, executable))
builder <+
$| CreateShortCut "$SMPROGRAMS\\\{product}.lnk" "$INSTDIR\\\{executable}"
$| ${If} $ProtonUpdate == "1"
$| Exec '"$INSTDIR\\\{executable}"'
$| Delete /REBOOTOK "$EXEPATH"
$| ${EndIf}
$|SectionEnd
$|
builder <+
$|Section "Uninstall"
$| nsExec::Exec 'taskkill /F /IM "\{executable}"'
$| Pop $0
$| Delete "$SMPROGRAMS\\\{product}.lnk"
$| RMDir /r "$INSTDIR"
$| RMDir /r "$INSTDIR.proton-previous"
$| RMDir /r "$INSTDIR.proton-next"
$| DeleteRegKey SHCTX "\{windows_installer_quote(windows_installer_registry_key(plan))}"
$| DeleteRegKey SHCTX "\{windows_installer_quote(windows_uninstall_registry_key(plan))}"
builder.write_char('\n')
builder.write_string(windows_protocol_uninstall_script(plan, executable))
builder <+
$|SectionEnd
$|
builder.to_string()
}
///|
/// Builds the installer from an already-staged portable directory.
async fn create_windows_installer(
plan : PackageSpec,
source : String,
announce : Bool,
) -> Unit {
let installer = windows_installer_path(plan)
let staging = windows_installer_staging_path(plan)
let script = windows_installer_script_path(plan)
remove_tree(staging)
@async_fs.write_file(
script,
windows_installer_script(plan, source),
create_mode=CreateOrTruncate,
) catch {
error =>
raise WindowsPackagingError::ToolFailure(
stage="write Windows installer script",
detail="\{script}: \{@debug.render(Repr(error))}",
)
}
let (code, output) = @process.collect_output_merged(windows_installer_tool, [
"/V2", script,
]) catch {
_ =>
raise WindowsPackagingError::ToolFailure(
stage="create Windows installer",
detail="\{windows_installer_tool} is not available; install NSIS to build the installer target",
)
}
guard code == 0 else {
raise WindowsPackagingError::ToolFailure(
stage="create Windows installer",
detail="exit code " +
code.to_string() +
": " +
(output.text() catch { _ => "non-UTF-8 output" }),
)
}
guard path_exists(staging) else {
raise WindowsPackagingError::Verification(
detail="installer was not produced: \{staging}",
)
}
promote(staging, installer)
if announce {
println("Created installer: \{installer}")
}
()
}