#pragma warning disable SA1512,SA1124 // Single-line comments should not be followed by blank line using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using JetBrains.Annotations; using NugetForUnity.Configuration; using NugetForUnity.Helper; using NugetForUnity.Models; using UnityEditor; using UnityEngine; using Object = UnityEngine.Object; #region No ReShaper // ReSharper disable All // needed because 'JetBrains.Annotations.NotNull' and 'System.Diagnostics.CodeAnalysis.NotNull' collide if this file is compiled with a never version of Unity / C# using SuppressMessageAttribute = System.Diagnostics.CodeAnalysis.SuppressMessageAttribute; // ReSharper restore All #endregion #pragma warning restore SA1512,SA1124 // Single-line comments should not be followed by blank line namespace NugetForUnity { /// /// Hook into the import pipeline to change NuGet package related assets after / before they are imported. /// public class NugetAssetPostprocessor : AssetPostprocessor { /// /// Folder used to store Roslyn-Analyzers inside NuGet packages. /// private const string AnalyzersFolderName = "analyzers"; /// /// Used to mark an asset as already processed by this class. /// private const string ProcessedLabel = "NuGetForUnity"; /// /// Used to let unity know an asset is a Roslyn-Analyzer. /// private const string RoslynAnalyzerLabel = "RoslynAnalyzer"; /// /// Name of the root folder containing dotnet analyzers. /// private static readonly string AnalyzersRoslynVersionsFolderName = Path.Combine(AnalyzersFolderName, "dotnet"); /// /// Prefix for the path of dll's of roslyn analyzers. /// private static readonly string AnalyzersRoslynVersionSubFolderPrefix = Path.Combine(AnalyzersRoslynVersionsFolderName, "roslyn"); private static readonly List NonObsoleteBuildTargets = typeof(BuildTarget).GetFields(BindingFlags.Public | BindingFlags.Static) .Where(fieldInfo => fieldInfo.GetCustomAttribute(typeof(ObsoleteAttribute)) == null) .Select(fieldInfo => (BuildTarget)fieldInfo.GetValue(null)) .ToList(); private static readonly PropertyInfo PluginImporterIsExplicitlyReferencedProperty = typeof(PluginImporter).GetProperty("IsExplicitlyReferenced", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Can't find property 'IsExplicitlyReferenced'."); /// /// Called when the asset database finishes importing assets. /// We use it to check if packages.config has been changed and if so, we want to restore packages. /// /// The list of assets that were imported. /// The list of assets that were deleted. /// The list of assets that were moved. /// The list of paths of assets that were moved. internal static void OnPostprocessAllAssets( string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { // currently only importedAssets are important for us, so if there are none, we do nothing if (importedAssets.Length == 0) { return; } var packagesConfigFilePath = ConfigurationManager.NugetConfigFile.PackagesConfigFilePath; var foundPackagesConfigAsset = importedAssets.Any( importedAsset => Path.GetFullPath(importedAsset).Equals(packagesConfigFilePath, StringComparison.Ordinal)); if (!foundPackagesConfigAsset) { return; } InstalledPackagesManager.ReloadPackagesConfig(); PackageRestorer.Restore(ConfigurationManager.NugetConfigFile.SlimRestore); } [NotNull] private static IEnumerable<(string AssetType, string AssetPath, ResultStatus Status)> HandleAsset( [NotNull] string projectRelativeAssetPath, [NotNull] string absoluteRepositoryPath, bool reimport) { var assetFileName = Path.GetFileName(projectRelativeAssetPath); if (assetFileName.Equals(NugetConfigFile.FileName, StringComparison.OrdinalIgnoreCase) || assetFileName.Equals(PackagesConfigFile.FileName, StringComparison.OrdinalIgnoreCase)) { // Not sure why but for .config files we need to re-import always. I think this is because they are treated as native plug-ins. var result = ModifyImportSettingsOfConfigurationFile(projectRelativeAssetPath, true); yield return ("ConfigurationFile", projectRelativeAssetPath, result); yield break; } var absoluteAssetPath = Path.GetFullPath(Path.Combine(UnityPathHelper.AbsoluteProjectPath, projectRelativeAssetPath)); if (!AssetIsDllInsideNuGetRepository(absoluteAssetPath, absoluteRepositoryPath)) { yield break; } var assetPathRelativeToRepository = absoluteAssetPath.Substring(absoluteRepositoryPath.Length); // the first component is the package name with version number var assetPathComponents = GetPathComponents(assetPathRelativeToRepository); var packageNameParts = assetPathComponents.Length > 0 ? assetPathComponents[0].Split('.') : Array.Empty(); var packageName = string.Join(".", packageNameParts.TakeWhile(part => !part.All(char.IsDigit))); var packageConfig = InstalledPackagesManager.PackagesConfigFile.GetPackageConfigurationById(packageName); if (!GetPluginImporter(projectRelativeAssetPath, out var plugin)) { yield return ("GetPluginImporter", projectRelativeAssetPath, ResultStatus.Failure); yield break; } if (AlreadyProcessed(plugin)) { yield return ("AlreadyProcessed", projectRelativeAssetPath, ResultStatus.AlreadyProcessed); yield break; } if (packageConfig != null) { ModifyImportSettingsOfGeneralPlugin(packageConfig, plugin, reimport); yield return ("GeneralSetting", projectRelativeAssetPath, ResultStatus.Success); } if (assetPathComponents.Length > 1 && assetPathComponents[1].Equals(AnalyzersFolderName, StringComparison.OrdinalIgnoreCase)) { ModifyImportSettingsOfRoslynAnalyzer(plugin, reimport); yield return ("RoslynAnalyzer", projectRelativeAssetPath, ResultStatus.Success); yield break; } if (assetPathComponents.Length > 0 && UnityPreImportedLibraryResolver.GetAlreadyImportedEditorOnlyLibraries() .Contains(Path.GetFileNameWithoutExtension(assetPathComponents[assetPathComponents.Length - 1]))) { ModifyImportSettingsOfPlayerOnly(plugin, reimport); yield return ("PlayerOnly", projectRelativeAssetPath, ResultStatus.Success); } } [NotNull] private static string[] GetPathComponents([NotNull] string path) { return path.Split(Path.DirectorySeparatorChar); } private static bool AssetIsDllInsideNuGetRepository([NotNull] string absoluteAssetPath, [NotNull] string absoluteRepositoryPath) { return absoluteAssetPath.StartsWith(absoluteRepositoryPath, PathHelper.PathComparisonType) && absoluteAssetPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(absoluteAssetPath); } /// /// Gets the absolute path where NuGetForUnity restores NuGet packages, with trailing directory separator. /// /// The absolute path where NuGetForUnity restores NuGet packages, with trailing directory separator. [NotNull] private static string GetNuGetRepositoryPath() { return ConfigurationManager.NugetConfigFile.RepositoryPath + Path.DirectorySeparatorChar; } [CanBeNull] private static NugetPackageVersion GetMaxSupportedRoslynVersion() { var unityVersion = UnityVersion.Current; if (unityVersion >= new UnityVersion(2022, 3, 12, 'f', 1)) { return new NugetPackageVersion("4.3.0"); } if (unityVersion >= new UnityVersion(2022, 2, 1, 'f', 1)) { return new NugetPackageVersion("4.1.0"); } if (unityVersion >= new UnityVersion(2021, 2, 1, 'f', 1)) { return new NugetPackageVersion("3.8.0"); } return null; } [CanBeNull] private static NugetPackageVersion GetRoslynVersionNumberFromAnalyzerPath(string analyzerAssetPath) { var versionPrefixStartIndex = analyzerAssetPath.IndexOf(AnalyzersRoslynVersionSubFolderPrefix, StringComparison.Ordinal); if (versionPrefixStartIndex < 0) { return null; } var versionStartIndex = versionPrefixStartIndex + AnalyzersRoslynVersionSubFolderPrefix.Length; var separatorIndex = analyzerAssetPath.IndexOf(Path.DirectorySeparatorChar, versionStartIndex); var versionLength = separatorIndex >= 0 ? separatorIndex - versionStartIndex : analyzerAssetPath.Length - versionStartIndex; var versionString = analyzerAssetPath.Substring(versionStartIndex, versionLength); return string.IsNullOrEmpty(versionString) ? null : new NugetPackageVersion(versionString); } private static void ModifyImportSettingsOfRoslynAnalyzer([NotNull] PluginImporter plugin, bool reimport) { plugin.SetCompatibleWithAnyPlatform(false); plugin.SetCompatibleWithEditor(false); foreach (var platform in NonObsoleteBuildTargets) { plugin.SetExcludeFromAnyPlatform(platform, false); } var enableRoslynAnalyzer = true; // The nuget package can contain analyzers for multiple Roslyn versions. // In that case, for the same package, the most recent version must be chosen out of those available for the current Unity version. var assetPath = Path.GetFullPath(plugin.assetPath); var assetRoslynVersion = GetRoslynVersionNumberFromAnalyzerPath(assetPath); if (assetRoslynVersion != null) { var maxSupportedRoslynVersion = GetMaxSupportedRoslynVersion(); if (maxSupportedRoslynVersion == null) { // the current unity version doesn't support roslyn analyzers enableRoslynAnalyzer = false; } else { var versionPrefixIndex = assetPath.IndexOf(AnalyzersRoslynVersionsFolderName, StringComparison.Ordinal); var analyzerVersionsRootDirectoryPath = Path.Combine( assetPath.Substring(0, versionPrefixIndex), AnalyzersRoslynVersionsFolderName); var analyzersFolders = Directory.EnumerateDirectories(analyzerVersionsRootDirectoryPath); var allEnabledRoslynVersions = analyzersFolders.Select(GetRoslynVersionNumberFromAnalyzerPath) .Where(version => version != null && version.CompareTo(maxSupportedRoslynVersion) <= 0) .ToArray(); // If most recent valid analyzers exist elsewhere, don't add label `RoslynAnalyzer` var maxMatchingVersion = allEnabledRoslynVersions.Max(); if (!allEnabledRoslynVersions.Contains(assetRoslynVersion) || assetRoslynVersion < maxMatchingVersion) { enableRoslynAnalyzer = false; } } } AssetDatabase.SetLabels(plugin, enableRoslynAnalyzer ? new[] { RoslynAnalyzerLabel, ProcessedLabel } : new[] { ProcessedLabel }); if (reimport) { // Persist and reload the change to the meta file plugin.SaveAndReimport(); } NugetLogger.LogVerbose("Configured asset '{0}' as a Roslyn-Analyzer.", plugin.assetPath); } private static void ModifyImportSettingsOfGeneralPlugin([NotNull] PackageConfig packageConfig, [NotNull] PluginImporter plugin, bool reimport) { PluginImporterIsExplicitlyReferencedProperty.SetValue(plugin, !packageConfig.AutoReferenced); AssetDatabase.SetLabels(plugin, new[] { ProcessedLabel }); if (reimport) { // Persist and reload the change to the meta file plugin.SaveAndReimport(); } } /// /// Changes the importer settings to exclude it from editor. /// This is needed for assemblies that are imported by unity but only in edit-mode so we can only import it in play-mode. /// . /// /// The asset to edit. /// Whether or not to save and re-import the file. private static void ModifyImportSettingsOfPlayerOnly([NotNull] PluginImporter plugin, bool reimport) { plugin.SetCompatibleWithAnyPlatform(true); plugin.SetExcludeEditorFromAnyPlatform(true); AssetDatabase.SetLabels(plugin, new[] { ProcessedLabel }); if (reimport) { // Persist and reload the change to the meta file plugin.SaveAndReimport(); } NugetLogger.LogVerbose("Configured asset '{0}' as a Player Only.", plugin.assetPath); } /// /// Changes the importer settings to disables the export to WSA Platform setting. /// /// The path to the .config file. /// Whether or not to save and re-import the file. private static ResultStatus ModifyImportSettingsOfConfigurationFile([NotNull] string analyzerAssetPath, bool reimport) { if (!GetPluginImporter(analyzerAssetPath, out var plugin)) { return ResultStatus.Failure; } if (AlreadyProcessed(plugin)) { return ResultStatus.AlreadyProcessed; } plugin.SetCompatibleWithPlatform(BuildTarget.WSAPlayer, false); AssetDatabase.SetLabels(plugin, new[] { ProcessedLabel }); if (reimport) { // Persist and reload the change to the meta file plugin.SaveAndReimport(); } NugetLogger.LogVerbose("Disabling WSA platform on asset settings for {0}", plugin.assetPath); return ResultStatus.Success; } /// /// Logs the aggregated result of the processing of the assets. /// /// The aggregated result status. private static void LogResults([NotNull] IEnumerable<(string AssetType, string AssetPath, ResultStatus Status)> results) { var grouped = results.GroupBy(result => result.Status); foreach (var groupEntry in grouped) { if (groupEntry.Key == ResultStatus.Success && ConfigurationManager.NugetConfigFile.Verbose) { NugetLogger.LogVerbose( "NuGetForUnity: successfully processed: {0}", string.Join(",", groupEntry.Select(asset => $"'{asset.AssetPath}' ({asset.AssetType})"))); } if (groupEntry.Key == ResultStatus.Failure) { Debug.LogError( $"NuGetForUnity: failed to process: {string.Join(",", groupEntry.Select(asset => $"'{asset.AssetPath}' ({asset.AssetType})"))}"); } } } /// /// Given the load the for the asset it's associated /// with. /// /// Path to the assets. /// The loaded PluginImporter. /// True if the plug-in importer for the asset could be loaded. private static bool GetPluginImporter([NotNull] string assetFilePath, out PluginImporter plugin) { var assetPath = assetFilePath; plugin = AssetImporter.GetAtPath(assetPath) as PluginImporter; if (plugin == null) { Debug.LogWarning($"Failed to import plug-in at {assetPath}"); return false; } NugetLogger.LogVerbose("Plug-in loaded for file: {0}", assetPath); return true; } /// /// Check the labels on the asset to determine if we've already processed it. Calls to /// trigger call backs to this class to occur so not checking /// if an asset has already been processed triggers an infinite loop that Unity detects and logs as an error. /// /// Asset to check. /// True if already processed. private static bool AlreadyProcessed([NotNull] Object asset) { return AssetDatabase.GetLabels(asset).Contains(ProcessedLabel); } /// /// Get informed about a new asset that is added. /// This is called before unity tried to import a asset but the is already created /// so we can change the import settings before unity throws errors about incompatibility etc.. /// /// Currently we change the import settings of: /// /// /// /// Roslyn-Analyzers: are marked so unity knows that the *.dll's are analyzers and treats them accordingly. /// /// /// NuGetForUnity config files: so they are not exported to WSA /// /// /// PlayerOnly assemblies: configure the assemblies to be excluded form edit-mode /// /// /// Normal assemblies (*.dll): apply the IsExplicitlyReferenced setting read from the package.config. /// /// /// [UsedImplicitly] [SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Called by Unity.")] private void OnPreprocessAsset() { var absoluteRepositoryPath = GetNuGetRepositoryPath(); var results = HandleAsset(assetPath, absoluteRepositoryPath, false); LogResults(results); } [SuppressMessage( "StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "We like private enums at the botom of the file.")] private enum ResultStatus { Success, Failure, AlreadyProcessed, } } }