// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Utilities.Editor; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; namespace Microsoft.MixedReality.Toolkit.Build.Editor { public static class UwpAppxBuildTools { /// /// Query the build process to see if we're already building. /// public static bool IsBuilding { get; private set; } = false; /// /// The list of filename extensions that are valid VCProjects. /// private static readonly string[] VcProjExtensions = { "vcsproj", "vcxproj" }; /// /// Build the UWP appx bundle for this project. Requires that has already be run or a user has /// previously built the Unity Player with the WSA Player as the Build Target. /// /// True, if the appx build was successful. public static async Task BuildAppxAsync(UwpBuildInfo buildInfo, CancellationToken cancellationToken = default) { if (!EditorAssemblyReloadManager.LockReloadAssemblies) { Debug.LogError("Lock Reload assemblies before attempting to build appx!"); return false; } if (IsBuilding) { Debug.LogWarning("Build already in progress!"); return false; } if (Application.isBatchMode) { // We don't need stack traces on all our logs. Makes things a lot easier to read. Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.None); } Debug.Log("Starting Unity Appx Build..."); IsBuilding = true; string slnFilename = Path.Combine(buildInfo.OutputDirectory, $"{PlayerSettings.productName}.sln"); if (!File.Exists(slnFilename)) { Debug.LogError("Unable to find Solution to build from!"); return IsBuilding = false; } // Get and validate the msBuild path... var msBuildPath = await FindMsBuildPathAsync(); if (!File.Exists(msBuildPath)) { Debug.LogError($"MSBuild.exe is missing or invalid!\n{msBuildPath}"); return IsBuilding = false; } // Ensure that the generated .appx version increments by modifying Package.appxmanifest try { if (!UpdateAppxManifest(buildInfo)) { throw new Exception(); } } catch (Exception e) { Debug.LogError($"Failed to update appxmanifest!\n{e.Message}"); return IsBuilding = false; } string storagePath = Path.GetFullPath(Path.Combine(Path.Combine(Application.dataPath, ".."), buildInfo.OutputDirectory)); string solutionProjectPath = Path.GetFullPath(Path.Combine(storagePath, $@"{PlayerSettings.productName}.sln")); int exitCode; // Building the solution requires first restoring NuGet packages - when built through // Visual Studio, VS does this automatically - when building via msbuild like we're doing here, // we have to do that step manually. // We use msbuild for nuget restore by default, but if a path to nuget.exe is supplied then we use that executable if (string.IsNullOrEmpty(buildInfo.NugetExecutablePath)) { exitCode = await Run(msBuildPath, $"\"{solutionProjectPath}\" /t:restore {GetMSBuildLoggingCommand(buildInfo.LogDirectory, "nugetRestore.log")}", !Application.isBatchMode, cancellationToken); } else { exitCode = await Run(buildInfo.NugetExecutablePath, $"restore \"{solutionProjectPath}\"", !Application.isBatchMode, cancellationToken); } if (exitCode != 0) { IsBuilding = false; return false; } // Need to add ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch to MixedRealityToolkit.vcxproj if (buildInfo.BuildPlatform == "arm64") { if (!UpdateVSProj(buildInfo)) { return IsBuilding = false; } } // Now that NuGet packages have been restored, we can run the actual build process. exitCode = await Run(msBuildPath, $"\"{solutionProjectPath}\" {(buildInfo.Multicore ? "/m /nr:false" : "")} /t:{(buildInfo.RebuildAppx ? "Rebuild" : "Build")} /p:Configuration={buildInfo.Configuration} /p:Platform={buildInfo.BuildPlatform} {(string.IsNullOrEmpty(buildInfo.PlatformToolset) ? string.Empty : $"/p:PlatformToolset={buildInfo.PlatformToolset}")} {GetMSBuildLoggingCommand(buildInfo.LogDirectory, "buildAppx.log")}", !Application.isBatchMode, cancellationToken); AssetDatabase.SaveAssets(); IsBuilding = false; return exitCode == 0; } private static async Task Run(string fileName, string args, bool showDebug, CancellationToken cancellationToken) { Debug.Log($"Running command: {fileName} {args}"); var processResult = await new Process().StartProcessAsync( fileName, args, !Application.isBatchMode, cancellationToken); switch (processResult.ExitCode) { case 0: Debug.Log($"Command successful"); if (Application.isBatchMode) { Debug.Log(string.Join("\n", processResult.Output)); } break; case -1073741510: Debug.LogWarning("The build was terminated either by user's keyboard input CTRL+C or CTRL+Break or closing command prompt window."); break; default: { if (processResult.ExitCode != 0) { Debug.Log($"Command failed, errorCode: {processResult.ExitCode}"); if (Application.isBatchMode) { var output = "Command output:\n"; foreach (var message in processResult.Output) { output += $"{message}\n"; } output += "Command errors:"; foreach (var error in processResult.Errors) { output += $"{error}\n"; } Debug.LogError(output); } } break; } } return processResult.ExitCode; } private static async Task FindMsBuildPathAsync() { // Finding msbuild.exe involves different work depending on whether or not users // have VS2017 or VS2019 installed. foreach (VSWhereFindOption findOption in VSWhereFindOptions) { string arguments = findOption.arguments; if (string.IsNullOrWhiteSpace(EditorUserBuildSettings.wsaUWPVisualStudioVersion)) { arguments += " -latest"; } else { // Add version number with brackets to find only the specified version arguments += $" -version [{EditorUserBuildSettings.wsaUWPVisualStudioVersion}]"; } var result = await new Process().StartProcessAsync( new ProcessStartInfo { FileName = "cmd.exe", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, Arguments = arguments, WorkingDirectory = @"C:\Program Files (x86)\Microsoft Visual Studio\Installer" }); foreach (var path in result.Output) { if (!string.IsNullOrEmpty(path)) { string[] paths = path.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); if (paths.Length > 0) { // if there are multiple visual studio installs, // prefer enterprise, then pro, then community string bestPath = paths.OrderByDescending(p => p.ToLower().Contains("enterprise")) .ThenByDescending(p => p.ToLower().Contains("professional")) .ThenByDescending(p => p.ToLower().Contains("community")).First(); string finalPath = $@"{bestPath}{findOption.pathSuffix}"; if (File.Exists(finalPath)) { return finalPath; } } } } } return string.Empty; } private static bool UpdateVSProj(IBuildInfo buildInfo) { // For ARM64 builds we need to add ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch // to vcxproj file in order to ensure that the build passes string projectFilePath = GetProjectFilePath(buildInfo); if (projectFilePath == null) { return false; } var rootNode = XElement.Load(projectFilePath); var defaultNamespace = rootNode.GetDefaultNamespace(); var propertyGroupNode = rootNode.Element(defaultNamespace + "PropertyGroup"); if (propertyGroupNode == null) { propertyGroupNode = new XElement(defaultNamespace + "PropertyGroup", new XAttribute("Label", "Globals")); rootNode.Add(propertyGroupNode); } var newNode = propertyGroupNode.Element(defaultNamespace + "ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch"); if (newNode != null) { // If this setting already exists in the project, ensure its value is "None" newNode.Value = "None"; } else { propertyGroupNode.Add(new XElement(defaultNamespace + "ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch", "None")); } rootNode.Save(projectFilePath); return true; } /// /// Given the project name and build path, resolves the valid VcProject file (i.e. .vcsproj, vcxproj) /// /// A valid path if the project file exists, null otherwise private static string GetProjectFilePath(IBuildInfo buildInfo) { string projectName = PlayerSettings.productName; foreach (string extension in VcProjExtensions) { string projectFilePath = Path.Combine(Path.GetFullPath(buildInfo.OutputDirectory), projectName, $"{projectName}.{extension}"); if (File.Exists(projectFilePath)) { return projectFilePath; } } string projectDirectory = Path.Combine(Path.GetFullPath(buildInfo.OutputDirectory), projectName); string combinedExtensions = String.Join("|", VcProjExtensions); Debug.LogError($"Cannot find project file {projectDirectory} given names {projectName}.{combinedExtensions}"); return null; } private static bool UpdateAppxManifest(IBuildInfo buildInfo) { string manifestFilePath = GetManifestFilePath(buildInfo); if (manifestFilePath == null) { // Error has already been logged return false; } var rootNode = XElement.Load(manifestFilePath); var identityNode = rootNode.Element(rootNode.GetDefaultNamespace() + "Identity"); if (identityNode == null) { Debug.LogError($"Package.appxmanifest for build (in path - {manifestFilePath}) is missing an node"); return false; } var dependencies = rootNode.Element(rootNode.GetDefaultNamespace() + "Dependencies"); if (dependencies == null) { Debug.LogError($"Package.appxmanifest for build (in path - {manifestFilePath}) is missing node."); return false; } UpdateDependenciesElement(dependencies, rootNode.GetDefaultNamespace()); AddCapabilities(buildInfo, rootNode); // We use XName.Get instead of string -> XName implicit conversion because // when we pass in the string "Version", the program doesn't find the attribute. // Best guess as to why this happens is that implicit string conversion doesn't set the namespace to empty var versionAttr = identityNode.Attribute(XName.Get("Version")); if (versionAttr == null) { Debug.LogError($"Package.appxmanifest for build (in path - {manifestFilePath}) is missing a Version attribute in the node."); return false; } // Assume package version always has a '.' between each number. // According to https://msdn.microsoft.com/library/windows/apps/br211441.aspx // Package versions are always of the form Major.Minor.Build.Revision. // Note: Revision number reserved for Windows Store, and a value other than 0 will fail WACK. var version = PlayerSettings.WSA.packageVersion; var newVersion = new Version(version.Major, version.Minor, buildInfo.AutoIncrement ? version.Build + 1 : version.Build, version.Revision); PlayerSettings.WSA.packageVersion = newVersion; versionAttr.Value = newVersion.ToString(); rootNode.Save(manifestFilePath); return true; } /// /// Gets the AppX manifest path in the project output directory. /// private static string GetManifestFilePath(IBuildInfo buildInfo) { var fullPathOutputDirectory = Path.GetFullPath(buildInfo.OutputDirectory); Debug.Log($"Searching for appx manifest in {fullPathOutputDirectory}..."); // Find the manifest, assume the one we want is the first one string[] manifests = Directory.GetFiles(fullPathOutputDirectory, "Package.appxmanifest", SearchOption.AllDirectories); if (manifests.Length == 0) { Debug.LogError($"Unable to find Package.appxmanifest file for build (in path - {fullPathOutputDirectory})"); return null; } if (manifests.Length > 1) { Debug.LogWarning("Found more than one appxmanifest in the target build folder!"); } return manifests[0]; } /// /// Updates 'Assembly-CSharp.csproj' file according to the values set in buildInfo. /// /// An IBuildInfo containing a valid OutputDirectory /// Only used with the .NET backend in Unity 2018 or older, with Unity C# Projects enabled. public static void UpdateAssemblyCSharpProject(IBuildInfo buildInfo) { #if !UNITY_2019_1_OR_NEWER if (!EditorUserBuildSettings.wsaGenerateReferenceProjects || PlayerSettings.GetScriptingBackend(BuildTargetGroup.WSA) != ScriptingImplementation.WinRTDotNET) { // Assembly-CSharp.csproj is only generated when the above is true return; } string projectFilePath = GetAssemblyCSharpProjectFilePath(buildInfo); if (projectFilePath == null) { throw new FileNotFoundException("Unable to find 'Assembly-CSharp.csproj' file."); } var rootElement = XElement.Load(projectFilePath); var uwpBuildInfo = buildInfo as UwpBuildInfo; Debug.Assert(uwpBuildInfo != null); if (uwpBuildInfo.AllowUnsafeCode) { AllowUnsafeCode(rootElement); } rootElement.Save(projectFilePath); #endif // !UNITY_2019_1_OR_NEWER } /// /// Gets the 'Assembly-CSharp.csproj' files path in the project output directory. /// private static string GetAssemblyCSharpProjectFilePath(IBuildInfo buildInfo) { var fullPathOutputDirectory = Path.GetFullPath(buildInfo.OutputDirectory); Debug.Log($"Searching for 'Assembly-CSharp.csproj' in {fullPathOutputDirectory}..."); // Find the manifest, assume the one we want is the first one string[] manifests = Directory.GetFiles(fullPathOutputDirectory, "Assembly-CSharp.csproj", SearchOption.AllDirectories); if (manifests.Length == 0) { Debug.LogError($"Unable to find 'Assembly-CSharp.csproj' file for build (in path - {fullPathOutputDirectory})"); return null; } if (manifests.Length > 1) { Debug.LogWarning("Found more than one 'Assembly-CSharp.csproj' in the target build folder!"); } return manifests[0]; } private static void UpdateDependenciesElement(XElement dependencies, XNamespace defaultNamespace) { var values = (PlayerSettings.WSATargetFamily[])Enum.GetValues(typeof(PlayerSettings.WSATargetFamily)); if (string.IsNullOrWhiteSpace(EditorUserBuildSettings.wsaUWPSDK)) { var windowsSdkPaths = Directory.GetDirectories(@"C:\Program Files (x86)\Windows Kits\10\Lib"); int latestIndex = -1; int latestVersion = -1; for (int i = 0; i < windowsSdkPaths.Length; i++) { windowsSdkPaths[i] = windowsSdkPaths[i].Substring(windowsSdkPaths[i].LastIndexOf(@"\", StringComparison.Ordinal) + 1); string[] versionSplit = windowsSdkPaths[i].Split('.'); if (versionSplit.Length >= 3 && int.TryParse(versionSplit[2], out int currentVersion) && currentVersion > latestVersion) { latestVersion = currentVersion; latestIndex = i; } } EditorUserBuildSettings.wsaUWPSDK = windowsSdkPaths[latestIndex]; Debug.Log($"Using SDK version {EditorUserBuildSettings.wsaUWPSDK}"); } string maxVersionTested = EditorUserBuildSettings.wsaUWPSDK; if (string.IsNullOrWhiteSpace(EditorUserBuildSettings.wsaMinUWPSDK)) { EditorUserBuildSettings.wsaMinUWPSDK = UwpBuildDeployPreferences.MIN_PLATFORM_VERSION.ToString(); } string minVersion = EditorUserBuildSettings.wsaMinUWPSDK; // Clear any we had before. dependencies.RemoveAll(); foreach (PlayerSettings.WSATargetFamily family in values) { if (PlayerSettings.WSA.GetTargetDeviceFamily(family)) { dependencies.Add( new XElement(defaultNamespace + "TargetDeviceFamily", new XAttribute("Name", $"Windows.{family}"), new XAttribute("MinVersion", minVersion), new XAttribute("MaxVersionTested", maxVersionTested))); } } if (!dependencies.HasElements) { dependencies.Add( new XElement(defaultNamespace + "TargetDeviceFamily", new XAttribute("Name", "Windows.Universal"), new XAttribute("MinVersion", minVersion), new XAttribute("MaxVersionTested", maxVersionTested))); } } /// Gets the subpart of the msbuild.exe command to save log information /// in the given logFileName. /// /// /// Will return an empty string if logDirectory is not set. /// private static string GetMSBuildLoggingCommand(string logDirectory, string logFileName) { if (String.IsNullOrEmpty(logDirectory)) { Debug.Log($"Not logging {logFileName} because no logDirectory was provided"); return ""; } return $"-fl -flp:logfile={Path.Combine(logDirectory, logFileName)};verbosity=detailed"; } /// /// Adds capabilities according to the values in the buildInfo to the manifest file. /// /// An IBuildInfo containing a valid OutputDirectory and all capabilities public static void AddCapabilities(IBuildInfo buildInfo, XElement rootElement = null) { var manifestFilePath = GetManifestFilePath(buildInfo); if (manifestFilePath == null) { throw new FileNotFoundException("Unable to find manifest file"); } rootElement = rootElement ?? XElement.Load(manifestFilePath); var uwpBuildInfo = buildInfo as UwpBuildInfo; Debug.Assert(uwpBuildInfo != null); // Here, ResearchModeCapability must come first, in order to avoid schema errors // See https://docs.microsoft.com/windows/uwp/packaging/app-capability-declarations#restricted-capabilities if (uwpBuildInfo.ResearchModeCapabilityEnabled #if !UNITY_2021_2_OR_NEWER && EditorUserBuildSettings.wsaSubtarget == WSASubtarget.HoloLens #endif // !UNITY_2021_2_OR_NEWER ) { AddResearchModeCapability(rootElement); } if (uwpBuildInfo.DeviceCapabilities != null) { AddCapabilities(rootElement, uwpBuildInfo.DeviceCapabilities); } if (uwpBuildInfo.GazeInputCapabilityEnabled) { AddGazeInputCapability(rootElement); } rootElement.Save(manifestFilePath); } /// /// Adds a capability to the given rootNode, which must be the read AppX manifest from /// the build output. /// /// An XElement containing the AppX manifest from /// the build output /// The added capabilities tag as XName /// Value of the Name-XAttribute of the added capability public static void AddCapability(XElement rootNode, XName capability, string value) { // If the capabilities container tag is missing, make sure it gets added. var capabilitiesTag = rootNode.GetDefaultNamespace() + "Capabilities"; XElement capabilitiesNode = rootNode.Element(capabilitiesTag); if (capabilitiesNode == null) { capabilitiesNode = new XElement(capabilitiesTag); rootNode.Add(capabilitiesNode); } XElement existingCapability = capabilitiesNode.Elements(capability) .FirstOrDefault(element => element.Attribute("Name")?.Value == value); // Only add the capability if it isn't there already. if (existingCapability == null) { capabilitiesNode.Add( new XElement(capability, new XAttribute("Name", value))); } } /// /// Adds the 'Gaze Input' capability to the manifest. /// /// /// This is a workaround for versions of Unity which don't have native support /// for the 'Gaze Input' capability in its Player Settings preference location. /// Note that this function is only public to poke a hole for testing - do not /// take a dependency on this function. /// public static void AddGazeInputCapability(XElement rootNode) { AddCapability(rootNode, rootNode.GetDefaultNamespace() + "DeviceCapability", "gazeInput"); } /// /// Adds the given capabilities to the manifest. /// public static void AddCapabilities(XElement rootNode, List capabilities) { foreach (string capability in capabilities) { AddCapability(rootNode, rootNode.GetDefaultNamespace() + "DeviceCapability", capability); } } /// /// Adds the 'Research Mode' capability to the manifest. /// /// /// This is only for research projects and should not be used in production. /// For further information take a look at https://docs.microsoft.com/windows/mixed-reality/research-mode. /// Note that this function is only public to poke a hole for testing - do not /// take a dependency on this function. /// public static void AddResearchModeCapability(XElement rootNode) { // Add rescap Namespace to package tag XNamespace rescapNs = "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"; var rescapAttribute = rootNode.Attribute(XNamespace.Xmlns + "rescap"); if (rescapAttribute == null) { rescapAttribute = new XAttribute(XNamespace.Xmlns + "rescap", rescapNs); rootNode.Add(rescapAttribute); } // Add rescap to IgnorableNamespaces var ignNsAttribute = rootNode.Attribute("IgnorableNamespaces"); if (ignNsAttribute == null) { ignNsAttribute = new XAttribute("IgnorableNamespaces", "rescap"); rootNode.Add(ignNsAttribute); } if (!ignNsAttribute.Value.Contains("rescap")) { ignNsAttribute.Value += " rescap"; } AddCapability(rootNode, rescapNs + "Capability", "perceptionSensorsExperimental"); } /// /// Enables unsafe code in the generated Assembly-CSharp project. /// /// /// This is not required by the research mode, but not using unsafe code with /// direct memory access results in poor performance. So it is recommended /// to use unsafe code to an extent. /// For further information take a look at https://docs.microsoft.com/windows/mixed-reality/research-mode. /// Note that this function is only public to poke a hole for testing - do not /// take a dependency on this function. /// public static void AllowUnsafeCode(XElement rootNode) { foreach (XElement propertyGroupNode in rootNode.Descendants(rootNode.GetDefaultNamespace() + "PropertyGroup")) { if (propertyGroupNode.Attribute("Condition") != null) { var allowUnsafeBlocks = propertyGroupNode.Element(propertyGroupNode.GetDefaultNamespace() + "AllowUnsafeBlocks"); if (allowUnsafeBlocks == null) { allowUnsafeBlocks = new XElement(propertyGroupNode.GetDefaultNamespace() + "AllowUnsafeBlocks"); propertyGroupNode.Add(allowUnsafeBlocks); } allowUnsafeBlocks.Value = "true"; } } } /// /// This struct controls the behavior of the arguments that are used /// when finding msbuild.exe. /// private struct VSWhereFindOption { public VSWhereFindOption(string args, string suffix) { arguments = args; pathSuffix = suffix; } /// /// Used to populate the Arguments of ProcessStartInfo when invoking /// vswhere. /// public string arguments; /// /// This string is added as a suffix to the result of the vswhere path /// search. /// public string pathSuffix; } private static readonly VSWhereFindOption[] VSWhereFindOptions = { // This find option corresponds to the version of vswhere that ships with VS2019. new VSWhereFindOption( @"/C vswhere -all -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe", ""), // This find option corresponds to the version of vswhere that ships with VS2017 - this doesn't have // support for the -find command switch. new VSWhereFindOption( @"/C vswhere -all -products * -requires Microsoft.Component.MSBuild -property installationPath", "\\MSBuild\\15.0\\Bin\\MSBuild.exe"), }; } }