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);
+ }
+ }
+ }
+}