Re-implemented msi installer based on Clowd.Squirrel

This re-implements the msi installer that was present inside of Clowd.Squirrel. This is hidden behind some hidden options.
This commit is contained in:
Kevin Bost
2025-01-26 21:52:58 -08:00
committed by Caelan
parent 55fbc17450
commit e9871e1656
29 changed files with 375 additions and 56 deletions

View File

@@ -20,6 +20,8 @@ namespace Velopack
Portable = 3,
/// <summary> An application installer archive. </summary>
Installer = 4,
/// <summary> A Windows Installer package (.msi) for the application.</summary>
Msi = 5,
}
/// <summary>

View File

@@ -94,6 +94,10 @@ public class PackTask : MSBuildAsyncTask
public string? Compression { get; set; }
public bool BuildMsi { get; set; }
public string? MsiVersionOverride { get; set; }
protected override async Task<bool> ExecuteAsync(CancellationToken cancellationToken)
{
//System.Diagnostics.Debugger.Launch();

View File

@@ -1,7 +1,6 @@
using System;
using System.IO;
using Riok.Mapperly.Abstractions;
using Velopack.Core;
using Velopack.Packaging;
using Velopack.Packaging.Unix.Commands;
using Velopack.Packaging.Windows.Commands;

View File

@@ -40,6 +40,16 @@ 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}-DeploymentTool.msi";
else
throw new PlatformNotSupportedException("Platform not supported.");
}
private static string GetUniqueAssetSuffix(string channel)
{
return "-" + channel;

View File

@@ -37,4 +37,5 @@ public class LinuxPackOptions : IPackOptions
public string Categories { get; set; }
public string Compression { get; set; }
public bool BuildMsi => false;
}

View File

@@ -35,4 +35,5 @@ public class OsxPackOptions : OsxBundleOptions, IPackOptions
public string Channel { get; set; }
public string Exclude { get; set; }
public bool BuildMsi => false;
}

View File

