From 50103ee88e3e917f20d418162e1b5fe134b55406 Mon Sep 17 00:00:00 2001 From: Caelan Sayler Date: Sat, 11 Dec 2021 12:31:43 +0000 Subject: [PATCH] Remove package creation code from SquirrelLib; refactor helper exe code into class --- src/Squirrel/DeltaPackage.cs | 158 +------------ src/Squirrel/ReleasePackage.cs | 157 +------------ src/Squirrel/UpdateManager.ApplyReleases.cs | 2 +- src/Squirrel/Utility.cs | 91 +------- src/SquirrelCli/DeltaPackageBuilder.cs | 163 ++++++++++++++ src/SquirrelCli/HelperExe.cs | 237 ++++++++++++++++++++ src/SquirrelCli/Options.cs | 1 + src/SquirrelCli/Program.cs | 142 ++---------- src/SquirrelCli/ReleasePackageBuilder.cs | 181 +++++++++++++++ 9 files changed, 614 insertions(+), 518 deletions(-) create mode 100644 src/SquirrelCli/DeltaPackageBuilder.cs create mode 100644 src/SquirrelCli/HelperExe.cs create mode 100644 src/SquirrelCli/ReleasePackageBuilder.cs diff --git a/src/Squirrel/DeltaPackage.cs b/src/Squirrel/DeltaPackage.cs index 4829b5ad..4d7f34ae 100644 --- a/src/Squirrel/DeltaPackage.cs +++ b/src/Squirrel/DeltaPackage.cs @@ -17,80 +17,14 @@ using SharpCompress.Compressors.Deflate; namespace Squirrel { - public interface IDeltaPackageBuilder - { - ReleasePackage CreateDeltaPackage(ReleasePackage basePackage, ReleasePackage newPackage, string outputFile); - ReleasePackage ApplyDeltaPackage(ReleasePackage basePackage, ReleasePackage deltaPackage, string outputFile); - } - - public class DeltaPackageBuilder : IEnableLogger, IDeltaPackageBuilder + public class DeltaPackage : IEnableLogger { readonly string localAppDirectory; - public DeltaPackageBuilder(string localAppDataOverride = null) + public DeltaPackage(string localAppDataOverride = null) { this.localAppDirectory = localAppDataOverride; } - public ReleasePackage CreateDeltaPackage(ReleasePackage basePackage, ReleasePackage newPackage, string outputFile) - { - Contract.Requires(basePackage != null); - Contract.Requires(!String.IsNullOrEmpty(outputFile) && !File.Exists(outputFile)); - - if (basePackage.Version > newPackage.Version) { - var message = String.Format( - "You cannot create a delta package based on version {0} as it is a later version than {1}", - basePackage.Version, - newPackage.Version); - throw new InvalidOperationException(message); - } - - if (basePackage.ReleasePackageFile == null) { - throw new ArgumentException("The base package's release file is null", "basePackage"); - } - - if (!File.Exists(basePackage.ReleasePackageFile)) { - throw new FileNotFoundException("The base package release does not exist", basePackage.ReleasePackageFile); - } - - if (!File.Exists(newPackage.ReleasePackageFile)) { - throw new FileNotFoundException("The new package release does not exist", newPackage.ReleasePackageFile); - } - - string baseTempPath = null; - string tempPath = null; - - using (Utility.WithTempDirectory(out baseTempPath, null)) - using (Utility.WithTempDirectory(out tempPath, null)) { - var baseTempInfo = new DirectoryInfo(baseTempPath); - var tempInfo = new DirectoryInfo(tempPath); - - this.Log().Info("Extracting {0} and {1} into {2}", - basePackage.ReleasePackageFile, newPackage.ReleasePackageFile, tempPath); - - Utility.ExtractZipToDirectory(basePackage.ReleasePackageFile, baseTempInfo.FullName).Wait(); - Utility.ExtractZipToDirectory(newPackage.ReleasePackageFile, tempInfo.FullName).Wait(); - - // Collect a list of relative paths under 'lib' and map them - // to their full name. We'll use this later to determine in - // the new version of the package whether the file exists or - // not. - var baseLibFiles = baseTempInfo.GetAllFilesRecursively() - .Where(x => x.FullName.ToLowerInvariant().Contains("lib" + Path.DirectorySeparatorChar)) - .ToDictionary(k => k.FullName.Replace(baseTempInfo.FullName, ""), v => v.FullName); - - var newLibDir = tempInfo.GetDirectories().First(x => x.Name.ToLowerInvariant() == "lib"); - - foreach (var libFile in newLibDir.GetAllFilesRecursively()) { - createDeltaForSingleFile(libFile, tempInfo, baseLibFiles); - } - - ReleasePackage.addDeltaFilesToContentTypes(tempInfo.FullName); - Utility.CreateZipFromDirectory(outputFile, tempInfo.FullName).Wait(); - } - - return new ReleasePackage(outputFile); - } - public ReleasePackage ApplyDeltaPackage(ReleasePackage basePackage, ReleasePackage deltaPackage, string outputFile) { return ApplyDeltaPackage(basePackage, deltaPackage, outputFile, x => { }); @@ -176,76 +110,6 @@ namespace Squirrel return new ReleasePackage(outputFile); } - void createDeltaForSingleFile(FileInfo targetFile, DirectoryInfo workingDirectory, Dictionary baseFileListing) - { - // NB: There are three cases here that we'll handle: - // - // 1. Exists only in new => leave it alone, we'll use it directly. - // 2. Exists in both old and new => write a dummy file so we know - // to keep it. - // 3. Exists in old but changed in new => create a delta file - // - // The fourth case of "Exists only in old => delete it in new" - // is handled when we apply the delta package - var relativePath = targetFile.FullName.Replace(workingDirectory.FullName, ""); - - if (!baseFileListing.ContainsKey(relativePath)) { - this.Log().Info("{0} not found in base package, marking as new", relativePath); - return; - } - - var oldData = File.ReadAllBytes(baseFileListing[relativePath]); - var newData = File.ReadAllBytes(targetFile.FullName); - - if (bytesAreIdentical(oldData, newData)) { - this.Log().Info("{0} hasn't changed, writing dummy file", relativePath); - - File.Create(targetFile.FullName + ".diff").Dispose(); - File.Create(targetFile.FullName + ".shasum").Dispose(); - targetFile.Delete(); - return; - } - - this.Log().Info("Delta patching {0} => {1}", baseFileListing[relativePath], targetFile.FullName); - var msDelta = new MsDeltaCompression(); - - if (targetFile.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) || - targetFile.Extension.Equals(".dll", StringComparison.OrdinalIgnoreCase) || - targetFile.Extension.Equals(".node", StringComparison.OrdinalIgnoreCase)) { - try { - msDelta.CreateDelta(baseFileListing[relativePath], targetFile.FullName, targetFile.FullName + ".diff"); - goto exit; - } catch (Exception) { - this.Log().Warn("We couldn't create a delta for {0}, attempting to create bsdiff", targetFile.Name); - } - } - - try { - using (FileStream of = File.Create(targetFile.FullName + ".bsdiff")) { - BinaryPatchUtility.Create(oldData, newData, of); - - // NB: Create a dummy corrupt .diff file so that older - // versions which don't understand bsdiff will fail out - // until they get upgraded, instead of seeing the missing - // file and just removing it. - File.WriteAllText(targetFile.FullName + ".diff", "1"); - } - } catch (Exception ex) { - this.Log().WarnException(String.Format("We really couldn't create a delta for {0}", targetFile.Name), ex); - - Utility.DeleteFileHarder(targetFile.FullName + ".bsdiff", true); - Utility.DeleteFileHarder(targetFile.FullName + ".diff", true); - return; - } - - exit: - - var rl = ReleaseEntry.GenerateFromFile(new MemoryStream(newData), targetFile.Name + ".shasum"); - File.WriteAllText(targetFile.FullName + ".shasum", rl.EntryAsString, Encoding.UTF8); - targetFile.Delete(); - } - - void applyDiffToFile(string deltaPath, string relativeFilePath, string workingDirectory) { var inputFile = Path.Combine(deltaPath, relativeFilePath); @@ -312,23 +176,5 @@ namespace Squirrel throw new ChecksumFailedException() { Filename = relativeFilePath }; } } - - bool bytesAreIdentical(byte[] oldData, byte[] newData) - { - if (oldData == null || newData == null) { - return oldData == newData; - } - if (oldData.LongLength != newData.LongLength) { - return false; - } - - for (long i = 0; i < newData.LongLength; i++) { - if (oldData[i] != newData[i]) { - return false; - } - } - - return true; - } } } diff --git a/src/Squirrel/ReleasePackage.cs b/src/Squirrel/ReleasePackage.cs index 6fd1179d..3cb94fe0 100644 --- a/src/Squirrel/ReleasePackage.cs +++ b/src/Squirrel/ReleasePackage.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.Design; using System.Diagnostics.Contracts; @@ -23,8 +23,6 @@ namespace Squirrel string InputPackageFile { get; } string ReleasePackageFile { get; } string SuggestedReleaseFileName { get; } - - string CreateReleasePackage(string outputFile, Func releaseNotesProcessor = null, Action contentsPostProcessHook = null); } public class ReleasePackage : IEnableLogger, IReleasePackage @@ -50,106 +48,6 @@ namespace Squirrel public SemanticVersion Version { get { return InputPackageFile.ToSemanticVersion(); } } - public string CreateReleasePackage(string outputFile, Func releaseNotesProcessor = null, Action contentsPostProcessHook = null) - { - Contract.Requires(!String.IsNullOrEmpty(outputFile)); - releaseNotesProcessor = releaseNotesProcessor ?? (x => (new Markdown()).Transform(x)); - - if (ReleasePackageFile != null) { - return ReleasePackageFile; - } - - var package = new ZipPackage(InputPackageFile); - - var dontcare = default(SemanticVersion); - - // NB: Our test fixtures use packages that aren't SemVer compliant, - // we don't really care that they aren't valid - if (!ModeDetector.InUnitTestRunner() && !SemanticVersion.TryParseStrict(package.Version.ToString(), out dontcare)) { - throw new Exception( - String.Format( - "Your package version is currently {0}, which is *not* SemVer-compatible, change this to be a SemVer version number", - package.Version.ToString())); - } - - // we can tell from here what platform(s) the package targets - // but given this is a simple package we only - // ever expect one entry here (crash hard otherwise) - var frameworks = package.GetSupportedFrameworks(); - if (frameworks.Count() > 1) { - var platforms = frameworks - .Aggregate(new StringBuilder(), (sb, f) => sb.Append(f.ToString() + "; ")); - - throw new InvalidOperationException(String.Format( - "The input package file {0} targets multiple platforms - {1} - and cannot be transformed into a release package.", InputPackageFile, platforms)); - - } else if (!frameworks.Any()) { - throw new InvalidOperationException(String.Format( - "The input package file {0} targets no platform and cannot be transformed into a release package.", InputPackageFile)); - } - - // CS - docs say we don't support dependencies. I can't think of any reason allowing this is useful. - if (package.DependencySets.Any()) { - throw new InvalidOperationException(String.Format( - "The input package file {0} must have no dependencies.", InputPackageFile)); - } - - var targetFramework = frameworks.Single(); - - this.Log().Info("Creating release package: {0} => {1}", InputPackageFile, outputFile); - - string tempPath = null; - - using (Utility.WithTempDirectory(out tempPath, null)) { - var tempDir = new DirectoryInfo(tempPath); - - extractZipWithEscaping(InputPackageFile, tempPath).Wait(); - - var specPath = tempDir.GetFiles("*.nuspec").First().FullName; - - this.Log().Info("Removing unnecessary data"); - removeDependenciesFromPackageSpec(specPath); - - if (releaseNotesProcessor != null) { - renderReleaseNotesMarkdown(specPath, releaseNotesProcessor); - } - - addDeltaFilesToContentTypes(tempDir.FullName); - - contentsPostProcessHook?.Invoke(tempPath); - - Utility.CreateZipFromDirectory(outputFile, tempPath).Wait(); - - ReleasePackageFile = outputFile; - return ReleasePackageFile; - } - } - - static Task extractZipWithEscaping(string zipFilePath, string outFolder) - { - return Task.Run(() => { - using (var za = ZipArchive.Open(zipFilePath)) - using (var reader = za.ExtractAllEntries()) { - while (reader.MoveToNextEntry()) { - var parts = reader.Entry.Key.Split('\\', '/').Select(x => Uri.UnescapeDataString(x)); - var decoded = String.Join(Path.DirectorySeparatorChar.ToString(), parts); - - var fullTargetFile = Path.Combine(outFolder, decoded); - var fullTargetDir = Path.GetDirectoryName(fullTargetFile); - Directory.CreateDirectory(fullTargetDir); - - Utility.Retry(() => { - if (reader.Entry.IsDirectory) { - Directory.CreateDirectory(Path.Combine(outFolder, decoded)); - } else { - reader.WriteEntryToFile(Path.Combine(outFolder, decoded)); - } - }, 5); - } - } - }); - } - public static Task ExtractZipForInstall(string zipFilePath, string outFolder, string rootPackageFolder) { return ExtractZipForInstall(zipFilePath, outFolder, rootPackageFolder, x => { }); @@ -211,59 +109,6 @@ namespace Squirrel progress(100); }); } - - void renderReleaseNotesMarkdown(string specPath, Func releaseNotesProcessor) - { - var doc = new XmlDocument(); - doc.Load(specPath); - - // XXX: This code looks full tart - var metadata = doc.DocumentElement.ChildNodes - .OfType() - .First(x => x.Name.ToLowerInvariant() == "metadata"); - - var releaseNotes = metadata.ChildNodes - .OfType() - .FirstOrDefault(x => x.Name.ToLowerInvariant() == "releasenotes"); - - if (releaseNotes == null) { - this.Log().Info("No release notes found in {0}", specPath); - return; - } - - releaseNotes.InnerText = String.Format("", - releaseNotesProcessor(releaseNotes.InnerText)); - - doc.Save(specPath); - } - - void removeDependenciesFromPackageSpec(string specPath) - { - var xdoc = new XmlDocument(); - xdoc.Load(specPath); - - var metadata = xdoc.DocumentElement.FirstChild; - var dependenciesNode = metadata.ChildNodes.OfType().FirstOrDefault(x => x.Name.ToLowerInvariant() == "dependencies"); - if (dependenciesNode != null) { - metadata.RemoveChild(dependenciesNode); - } - - xdoc.Save(specPath); - } - - static internal void addDeltaFilesToContentTypes(string rootDirectory) - { - var doc = new XmlDocument(); - var path = Path.Combine(rootDirectory, "[Content_Types].xml"); - doc.Load(path); - - ContentType.Merge(doc); - ContentType.Clean(doc); - - using (var sw = new StreamWriter(path, false, Encoding.UTF8)) { - doc.Save(sw); - } - } } public class ChecksumFailedException : Exception diff --git a/src/Squirrel/UpdateManager.ApplyReleases.cs b/src/Squirrel/UpdateManager.ApplyReleases.cs index 7547f9e3..659a6d74 100644 --- a/src/Squirrel/UpdateManager.ApplyReleases.cs +++ b/src/Squirrel/UpdateManager.ApplyReleases.cs @@ -358,7 +358,7 @@ namespace Squirrel var basePkg = new ReleasePackage(Path.Combine(rootAppDirectory, "packages", currentVersion.Filename)); var deltaPkg = new ReleasePackage(Path.Combine(rootAppDirectory, "packages", releasesToApply.First().Filename)); - var deltaBuilder = new DeltaPackageBuilder(Directory.GetParent(this.rootAppDirectory).FullName); + var deltaBuilder = new DeltaPackage(Directory.GetParent(this.rootAppDirectory).FullName); return deltaBuilder.ApplyDeltaPackage(basePkg, deltaPkg, Regex.Replace(deltaPkg.InputPackageFile, @"-delta.nupkg$", ".nupkg", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), diff --git a/src/Squirrel/Utility.cs b/src/Squirrel/Utility.cs index 564d94ab..03eba9a0 100644 --- a/src/Squirrel/Utility.cs +++ b/src/Squirrel/Utility.cs @@ -20,7 +20,7 @@ using Squirrel.NuGet; namespace Squirrel { - static class Utility + internal static class Utility { public static string RemoveByteOrderMarkerIfPresent(string content) { @@ -233,7 +233,7 @@ namespace Squirrel } } - private static ProcessStartInfo CreateProcessStartInfo(string fileName, string arguments, string workingDirectory = "") + public static ProcessStartInfo CreateProcessStartInfo(string fileName, string arguments, string workingDirectory = "") { var psi = new ProcessStartInfo(fileName, arguments); psi.UseShellExecute = false; @@ -246,7 +246,7 @@ namespace Squirrel return psi; } - private static async Task<(int ExitCode, string StdOutput)> InvokeProcessUnsafeAsync(ProcessStartInfo psi, CancellationToken ct) + public static async Task<(int ExitCode, string StdOutput)> InvokeProcessUnsafeAsync(ProcessStartInfo psi, CancellationToken ct) { var pi = Process.Start(psi); await Task.Run(() => { @@ -405,88 +405,6 @@ namespace Squirrel } } - public static string FindHelperExecutable(string toFind, IEnumerable additionalDirs = null, bool throwWhenNotFound = false) - { - if (File.Exists(toFind)) - return Path.GetFullPath(toFind); - - additionalDirs = additionalDirs ?? Enumerable.Empty(); - var dirs = (new[] { AppContext.BaseDirectory, Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) }) - .Concat(additionalDirs ?? Enumerable.Empty()).Select(Path.GetFullPath); - - var exe = @".\" + toFind; - var result = dirs - .Select(x => Path.Combine(x, toFind)) - .FirstOrDefault(x => File.Exists(x)); - - if (result == null && throwWhenNotFound) - throw new Exception($"Could not find helper '{exe}'."); - - return result ?? exe; - } - - static string find7Zip() - { - if (ModeDetector.InUnitTestRunner()) { - var vendorDir = Path.Combine( - Path.GetDirectoryName(Assembly.GetExecutingAssembly().CodeBase.Replace("file:///", "")), - "..", "..", "..", "..", - "vendor", "7zip" - ); - return FindHelperExecutable("7z.exe", new[] { vendorDir }); - } else { - return FindHelperExecutable("7z.exe"); - } - } - - public static async Task ExtractZipToDirectory(string zipFilePath, string outFolder) - { - var sevenZip = find7Zip(); - - try { - var cmd = sevenZip; - var args = String.Format("x \"{0}\" -tzip -mmt on -aoa -y -o\"{1}\" *", zipFilePath, outFolder); - - // TODO this should probably fall back to SharpCompress if not on windows - if (Environment.OSVersion.Platform != PlatformID.Win32NT) { - cmd = "wine"; - args = sevenZip + " " + args; - } - - var psi = CreateProcessStartInfo(cmd, args); - - var result = await Utility.InvokeProcessUnsafeAsync(psi, CancellationToken.None); - if (result.ExitCode != 0) throw new Exception(result.StdOutput); - } catch (Exception ex) { - Log().Error($"Failed to extract file {zipFilePath} to {outFolder}\n{ex.Message}"); - throw; - } - } - - public static async Task CreateZipFromDirectory(string zipFilePath, string inFolder) - { - var sevenZip = find7Zip(); - - try { - var cmd = sevenZip; - var args = String.Format("a \"{0}\" -tzip -aoa -y -mmt on *", zipFilePath); - - // TODO this should probably fall back to SharpCompress if not on windows - if (Environment.OSVersion.Platform != PlatformID.Win32NT) { - cmd = "wine"; - args = sevenZip + " " + args; - } - - var psi = CreateProcessStartInfo(cmd, args, inFolder); - - var result = await Utility.InvokeProcessUnsafeAsync(psi, CancellationToken.None); - if (result.ExitCode != 0) throw new Exception(result.StdOutput); - } catch (Exception ex) { - Log().Error($"Failed to extract file {zipFilePath} to {inFolder}\n{ex.Message}"); - throw; - } - } - public static string AppDirForRelease(string rootAppDirectory, ReleaseEntry entry) { return Path.Combine(rootAppDirectory, "app-" + entry.Version.ToString()); @@ -790,8 +708,7 @@ namespace Squirrel static IFullLogger logger; static IFullLogger Log() { - return logger ?? - (logger = SquirrelLocator.CurrentMutable.GetService().GetLogger(typeof(Utility))); + return logger ?? (logger = SquirrelLocator.CurrentMutable.GetService().GetLogger(typeof(Utility))); } [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] diff --git a/src/SquirrelCli/DeltaPackageBuilder.cs b/src/SquirrelCli/DeltaPackageBuilder.cs new file mode 100644 index 00000000..1ffe9675 --- /dev/null +++ b/src/SquirrelCli/DeltaPackageBuilder.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Squirrel; +using Squirrel.Bsdiff; +using Squirrel.SimpleSplat; + +namespace SquirrelCli +{ + internal class DeltaPackageBuilder : IEnableLogger + { + public ReleasePackage CreateDeltaPackage(ReleasePackage basePackage, ReleasePackage newPackage, string outputFile) + { + Contract.Requires(basePackage != null); + Contract.Requires(!String.IsNullOrEmpty(outputFile) && !File.Exists(outputFile)); + + if (basePackage.Version > newPackage.Version) { + var message = String.Format( + "You cannot create a delta package based on version {0} as it is a later version than {1}", + basePackage.Version, + newPackage.Version); + throw new InvalidOperationException(message); + } + + if (basePackage.ReleasePackageFile == null) { + throw new ArgumentException("The base package's release file is null", "basePackage"); + } + + if (!File.Exists(basePackage.ReleasePackageFile)) { + throw new FileNotFoundException("The base package release does not exist", basePackage.ReleasePackageFile); + } + + if (!File.Exists(newPackage.ReleasePackageFile)) { + throw new FileNotFoundException("The new package release does not exist", newPackage.ReleasePackageFile); + } + + string baseTempPath = null; + string tempPath = null; + + using (Utility.WithTempDirectory(out baseTempPath, null)) + using (Utility.WithTempDirectory(out tempPath, null)) { + var baseTempInfo = new DirectoryInfo(baseTempPath); + var tempInfo = new DirectoryInfo(tempPath); + + this.Log().Info("Extracting {0} and {1} into {2}", + basePackage.ReleasePackageFile, newPackage.ReleasePackageFile, tempPath); + + HelperExe.ExtractZipToDirectory(basePackage.ReleasePackageFile, baseTempInfo.FullName).Wait(); + HelperExe.ExtractZipToDirectory(newPackage.ReleasePackageFile, tempInfo.FullName).Wait(); + + // Collect a list of relative paths under 'lib' and map them + // to their full name. We'll use this later to determine in + // the new version of the package whether the file exists or + // not. + var baseLibFiles = baseTempInfo.GetAllFilesRecursively() + .Where(x => x.FullName.ToLowerInvariant().Contains("lib" + Path.DirectorySeparatorChar)) + .ToDictionary(k => k.FullName.Replace(baseTempInfo.FullName, ""), v => v.FullName); + + var newLibDir = tempInfo.GetDirectories().First(x => x.Name.ToLowerInvariant() == "lib"); + + foreach (var libFile in newLibDir.GetAllFilesRecursively()) { + createDeltaForSingleFile(libFile, tempInfo, baseLibFiles); + } + + ReleasePackageBuilder.addDeltaFilesToContentTypes(tempInfo.FullName); + HelperExe.CreateZipFromDirectory(outputFile, tempInfo.FullName).Wait(); + } + + return new ReleasePackage(outputFile); + } + + void createDeltaForSingleFile(FileInfo targetFile, DirectoryInfo workingDirectory, Dictionary baseFileListing) + { + // NB: There are three cases here that we'll handle: + // + // 1. Exists only in new => leave it alone, we'll use it directly. + // 2. Exists in both old and new => write a dummy file so we know + // to keep it. + // 3. Exists in old but changed in new => create a delta file + // + // The fourth case of "Exists only in old => delete it in new" + // is handled when we apply the delta package + var relativePath = targetFile.FullName.Replace(workingDirectory.FullName, ""); + + if (!baseFileListing.ContainsKey(relativePath)) { + this.Log().Info("{0} not found in base package, marking as new", relativePath); + return; + } + + var oldData = File.ReadAllBytes(baseFileListing[relativePath]); + var newData = File.ReadAllBytes(targetFile.FullName); + + if (bytesAreIdentical(oldData, newData)) { + this.Log().Info("{0} hasn't changed, writing dummy file", relativePath); + + File.Create(targetFile.FullName + ".diff").Dispose(); + File.Create(targetFile.FullName + ".shasum").Dispose(); + targetFile.Delete(); + return; + } + + this.Log().Info("Delta patching {0} => {1}", baseFileListing[relativePath], targetFile.FullName); + var msDelta = new MsDeltaCompression(); + + if (targetFile.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) || + targetFile.Extension.Equals(".dll", StringComparison.OrdinalIgnoreCase) || + targetFile.Extension.Equals(".node", StringComparison.OrdinalIgnoreCase)) { + try { + msDelta.CreateDelta(baseFileListing[relativePath], targetFile.FullName, targetFile.FullName + ".diff"); + goto exit; + } catch (Exception) { + this.Log().Warn("We couldn't create a delta for {0}, attempting to create bsdiff", targetFile.Name); + } + } + + try { + using (FileStream of = File.Create(targetFile.FullName + ".bsdiff")) { + BinaryPatchUtility.Create(oldData, newData, of); + + // NB: Create a dummy corrupt .diff file so that older + // versions which don't understand bsdiff will fail out + // until they get upgraded, instead of seeing the missing + // file and just removing it. + File.WriteAllText(targetFile.FullName + ".diff", "1"); + } + } catch (Exception ex) { + this.Log().WarnException(String.Format("We really couldn't create a delta for {0}", targetFile.Name), ex); + + Utility.DeleteFileHarder(targetFile.FullName + ".bsdiff", true); + Utility.DeleteFileHarder(targetFile.FullName + ".diff", true); + return; + } + + exit: + + var rl = ReleaseEntry.GenerateFromFile(new MemoryStream(newData), targetFile.Name + ".shasum"); + File.WriteAllText(targetFile.FullName + ".shasum", rl.EntryAsString, Encoding.UTF8); + targetFile.Delete(); + } + + static bool bytesAreIdentical(byte[] oldData, byte[] newData) + { + if (oldData == null || newData == null) { + return oldData == newData; + } + if (oldData.LongLength != newData.LongLength) { + return false; + } + + for (long i = 0; i < newData.LongLength; i++) { + if (oldData[i] != newData[i]) { + return false; + } + } + + return true; + } + } +} diff --git a/src/SquirrelCli/HelperExe.cs b/src/SquirrelCli/HelperExe.cs new file mode 100644 index 00000000..4b9709d2 --- /dev/null +++ b/src/SquirrelCli/HelperExe.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Squirrel; +using Squirrel.SimpleSplat; + +namespace SquirrelCli +{ + internal static class HelperExe + { + public static string SetupPath => FindHelperExecutable("Setup.exe", _searchPaths); + public static string UpdatePath => FindHelperExecutable("Update.exe", _searchPaths); + public static string StubExecutablePath => FindHelperExecutable("StubExecutable.exe", _searchPaths); + + // private so we don't expose paths to internal tools. these should be exposed as a helper function + private static string NugetPath => FindHelperExecutable("NuGet.exe", _searchPaths); + private static string RceditPath => FindHelperExecutable("rcedit.exe", _searchPaths); + private static string SevenZipPath => FindHelperExecutable("7z.exe", _searchPaths); + private static string SignToolPath => FindHelperExecutable("signtool.exe", _searchPaths); + private static string SetupZipBuilderPath => FindHelperExecutable("WriteZipToSetup.exe", _searchPaths); + + private static List _searchPaths = new List(); + private static IFullLogger Log = SquirrelLocator.CurrentMutable.GetService().GetLogger(typeof(HelperExe)); + + static HelperExe() + { + if (ModeDetector.InUnitTestRunner()) { + var baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().CodeBase.Replace("file:///", "")); + AddSearchPath(Path.Combine(baseDir, "..", "..", "..", "..", "vendor")); + AddSearchPath(Path.Combine(baseDir, "..", "..", "..", "..", "vendor", "7zip")); + AddSearchPath(Path.Combine(baseDir, "..", "..", "..", "..", "vendor", "wix")); + } else { +#if DEBUG + AddSearchPath(Path.Combine(AssemblyRuntimeInfo.BaseDirectory, "..", "..", "..", "build", "publish")); + AddSearchPath(Path.Combine(AssemblyRuntimeInfo.BaseDirectory, "..", "..", "..", "vendor")); + AddSearchPath(Path.Combine(AssemblyRuntimeInfo.BaseDirectory, "..", "..", "..", "vendor", "7zip")); + AddSearchPath(Path.Combine(AssemblyRuntimeInfo.BaseDirectory, "..", "..", "..", "vendor", "wix")); +#endif + } + } + + public static void AddSearchPath(string path) + { + if (Directory.Exists(path)) + _searchPaths.Insert(0, path); + } + + private static string FindHelperExecutable(string toFind, IEnumerable additionalDirs = null, bool throwWhenNotFound = true) + { + if (File.Exists(toFind)) + return Path.GetFullPath(toFind); + + additionalDirs = additionalDirs ?? Enumerable.Empty(); + var dirs = (new[] { AppContext.BaseDirectory, Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) }) + .Concat(additionalDirs ?? Enumerable.Empty()).Select(Path.GetFullPath); + + var exe = @".\" + toFind; + var result = dirs + .Select(x => Path.Combine(x, toFind)) + .FirstOrDefault(x => File.Exists(x)); + + if (result == null && throwWhenNotFound) + throw new Exception($"Could not find helper '{exe}'. If not in the default location, add additional search paths using command arguments."); + + return result ?? exe; + } + + public static async Task SetExeIcon(string exePath, string iconPath) + { + var args = new[] { Path.GetFullPath(exePath), "--set-icon", iconPath }; + var processResult = await Utility.InvokeProcessAsync(RceditPath, args, CancellationToken.None); + + if (processResult.ExitCode != 0) { + var msg = String.Format( + "Failed to modify resources, command invoked was: '{0} {1}'\n\nOutput was:\n{2}", + RceditPath, args, processResult.StdOutput); + + throw new Exception(msg); + } else { + Console.WriteLine(processResult.StdOutput); + } + } + + public static async Task SetPEVersionBlockFromPackageInfo(string exePath, Squirrel.NuGet.IPackage package, string iconPath = null) + { + var realExePath = Path.GetFullPath(exePath); + var company = String.Join(",", package.Authors); + + List args = new List() { + realExePath, + "--set-version-string", "CompanyName", company, + "--set-version-string", "LegalCopyright", package.Copyright ?? "Copyright © " + DateTime.Now.Year.ToString() + " " + company, + "--set-version-string", "FileDescription", package.Summary ?? package.Description ?? "Installer for " + package.Id, + "--set-version-string", "ProductName", package.Description ?? package.Summary ?? package.Id, + "--set-file-version", package.Version.ToString(), + "--set-product-version", package.Version.ToString(), + }; + + if (iconPath != null) { + args.Add("--set-icon"); + args.Add(Path.GetFullPath(iconPath)); + } + + var processResult = await Utility.InvokeProcessAsync(RceditPath, args, CancellationToken.None); + + if (processResult.ExitCode != 0) { + var msg = String.Format( + "Failed to modify resources, command invoked was: '{0} {1}'\n\nOutput was:\n{2}", + RceditPath, args, processResult.StdOutput); + + throw new Exception(msg); + } else { + Console.WriteLine(processResult.StdOutput); + } + } + + public static async Task SignPEFile(string exePath, string signingOpts) + { + var psi = Utility.CreateProcessStartInfo(SignToolPath, $"sign {signingOpts} \"{exePath}\""); + var processResult = await Utility.InvokeProcessUnsafeAsync(psi, CancellationToken.None); + + if (processResult.ExitCode != 0) { + var optsWithPasswordHidden = new Regex(@"/p\s+\w+").Replace(signingOpts, "/p ********"); + var msg = String.Format("Failed to sign, command invoked was: '{0} sign {1} {2}'\r\n{3}", + SignToolPath, optsWithPasswordHidden, exePath, processResult.StdOutput); + throw new Exception(msg); + } else { + Log.Info("Sign successful: " + processResult.StdOutput); + } + } + + public static async Task ValidateFrameworkVersion(string frameworkVersion) + { + if (String.IsNullOrWhiteSpace(frameworkVersion)) { + return; + } + + var chkFrameworkResult = await Utility.InvokeProcessAsync(SetupPath, new string[] { "--checkFramework ", frameworkVersion }, CancellationToken.None); + if (chkFrameworkResult.ExitCode != 0) { + throw new ArgumentException($"Unsupported FrameworkVersion: '{frameworkVersion}'. {chkFrameworkResult.StdOutput}"); + } + } + + public static async Task CopyResourcesToTargetStubExe(string targetStubExe, string copyResourcesFromExe) + { + var processResult = await Utility.InvokeProcessAsync( + SetupZipBuilderPath, + new string[] { "--copy-stub-resources", copyResourcesFromExe, targetStubExe }, + CancellationToken.None); + + if (processResult.ExitCode != 0) { + throw new Exception("Unable to copy resources to stub exe: " + processResult.StdOutput); + } + } + + public static async Task BundleZipIntoTargetSetupExe(string targetSetupExe, string zipPath, string frameworkVersion, string backgroundGif) + { + List arguments = new List() { + targetSetupExe, + zipPath + }; + if (!String.IsNullOrWhiteSpace(frameworkVersion)) { + arguments.Add("--set-required-framework"); + arguments.Add(frameworkVersion); + } + if (!String.IsNullOrWhiteSpace(backgroundGif)) { + arguments.Add("--set-splash"); + arguments.Add(Path.GetFullPath(backgroundGif)); + } + + var result = await Utility.InvokeProcessAsync(SetupZipBuilderPath, arguments, CancellationToken.None); + if (result.ExitCode != 0) + throw new Exception("Failed to write Zip to Setup.exe!\n\n" + result.StdOutput); + } + + public static async Task NugetPack(string nuspecPath, string baseDirectory, string outputDirectory) + { + var args = new string[] { "pack", nuspecPath, "-BasePath", baseDirectory, "-OutputDirectory", outputDirectory }; + + Log.Info($"Packing '{baseDirectory}' into nupkg."); + var res = await Utility.InvokeProcessAsync(NugetPath, args, CancellationToken.None); + + if (res.ExitCode != 0) + throw new Exception($"Failed nuget pack (exit {res.ExitCode}): \r\n " + res.StdOutput); + } + + public static async Task ExtractZipToDirectory(string zipFilePath, string outFolder) + { + try { + var cmd = SevenZipPath; + var args = String.Format("x \"{0}\" -tzip -mmt on -aoa -y -o\"{1}\" *", zipFilePath, outFolder); + + // TODO this should probably fall back to SharpCompress if not on windows + if (Environment.OSVersion.Platform != PlatformID.Win32NT) { + cmd = "wine"; + args = SevenZipPath + " " + args; + } + + var psi = Utility.CreateProcessStartInfo(cmd, args); + + var result = await Utility.InvokeProcessUnsafeAsync(psi, CancellationToken.None); + if (result.ExitCode != 0) throw new Exception(result.StdOutput); + } catch (Exception ex) { + Log.Error($"Failed to extract file {zipFilePath} to {outFolder}\n{ex.Message}"); + throw; + } + } + + public static async Task CreateZipFromDirectory(string zipFilePath, string inFolder) + { + try { + var cmd = SevenZipPath; + var args = String.Format("a \"{0}\" -tzip -aoa -y -mmt on *", zipFilePath); + + // TODO this should probably fall back to SharpCompress if not on windows + if (Environment.OSVersion.Platform != PlatformID.Win32NT) { + cmd = "wine"; + args = SevenZipPath + " " + args; + } + + var psi = Utility.CreateProcessStartInfo(cmd, args, inFolder); + + var result = await Utility.InvokeProcessUnsafeAsync(psi, CancellationToken.None); + if (result.ExitCode != 0) throw new Exception(result.StdOutput); + } catch (Exception ex) { + Log.Error($"Failed to extract file {zipFilePath} to {inFolder}\n{ex.Message}"); + throw; + } + } + } +} diff --git a/src/SquirrelCli/Options.cs b/src/SquirrelCli/Options.cs index 55fafa46..10147383 100644 --- a/src/SquirrelCli/Options.cs +++ b/src/SquirrelCli/Options.cs @@ -37,6 +37,7 @@ namespace SquirrelCli Add("f=|framework=", "Set the required .NET framework version, e.g. net461", v => framework = v); Add("no-delta", "Don't generate delta packages to save time", v => noDelta = true); Add("b=|baseUrl=", "Provides a base URL to prefix the RELEASES file packages with", v => baseUrl = v, true); + Add("addSearchPath=", "Add additional search directories when looking for helper exe's such as Setup.exe, Update.exe, etc", v => HelperExe.AddSearchPath(v)); } public override void Validate() diff --git a/src/SquirrelCli/Program.cs b/src/SquirrelCli/Program.cs index 450698aa..851109cf 100644 --- a/src/SquirrelCli/Program.cs +++ b/src/SquirrelCli/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; @@ -76,13 +76,6 @@ namespace SquirrelCli static IFullLogger Log => SquirrelLocator.Current.GetService().GetLogger(typeof(Program)); - static string[] VendorDirs => new string[] { - Path.Combine(AssemblyRuntimeInfo.BaseDirectory, "..", "..", "..", "vendor") - }; - static string BootstrapperPath = Utility.FindHelperExecutable("Setup.exe", throwWhenNotFound: true); - static string UpdatePath = Utility.FindHelperExecutable("Update.exe", throwWhenNotFound: true); - static string NugetPath = Utility.FindHelperExecutable("NuGet.exe", VendorDirs, throwWhenNotFound: true); - static void Pack(PackOptions options) { using (Utility.WithTempDirectory(out var tmpDir)) { @@ -104,13 +97,7 @@ namespace SquirrelCli var nuspecPath = Path.Combine(tmpDir, options.packName + ".nuspec"); File.WriteAllText(nuspecPath, nuspec); - var args = new string[] { "pack", nuspecPath, "-BasePath", options.packDirectory, "-OutputDirectory", tmpDir }; - - Log.Info($"Packing '{options.packDirectory}' into nupkg."); - var res = Utility.InvokeProcessAsync(NugetPath, args, CancellationToken.None).Result; - - if (res.ExitCode != 0) - throw new Exception($"Failed nuget pack (exit {res.ExitCode}): \r\n " + res.StdOutput); + HelperExe.NugetPack(nuspecPath, options.packDirectory, tmpDir).Wait(); var nupkgPath = Directory.EnumerateFiles(tmpDir).Where(f => f.EndsWith(".nupkg")).FirstOrDefault(); if (nupkgPath == null) @@ -136,12 +123,11 @@ namespace SquirrelCli var backgroundGif = options.splashImage; var setupIcon = options.setupIcon; + var updatePath = HelperExe.UpdatePath; + // validate that the provided "frameworkVersion" is supported by Setup.exe if (!String.IsNullOrWhiteSpace(frameworkVersion)) { - var chkFrameworkResult = Utility.InvokeProcessAsync(BootstrapperPath, new string[] { "--checkFramework ", frameworkVersion }, CancellationToken.None).Result; - if (chkFrameworkResult.ExitCode != 0) { - throw new ArgumentException($"Unsupported FrameworkVersion: '{frameworkVersion}'. {chkFrameworkResult.StdOutput}"); - } + HelperExe.ValidateFrameworkVersion(frameworkVersion).Wait(); } // copy input package to target output directory @@ -165,7 +151,7 @@ namespace SquirrelCli foreach (var file in toProcess) { Log.Info("Creating release package: " + file.FullName); - var rp = new ReleasePackage(file.FullName); + var rp = new ReleasePackageBuilder(file.FullName); rp.CreateReleasePackage(Path.Combine(di.FullName, rp.SuggestedReleaseFileName), contentsPostProcessHook: pkgPath => { // create stub executable for all exe's in this package (except Squirrel!) @@ -185,7 +171,7 @@ namespace SquirrelCli .Where(d => re.IsMatch(d)) .OrderBy(d => d.Length) .FirstOrDefault(); - File.Copy(UpdatePath, Path.Combine(libDir, "Squirrel.exe")); + File.Copy(updatePath, Path.Combine(libDir, "Squirrel.exe")); // sign all exe's in this package if (signingOpts == null) return; @@ -198,7 +184,7 @@ namespace SquirrelCli } Log.Info("About to sign {0}", x.FullName); - await signPEFile(x.FullName, signingOpts); + await HelperExe.SignPEFile(x.FullName, signingOpts); }, 1) .Wait(); }); @@ -207,8 +193,7 @@ namespace SquirrelCli var prev = ReleaseEntry.GetPreviousRelease(previousReleases, rp, targetDir); if (prev != null && generateDeltas) { - var deltaBuilder = new DeltaPackageBuilder(null); - + var deltaBuilder = new DeltaPackageBuilder(); var dp = deltaBuilder.CreateDeltaPackage(prev, rp, Path.Combine(di.FullName, rp.SuggestedReleaseFileName.Replace("full", "delta"))); processed.Insert(0, dp.InputPackageFile); @@ -229,27 +214,11 @@ namespace SquirrelCli var targetSetupExe = Path.Combine(di.FullName, "Setup.exe"); var newestFullRelease = Squirrel.EnumerableExtensions.MaxBy(releaseEntries, x => x.Version).Where(x => !x.IsDelta).First(); - File.Copy(BootstrapperPath, targetSetupExe, true); - var zipPath = createSetupEmbeddedZip(Path.Combine(di.FullName, newestFullRelease.Filename), di.FullName, signingOpts, setupIcon).Result; - - var writeZipToSetup = Utility.FindHelperExecutable("WriteZipToSetup.exe"); + File.Copy(HelperExe.SetupPath, targetSetupExe, true); + var zipPath = createSetupEmbeddedZip(Path.Combine(di.FullName, newestFullRelease.Filename), di.FullName, signingOpts, setupIcon, updatePath).Result; try { - List arguments = new List() { - targetSetupExe, - zipPath - }; - if (!String.IsNullOrWhiteSpace(frameworkVersion)) { - arguments.Add("--set-required-framework"); - arguments.Add(frameworkVersion); - } - if (!String.IsNullOrWhiteSpace(backgroundGif)) { - arguments.Add("--set-splash"); - arguments.Add(Path.GetFullPath(backgroundGif)); - } - - var result = Utility.InvokeProcessAsync(writeZipToSetup, arguments, CancellationToken.None).Result; - if (result.ExitCode != 0) throw new Exception("Failed to write Zip to Setup.exe!\n\n" + result.StdOutput); + HelperExe.BundleZipIntoTargetSetupExe(targetSetupExe, zipPath, frameworkVersion, backgroundGif).Wait(); } catch (Exception ex) { Log.ErrorException("Failed to update Setup.exe with new Zip file", ex); throw; @@ -258,10 +227,10 @@ namespace SquirrelCli } Utility.Retry(() => - setPEVersionInfoAndIcon(targetSetupExe, new ZipPackage(package), setupIcon).Wait()); + HelperExe.SetPEVersionBlockFromPackageInfo(targetSetupExe, new ZipPackage(package), setupIcon).Wait()); if (signingOpts != null) { - signPEFile(targetSetupExe, signingOpts).Wait(); + HelperExe.SignPEFile(targetSetupExe, signingOpts).Wait(); } //if (generateMsi) { @@ -273,14 +242,14 @@ namespace SquirrelCli //} } - static async Task createSetupEmbeddedZip(string fullPackage, string releasesDir, string signingOpts, string setupIcon) + static async Task createSetupEmbeddedZip(string fullPackage, string releasesDir, string signingOpts, string setupIcon, string updatePath) { string tempPath; Log.Info("Building embedded zip file for Setup.exe"); using (Utility.WithTempDirectory(out tempPath, null)) { Log.ErrorIfThrows(() => { - File.Copy(UpdatePath, Path.Combine(tempPath, "Update.exe")); + File.Copy(updatePath, Path.Combine(tempPath, "Update.exe")); File.Copy(fullPackage, Path.Combine(tempPath, Path.GetFileName(fullPackage))); }, "Failed to write package files to temp dir: " + tempPath); @@ -305,7 +274,7 @@ namespace SquirrelCli .Where(x => x.Name.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase)) .Select(x => x.FullName); - await files.ForEachAsync(x => signPEFile(x, signingOpts)); + await files.ForEachAsync(x => HelperExe.SignPEFile(x, signingOpts)); } Log.ErrorIfThrows(() => @@ -316,31 +285,6 @@ namespace SquirrelCli } } - static async Task signPEFile(string exePath, string signingOpts) - { - // Try to find SignTool.exe - var exe = @".\signtool.exe"; - if (!File.Exists(exe)) { - exe = Path.Combine(AssemblyRuntimeInfo.BaseDirectory, "signtool.exe"); - - // Run down PATH and hope for the best - if (!File.Exists(exe)) exe = "signtool.exe"; - } - - var processResult = await Utility.InvokeProcessAsync(exe, - new string[] { "sign", signingOpts, exePath }, - CancellationToken.None); - - if (processResult.ExitCode != 0) { - var optsWithPasswordHidden = new Regex(@"/p\s+\w+").Replace(signingOpts, "/p ********"); - var msg = String.Format("Failed to sign, command invoked was: '{0} sign {1} {2}'", - exe, optsWithPasswordHidden, exePath); - - throw new Exception(msg); - } else { - Console.WriteLine(processResult.StdOutput); - } - } static bool isPEFileSigned(string path) { try { @@ -351,54 +295,14 @@ namespace SquirrelCli } } - static async Task createExecutableStubForExe(string fullName) + static async Task createExecutableStubForExe(string exeToCopy) { - var exe = Utility.FindHelperExecutable(@"StubExecutable.exe"); - var target = Path.Combine( - Path.GetDirectoryName(fullName), - Path.GetFileNameWithoutExtension(fullName) + "_ExecutionStub.exe"); + Path.GetDirectoryName(exeToCopy), + Path.GetFileNameWithoutExtension(exeToCopy) + "_ExecutionStub.exe"); - await Utility.CopyToAsync(exe, target); - - await Utility.InvokeProcessAsync( - Utility.FindHelperExecutable("WriteZipToSetup.exe"), - new string[] { "--copy-stub-resources", fullName, target }, - CancellationToken.None); - } - - static async Task setPEVersionInfoAndIcon(string exePath, IPackage package, string iconPath = null) - { - var realExePath = Path.GetFullPath(exePath); - var company = String.Join(",", package.Authors); - - List args = new List() { - realExePath, - "--set-version-string", "CompanyName", company, - "--set-version-string", "LegalCopyright", package.Copyright ?? "Copyright © " + DateTime.Now.Year.ToString() + " " + company, - "--set-version-string", "FileDescription", package.Summary ?? package.Description ?? "Installer for " + package.Id, - "--set-version-string", "ProductName", package.Description ?? package.Summary ?? package.Id, - "--set-file-version", package.Version.ToString(), - "--set-product-version", package.Version.ToString(), - }; - - if (iconPath != null) { - args.Add("--set-icon"); - args.Add(Path.GetFullPath(iconPath)); - } - - string exe = Utility.FindHelperExecutable("rcedit.exe"); - var processResult = await Utility.InvokeProcessAsync(exe, args, CancellationToken.None); - - if (processResult.ExitCode != 0) { - var msg = String.Format( - "Failed to modify resources, command invoked was: '{0} {1}'\n\nOutput was:\n{2}", - exe, args, processResult.StdOutput); - - throw new Exception(msg); - } else { - Console.WriteLine(processResult.StdOutput); - } + await Utility.CopyToAsync(HelperExe.StubExecutablePath, target); + await HelperExe.CopyResourcesToTargetStubExe(exeToCopy, target); } } @@ -416,8 +320,10 @@ namespace SquirrelCli string lvl = logLevel.ToString().Substring(0, 4).ToUpper(); if (logLevel == LogLevel.Error || logLevel == LogLevel.Fatal) { Utility.ConsoleWriteWithColor($"[{lvl}] {message}\r\n", ConsoleColor.Red); + Console.WriteLine(); } else if (logLevel == LogLevel.Warn) { Utility.ConsoleWriteWithColor($"[{lvl}] {message}\r\n", ConsoleColor.Yellow); + Console.WriteLine(); } else { Console.WriteLine($"[{lvl}] {message}"); } diff --git a/src/SquirrelCli/ReleasePackageBuilder.cs b/src/SquirrelCli/ReleasePackageBuilder.cs new file mode 100644 index 00000000..a8e45cbb --- /dev/null +++ b/src/SquirrelCli/ReleasePackageBuilder.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Design; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using Squirrel.MarkdownSharp; +using Squirrel.NuGet; +using Squirrel.SimpleSplat; +using System.Threading.Tasks; +using SharpCompress.Archives.Zip; +using SharpCompress.Readers; +using Squirrel; + +namespace SquirrelCli +{ + internal class ReleasePackageBuilder : ReleasePackage + { + public ReleasePackageBuilder(string inputPackageFile, bool isReleasePackage = false) : base(inputPackageFile, isReleasePackage) + { + } + + public string CreateReleasePackage(string outputFile, Func releaseNotesProcessor = null, Action contentsPostProcessHook = null) + { + Contract.Requires(!String.IsNullOrEmpty(outputFile)); + releaseNotesProcessor = releaseNotesProcessor ?? (x => (new Markdown()).Transform(x)); + + if (ReleasePackageFile != null) { + return ReleasePackageFile; + } + + var package = new ZipPackage(InputPackageFile); + + var dontcare = default(SemanticVersion); + + // NB: Our test fixtures use packages that aren't SemVer compliant, + // we don't really care that they aren't valid + if (!ModeDetector.InUnitTestRunner() && !SemanticVersion.TryParseStrict(package.Version.ToString(), out dontcare)) { + throw new Exception( + String.Format( + "Your package version is currently {0}, which is *not* SemVer-compatible, change this to be a SemVer version number", + package.Version.ToString())); + } + + // we can tell from here what platform(s) the package targets + // but given this is a simple package we only + // ever expect one entry here (crash hard otherwise) + var frameworks = package.GetSupportedFrameworks(); + if (frameworks.Count() > 1) { + var platforms = frameworks + .Aggregate(new StringBuilder(), (sb, f) => sb.Append(f.ToString() + "; ")); + + throw new InvalidOperationException(String.Format( + "The input package file {0} targets multiple platforms - {1} - and cannot be transformed into a release package.", InputPackageFile, platforms)); + + } else if (!frameworks.Any()) { + throw new InvalidOperationException(String.Format( + "The input package file {0} targets no platform and cannot be transformed into a release package.", InputPackageFile)); + } + + // CS - docs say we don't support dependencies. I can't think of any reason allowing this is useful. + if (package.DependencySets.Any()) { + throw new InvalidOperationException(String.Format( + "The input package file {0} must have no dependencies.", InputPackageFile)); + } + + var targetFramework = frameworks.Single(); + + this.Log().Info("Creating release package: {0} => {1}", InputPackageFile, outputFile); + + string tempPath = null; + + using (Utility.WithTempDirectory(out tempPath, null)) { + var tempDir = new DirectoryInfo(tempPath); + + extractZipWithEscaping(InputPackageFile, tempPath).Wait(); + + var specPath = tempDir.GetFiles("*.nuspec").First().FullName; + + this.Log().Info("Removing unnecessary data"); + removeDependenciesFromPackageSpec(specPath); + + if (releaseNotesProcessor != null) { + renderReleaseNotesMarkdown(specPath, releaseNotesProcessor); + } + + addDeltaFilesToContentTypes(tempDir.FullName); + + contentsPostProcessHook?.Invoke(tempPath); + + HelperExe.CreateZipFromDirectory(outputFile, tempPath).Wait(); + + ReleasePackageFile = outputFile; + return ReleasePackageFile; + } + } + + static Task extractZipWithEscaping(string zipFilePath, string outFolder) + { + return Task.Run(() => { + using (var za = ZipArchive.Open(zipFilePath)) + using (var reader = za.ExtractAllEntries()) { + while (reader.MoveToNextEntry()) { + var parts = reader.Entry.Key.Split('\\', '/').Select(x => Uri.UnescapeDataString(x)); + var decoded = String.Join(Path.DirectorySeparatorChar.ToString(), parts); + + var fullTargetFile = Path.Combine(outFolder, decoded); + var fullTargetDir = Path.GetDirectoryName(fullTargetFile); + Directory.CreateDirectory(fullTargetDir); + + Utility.Retry(() => { + if (reader.Entry.IsDirectory) { + Directory.CreateDirectory(Path.Combine(outFolder, decoded)); + } else { + reader.WriteEntryToFile(Path.Combine(outFolder, decoded)); + } + }, 5); + } + } + }); + } + + void renderReleaseNotesMarkdown(string specPath, Func releaseNotesProcessor) + { + var doc = new XmlDocument(); + doc.Load(specPath); + + // XXX: This code looks full tart + var metadata = doc.DocumentElement.ChildNodes + .OfType() + .First(x => x.Name.ToLowerInvariant() == "metadata"); + + var releaseNotes = metadata.ChildNodes + .OfType() + .FirstOrDefault(x => x.Name.ToLowerInvariant() == "releasenotes"); + + if (releaseNotes == null) { + this.Log().Info("No release notes found in {0}", specPath); + return; + } + + releaseNotes.InnerText = String.Format("", + releaseNotesProcessor(releaseNotes.InnerText)); + + doc.Save(specPath); + } + + void removeDependenciesFromPackageSpec(string specPath) + { + var xdoc = new XmlDocument(); + xdoc.Load(specPath); + + var metadata = xdoc.DocumentElement.FirstChild; + var dependenciesNode = metadata.ChildNodes.OfType().FirstOrDefault(x => x.Name.ToLowerInvariant() == "dependencies"); + if (dependenciesNode != null) { + metadata.RemoveChild(dependenciesNode); + } + + xdoc.Save(specPath); + } + + static internal void addDeltaFilesToContentTypes(string rootDirectory) + { + var doc = new XmlDocument(); + var path = Path.Combine(rootDirectory, "[Content_Types].xml"); + doc.Load(path); + + ContentType.Merge(doc); + ContentType.Clean(doc); + + using (var sw = new StreamWriter(path, false, Encoding.UTF8)) { + doc.Save(sw); + } + } + } +}