Sort out some msi unicode issues

This commit is contained in:
Caelan Sayler
2025-05-27 21:03:04 +01:00
parent 76c551fda7
commit 10b2dfc63f
10 changed files with 102 additions and 132 deletions

View File

@@ -16,10 +16,10 @@ Velopack is an installation and auto-update framework for cross-platform applica
## Features ## Features
- 😍 **Zero config** Velopack takes your compiler output and generates an installer, updates, delta packages, and self-updating portable package in just one command. - 😍 **Zero config** - Velopack takes your compiler output and generates an installer, updates, delta packages, and self-updating portable package in just one command.
- 🎯 **Cross platform** Velopack supports building packages for **Windows**, **OSX**, and **Linux**, so you can use one solution for every target. - 🎯 **Cross platform** - Velopack supports building packages for **Windows**, **OSX**, and **Linux**, so you can use one solution for every target.
- 🚀 **Automatic migrations** - If you are coming from other popular frameworks (eg. [Squirrel](https://github.com/Squirrel/Squirrel.Windows)), Velopack can automatically migrate your application. - 🚀 **Automatic migrations** - If you are coming from other popular frameworks (eg. [Squirrel](https://github.com/Squirrel/Squirrel.Windows)), Velopack can automatically migrate your application.
- ⚡️ **Lightning fast** Velopack is written in Rust for native performance. Delta packages mean your user only downloads what's changed between versions. - ⚡️ **Lightning fast** - Velopack is written in Rust for native performance. Delta packages mean your user only downloads what's changed between versions.
- 📔 **Language agnostic** - With support for C#, C++, JS, Rust and more. Use a familiar API for updates no matter what language your project is. - 📔 **Language agnostic** - With support for C#, C++, JS, Rust and more. Use a familiar API for updates no matter what language your project is.
https://github.com/velopack/velopack/assets/1287295/0ff1bea7-15ed-42ae-8bdd-9519f1033432 https://github.com/velopack/velopack/assets/1287295/0ff1bea7-15ed-42ae-8bdd-9519f1033432

View File

@@ -1,7 +1,5 @@
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Markdig;
using MarkdigExtensions.RtfRenderer;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Velopack.Core; using Velopack.Core;
using Velopack.Core.Abstractions; using Velopack.Core.Abstractions;
@@ -340,32 +338,9 @@ public class WindowsPackCommandRunner : PackageBuilder<WindowsPackOptions>
[SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")]
private void CompileWixTemplateToMsi(Action<int> progress, DirectoryInfo portableDirectory, string msiFilePath) private void CompileWixTemplateToMsi(Action<int> progress, DirectoryInfo portableDirectory, string msiFilePath)
{ {
string GetLicenseRtfFile()
{
string license = Options.InstLicenseRtf;
if (!string.IsNullOrWhiteSpace(license)) {
return license;
}
license = Options.InstLicense;
if (!string.IsNullOrWhiteSpace(license)) {
var licenseFile = Path.Combine(portableDirectory.Parent!.FullName, "license.rtf");
using var writer = new StreamWriter(licenseFile);
var renderer = new RtfRenderer(writer);
renderer.StartDocument();
_ = Markdown.Convert(File.ReadAllText(license), renderer);
renderer.CloseDocument();
return licenseFile;
}
return null;
}
var licenseRtfPath = GetLicenseRtfFile();
var templateData = MsiBuilder.ConvertOptionsToTemplateData( var templateData = MsiBuilder.ConvertOptionsToTemplateData(
portableDirectory, portableDirectory,
GetShortcuts(), GetShortcuts(),
licenseRtfPath,
GetRuntimeDependencies(), GetRuntimeDependencies(),
Options); Options);
MsiBuilder.CompileWixMsi(Log, templateData, progress, msiFilePath); MsiBuilder.CompileWixMsi(Log, templateData, progress, msiFilePath);

View File

@@ -29,7 +29,6 @@ public class WindowsPackOptions : WindowsReleasifyOptions, INugetPackCommand, IP
public string InstReadme { get; set; } public string InstReadme { get; set; }
public string InstLicense { get; set; } public string InstLicense { get; set; }
public string InstLicenseRtf { get; set; }
public string InstConclusion { get; set; } public string InstConclusion { get; set; }

View File

@@ -3,10 +3,13 @@ using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Xml;
using HandlebarsDotNet; using HandlebarsDotNet;
using Markdig;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NuGet.Versioning; using NuGet.Versioning;
using Velopack.Core; using Velopack.Core;
using Velopack.Packaging.Rtf;
using Velopack.Packaging.Windows.Commands; using Velopack.Packaging.Windows.Commands;
using Velopack.Util; using Velopack.Util;
using Velopack.Windows; using Velopack.Windows;
@@ -29,9 +32,81 @@ public static class MsiBuilder
return (template(data), locale(data)); return (template(data), locale(data));
} }
public static MsiTemplateData ConvertOptionsToTemplateData(DirectoryInfo portableDir, ShortcutLocation shortcuts, string licenseRtfPath, private static string GetPlainTextMessage(string filePath)
string runtimeDeps, {
WindowsPackOptions options) if (string.IsNullOrWhiteSpace(filePath))
return "";
if (!File.Exists(filePath))
throw new FileNotFoundException("File not found", filePath);
var extension = Path.GetExtension(filePath);
var content = File.ReadAllText(filePath, Encoding.UTF8);
// if extension is .md render it to plain text
if (extension.Equals(".md", StringComparison.OrdinalIgnoreCase)) {
content = Markdown.ToPlainText(content);
} else if (extension.Equals(".txt", StringComparison.OrdinalIgnoreCase)) {
// do nothing but it's valid
} else {
throw new ArgumentException("Installer plain-text messages must be .md or .txt", nameof(filePath));
}
return FormatXmlMessage(content);
}
private static string GetLicenseRtfPath(string licensePath, DirectoryInfo tempDir)
{
if (string.IsNullOrWhiteSpace(licensePath))
return "";
if (!File.Exists(licensePath))
throw new FileNotFoundException("File not found", licensePath);
var extension = Path.GetExtension(licensePath);
var content = File.ReadAllText(licensePath, Encoding.UTF8);
// if extension is .md, render it to rtf
if (extension.Equals(".md", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".txt", StringComparison.OrdinalIgnoreCase)) {
licensePath = Path.Combine(tempDir.FullName, "rendered_license.rtf");
using var writer = new StreamWriter(licensePath);
var renderer = new RtfRenderer(writer);
renderer.WriteRtfStart();
_ = Markdown.Convert(content, renderer);
renderer.WriteRtfEnd();
} else if (extension.Equals(".rtf", StringComparison.OrdinalIgnoreCase)) {
// do nothing but it's valid
} else {
throw new ArgumentException("Installer license must be .txt, .md, or .rtf", nameof(licensePath));
}
return licensePath;
}
public static string SanitizeDirectoryString(string name)
=> string.Join("_", name.Split(Path.GetInvalidPathChars()));
public static string FormatXmlMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return "";
StringBuilder sb = new();
XmlWriterSettings settings = new() {
ConformanceLevel = ConformanceLevel.Fragment,
NewLineHandling = NewLineHandling.None,
};
using XmlWriter writer = XmlWriter.Create(sb, settings);
writer.WriteString(message);
writer.Flush();
var rv = sb.ToString();
rv = rv.Replace("\r", "&#10;").Replace("\n", "&#13;");
return rv;
}
public static MsiTemplateData ConvertOptionsToTemplateData(DirectoryInfo portableDir, ShortcutLocation shortcuts,
string runtimeDeps, WindowsPackOptions options)
{ {
// WiX Identifiers may contain ASCII characters A-Z, a-z, digits, underscores (_), or // 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. // periods(.). Every identifier must begin with either a letter or an underscore.
@@ -45,10 +120,6 @@ public static class MsiBuilder
msiVersion = $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}.0"; msiVersion = $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}.0";
} }
string welcomeMessage = MsiUtil.FormatXmlMessage(MsiUtil.RenderMarkdownAsPlainText(MsiUtil.GetFileContent(options.InstWelcome)));
string readmeMessage = MsiUtil.FormatXmlMessage(MsiUtil.RenderMarkdownAsPlainText(MsiUtil.GetFileContent(options.InstReadme)));
string conclusionMessage = MsiUtil.FormatXmlMessage(MsiUtil.RenderMarkdownAsPlainText(MsiUtil.GetFileContent(options.InstConclusion)));
return new MsiTemplateData() { return new MsiTemplateData() {
WixId = wixId, WixId = wixId,
AppId = options.PackId, AppId = options.PackId,
@@ -59,7 +130,6 @@ public static class MsiBuilder
SourceDirectoryPath = portableDir.FullName, SourceDirectoryPath = portableDir.FullName,
Is64Bit = options.TargetRuntime.Architecture is not RuntimeCpu.x86 and not RuntimeCpu.Unknown, Is64Bit = options.TargetRuntime.Architecture is not RuntimeCpu.x86 and not RuntimeCpu.Unknown,
IsArm64 = options.TargetRuntime.Architecture is RuntimeCpu.arm64, IsArm64 = options.TargetRuntime.Architecture is RuntimeCpu.arm64,
CultureLCID = CultureInfo.GetCultureInfo("en-US").TextInfo.ANSICodePage,
InstallForAllUsers = options.InstLocation.HasFlag(InstallLocation.PerMachine), InstallForAllUsers = options.InstLocation.HasFlag(InstallLocation.PerMachine),
InstallForCurrentUser = options.InstLocation.HasFlag(InstallLocation.PerUser), InstallForCurrentUser = options.InstLocation.HasFlag(InstallLocation.PerUser),
UpgradeCodeGuid = GuidUtil.CreateGuidFromHash($"{options.PackId}:UpgradeCode").ToString(), UpgradeCodeGuid = GuidUtil.CreateGuidFromHash($"{options.PackId}:UpgradeCode").ToString(),
@@ -73,10 +143,10 @@ public static class MsiBuilder
SideBannerImagePath = options.MsiBanner ?? HelperFile.WixAssetsDialogBackground, SideBannerImagePath = options.MsiBanner ?? HelperFile.WixAssetsDialogBackground,
TopBannerImagePath = options.MsiLogo ?? HelperFile.WixAssetsTopBanner, TopBannerImagePath = options.MsiLogo ?? HelperFile.WixAssetsTopBanner,
RuntimeDependencies = runtimeDeps, RuntimeDependencies = runtimeDeps,
ConclusionMessage = conclusionMessage, ConclusionMessage = GetPlainTextMessage(options.InstConclusion),
ReadmeMessage = readmeMessage, ReadmeMessage = GetPlainTextMessage(options.InstReadme),
WelcomeMessage = welcomeMessage, WelcomeMessage = GetPlainTextMessage(options.InstWelcome),
LicenseRtfFilePath = licenseRtfPath, LicenseRtfFilePath = GetLicenseRtfPath(options.InstLicense, portableDir.Parent),
}; };
} }