@@ -1,6 +1,10 @@
using System.Runtime.Versioning;
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;
using Velopack.Core.Abstractions;
@@ -25,7 +29,7 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
.Select(x => x.FullName)
.ToArray();
SignFilesImpl(Options, progress, filesToSign);
SignFilesImpl(progress, filesToSign);
return Task.CompletedTask;
}
@@ -198,7 +202,7 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
SetupBundle.CreatePackageBundle(targetSetupExe, releasePkg);
progress(50);
Log.Debug("Signing Setup bundle");
SignFilesImpl(Options, CoreUtil.CreateProgressDelegate(progress, 50, 100), targetSetupExe);
SignFilesImpl(CoreUtil.CreateProgressDelegate(progress, 50, 100), targetSetupExe);
Log.Debug($"Setup bundle created '{Path.GetFileName(targetSetupExe)}'.");
progress(100);
@@ -237,6 +241,14 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
return dict;
}
protected override Task CreateMsiPackage(Action<int> progress, string setupExePath, string msiPath)
{
if (VelopackRuntimeInfo.IsWindows) {
CompileWixTemplateToMsi(progress, setupExePath, msiPath);
}
return Task.CompletedTask;
}
private void CreateExecutableStubForExe(string exeToCopy, string targetStubPath)
{
if (!File.Exists(exeToCopy)) {
@@ -253,12 +265,12 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
}
}
private void SignFilesImpl(WindowsSigningOptions options, Action<int> progress, params string[] filePaths)
private void SignFilesImpl(Action<int> progress, params string[] filePaths)
{
var signParams = options.SignParameters;
var signTemplate = options.SignTemplate;
var signParallel = options.SignParallel;
var trustedSignMetadataPath = options.AzureTrustedSignFile;
var signParams = Options.SignParameters;
var signTemplate = Options.SignTemplate;
var signParallel = Options.SignParallel;
var trustedSignMetadataPath = Options.AzureTrustedSignFile;
var helper = new CodeSign(Log);
if (string.IsNullOrEmpty(signParams) && string.IsNullOrEmpty(signTemplate) && string.IsNullOrEmpty(trustedSignMetadataPath)) {
@@ -317,6 +329,83 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
// return dlibPath;
}
[SupportedOSPlatform("windows")]
private void CompileWixTemplateToMsi(Action<int> progress,
string setupExePath, string msiFilePath)
{
bool packageAs64Bit =
Options.TargetRuntime.Architecture is RuntimeCpu.x64 or RuntimeCpu.arm64;
Log.Info($"Compiling machine-wide msi deployment tool in {(packageAs64Bit ? "64-bit" : "32-bit")} mode");
var outputDirectory = Path.GetDirectoryName(setupExePath);
var setupName = Path.GetFileNameWithoutExtension(setupExePath);
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;
Regex stacheRegex = new(@"\{\{(?<key>[^\}]+)\}\}", RegexOptions.Compiled);
var wxsFile = Path.Combine(outputDirectory, wixId + ".wxs");
var objFile = Path.Combine(outputDirectory, wixId + ".wixobj");
var msiVersion = Options.MsiVersionOverride;
if (string.IsNullOrWhiteSpace(msiVersion)) {
var parsedVersion = SemanticVersion.Parse(Options.PackVersion);
msiVersion = $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}.0";
}
try {
// apply dictionary to wsx template
var templateText = File.ReadAllText(HelperFile.WixTemplatePath);
var templateResult = stacheRegex.Replace(templateText, match => {
string key = match.Groups["key"].Value;
return key switch {
"Id" => wixId,
"Title" => GetEffectiveTitle(),
"Author" => GetEffectiveAuthors(),
"Version" => msiVersion,
"Summary" => GetEffectiveTitle(),
"Codepage" => $"{culture}",
"Platform" => packageAs64Bit ? "x64" : "x86",
"ProgramFilesFolder" => packageAs64Bit ? "ProgramFiles64Folder" : "ProgramFilesFolder",
"Win64YesNo" => packageAs64Bit ? "yes" : "no",
"SetupName" => setupName,
_ when key.StartsWith("IdAsGuid") => GuidUtil.CreateGuidFromHash($"{Options.PackId}:{key.Substring(8)}").ToString(),
_ => match.Value,
};
});
File.WriteAllText(wxsFile, templateResult, Encoding.UTF8);
// Candle reprocesses 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}\"";
_ = 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}\"";
_ = Exe.RunHostedCommand(lightCommand);
progress(90);
} finally {
IoUtil.DeleteFileOrDirectoryHard(wxsFile, throwOnFailure: false);
IoUtil.DeleteFileOrDirectoryHard(objFile, throwOnFailure: false);
}
progress(100);
}
protected override string[] GetMainExeSearchPaths(string packDirectory, string mainExeName)
{
return [

View File

@@ -23,4 +23,8 @@ public class WindowsPackOptions : WindowsReleasifyOptions, INugetPackCommand, IP
public bool NoInst { get; set; }
public string Shortcuts { get; set; }
public bool BuildMsi { get; set; }
public string MsiVersionOverride { get; set; }
}

View File

@@ -1,6 +1,4 @@
using Velopack.Core;
namespace Velopack.Packaging.Windows.Commands;
namespace Velopack.Packaging.Windows.Commands;
public class WindowsReleasifyOptions : WindowsSigningOptions
{

View File

@@ -152,7 +152,7 @@ public class ResourceEdit
var file = PEFile.FromBytes(File.ReadAllBytes(otherExeFile));
var image = PEImage.FromFile(file);
_resources = image.Resources;
_resources = image.Resources ?? new ResourceDirectory((uint) 0);
}
public void Commit()

View File

@@ -9,4 +9,5 @@ public interface IPackOptions : INugetPackCommand, IPlatformOptions
string Exclude { get; set; }
bool NoPortable { get; set; }
bool NoInst { get; set; }
bool BuildMsi { get; }
}

View File

@@ -69,7 +69,7 @@ public static class Exe
var stdout = IoUtil.Retry(() => File.ReadAllText(outputFile).Trim(), 10, 1000);
var result = (process.ExitCode, stdout, "", command);
ProcessFailedException.ThrowIfNonZero(result);
return result.Item2;
return stdout;
}
public static void RunHostedCommandNoWait(string command, string workDir = null)

View File

