bat scripting to work around Rust CLI argument parsing on windows
This commit is contained in:
Kevin Bost
2025-03-15 00:47:55 -07:00
parent 14b7119637
commit a8d631dab3
14 changed files with 181 additions and 41 deletions

9
Cargo.lock generated
View File

@@ -2319,15 +2319,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "waitpid-any"
version = "0.3.0"

View File

@@ -16,7 +16,7 @@ use std::{
path::PathBuf,
};
pub fn install(pkg: &mut BundleZip, install_to: (PathBuf, bool), start_args: Option<Vec<&str>>) -> Result<()> {
pub fn install(pkg: &mut BundleZip, install_to: (PathBuf, bool), is_bootstrap_install: bool, start_args: Option<Vec<&str>>) -> Result<()> {
// find and parse nuspec
info!("Reading package manifest...");
let app = pkg.read_manifest()?;
@@ -80,7 +80,7 @@ pub fn install(pkg: &mut BundleZip, install_to: (PathBuf, bool), start_args: Opt
let mut root_path_renamed = String::new();
// does the target directory exist and have files? (eg. already installed)
if !shared::is_dir_empty(&root_path) {
if !shared::is_dir_empty(&root_path) && !is_bootstrap_install {
// the target directory is not empty, and not dead
if !dialogs::show_overwrite_repair_dialog(&app, &root_path, root_is_default) {
// user cancelled overwrite prompt
@@ -104,9 +104,11 @@ pub fn install(pkg: &mut BundleZip, install_to: (PathBuf, bool), start_args: Opt
})?;
}
info!("Preparing and cleaning installation directory...");
remove_dir_all::ensure_empty_dir(&root_path)?;
if !is_bootstrap_install {
info!("Preparing and cleaning installation directory...");
remove_dir_all::ensure_empty_dir(&root_path)?;
}
info!("Acquiring lock...");
let paths = create_config_from_root_dir(&root_path);
let locator = VelopackLocator::new_with_manifest(paths, app);
@@ -122,7 +124,7 @@ pub fn install(pkg: &mut BundleZip, install_to: (PathBuf, bool), start_args: Opt
windows::splash::show_splash_dialog(locator.get_manifest_title(), splash_bytes)
};
let install_result = install_impl(pkg, &locator, &tx, start_args);
let install_result = install_impl(pkg, &locator, &tx, is_bootstrap_install, start_args);
let _ = tx.send(windows::splash::MSG_CLOSE);
if install_result.is_ok() {
@@ -149,6 +151,7 @@ fn install_impl(
pkg: &mut BundleZip,
locator: &VelopackLocator,
tx: &std::sync::mpsc::Sender<i16>,
is_bootstrap_install: bool,
start_args: Option<Vec<&str>>,
) -> Result<()> {
info!("Starting installation!");
@@ -195,7 +198,11 @@ fn install_impl(
}
let _ = tx.send(100);
windows::registry::write_uninstall_entry(&locator)?;
if is_bootstrap_install {
info!("Skipping uninstall entry creation.");
} else {
windows::registry::write_uninstall_entry(&locator)?;
}
if !dialogs::get_silent() {
info!("Starting app...");

View File

@@ -1,4 +1,4 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
//#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![allow(dead_code)]
#[macro_use]
@@ -67,6 +67,7 @@ fn main_inner() -> Result<()> {
.arg(arg!(-v --verbose "Print debug messages to console"))
.arg(arg!(-l --log <FILE> "Enable file logging and set location").required(false).value_parser(value_parser!(PathBuf)))
.arg(arg!(-t --installto <DIR> "Installation directory to install the application").required(false).value_parser(value_parser!(PathBuf)))
.arg(arg!(-b --bootstrap "Just apply install files, do not write uninstall registry keys"))
.arg(arg!([EXE_ARGS] "Arguments to pass to the started executable. Must be preceded by '--'.").required(false).last(true).num_args(0..));
if cfg!(debug_assertions) {
@@ -114,6 +115,7 @@ fn run_inner(arg_config: Command) -> Result<()> {
let debug = matches.get_one::<PathBuf>("debug");
let install_to = matches.get_one::<PathBuf>("installto");
let exe_args: Option<Vec<&str>> = matches.get_many::<String>("EXE_ARGS").map(|v| v.map(|f| f.as_str()).collect());
let is_bootstrap_install = matches.get_flag("bootstrap");
info!("Starting Velopack Setup ({})", env!("NGBV_VERSION"));
info!(" Location: {:?}", env::current_exe()?);
@@ -123,6 +125,7 @@ fn run_inner(arg_config: Command) -> Result<()> {
info!(" Install To: {:?}", install_to);
if cfg!(debug_assertions) {
info!(" Debug: {:?}", debug);
info!(" Bootstrap: {:?}", is_bootstrap_install);
}
// change working directory to the containing directory of the exe
@@ -225,6 +228,6 @@ fn run_inner(arg_config: Command) -> Result<()> {
}
}
commands::install(&mut bundle, (root_path, root_is_default), exe_args)?;
commands::install(&mut bundle, (root_path, root_is_default), is_bootstrap_install, exe_args)?;
Ok(())
}

View File

@@ -1,4 +1,4 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![allow(dead_code)]
#[macro_use]

View File

@@ -23,7 +23,7 @@ pub fn write_uninstall_entry(locator: &VelopackLocator) -> Result<()> {
let formatted_date = format!("{}{:02}{:02}", now.year(), now.month(), now.day());
let uninstall_cmd = format!("\"{}\" --uninstall", updater_path);
let uninstall_quiet = format!("\"{}\" --uninstall --silent", updater_path);
let uninstall_quiet: String = format!("\"{}\" --uninstall --silent", updater_path);
let reg_uninstall =
w::HKEY::CURRENT_USER.RegCreateKeyEx(UNINSTALL_REGISTRY_KEY, None, co::REG_OPTION::NoValue, co::KEY::CREATE_SUB_KEY, None)?.0;

View File

@@ -11,6 +11,7 @@ use winsafe::{self as w, co};
use velopack::bundle::load_bundle_from_file;
use velopack::locator::{auto_locate_app_manifest, LocationContext};
#[cfg(target_os = "windows")]
#[test]
pub fn test_install_apply_uninstall() {

View File

@@ -21,7 +21,9 @@ namespace Velopack
/// <summary> An application installer archive. </summary>
Installer = 4,
/// <summary> A Windows Installer package (.msi) for the deployment tool.</summary>
MsiDeploymentTool = 5
MsiDeploymentTool = 5,
/// <summary> A Windows Installer package (.msi) that bootstraps the installer.</summary>
Msi = 6,
}
/// <summary>

View File

@@ -97,7 +97,7 @@ fn get_known_folder(rfid: *const GUID) -> Result<String, Error> {
#[cfg(target_os = "windows")]
fn pwstr_to_string(input: PWSTR) -> Result<String, Error> {
unsafe {
let hstring = input.to_hstring()?;
let hstring = input.to_hstring();
let string = hstring.to_string_lossy();
Ok(string.trim_end_matches('\0').to_string())
}

View File

@@ -97,6 +97,7 @@ public class PackTask : MSBuildAsyncTask
public string? Compression { get; set; }
public bool BuildMsiDeploymentTool { get; set; }
public bool BuildMsi { get; set; }
public string? MsiVersionOverride { get; set; }

View File

@@ -41,7 +41,7 @@ public static class DefaultName
}
public static string GetSuggestedMsiName(string id, string channel, RuntimeOs os)
public static string GetSuggestedMsiDeploymentToolName(string id, string channel, RuntimeOs os)
{
var suffix = GetUniqueAssetSuffix(channel);
if (os == RuntimeOs.Windows)
@@ -50,6 +50,15 @@ public static class DefaultName
throw new PlatformNotSupportedException("Platform not supported.");
}
public static string GetSuggestedMsiName(string id, string channel, RuntimeOs os)
{
var suffix = GetUniqueAssetSuffix(channel);
if (os == RuntimeOs.Windows)
return $"{id}{suffix}.msi";
else
throw new PlatformNotSupportedException("Platform not supported.");
}
private static string GetUniqueAssetSuffix(string channel)
{
return "-" + channel;

View File

@@ -1,9 +1,9 @@
using System.Globalization;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NuGet.Versioning;
using Velopack.Compression;
using Velopack.Core;
@@ -220,10 +220,16 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
Log.Debug($"Setup bundle created '{Path.GetFileName(targetSetupExe)}'.");
setupExeProgress(100);
if (Options.BuildMsiDeploymentTool && VelopackRuntimeInfo.IsWindows) {
var msiName = DefaultName.GetSuggestedMsiDeploymentToolName(Options.PackId, Options.Channel, TargetOs);
var msiPath = createAsset(msiName, VelopackAssetType.MsiDeploymentTool);
CompileWixTemplateToMsiDeploymentTool(msiProgress, targetSetupExe, msiPath);
}
if (Options.BuildMsi && VelopackRuntimeInfo.IsWindows) {
var msiName = DefaultName.GetSuggestedMsiName(Options.PackId, Options.Channel, TargetOs);
var msiPath = createAsset(msiName, VelopackAssetType.MsiDeploymentTool);
CompileWixTemplateToMsi(msiProgress, targetSetupExe, msiPath);
var msiPath = createAsset(msiName, VelopackAssetType.Msi);
CompileWixTemplateToMsi(msiProgress, releasePkg, packDir, msiPath);
}
return Task.CompletedTask;
@@ -261,7 +267,6 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
return dict;
}
private void CreateExecutableStubForExe(string exeToCopy, string targetStubPath)
{
if (!File.Exists(exeToCopy)) {
@@ -345,6 +350,126 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
[SupportedOSPlatform("windows")]
private void CompileWixTemplateToMsi(Action<int> progress,
string releasePkg, string setupExePath, string msiFilePath)
{
bool packageAs64Bit =
Options.TargetRuntime.Architecture is not RuntimeCpu.x86;
Log.Info($"Compiling msi installer tool in {(packageAs64Bit ? "64-bit" : "32-bit")} mode");
var outputDirectory = Path.GetDirectoryName(releasePkg);
var culture = CultureInfo.GetCultureInfo("en-US").TextInfo.ANSICodePage;
// WiX Identifiers may contain ASCII characters A-Z, a-z, digits, underscores (_), or
// periods(.). Every identifier must begin with either a letter or an underscore.
var wixId = Regex.Replace(Options.PackId, @"[^\w\.]", "_");
if (char.GetUnicodeCategory(wixId[0]) == UnicodeCategory.DecimalDigitNumber)
wixId = "_" + wixId;
var msiVersion = Options.MsiVersionOverride;
if (string.IsNullOrWhiteSpace(msiVersion)) {
var parsedVersion = SemanticVersion.Parse(Options.PackVersion);
msiVersion = $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}.0";
}
static string SanitizeDirectoryString(string name)
=> name;//TODO
string wixVersion = "5.0.2";
string wixProjectFile = $"""
<Project Sdk="WixToolset.Sdk/{wixVersion}">
<PropertyGroup>
<InstallerPlatform>{(packageAs64Bit ? "x64" : "x86")}</InstallerPlatform>
<TargetFileName>{Path.GetFileName(msiFilePath)}</TargetFileName>
</PropertyGroup>
</Project>
""";
//Scope can be perMachine or perUser or perUserOrMachine, https://docs.firegiant.com/wix/schema/wxs/packagescopetype/
//TODO: It is recommended to use ID rather than UpgradeCode. But this should be a namespaced id. This could probably just be our wixId above
string wixPackage = $"""
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="{GetEffectiveTitle()}"
Manufacturer="{GetEffectiveAuthors()}"
Version="{msiVersion}"
Codepage="{culture}"
Language="1033"
Scope="perMachine"
UpgradeCode="{GuidUtil.CreateGuidFromHash($"{Options.PackId}:UpgradeCode")}"
>
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
<StandardDirectory Id="{(packageAs64Bit ? "ProgramFiles64Folder" : "ProgramFiles6432Folder")}">
<Directory Id="INSTALLFOLDER" Name="{SanitizeDirectoryString(GetEffectiveAuthors())}">
<Directory Name="current" />
<Directory Id="PACKAGES_DIR" Name="packages" />
</Directory>
</StandardDirectory>
<File Id="SetupExe" Source="{setupExePath}" KeyPath="yes" />
<File Id="InstallBat" Source="install.bat" KeyPath="yes" />
<!--
Impersonate="no" is required for the script to run with elevation.
This then breaks user checks such as attempting to find the user's Desktop location for shortcuts.
https://docs.firegiant.com/wix/schema/wxs/customaction/
-->
<CustomAction Id="RunSetup"
Directory="INSTALLFOLDER"
ExeCommand="[INSTALLFOLDER]install.bat"
Impersonate="yes"
TerminalServerAware="yes"
Execute="deferred"
Return="check" />
<InstallExecuteSequence>
<!-- https://docs.firegiant.com/wix3/xsd/wix/installexecutesequence/-->
<Custom Action="RunSetup" After="InstallFiles" />
</InstallExecuteSequence>
</Package>
</Wix>
""";
//<Files Include="{packDir}\**" />
string installBatchScript = $"""
@REM @echo off
set CURRENT_DIR=%~dp0:~0,-1%
set SETUP_EXE="%CURRENT_DIR%\{Path.GetFileName(setupExePath)}"
%SETUP_EXE% -s --bootstrap -t "%CURRENT_DIR%"
""";
//File.Copy(Path.Combine(packDir, "Squirrel.exe"), Path.Combine(dir.FullName, "Update.exe"), true);
var wixproj = Path.Combine(outputDirectory, wixId + ".wixproj");
var wxs = Path.Combine(outputDirectory, wixId + ".wxs");
try {
File.WriteAllText(Path.Combine(outputDirectory, "install.bat"), installBatchScript, Encoding.UTF8);
File.WriteAllText(wixproj, wixProjectFile, Encoding.UTF8);
File.WriteAllText(wxs, wixPackage, Encoding.UTF8);
//TODO: Allow for some level of customization
progress(30);
// NB: Assuming dotnet is installed
Log.Info("Compiling WiX Template (dotnet build)");
var buildCommand = $"dotnet build -c Release \"{wixproj}\" -o \"{Path.GetDirectoryName(msiFilePath)}\"";
//Debugger.Launch();
_ = Exe.RunHostedCommand(buildCommand);
progress(90);
} finally {
IoUtil.DeleteFileOrDirectoryHard(wixproj, throwOnFailure: false);
IoUtil.DeleteFileOrDirectoryHard(wxs, throwOnFailure: false);
}
progress(100);
}
[SupportedOSPlatform("windows")]
private void CompileWixTemplateToMsiDeploymentTool(Action<int> progress,
string setupExePath, string msiFilePath)
{
bool packageAs64Bit =
@@ -391,23 +516,23 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
"ProgramFilesFolder" => packageAs64Bit ? "ProgramFiles64Folder" : "ProgramFilesFolder",
"Win64YesNo" => packageAs64Bit ? "yes" : "no",
"SetupName" => setupName,
_ when key.StartsWith("IdAsGuid") => GuidUtil.CreateGuidFromHash($"{Options.PackId}:{key.Substring(8)}").ToString(),
_ when key.StartsWith("IdAsGuid", StringComparison.OrdinalIgnoreCase) => GuidUtil.CreateGuidFromHash($"{Options.PackId}:{key.Substring("IdAsGuid".Length)}").ToString(),
_ => match.Value,
};
});
File.WriteAllText(wxsFile, templateResult, Encoding.UTF8);
// Candle reprocesses and compiles WiX source files into object files (.wixobj).
// Candle preprocesses and compiles WiX source files into object files (.wixobj).
Log.Info("Compiling WiX Template (candle.exe)");
var candleCommand = $"{HelperFile.WixCandlePath} -nologo -ext WixNetFxExtension -out \"{objFile}\" \"{wxsFile}\"";
var candleCommand = $"{HelperFile.WixCandlePath} -nologo -out \"{objFile}\" \"{wxsFile}\"";
_ = Exe.RunHostedCommand(candleCommand);
progress(45);
// Light links and binds one or more .wixobj files and creates a Windows Installer database (.msi or .msm).
Log.Info("Linking WiX Template (light.exe)");
var lightCommand = $"{HelperFile.WixLightPath} -ext WixNetFxExtension -spdb -sval -out \"{msiFilePath}\" \"{objFile}\"";
var lightCommand = $"{HelperFile.WixLightPath} -spdb -sval -out \"{msiFilePath}\" \"{objFile}\"";
_ = Exe.RunHostedCommand(lightCommand);
progress(90);

View File

@@ -25,6 +25,7 @@ public class WindowsPackOptions : WindowsReleasifyOptions, INugetPackCommand, IP
public string Shortcuts { get; set; }
public bool BuildMsi { get; set; }
public bool BuildMsiDeploymentTool { get; set; }
public string MsiVersionOverride { get; set; }
}

View File

@@ -21,6 +21,7 @@ public class WindowsPackCommand : PackCommand
public string Shortcuts { get; private set; }
public bool BuildMsi { get; private set; }
public bool BuildMsiDeploymentTool { get; private set; }
public string MsiVersionOverride { get; private set; }
@@ -74,15 +75,20 @@ public class WindowsPackCommand : PackCommand
this.AreMutuallyExclusive(signTemplate, signParams, azTrustedSign);
AddOption<bool>((v) => BuildMsi = v, "--msiDeploymentTool")
AddOption<bool>((v) => BuildMsiDeploymentTool = v, "--msiDeploymentTool")
.SetDescription("Compile a .msi machine-wide deployment tool.")
.SetHidden();
;//.SetHidden();
AddOption<string>((v) => MsiVersionOverride = v, "--msiDeploymentToolVersion")
.SetDescription("Override the product version for the generated msi.")
.SetArgumentHelpName("VERSION")
.SetHidden()
//.SetHidden()
.MustBeValidMsiVersion();
AddOption<bool>((v) => BuildMsi = v, "--msi")
.SetDescription("Compile a .msi machine-wide bootstrap package.")
;//.SetHidden();
}
}
}

View File

@@ -5,12 +5,6 @@
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A later version of this product is already installed. Setup will now exit."/>
<Media Id="1" Cabinet="contents.cab" EmbedCab="yes" CompressionLevel="high"/>
<PropertyRef Id="NETFRAMEWORK45" />
<Condition Message="This application requires .NET Framework 4.5 or higher. Please install the latest .NET Framework then run this installer again.">
<![CDATA[Installed OR NETFRAMEWORK45]]>
</Condition>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="{{ProgramFilesFolder}}">
<Directory Id="APPLICATIONROOTDIRECTORY" Name="{{Title}} Deployment Tool" />