View File

@@ -7,7 +7,6 @@ public class MsiTemplateData
public string RustNativeModulePath; public string RustNativeModulePath;
public bool Is64Bit; public bool Is64Bit;
public bool IsArm64; public bool IsArm64;
public int CultureLCID;
public string UpgradeCodeGuid; public string UpgradeCodeGuid;
public string ComponentGenerationSeedGuid; public string ComponentGenerationSeedGuid;
@@ -17,9 +16,9 @@ public class MsiTemplateData
public string AppId; public string AppId;
public string AppTitle; public string AppTitle;
public string AppTitleSanitized => MsiUtil.SanitizeDirectoryString(AppTitle); public string AppTitleSanitized => MsiBuilder.SanitizeDirectoryString(AppTitle);
public string AppPublisher; public string AppPublisher;
public string AppPublisherSanitized => MsiUtil.SanitizeDirectoryString(AppPublisher); public string AppPublisherSanitized => MsiBuilder.SanitizeDirectoryString(AppPublisher);
public string AppMsiVersion; public string AppMsiVersion;
public string AppVersion; public string AppVersion;

View File

@@ -1,56 +0,0 @@
using System.Text;
using System.Xml;
using Markdig;
using MarkdigExtensions.RtfRenderer;
namespace Velopack.Packaging.Windows.Msi;
public static class MsiUtil
{
public static string SanitizeDirectoryString(string name)
=> string.Join("_", name.Split(Path.GetInvalidPathChars()));
public static string FormatXmlMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return "";
StringBuilder sb = new();
XmlWriterSettings settings = new() {
ConformanceLevel = ConformanceLevel.Fragment,
NewLineHandling = NewLineHandling.None,
};
using XmlWriter writer = XmlWriter.Create(sb, settings);
writer.WriteString(message);
writer.Flush();
var rv = sb.ToString();
rv = rv.Replace("\r", "&#10;").Replace("\n", "&#13;");
return rv;
}
public static string GetFileContent(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return "";
string fileContents = File.ReadAllText(filePath, Encoding.UTF8);
return fileContents;
}
public static string RenderMarkdownAsPlainText(string markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
return "";
return Markdown.ToPlainText(markdown);
}
public static string RenderMarkdownAsRtf(string markdown)
{
var builder = new StringBuilder();
using var writer = new StringWriter(builder);
var renderer = new RtfRenderer(writer);
renderer.StartDocument();
_ = Markdown.Convert(markdown, renderer);
renderer.CloseDocument();
return builder.ToString();
}
}

