mirror of
https://github.com/velopack/velopack.git
synced 2025-10-25 15:19:22 +00:00
WIP add code-signing and notarization for MacOS
This commit is contained in:
@@ -100,14 +100,30 @@ namespace Squirrel.CommandLine
|
||||
InvokeAndThrowIfNonZero(SevenZipPath, args, inFolder);
|
||||
}
|
||||
|
||||
protected static void InvokeAndThrowIfNonZero(string exePath, IEnumerable<string> args, string workingDir)
|
||||
protected static string InvokeAndThrowIfNonZero(string exePath, IEnumerable<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Linq;
|
||||
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 @@ namespace Squirrel.CommandLine.OSX
|
||||
{
|
||||
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<ReleaseEntry>();
|
||||
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 @@ namespace Squirrel.CommandLine.OSX
|
||||
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 @@ namespace Squirrel.CommandLine.OSX
|
||||
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<ReleaseEntry>();
|
||||
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)
|
||||
|
||||
@@ -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<string> {
|
||||
"-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<string> {
|
||||
"--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<string> {
|
||||
"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<string> {
|
||||
"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<NotaryToolResult>(ntresultjson.StdOutput);
|
||||
|
||||
if (ntresultjson.ExitCode != 0) {
|
||||
// find and report notarization errors
|
||||
if (ntresult?.id != null) {
|
||||
var logargs = new List<string> {
|
||||
"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<string> {
|
||||
"-c",
|
||||
"-k",
|
||||
"--keepParent",
|
||||
folder,
|
||||
outputZip
|
||||
};
|
||||
|
||||
InvokeAndThrowIfNonZero("ditto", args, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using Microsoft.NET.HostModel.AppHost;
|
||||
|
||||
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 @@ namespace Squirrel.CommandLine.OSX
|
||||
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 @@ namespace Squirrel.CommandLine.OSX
|
||||
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'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ namespace Squirrel.CommandLine
|
||||
// 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
|
||||
|
||||
@@ -145,7 +145,7 @@ namespace Squirrel.CommandLine.Windows
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -27,16 +27,12 @@ namespace Squirrel.NuGet
|
||||
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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user