@@ -1,4 +1,5 @@
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Extensions.Logging;
#if !DEBUG
@@ -70,6 +71,13 @@ public static class HelperFile
public static string StubExecutablePath => FindHelperFile("stub.exe");
[SupportedOSPlatform("windows")]
public static string WixTemplatePath => FindHelperFile("wix\\template.wxs");
[SupportedOSPlatform("windows")]
public static string WixCandlePath => FindHelperFile("wix\\candle.exe");
[SupportedOSPlatform("windows")]
public static string WixLightPath => FindHelperFile("wix\\light.exe");
[SupportedOSPlatform("windows")]
public static string SignToolPath => FindHelperFile("signing\\signtool.exe");
@@ -135,8 +143,14 @@ public static class HelperFile
files = files.Where(predicate);
var result = files.FirstOrDefault();
if (result == null && throwWhenNotFound)
throw new Exception($"HelperFile could not find '{toFind}'.");
if (result == null && throwWhenNotFound) {
StringBuilder msg = new();
msg.AppendLine($"HelperFile could not find '{toFind}'.");
msg.AppendLine("Search paths:");
foreach (var path in _searchPaths)
msg.AppendLine($" {Path.GetFullPath(path)}");
throw new Exception(msg.ToString());
}
return result;
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using System.Security;
using System.Security;
using System.Text.RegularExpressions;
using Markdig;
using Microsoft.Extensions.Logging;
@@ -149,12 +148,13 @@ public abstract class PackageBuilder<T> : ICommand<T>
});
Task setupTask = null;
string setupExePath = null;
if (!Options.NoInst && TargetOs != RuntimeOs.Linux) {
setupTask = ctx.RunTask(
"Building setup package",
async (progress) => {
var suggestedName = DefaultName.GetSuggestedSetupName(packId, channel, TargetOs);
var path = assetCache.MakeAssetPath(suggestedName, VelopackAssetType.Installer);
var path = setupExePath = assetCache.MakeAssetPath(suggestedName, VelopackAssetType.Installer);
await CreateSetupPackage(progress, releasePath, packDirectory, path);
});
}
@@ -177,6 +177,16 @@ public abstract class PackageBuilder<T> : ICommand<T>
if (TargetOs != RuntimeOs.Linux && portableTask != null) await portableTask;
if (setupTask != null) await setupTask;
if (!Options.NoInst && Options.BuildMsi && TargetOs == RuntimeOs.Windows) {
await ctx.RunTask(
"Building MSI package",
async (progress) => {
var msiName = DefaultName.GetSuggestedMsiName(packId, channel, TargetOs);
var msiPath = assetCache.MakeAssetPath(msiName, VelopackAssetType.Msi);
await CreateMsiPackage(progress, setupExePath, msiPath);
});
}
await ctx.RunTask(
"Post-process steps",
(progress) => {
@@ -196,8 +206,8 @@ public abstract class PackageBuilder<T> : ICommand<T>
protected virtual string GenerateNuspecContent()
{
var packId = Options.PackId;
var packTitle = Options.PackTitle ?? Options.PackId;
var packAuthors = Options.PackAuthors ?? Options.PackId;
var packTitle = GetEffectiveTitle();
var packAuthors = GetEffectiveAuthors();
var packVersion = Options.PackVersion;
var releaseNotes = Options.ReleaseNotes;
var rid = Options.TargetRuntime;
@@ -240,8 +250,8 @@ public abstract class PackageBuilder<T> : ICommand<T>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>{packId}</id>
<title>{packTitle ?? packId}</title>
<description>{packTitle ?? packId}</description>
<title>{packTitle}</title>
<description>{packTitle}</description>
<authors>{packAuthors ?? packId}</authors>
<version>{packVersion}</version>
<channel>{Options.Channel}</channel>
@@ -277,6 +287,11 @@ public abstract class PackageBuilder<T> : ICommand<T>
return Task.CompletedTask;
}
protected virtual Task CreateMsiPackage(Action<int> progress, string setupExePath, string msiPath)
{
return Task.CompletedTask;
}
protected virtual async Task CreateReleasePackage(Action<int> progress, string packDir, string outputPath)
{
var stagingDir = TempDir.CreateSubdirectory("CreateReleasePackage");
@@ -395,4 +410,8 @@ public abstract class PackageBuilder<T> : ICommand<T>
""";
File.WriteAllText(Path.Combine(relsDir, ".rels"), rels);
}
protected string GetEffectiveTitle() => Options.PackTitle ?? Options.PackId;
protected string GetEffectiveAuthors() => Options.PackAuthors ?? Options.PackId;
}

View File

@@ -20,6 +20,10 @@ public class WindowsPackCommand : PackCommand
public string Shortcuts { get; private set; }
public bool BuildMsi { get; private set; }
public string MsiVersionOverride { get; private set; }
public WindowsPackCommand()
: base("pack", "Creates a release from a folder containing application files.", RuntimeOs.Windows)
{
@@ -69,6 +73,17 @@ public class WindowsPackCommand : PackCommand
.SetArgumentHelpName("PATH");
this.AreMutuallyExclusive(signTemplate, signParams, azTrustedSign);
AddOption<bool>((v) => BuildMsi = v, "--msi")
.SetDescription("Compile a .msi machine-wide deployment tool.")
.SetHidden()
.SetArgumentHelpName("BITNESS");
AddOption<string>((v) => MsiVersionOverride = v, "--msiVersion")
.SetDescription("Override the product version for the generated msi.")
.SetArgumentHelpName("VERSION")
.SetHidden()
.MustBeValidMsiVersion();
}
}
}