View File

@@ -1,11 +1,6 @@
<!--suppress CheckEmptyScriptTag --> <!--suppress CheckEmptyScriptTag -->
<WixLocalization Culture="en-US" Codepage="1252" xmlns="http://wixtoolset.org/schemas/v4/wxl"> <WixLocalization Culture="en-US" Language="1033" Codepage="65001" SummaryInformationCodepage="0" xmlns="http://wixtoolset.org/schemas/v4/wxl">
<String Id="InstallPrerequisitesDlgInstallTitle" <String Id="ConclusionLaunchCheckboxText" Value="Launch"/>
Value="Missing System Requirements"/>
<String Id="InstallPrerequisitesDlgInstallText"
Value="There are several prerequisites which need to be installed before setup can continue: "/>
<String Id="InstallPrerequisitesDlgContinue"
Value="Continue"/>
<!-- Message on first welcome dialog; covers both initial install and update --> <!-- Message on first welcome dialog; covers both initial install and update -->
{{#if HasWelcomeMessage}} {{#if HasWelcomeMessage}}

View File

@@ -3,7 +3,6 @@
<Package Name="{{AppTitle}}" <Package Name="{{AppTitle}}"
Manufacturer="{{AppPublisher}}" Manufacturer="{{AppPublisher}}"
Version="{{AppMsiVersion}}" Version="{{AppMsiVersion}}"
Codepage="{{CultureLCID}}"
Language="1033" Language="1033"
Scope="perUserOrMachine" Scope="perUserOrMachine"
UpgradeCode="{{UpgradeCodeGuid}}"> UpgradeCode="{{UpgradeCodeGuid}}">
@@ -91,7 +90,7 @@
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/> <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
<!-- Check box for launching --> <!-- Check box for launching -->
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch {{AppTitle}}"/> <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.ConclusionLaunchCheckboxText) {{AppTitle}}"/>
<Property Id="WixAppFolder" Value="WixPerMachineFolder"/> <Property Id="WixAppFolder" Value="WixPerMachineFolder"/>
<Property Id="ApplicationFolderName" Value="{{AppId}}"/> <Property Id="ApplicationFolderName" Value="{{AppId}}"/>

View File

@@ -15,7 +15,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AsmResolver.DotNet" Version="5.5.1" /> <PackageReference Include="AsmResolver.DotNet" Version="5.5.1" />
<PackageReference Include="AsmResolver.PE.Win32Resources" Version="5.5.1" /> <PackageReference Include="AsmResolver.PE.Win32Resources" Version="5.5.1" />
<PackageReference Include="MarkdigExtensions.RtfRenderer" Version="1.1.0" />
<PackageReference Include="Microsoft.Security.Extensions" Version="1.4.0" /> <PackageReference Include="Microsoft.Security.Extensions" Version="1.4.0" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <PackageReference Include="Handlebars.Net" Version="2.1.6" />
</ItemGroup> </ItemGroup>

View File

@@ -61,19 +61,19 @@ public class WindowsPackCommand : PackCommand
.SetHidden(); .SetHidden();
var signTemplate = AddOption<string>((v) => SignTemplate = v, "--signTemplate") var signTemplate = AddOption<string>((v) => SignTemplate = v, "--signTemplate")
.SetDescription("Use a custom signing command. {{file}} will be substituted.") .SetDescription("Use a custom signing command. {{file}} will be substituted.")
.SetArgumentHelpName("COMMAND"); .SetArgumentHelpName("COMMAND");
AddOption<string>((v) => SignExclude = v, "--signExclude") AddOption<string>((v) => SignExclude = v, "--signExclude")
.SetDescription("A regex which excludes matched files from signing.") .SetDescription("A regex which excludes matched files from signing.")
.SetHidden(); .SetHidden();
AddOption<int>((v) => SignParallel = v, "--signParallel") AddOption<int>((v) => SignParallel = v, "--signParallel")
.SetDescription("The number of files to sign in each signing command.") .SetDescription("The number of files to sign in each signing command.")
.SetArgumentHelpName("NUM") .SetArgumentHelpName("NUM")
.MustBeBetween(1, 1000) .MustBeBetween(1, 1000)
.SetHidden() .SetHidden()
.SetDefault(10); .SetDefault(10);
AddOption<string>((v) => Shortcuts = v, "--shortcuts") AddOption<string>((v) => Shortcuts = v, "--shortcuts")
.SetDescription("List of locations to install shortcuts to during setup.") .SetDescription("List of locations to install shortcuts to during setup.")
@@ -100,29 +100,19 @@ public class WindowsPackCommand : PackCommand
.MustBeValidMsiVersion(); .MustBeValidMsiVersion();
AddOption<FileInfo>(v => InstWelcome = v.ToFullNameOrNull(), "--instWelcome") AddOption<FileInfo>(v => InstWelcome = v.ToFullNameOrNull(), "--instWelcome")
.SetDescription("Set the installer package welcome content. Most formatting is not supported.") .SetDescription("Set the plain-text installer package welcome content.")
.RequiresExtension(".md")
.SetArgumentHelpName("PATH"); .SetArgumentHelpName("PATH");
AddOption<FileInfo>(v => InstLicense = v.ToFullNameOrNull(), "--instLicense") AddOption<FileInfo>(v => InstLicense = v.ToFullNameOrNull(), "--instLicense")
.SetDescription("Set the installer package license content. This will be rendered as RTF content for the MSI. Formatting is done using https://github.com/uniederer/MarkdigExtensions.RtfRenderer.") .SetDescription("Set the installer package license content. Can be either RTF or Markdown.")
.RequiresExtension(".md")
.SetArgumentHelpName("PATH"); .SetArgumentHelpName("PATH");
AddOption<FileInfo>(v => InstLicenseRtf = v.ToFullNameOrNull(), "--instLicenseRtf")
.SetDescription("Set the installer package license RTF content.")
.RequiresExtension(".rtf")
.SetArgumentHelpName("PATH")
.SetHidden();
AddOption<FileInfo>(v => InstReadme = v.ToFullNameOrNull(), "--instReadme") AddOption<FileInfo>(v => InstReadme = v.ToFullNameOrNull(), "--instReadme")
.SetDescription("Set the installer package readme content. Most formatting is not supported.") .SetDescription("Set the plain-text installer package readme content.")
.RequiresExtension(".md")
.SetArgumentHelpName("PATH"); .SetArgumentHelpName("PATH");
AddOption<FileInfo>(v => InstConclusion = v.ToFullNameOrNull(), "--instConclusion") AddOption<FileInfo>(v => InstConclusion = v.ToFullNameOrNull(), "--instConclusion")
.SetDescription("Set the installer package conclusion content. Most formatting is not supported.") .SetDescription("Set the plain-text installer package conclusion content.")
.RequiresExtension(".md")
.SetArgumentHelpName("PATH"); .SetArgumentHelpName("PATH");
AddOption<InstallLocation>(v => InstLocation = v, "--instLocation") AddOption<InstallLocation>(v => InstLocation = v, "--instLocation")