diff --git a/Arcade.sln b/Arcade.sln index 3e355769f02..3bd9171814a 100644 --- a/Arcade.sln +++ b/Arcade.sln @@ -151,6 +151,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Internal.S EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.ArcadeAzureIntegration", "src\Microsoft.DotNet.ArcadeAzureIntegration\Microsoft.DotNet.ArcadeAzureIntegration.csproj", "{CA159C84-CD7D-4364-9121-3842F97D4B60}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.MacOsPkg", "src\Microsoft.DotNet.MacOsPkg\Microsoft.DotNet.MacOsPkg.csproj", "{CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.MacOsPkg.Tests", "src\Microsoft.DotNet.MacOsPkg.Tests\Microsoft.DotNet.MacOsPkg.Tests.csproj", "{1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -949,6 +953,30 @@ Global {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x64.Build.0 = Release|Any CPU {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x86.ActiveCfg = Release|Any CPU {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x86.Build.0 = Release|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Debug|x64.Build.0 = Debug|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Debug|x86.Build.0 = Debug|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Release|Any CPU.Build.0 = Release|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Release|x64.ActiveCfg = Release|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Release|x64.Build.0 = Release|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Release|x86.ActiveCfg = Release|Any CPU + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0}.Release|x86.Build.0 = Release|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Debug|x64.Build.0 = Debug|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Debug|x86.Build.0 = Debug|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Release|Any CPU.Build.0 = Release|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Release|x64.ActiveCfg = Release|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Release|x64.Build.0 = Release|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Release|x86.ActiveCfg = Release|Any CPU + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -986,6 +1014,8 @@ Global {14462553-E4E1-4F67-B954-4BF24B1DAAFE} = {3C542789-2576-48C8-9772-C9D7575F7E42} {650B7526-7B8A-45B5-B14E-C16D828891B2} = {C53DD924-C212-49EA-9BC4-1827421361EF} {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC} = {C53DD924-C212-49EA-9BC4-1827421361EF} + {CE0FAEB2-4B8A-4A37-840D-7FF88ECB42A0} = {6DA9F58A-34D5-45A6-998E-5D2B8037C3FE} + {1F5118A8-A5C5-4D18-AF34-FFB60FECCD45} = {6DA9F58A-34D5-45A6-998E-5D2B8037C3FE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {32B9C883-432E-4FC8-A1BF-090EB033DD5B} diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/DefaultVersions.props b/src/Microsoft.DotNet.Arcade.Sdk/tools/DefaultVersions.props index 737e0bd375f..6646327a90e 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/DefaultVersions.props +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/DefaultVersions.props @@ -80,6 +80,7 @@ $(ArcadeSdkVersion) $(ArcadeSdkVersion) $(ArcadeSdkVersion) + $(ArcadeSdkVersion) 16.5.0 diff --git a/src/Microsoft.DotNet.Arcade.Sdk/tools/Tools.proj b/src/Microsoft.DotNet.Arcade.Sdk/tools/Tools.proj index d7cc34a6f50..f7c3afdc6db 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/tools/Tools.proj +++ b/src/Microsoft.DotNet.Arcade.Sdk/tools/Tools.proj @@ -56,6 +56,7 @@ + diff --git a/src/Microsoft.DotNet.MacOsPkg.Tests/Microsoft.DotNet.MacOsPkg.Tests.csproj b/src/Microsoft.DotNet.MacOsPkg.Tests/Microsoft.DotNet.MacOsPkg.Tests.csproj new file mode 100644 index 00000000000..4a9589913a0 --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg.Tests/Microsoft.DotNet.MacOsPkg.Tests.csproj @@ -0,0 +1,44 @@ + + + + $(NetToolCurrent) + enable + + + + + + + + + + + + + + + + + + + + + + <_MacOSPkgToolPattern>@(_MacOsPkgToolPath->'%(RootDir)%(Directory)')**\*.* + + + <_MacOSPkgToolFiles Include="$(_MacOsPkgToolPattern)"/> + + + + + + + diff --git a/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/Simple.pkg b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/Simple.pkg new file mode 100644 index 00000000000..349d3a6cc07 Binary files /dev/null and b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/Simple.pkg differ diff --git a/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/SimpleInstaller.pkg b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/SimpleInstaller.pkg new file mode 100644 index 00000000000..47a9ba9513f Binary files /dev/null and b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/SimpleInstaller.pkg differ diff --git a/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/WithApp.pkg b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/WithApp.pkg new file mode 100644 index 00000000000..ee5974edf8a Binary files /dev/null and b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/WithApp.pkg differ diff --git a/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/WithAppInstaller.pkg b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/WithAppInstaller.pkg new file mode 100644 index 00000000000..5d28b08f314 Binary files /dev/null and b/src/Microsoft.DotNet.MacOsPkg.Tests/Resources/WithAppInstaller.pkg differ diff --git a/src/Microsoft.DotNet.MacOsPkg.Tests/UnpackPackTests.cs b/src/Microsoft.DotNet.MacOsPkg.Tests/UnpackPackTests.cs new file mode 100644 index 00000000000..34755962731 --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg.Tests/UnpackPackTests.cs @@ -0,0 +1,242 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.MacOsPkg.Tests +{ + public class UnpackPackTests + { + private readonly ITestOutputHelper output; + private static readonly string simplePkg = GetResourceFilePath("Simple.pkg"); + private static readonly string withAppPkg = GetResourceFilePath("WithApp.pkg"); + private static readonly string simpleInstallerPkg = GetResourceFilePath("SimpleInstaller.pkg"); + private static readonly string withAppInstallerPkg = GetResourceFilePath("WithAppInstaller.pkg"); + + private static readonly string pkgToolPath = Path.Combine( + Path.GetDirectoryName(typeof(UnpackPackTests).Assembly.Location)!, + "tools", + "macospkg", + "Microsoft.Dotnet.MacOsPkg.dll"); + + private static readonly string[] simplePkgFiles = + [ + "Bom", + "PackageInfo", + Path.Combine("Payload", "Sample.txt") + ]; + + private static readonly string[] withAppPkgFiles = + [ + "Bom", + "PackageInfo", + Path.Combine("Payload", "test.app") + ]; + + private static readonly string[] appFiles = + [ + Path.Combine("Contents", "Info.plist"), + Path.Combine("Contents", "MacOS", "main"), + Path.Combine("Contents", "Resources", "libexample.dylib") + ]; + + private static readonly string[] simpleInstallerFiles = + [ + "Distribution", + "Simple.pkg" + ]; + + private static readonly string[] withAppInstallerFiles = + [ + "Distribution", + "WithApp.pkg" + ]; + + public UnpackPackTests(ITestOutputHelper output) => this.output = output; + + [MacOSOnlyFact] + public void UnpackPackSimplePkg() + { + string unpackPath = Path.GetTempFileName(); + string packPath = GetTempPkgPath(); + + ExecuteWithCleanup(() => + { + Unpack(simplePkg, unpackPath, simplePkgFiles); + Pack(unpackPath, packPath, simplePkgFiles); + }, [ unpackPath, packPath ]); + } + + [MacOSOnlyFact] + public void UnpackPackWithAppPkg() + { + string unpackPath = Path.GetTempFileName(); + string packPath = GetTempPkgPath(); + + ExecuteWithCleanup(() => + { + Unpack(withAppPkg, unpackPath, withAppPkgFiles); + Pack(unpackPath, packPath, withAppPkgFiles); + }, [ unpackPath, packPath ]); + } + + [MacOSOnlyFact] + public void UnpackPackAppBundle() + { + string unpackPkgPath = Path.GetTempFileName(); + string unpackAppPath = Path.GetTempFileName(); + string packAppPath = GetTempAppPath(); + + ExecuteWithCleanup(() => + { + Unpack(withAppPkg, unpackPkgPath, withAppPkgFiles); + Unpack(Path.Combine(unpackPkgPath, "Payload", "test.app"), unpackAppPath, appFiles); + Pack(unpackAppPath, packAppPath, appFiles); + }, [ unpackPkgPath, unpackAppPath ]); + } + + [MacOSOnlyFact] + public void UnpackPackSimpleInstallerPkg() + { + string unpackPath = Path.GetTempFileName(); + string packPath = GetTempPkgPath(); + + ExecuteWithCleanup(() => + { + Unpack(simpleInstallerPkg, unpackPath, simpleInstallerFiles); + Pack(unpackPath, packPath, simpleInstallerFiles); + }, [ unpackPath, packPath ]); + } + + [MacOSOnlyFact] + public void UnpackPackSimplePkgInSimpleInstallerPkg() + { + string unpackInstallerPath = Path.GetTempFileName(); + string unpackComponentPath = Path.GetTempFileName(); + string packInstallerPath = GetTempPkgPath(); + + string componentPkgPath = Path.Combine(unpackInstallerPath, "Simple.pkg"); + + ExecuteWithCleanup(() => + { + Unpack(simpleInstallerPkg, unpackInstallerPath, simpleInstallerFiles); + Unpack(componentPkgPath, unpackComponentPath, simplePkgFiles); + Pack(unpackComponentPath, componentPkgPath, simplePkgFiles); + Pack(unpackInstallerPath, packInstallerPath, simpleInstallerFiles); + }, [ unpackInstallerPath, unpackComponentPath, packInstallerPath ]); + } + + [MacOSOnlyFact] + public void UnpackPackAppBundleAndWithAppPkgInWithAppInstallerPkg() + { + string unpackInstallerPath = Path.GetTempFileName(); + string unpackComponentPath = Path.GetTempFileName(); + string unpackAppPath = Path.GetTempFileName(); + string packInstallerPath = GetTempPkgPath(); + + string componentPkgPath = Path.Combine(unpackInstallerPath, "WithApp.pkg"); + string appPath = Path.Combine(unpackComponentPath, "Payload", "test.app"); + + ExecuteWithCleanup(() => + { + Unpack(withAppInstallerPkg, unpackInstallerPath, withAppInstallerFiles); + Unpack(componentPkgPath, unpackComponentPath, withAppPkgFiles); + Unpack(appPath, unpackAppPath, appFiles); + Pack(unpackAppPath, appPath, appFiles); + Pack(unpackComponentPath, componentPkgPath, withAppPkgFiles); + Pack(unpackInstallerPath, packInstallerPath, withAppInstallerFiles); + }, [ unpackInstallerPath, unpackComponentPath, unpackAppPath, packInstallerPath ]); + } + + private static void ExecuteWithCleanup(Action action, List cleanupPaths) + { + try + { + action(); + } + finally + { + foreach (string path in cleanupPaths) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + else if (File.Exists(path)) + { + File.Delete(path); + } + } + } + } + + private void Unpack(string srcPath, string dstPath, string[] expectedFiles) + { + RunPkgProcess(srcPath, dstPath, "unpack").Should().BeTrue(); + + Directory.Exists(dstPath).Should().BeTrue(); + + CompareContent(dstPath, expectedFiles); + } + + private void Pack(string srcPath, string dstPath, string[] expectedFiles) + { + RunPkgProcess(srcPath, dstPath, "pack").Should().BeTrue(); + + File.Exists(dstPath).Should().BeTrue(); + + // Unpack the packed pkg and verify the content + string unpackPath = Path.GetTempFileName(); + ExecuteWithCleanup(() => + { + Unpack(dstPath, unpackPath, expectedFiles); + }, [ unpackPath ]); + } + + private bool RunPkgProcess(string inputPath, string outputPath, string action) + { + var process = Process.Start(new ProcessStartInfo() + { + FileName = "dotnet", + Arguments = $@"exec ""{pkgToolPath}"" ""{inputPath}"" ""{outputPath}"" {action}", + UseShellExecute = false, + RedirectStandardError = true, + }); + + process!.WaitForExit(60000); // 60 seconds + bool success = process.ExitCode == 0; + if (!success) + { + output.WriteLine($"Error: {process.StandardError.ReadToEnd()}"); + } + return success; + } + + private static string GetResourceFilePath(string resourceName) + { + return Path.Combine( + Path.GetDirectoryName(typeof(UnpackPackTests).Assembly.Location)!, + "Resources", + resourceName); + } + + private static string GetTempPkgPath() => $"{Path.GetTempFileName()}.pkg"; + + private static string GetTempAppPath() => $"{Path.GetTempFileName()}.app"; + + private static void CompareContent(string basePath, string[] expectedFiles) + { + string[] actualFiles = Directory.GetFiles(basePath, "*.*", SearchOption.AllDirectories) + .Select(f => f.Substring(basePath.Length + 1)) + .ToArray(); + actualFiles.Should().BeEquivalentTo(expectedFiles); + } + } +} diff --git a/src/Microsoft.DotNet.MacOsPkg/AppBundle.cs b/src/Microsoft.DotNet.MacOsPkg/AppBundle.cs new file mode 100644 index 00000000000..ceb0bdce1ef --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg/AppBundle.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.MacOsPkg +{ + internal static class AppBundle + { + internal static void Unpack(string inputPath, string outputPath) + { + string args = $"-V -xk {inputPath} {outputPath}"; + ExecuteHelper.Run("ditto", args); + } + + internal static void Pack(string inputPath, string outputPath) + { + string args = $"-c -k --sequesterRsrc {inputPath} {outputPath}"; + ExecuteHelper.Run("ditto", args); + } + } +} diff --git a/src/Microsoft.DotNet.MacOsPkg/ExecuteHelper.cs b/src/Microsoft.DotNet.MacOsPkg/ExecuteHelper.cs new file mode 100644 index 00000000000..123bbf8bc62 --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg/ExecuteHelper.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.IO; + +namespace Microsoft.DotNet.MacOsPkg +{ + public static class ExecuteHelper + { + public static string Run(string command, string arguments = "", string workingDirectory = "") + { + if (string.IsNullOrEmpty(command)) + { + throw new ArgumentNullException(nameof(command)); + } + + string output = string.Empty; + string escapedArgs = $"-c \"{command} {arguments}\""; + + ProcessStartInfo processStartInfo = CreateProcessStartInfo("sh", escapedArgs, workingDirectory); + using (Process process = new Process { StartInfo = processStartInfo }) + { + process.Start(); + output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(60000); // 60 seconds + if (process.ExitCode != 0) + { + throw new Exception($"Command '{command} {arguments}' failed with exit code {process.ExitCode}: {process.StandardError.ReadToEnd()}"); + } + } + return output; + } + + private static ProcessStartInfo CreateProcessStartInfo(string command, string arguments, string workingDirectory = "") => + new ProcessStartInfo + { + FileName = command, + Arguments = $@"{arguments}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + } +} diff --git a/src/Microsoft.DotNet.MacOsPkg/Microsoft.DotNet.MacOsPkg.csproj b/src/Microsoft.DotNet.MacOsPkg/Microsoft.DotNet.MacOsPkg.csproj new file mode 100644 index 00000000000..972c7594915 --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg/Microsoft.DotNet.MacOsPkg.csproj @@ -0,0 +1,15 @@ + + + + $(NetToolCurrent) + Exe + enable + true + MacOsPkg + Arcade Build Tool MacOS Pkg + false + true + dotnet-macos-pkg + + + diff --git a/src/Microsoft.DotNet.MacOsPkg/Package.cs b/src/Microsoft.DotNet.MacOsPkg/Package.cs new file mode 100644 index 00000000000..b1806edffc1 --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg/Package.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.DotNet.MacOsPkg +{ + internal static class Package + { + internal static void Unpack(string srcPath, string dstPath) + { + ExpandPackage(srcPath, dstPath); + + string? distribution = Utilities.FindInPath("Distribution", dstPath, isDirectory: false, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(distribution)) + { + UnpackInstallerPackage(dstPath, distribution!); + return; + } + + string? packageInfo = Utilities.FindInPath("PackageInfo", dstPath, isDirectory: false, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(packageInfo)) + { + UnpackComponentPackage(dstPath); + return; + } + + throw new Exception("Cannot unpack: no 'Distribution' or 'PackageInfo' file found in package"); + } + + internal static void Pack(string srcPath, string dstPath) + { + string? distribution = Utilities.FindInPath("Distribution", srcPath, isDirectory: false, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(distribution)) + { + PackInstallerPackage(srcPath, dstPath, distribution!); + return; + } + + string? packageInfo = Utilities.FindInPath("PackageInfo", srcPath, isDirectory: false, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(packageInfo)) + { + PackComponentPackage(srcPath, dstPath, packageInfo!); + return; + } + + throw new Exception("Cannot pack: no 'Distribution' or 'PackageInfo' file found in package"); + } + + private static void UnpackInstallerPackage(string dstPath, string distribution) + { + var xml = XElement.Load(distribution); + List componentPackages = xml.Elements("pkg-ref").Where(e => e.Value.Trim() != "").ToList(); + foreach (var package in componentPackages) + { + // Expanding the installer will unpack the nested component packages to a directory with a .pkg extension + // so we repack the component packages to a temporary file and then rename the file with the .pkg extension. + // Repacking is needed so that the signtool can properly identify and sign the nested component packages. + string packageName = Path.Combine(dstPath, package.Value.Substring(1)); + string tempDest = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + FlattenComponentPackage(packageName, tempDest); + + Directory.Delete(packageName, true); + File.Move(tempDest, packageName); + } + } + + private static void UnpackComponentPackage(string dstPath) + { + UnpackPayload(dstPath); + + // Zip the nested app bundles + IEnumerable nestedApps = Directory.GetDirectories(dstPath, "*.app", SearchOption.AllDirectories); + foreach (string app in nestedApps) + { + string tempDest = $"{app}.zip"; + AppBundle.Pack(app, tempDest); + Directory.Delete(app, true); + + // Rename the zipped file to .app + // This is needed so that the signtool + // can properly identify and sign app bundles + File.Move(tempDest, app); + } + } + + private static void PackInstallerPackage(string srcPath, string dstPath, string distribution) + { + string args = $"--distribution {distribution}"; + + if (Directory.GetFiles(srcPath, "*.pkg", SearchOption.TopDirectoryOnly).Any()) + { + args += $" --package-path {srcPath}"; + } + + string? resources = Utilities.FindInPath("Resources", srcPath, isDirectory: true, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(resources)) + { + args += $" --resources {resources}"; + } + + string? scripts = Utilities.FindInPath("Scripts", srcPath, isDirectory: true, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(scripts)) + { + args += $" --scripts {scripts}"; + } + + args += $" {dstPath}"; + + ExecuteHelper.Run("productbuild", args); + } + + private static void PackComponentPackage(string srcPath, string dstPath, string packageInfo) + { + // Unzip the nested app bundles + IEnumerable zippedNestedApps = Directory.GetFiles(srcPath, "*.app", SearchOption.AllDirectories); + foreach (string appZip in zippedNestedApps) + { + // Unzip the .app directory + string tempDest = appZip + ".unzipped"; + AppBundle.Unpack(appZip, tempDest); + File.Delete(appZip); + + // Rename the unzipped directory back to .app + // so that it can be packed properly + Directory.Move(tempDest, appZip); + } + + XElement pkgInfo = XElement.Load(packageInfo); + + string payloadDirectoryPath = GetPayloadPath(srcPath, isDirectory: true); + string identifier = GetPackageInfoAttribute(pkgInfo, "identifier"); + string version = GetPackageInfoAttribute(pkgInfo, "version"); + string installLocation = GetPackageInfoAttribute(pkgInfo, "install-location"); + + string args = $"--root {payloadDirectoryPath} --identifier {identifier} --version {version} --install-location {installLocation}"; + string? script = Utilities.FindInPath("Scripts", srcPath, isDirectory: true, searchOption: SearchOption.TopDirectoryOnly); + if (!string.IsNullOrEmpty(script)) + { + args += $" --scripts {script}"; + } + args += $" {dstPath}"; + + ExecuteHelper.Run("pkgbuild", args); + } + + private static void FlattenComponentPackage(string sourcePath, string destinationPath) + => ExecuteHelper.Run("pkgutil", $"--flatten {sourcePath} {destinationPath}"); + + private static void UnpackPayload(string dstPath) + { + string payloadFilePath = GetPayloadPath(dstPath, isDirectory: false); + + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + ExecuteHelper.Run("cat", $"{payloadFilePath} | gzip -d | cpio -id", tempDir); + + // Remove the payload file and replace it with + // a directory of the same name containing the unpacked contents + File.Delete(payloadFilePath); + Directory.Move(tempDir, payloadFilePath); + } + + private static string GetPayloadPath(string searchPath, bool isDirectory) => + Path.GetFullPath(Utilities.FindInPath("Payload", searchPath, isDirectory, searchOption: SearchOption.TopDirectoryOnly) + ?? throw new Exception("Payload was not found")); + + private static void ExpandPackage(string srcPath, string dstPath) => + ExecuteHelper.Run("pkgutil", $"--expand {srcPath} {dstPath}"); + + private static string GetPackageInfoAttribute(XElement pkgInfo, string elementName) => + pkgInfo.Attribute(elementName)?.Value ?? throw new Exception($"{elementName} is required in PackageInfo"); + } +} diff --git a/src/Microsoft.DotNet.MacOsPkg/Program.cs b/src/Microsoft.DotNet.MacOsPkg/Program.cs new file mode 100644 index 00000000000..c3698745abc --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg/Program.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.DotNet.MacOsPkg; + +if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) +{ + Console.Error.WriteLine("This tool is only supported on macOS."); + return 1; +} + +if (args.Length != 3) +{ + Console.Error.WriteLine("Usage: "); + return 1; +} + +string srcPath = args[0]; +string dstPath = args[1]; +string op = args[2]; + +var cleanTarget = () => +{ + Utilities.CleanupPath(dstPath); + Utilities.CreateParentDirectory(dstPath); +}; + +try +{ + if (op == "unpack") + { + if (!File.Exists(srcPath) || (!Utilities.IsPkg(srcPath) && !Utilities.IsAppBundle(srcPath))) + { + throw new Exception("Input path must be an existing .pkg or .app (zipped) file."); + } + + cleanTarget(); + + if (Utilities.IsPkg(srcPath)) + { + Package.Unpack(srcPath, dstPath); + } + else if (Utilities.IsAppBundle(srcPath)) + { + AppBundle.Unpack(srcPath, dstPath); + } + } + else if(op == "pack") + { + if (!Directory.Exists(srcPath)) + { + throw new Exception("Input path must be a valid directory."); + } + + if (!Utilities.IsPkg(dstPath) && !Utilities.IsAppBundle(dstPath)) + { + throw new Exception("Output path must be a .pkg or .app (zipped) file."); + } + + cleanTarget(); + + if (Utilities.IsPkg(dstPath)) + { + Package.Pack(srcPath, dstPath); + } + else if (Utilities.IsAppBundle(dstPath)) + { + AppBundle.Pack(srcPath, dstPath); + } + } + else + { + Console.Error.WriteLine($"Invalid operation {op}."); + return 1; + } +} +catch (Exception e) +{ + Console.Error.Write(e.Message); + return 1; +} + +return 0; diff --git a/src/Microsoft.DotNet.MacOsPkg/Utilities.cs b/src/Microsoft.DotNet.MacOsPkg/Utilities.cs new file mode 100644 index 00000000000..5e2ba659533 --- /dev/null +++ b/src/Microsoft.DotNet.MacOsPkg/Utilities.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; + +namespace Microsoft.DotNet.MacOsPkg +{ + public static class Utilities + { + internal static bool IsPkg(string path) => + Path.GetExtension(path).Equals(".pkg"); + + internal static bool IsAppBundle(string path) => + Path.GetExtension(path).Equals(".app"); + + internal static string? FindInPath(string name, string path, bool isDirectory, SearchOption searchOption = SearchOption.AllDirectories) + { + string[] results = isDirectory ? Directory.GetDirectories(path, name, searchOption) : Directory.GetFiles(path, name, searchOption); + return results.FirstOrDefault(); + } + + internal static void CleanupPath(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + else if (File.Exists(path)) + { + File.Delete(path); + } + } + + internal static void CreateParentDirectory(string path) + { + string? parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + } + } +}