diff --git a/src/Squirrel.CommandLine/HelperFile.cs b/src/Squirrel.CommandLine/HelperFile.cs index 1cc7762f5..b05faccd7 100644 --- a/src/Squirrel.CommandLine/HelperFile.cs +++ b/src/Squirrel.CommandLine/HelperFile.cs @@ -100,14 +100,30 @@ public static void CompressLzma7z(string zipFilePath, string inFolder) InvokeAndThrowIfNonZero(SevenZipPath, args, inFolder); } - protected static void InvokeAndThrowIfNonZero(string exePath, IEnumerable args, string workingDir) + protected static string InvokeAndThrowIfNonZero(string exePath, IEnumerable args, string workingDir) { var result = PlatformUtil.InvokeProcess(exePath, args, workingDir, CancellationToken.None); - if (result.ExitCode != 0) { - throw new Exception( - $"Command failed:\n{result.Command}\n\n" + - $"Output was:\n" + result.StdOutput); - } + ProcessFailedException.ThrowIfNonZero(result); + return result.StdOutput; + } + } + + public class ProcessFailedException : Exception + { + public string Command { get; } + public string StdOutput { get; } + + public ProcessFailedException(string command, string stdOutput) + : base($"Command failed:\n{command}\n\nOutput was:\n{stdOutput}") + { + Command = command; + StdOutput = stdOutput; + } + + public static void ThrowIfNonZero((int ExitCode, string StdOutput, string Command) result) + { + if (result.ExitCode != 0) + throw new ProcessFailedException(result.Command, result.StdOutput); } } } diff --git a/src/Squirrel.CommandLine/OSX/Commands.cs b/src/Squirrel.CommandLine/OSX/Commands.cs index 8078f1c23..dbafc8ce9 100644 --- a/src/Squirrel.CommandLine/OSX/Commands.cs +++ b/src/Squirrel.CommandLine/OSX/Commands.cs @@ -5,6 +5,8 @@ using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; +using Microsoft.NET.HostModel.AppHost; +using NuGet.Versioning; using Squirrel.NuGet; using Squirrel.PropertyList; using Squirrel.SimpleSplat; @@ -19,49 +21,11 @@ public static CommandSet GetCommands() { return new CommandSet { "[ Package Authoring ]", - { "bundle", "Convert a build directory into a OSX '.app' bundle", new BundleOptions(), Bundle }, - { "pack", "Create a Squirrel release from a '.app' bundle", new PackOptions(), Pack }, + { "pack", "Convert a build or '.app' dir into a Squirrel release", new PackOptions(), Pack }, }; } private static void Pack(PackOptions options) - { - var releasesDir = options.GetReleaseDirectory(); - using var _ = Utility.GetTempDirectory(out var tmp); - - var manifest = Utility.ReadManifestFromVersionDir(options.package); - if (manifest == null) - throw new Exception("Package directory is not a valid Squirrel bundle. Execute 'bundle' command on this app first."); - - var nupkgPath = NugetConsole.CreatePackageFromNuspecPath(tmp, options.package, manifest.FilePath); - - var releaseFilePath = Path.Combine(releasesDir.FullName, "RELEASES"); - var releases = new List(); - if (File.Exists(releaseFilePath)) { - releases.AddRange(ReleaseEntry.ParseReleaseFile(File.ReadAllText(releaseFilePath, Encoding.UTF8))); - } - - Log.Info("Creating Squirrel Release"); - var rp = new ReleasePackageBuilder(nupkgPath); - var newPkgPath = rp.CreateReleasePackage(Path.Combine(releasesDir.FullName, rp.SuggestedReleaseFileName)); - - Log.Info("Creating Delta Packages"); - var prev = ReleasePackageBuilder.GetPreviousRelease(releases, rp, releasesDir.FullName); - if (prev != null && !options.noDelta) { - var deltaBuilder = new DeltaPackageBuilder(); - var deltaFile = Path.Combine(releasesDir.FullName, rp.SuggestedReleaseFileName.Replace("full", "delta")); - var dp = deltaBuilder.CreateDeltaPackage(prev, rp, deltaFile); - releases.Add(ReleaseEntry.GenerateFromFile(deltaFile)); - } - - releases.Add(ReleaseEntry.GenerateFromFile(newPkgPath)); - ReleaseEntry.WriteReleaseFile(releases, releaseFilePath); - // EasyZip.CreateZipFromDirectory(Path.Combine(releasesDir.FullName, $"{rp.Id}.app.zip"), options.package, nestDirectory: true); - - Log.Info("Done"); - } - - private static void Bundle(BundleOptions options) { var releaseDir = options.GetReleaseDirectory(); string appBundlePath; @@ -105,13 +69,15 @@ private static void Bundle(BundleOptions options) var appleId = $"com.{options.packAuthors ?? options.packId}.{options.packId}"; var escapedAppleId = Regex.Replace(appleId, @"[^\w\.]", "_"); + var appleSafeVersion = NuGetVersion.Parse(options.packVersion).Version.ToString(); + var info = new AppInfo { CFBundleName = options.packTitle ?? options.packId, CFBundleDisplayName = options.packTitle ?? options.packId, CFBundleExecutable = options.mainExe, CFBundleIdentifier = escapedAppleId, CFBundlePackageType = "APPL", - CFBundleShortVersionString = options.packVersion, + CFBundleShortVersionString = appleSafeVersion, CFBundleVersion = options.packVersion, CFBundleSignature = "????", NSPrincipalClass = "NSApplication", @@ -149,11 +115,53 @@ private static void Bundle(BundleOptions options) var nuspecText = NugetConsole.CreateNuspec( options.packId, options.packTitle, options.packAuthors, options.packVersion, options.releaseNotes, options.includePdb, "osx"); - File.WriteAllText(Path.Combine(contentsDir, Utility.SpecVersionFileName), nuspecText); + var nuspecPath = Path.Combine(contentsDir, Utility.SpecVersionFileName); + + // nuspec and UpdateMac need to be in contents dir or this package can't update + File.WriteAllText(nuspecPath, nuspecText); File.Copy(HelperExe.UpdateMacPath, Path.Combine(contentsDir, "UpdateMac")); - Log.Info("MacOS '.app' bundle prepared for Squirrel at: " + appBundlePath); - Log.Info("CodeSign and Notarize this app bundle before packing a Squirrel release."); + // code signing + var machoFiles = Directory.EnumerateFiles(appBundlePath, "*", SearchOption.AllDirectories) + .Where(f => PlatformUtil.IsMachOImage(f)) + .ToArray(); + + HelperExe.CodeSign(options.signAppIdentity, options.signEntitlements, machoFiles); + + Log.Info("Creating Squirrel Release"); + + using var _ = Utility.GetTempDirectory(out var tmp); + var nupkgPath = NugetConsole.CreatePackageFromNuspecPath(tmp, appBundlePath, nuspecPath); + + var releaseFilePath = Path.Combine(releaseDir.FullName, "RELEASES"); + var releases = new List(); + if (File.Exists(releaseFilePath)) { + releases.AddRange(ReleaseEntry.ParseReleaseFile(File.ReadAllText(releaseFilePath, Encoding.UTF8))); + } + + var rp = new ReleasePackageBuilder(nupkgPath); + var newPkgPath = rp.CreateReleasePackage(Path.Combine(releaseDir.FullName, rp.SuggestedReleaseFileName)); + + Log.Info("Creating Delta Packages"); + var prev = ReleasePackageBuilder.GetPreviousRelease(releases, rp, releaseDir.FullName); + if (prev != null && !options.noDelta) { + var deltaBuilder = new DeltaPackageBuilder(); + var deltaFile = Path.Combine(releaseDir.FullName, rp.SuggestedReleaseFileName.Replace("-full", "-delta")); + var dp = deltaBuilder.CreateDeltaPackage(prev, rp, deltaFile); + releases.Add(ReleaseEntry.GenerateFromFile(deltaFile)); + } + + releases.Add(ReleaseEntry.GenerateFromFile(newPkgPath)); + ReleaseEntry.WriteReleaseFile(releases, releaseFilePath); + + // need to pack as zip using ditto for notarization to succeed + // var zipPath = Path.Combine(releaseDir.FullName, options.packId + ".zip"); + + var pkgPath = Path.Combine(releaseDir.FullName, options.packId + ".pkg"); + HelperExe.CreateInstallerPkg(appBundlePath, pkgPath, options.signInstallIdentity); + + HelperExe.NotarizePkg(pkgPath, options.notaryProfile); + HelperExe.StapleNotarization(pkgPath); } private static void CopyFiles(DirectoryInfo source, DirectoryInfo target) diff --git a/src/Squirrel.CommandLine/OSX/HelperExe.cs b/src/Squirrel.CommandLine/OSX/HelperExe.cs index d61579467..19447be4c 100644 --- a/src/Squirrel.CommandLine/OSX/HelperExe.cs +++ b/src/Squirrel.CommandLine/OSX/HelperExe.cs @@ -1,11 +1,149 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Runtime.Versioning; +using System.Threading; +using Newtonsoft.Json; +using Squirrel.SimpleSplat; namespace Squirrel.CommandLine.OSX { internal class HelperExe : HelperFile { - public static string UpdateMacPath + public static string UpdateMacPath => FindHelperFile("UpdateMac", p => Microsoft.NET.HostModel.AppHost.HostWriter.IsBundle(p, out var _)); + + public static string SquirrelEntitlements => FindHelperFile("Squirrel.entitlements"); + + [SupportedOSPlatform("osx")] + public static void CodeSign(string identity, string entitlements, string[] files) + { + Log.Info($"Preparing to code-sign {files.Length} Mach-O files."); + + if (String.IsNullOrEmpty(entitlements)) { + Log.Info("No entitlements provided, using default dotnet entitlements: " + + "https://docs.microsoft.com/en-us/dotnet/core/install/macos-notarization-issues"); + entitlements = SquirrelEntitlements; + } + + if (!File.Exists(entitlements)) { + throw new Exception("Could not find entitlements file at: " + entitlements); + } + + var args = new List { + "-s", identity, + "-f", + "-v", + "--timestamp", + "--options", "runtime", + "--entitlements", entitlements + }; + + args.AddRange(files); + + InvokeAndThrowIfNonZero("codesign", args, null); + + Log.Info("Code-sign completed successfully"); + } + + [SupportedOSPlatform("osx")] + public static void CreateInstallerPkg(string appBundlePath, string pkgOutputPath, string signIdentity) + { + Log.Info($"Creating installer '.pkg' for app at '{appBundlePath}'"); + + var args = new List { + "--install-location", "~/Applications", + "--component", appBundlePath, + }; + + if (!String.IsNullOrEmpty(signIdentity)) { + args.Add("--sign"); + args.Add(signIdentity); + } else { + Log.Warn("No Installer signing identity provided. The '.pkg' will not be signed."); + } + + args.Add(pkgOutputPath); + + InvokeAndThrowIfNonZero("pkgbuild", args, null); + + Log.Info("Installer created successfully"); + } + + [SupportedOSPlatform("osx")] + public static void StapleNotarization(string filePath) + { + Log.Info($"Stapling Notarization to '{filePath}'"); + var args = new List { + "stapler", "staple", filePath, + }; + InvokeAndThrowIfNonZero("xcrun", args, null); + } + + [SupportedOSPlatform("osx")] + public static void NotarizePkg(string pkgPath, string profileName) + { + Log.Info($"Preparing to Notarize '{pkgPath}'. This will upload to Apple and usually takes minutes, but could take hours."); + + var args = new List { + "notarytool", + "submit", + // "--apple-id", appleId, + // "--password", appPwd, + // "--team-id", teamId, + "--keychain-profile", profileName, + "-f", "json", + "--wait", + pkgPath + }; + + var ntresultjson = PlatformUtil.InvokeProcess("xcrun", args, null, CancellationToken.None); + Log.Info(ntresultjson); + + var ntresult = JsonConvert.DeserializeObject(ntresultjson.StdOutput); + + if (ntresultjson.ExitCode != 0) { + // find and report notarization errors + if (ntresult?.id != null) { + var logargs = new List { + "notarytool", + "log", + ntresult?.id, + "--keychain-profile", profileName, + // "--apple-id", appleId, + // "--password", appPwd, + // "--team-id", teamId, + }; + + var result = PlatformUtil.InvokeProcess("xcrun", logargs, null, CancellationToken.None); + Log.Warn(result.StdOutput); + } + + throw new Exception("Notarization failed."); + } + + Log.Info("Notarization completed successfully"); + } + + private class NotaryToolResult + { + public string id { get; set; } + public string message { get; set; } + public string status { get; set; } + } + + [SupportedOSPlatform("osx")] + public static void CreateDittoZip(string folder, string outputZip) + { + var args = new List { + "-c", + "-k", + "--keepParent", + folder, + outputZip + }; + + InvokeAndThrowIfNonZero("ditto", args, null); + } } -} +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/OSX/Options.cs b/src/Squirrel.CommandLine/OSX/Options.cs index 7f8098934..e4634016f 100644 --- a/src/Squirrel.CommandLine/OSX/Options.cs +++ b/src/Squirrel.CommandLine/OSX/Options.cs @@ -8,7 +8,7 @@ namespace Squirrel.CommandLine.OSX { - internal class BundleOptions : BaseOptions + internal class PackOptions : BaseOptions { public string packId { get; private set; } public string packTitle { get; private set; } @@ -19,8 +19,13 @@ internal class BundleOptions : BaseOptions public string releaseNotes { get; private set; } public string icon { get; private set; } public string mainExe { get; private set; } + public bool noDelta { get; private set; } + public string signAppIdentity { get; private set; } + public string signInstallIdentity { get; private set; } + public string signEntitlements { get; private set; } + public string notaryProfile { get; private set; } - public BundleOptions() + public PackOptions() { Add("u=|packId=", "Unique {ID} for bundle", v => packId = v); Add("v=|packVersion=", "Current {VERSION} for bundle", v => packVersion = v); @@ -32,36 +37,20 @@ public BundleOptions() Add("releaseNotes=", "{PATH} to file with markdown notes for version", v => releaseNotes = v); Add("e=|mainExe=", "The file {NAME} of the main executable", v => mainExe = v); Add("i=|icon=", "{PATH} to the .icns file for this bundle", v => icon = v); + Add("noDelta", "Skip the generation of delta packages", v => noDelta = true); + Add("signAppIdentity=", "The {SUBJECT} name of the cert to use for app code signing", v => signAppIdentity = v); + Add("signInstallIdentity=", "The {SUBJECT} name of the cert to use for installation packages", v => signInstallIdentity = v); + Add("signEntitlements=", "{PATH} to entitlements file for hardened runtime", v => signEntitlements = v); + Add("notaryProfile=", "{NAME} of profile containing Apple credentials stored with notarytool", v => notaryProfile = v); } public override void Validate() { IsRequired(nameof(packId), nameof(packVersion), nameof(packDirectory)); + IsValidFile(nameof(signEntitlements), "entitlements"); NuGet.NugetUtil.ThrowIfInvalidNugetId(packId); - NuGet.NugetUtil.ThrowIfVersionNotSemverCompliant(packVersion, false); + NuGet.NugetUtil.ThrowIfVersionNotSemverCompliant(packVersion); IsValidDirectory(nameof(packDirectory), true); } } - - internal class PackOptions : BaseOptions - { - public string package { get; set; } - public bool noDelta { get; private set; } - // public string baseUrl { get; private set; } - - public PackOptions() - { - // Add("b=|baseUrl=", "Provides a base URL to prefix the RELEASES file packages with", v => baseUrl = v, true); - Add("p=|package=", "{PATH} to a '.app' directory to releasify", v => package = v); - Add("noDelta", "Skip the generation of delta packages", v => noDelta = true); - } - - public override void Validate() - { - IsRequired(nameof(package)); - IsValidDirectory(nameof(package), true); - if (!Utility.PathPartEndsWith(package, ".app")) - throw new OptionValidationException("-p argument must point to a macos bundle directory ending in '.app'."); - } - } } \ No newline at end of file diff --git a/src/Squirrel.CommandLine/ReleasePackageBuilder.cs b/src/Squirrel.CommandLine/ReleasePackageBuilder.cs index a334ebf22..7f88374a7 100644 --- a/src/Squirrel.CommandLine/ReleasePackageBuilder.cs +++ b/src/Squirrel.CommandLine/ReleasePackageBuilder.cs @@ -68,7 +68,7 @@ internal string CreateReleasePackage(string outputFile, Func rel // we don't really care that they aren't valid if (!ModeDetector.InUnitTestRunner()) { // verify that the .nuspec version is semver compliant - NugetUtil.ThrowIfVersionNotSemverCompliant(package.Version.ToString(), true); + NugetUtil.ThrowIfVersionNotSemverCompliant(package.Version.ToString()); // verify that the suggested filename can be round-tripped as an assurance // someone won't run across an edge case and install a broken app somehow diff --git a/src/Squirrel.CommandLine/Windows/Options.cs b/src/Squirrel.CommandLine/Windows/Options.cs index 214d509f0..fd956c4ef 100644 --- a/src/Squirrel.CommandLine/Windows/Options.cs +++ b/src/Squirrel.CommandLine/Windows/Options.cs @@ -145,7 +145,7 @@ public override void Validate() { IsRequired(nameof(packId), nameof(packVersion), nameof(packDirectory)); Squirrel.NuGet.NugetUtil.ThrowIfInvalidNugetId(packId); - Squirrel.NuGet.NugetUtil.ThrowIfVersionNotSemverCompliant(packVersion, true); + Squirrel.NuGet.NugetUtil.ThrowIfVersionNotSemverCompliant(packVersion); IsValidDirectory(nameof(packDirectory), true); IsValidFile(nameof(releaseNotes)); base.ValidateInternal(false); diff --git a/src/Squirrel/NuGet/NugetUtil.cs b/src/Squirrel/NuGet/NugetUtil.cs index 7222a5c2c..668ea4c76 100644 --- a/src/Squirrel/NuGet/NugetUtil.cs +++ b/src/Squirrel/NuGet/NugetUtil.cs @@ -27,16 +27,12 @@ public static void ThrowIfInvalidNugetId(string id) throw new ArgumentException($"Invalid package Id '{id}', it must contain only alphanumeric characters, underscores, dashes, and dots."); } - public static void ThrowIfVersionNotSemverCompliant(string version, bool allowTags = true) + public static void ThrowIfVersionNotSemverCompliant(string version) { if (SemanticVersion.TryParse(version, out var parsed)) { if (parsed < new SemanticVersion(0, 0, 1)) { throw new Exception($"Invalid package version '{version}', it must be >= 0.0.1."); } - - if (!allowTags && (parsed.HasMetadata || parsed.IsPrerelease)) { - throw new Exception($"Invalid package version '{version}', metadata/pre-release tags are not permitted."); - } } else { throw new Exception($"Invalid package version '{version}', it must be a 3-part SemVer2 compliant version string."); }