diff --git a/src/PepperDash.Essentials.Core/Plugins/PluginLoader.cs b/src/PepperDash.Essentials.Core/Plugins/PluginLoader.cs index 3de08806..932ce2e3 100644 --- a/src/PepperDash.Essentials.Core/Plugins/PluginLoader.cs +++ b/src/PepperDash.Essentials.Core/Plugins/PluginLoader.cs @@ -4,6 +4,9 @@ using System.Linq; using Crestron.SimplSharp; // using Crestron.SimplSharp.CrestronIO; using System.Reflection; +using System.IO; +using System.Reflection.PortableExecutable; +using System.Reflection.Metadata; using PepperDash.Core; using PepperDash.Essentials.Core; @@ -28,6 +31,11 @@ namespace PepperDash.Essentials /// static List LoadedPluginFolderAssemblies; + /// + /// List of plugins that were found to be incompatible with .NET 8 + /// + public static List IncompatiblePlugins { get; private set; } + public static LoadedAssembly EssentialsAssembly { get; private set; } public static LoadedAssembly PepperDashCoreAssembly { get; private set; } @@ -47,12 +55,28 @@ namespace PepperDash.Essentials // The temp directory where .cplz archives will be unzipped to static string _tempDirectory => _pluginDirectory + Global.DirectorySeparator + "temp"; + // Known incompatible types in .NET 8 + private static readonly HashSet KnownIncompatibleTypes = new HashSet + { + "System.Net.ICertificatePolicy", + "System.Security.Cryptography.SHA1CryptoServiceProvider", + "System.Web.HttpUtility", + "System.Configuration.ConfigurationManager", + "System.Web.Services.Protocols.SoapHttpClientProtocol", + "System.Runtime.Remoting", + "System.EnterpriseServices", + "System.Runtime.Serialization.Formatters.Binary.BinaryFormatter", + "System.Security.SecurityManager", + "System.Security.Permissions.FileIOPermission", + "System.AppDomain.CreateDomain" + }; static PluginLoader() { LoadedAssemblies = new List(); LoadedPluginFolderAssemblies = new List(); EssentialsPluginAssemblies = new List(); + IncompatiblePlugins = new List(); } /// @@ -62,7 +86,7 @@ namespace PepperDash.Essentials { Debug.LogMessage(LogEventLevel.Verbose, "Getting Assemblies loaded with Essentials"); // Get the loaded assembly filenames - var appDi = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix); + var appDi = new SystemIO.DirectoryInfo(Global.ApplicationDirectoryPathPrefix); var assemblyFiles = appDi.GetFiles("*.dll"); Debug.LogMessage(LogEventLevel.Verbose, "Found {0} Assemblies", assemblyFiles.Length); @@ -114,7 +138,6 @@ namespace PepperDash.Essentials } } - public static void SetEssentialsAssembly(string name, Assembly assembly) { var loadedAssembly = LoadedAssemblies.FirstOrDefault(la => la.Name.Equals(name)); @@ -126,19 +149,108 @@ namespace PepperDash.Essentials } /// - /// Loads an assembly via Reflection and adds it to the list of loaded assemblies + /// Checks if a plugin is compatible with .NET 8 by examining its metadata /// - /// - static LoadedAssembly LoadAssembly(string filePath) + /// Path to the plugin assembly + /// Tuple with compatibility result, reason if incompatible, and referenced assemblies + public static (bool IsCompatible, string Reason, List References) IsPluginCompatibleWithNet8(string filePath) { try { - //Debug.LogMessage(LogEventLevel.Verbose, "Attempting to load {0}", filePath); + List referencedAssemblies = new List(); + + using (SystemIO.FileStream fs = new SystemIO.FileStream(filePath, SystemIO.FileMode.Open, + SystemIO.FileAccess.Read, SystemIO.FileShare.ReadWrite)) + using (PEReader peReader = new PEReader(fs)) + { + if (!peReader.HasMetadata) + return (false, "Not a valid .NET assembly", referencedAssemblies); + + MetadataReader metadataReader = peReader.GetMetadataReader(); + + // Collect assembly references + foreach (var assemblyRefHandle in metadataReader.AssemblyReferences) + { + var assemblyRef = metadataReader.GetAssemblyReference(assemblyRefHandle); + string assemblyName = metadataReader.GetString(assemblyRef.Name); + referencedAssemblies.Add(assemblyName); + } + + // Check for references to known incompatible types + foreach (var typeRefHandle in metadataReader.TypeReferences) + { + var typeRef = metadataReader.GetTypeReference(typeRefHandle); + string typeNamespace = metadataReader.GetString(typeRef.Namespace); + string typeName = metadataReader.GetString(typeRef.Name); + + string fullTypeName = $"{typeNamespace}.{typeName}"; + if (KnownIncompatibleTypes.Contains(fullTypeName)) + { + return (false, $"Uses incompatible type: {fullTypeName}", referencedAssemblies); + } + } + + // Check for explicit .NET 8 compatibility attribute + bool hasNet8Attribute = false; + foreach (var customAttributeHandle in metadataReader.GetAssemblyDefinition().GetCustomAttributes()) + { + var customAttribute = metadataReader.GetCustomAttribute(customAttributeHandle); + var ctorHandle = customAttribute.Constructor; + + if (ctorHandle.Kind == HandleKind.MemberReference) + { + var memberRef = metadataReader.GetMemberReference((MemberReferenceHandle)ctorHandle); + var typeRef = metadataReader.GetTypeReference((TypeReferenceHandle)memberRef.Parent); + + string typeName = metadataReader.GetString(typeRef.Name); + if (typeName == "Net8CompatibleAttribute" || typeName == "TargetFrameworkAttribute") + { + hasNet8Attribute = true; + break; + } + } + } + + if (hasNet8Attribute) + { + return (true, null, referencedAssemblies); + } + + // If we can't determine incompatibility, assume it's compatible + return (true, null, referencedAssemblies); + } + } + catch (Exception ex) + { + return (false, $"Error analyzing assembly: {ex.Message}", new List()); + } + } + + /// + /// Loads an assembly via Reflection and adds it to the list of loaded assemblies + /// + /// Path to the assembly file + /// Name of the plugin requesting this assembly (null for direct loads) + static LoadedAssembly LoadAssembly(string filePath, string requestedBy = null) + { + try + { + // Check .NET 8 compatibility before loading + var (isCompatible, reason, references) = IsPluginCompatibleWithNet8(filePath); + if (!isCompatible) + { + string fileName = CrestronIO.Path.GetFileName(filePath); + Debug.LogMessage(LogEventLevel.Warning, "Assembly '{0}' is not compatible with .NET 8: {1}", fileName, reason); + + var incompatiblePlugin = new IncompatiblePlugin(fileName, reason, requestedBy); + IncompatiblePlugins.Add(incompatiblePlugin); + return null; + } + var assembly = Assembly.LoadFrom(filePath); if (assembly != null) { var assyVersion = GetAssemblyVersion(assembly); - var loadedAssembly = new LoadedAssembly(assembly.GetName().Name, assyVersion, assembly); LoadedAssemblies.Add(loadedAssembly); Debug.LogMessage(LogEventLevel.Information, "Loaded assembly '{0}', version {1}", loadedAssembly.Name, loadedAssembly.Version); @@ -148,14 +260,47 @@ namespace PepperDash.Essentials { Debug.LogMessage(LogEventLevel.Information, "Unable to load assembly: '{0}'", filePath); } - - return null; - } catch(Exception ex) - { - Debug.LogMessage(ex, "Error loading assembly from {path}", null, filePath); return null; } - + catch(SystemIO.FileLoadException ex) when (ex.Message.Contains("Assembly with same name is already loaded")) + { + // Get the assembly name from the file path + string assemblyName = CrestronIO.Path.GetFileNameWithoutExtension(filePath); + + // Try to find the already loaded assembly + var existingAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name.Equals(assemblyName, StringComparison.OrdinalIgnoreCase)); + + if (existingAssembly != null) + { + Debug.LogMessage(LogEventLevel.Information, "Assembly '{0}' is already loaded, using existing instance", assemblyName); + var assyVersion = GetAssemblyVersion(existingAssembly); + var loadedAssembly = new LoadedAssembly(existingAssembly.GetName().Name, assyVersion, existingAssembly); + LoadedAssemblies.Add(loadedAssembly); + return loadedAssembly; + } + + Debug.LogMessage(LogEventLevel.Warning, "Assembly with same name already loaded but couldn't find it: {0}", filePath); + return null; + } + catch(Exception ex) + { + string fileName = CrestronIO.Path.GetFileName(filePath); + + // Check if this might be a .NET Framework compatibility issue + if (ex.Message.Contains("Could not load type") || + ex.Message.Contains("Unable to load one or more of the requested types")) + { + Debug.LogMessage(LogEventLevel.Error, "Error loading assembly {0}: Likely .NET 8 compatibility issue: {1}", + fileName, ex.Message); + IncompatiblePlugins.Add(new IncompatiblePlugin(fileName, ex.Message, requestedBy)); + } + else + { + Debug.LogMessage(ex, "Error loading assembly from {path}", null, filePath); + } + return null; + } } /// @@ -217,12 +362,25 @@ namespace PepperDash.Essentials CrestronConsole.ConsoleCommandResponse("{0} Version: {1}" + CrestronEnvironment.NewLine, assembly.Name, assembly.Version); } - //CrestronConsole.ConsoleCommandResponse("Loaded Assemblies:" + CrestronEnvironment.NewLine); - //foreach (var assembly in LoadedAssemblies) - //{ - // CrestronConsole.ConsoleCommandResponse("{0} Version: {1}" + CrestronEnvironment.NewLine, assembly.Name, assembly.Version); - //} + if (IncompatiblePlugins.Count > 0) + { + CrestronConsole.ConsoleCommandResponse("Incompatible Plugins:" + CrestronEnvironment.NewLine); + foreach (var plugin in IncompatiblePlugins) + { + if (plugin.TriggeredBy != "Direct load") + { + CrestronConsole.ConsoleCommandResponse("{0}: {1} (Required by: {2})" + CrestronEnvironment.NewLine, + plugin.Name, plugin.Reason, plugin.TriggeredBy); + } + else + { + CrestronConsole.ConsoleCommandResponse("{0}: {1}" + CrestronEnvironment.NewLine, + plugin.Name, plugin.Reason); + } + } + } } + /// /// Moves any .dll assemblies not already loaded from the plugins folder to loadedPlugins folder /// @@ -230,14 +388,14 @@ namespace PepperDash.Essentials { Debug.LogMessage(LogEventLevel.Information, "Looking for .dll assemblies from plugins folder..."); - var pluginDi = new DirectoryInfo(_pluginDirectory); + var pluginDi = new SystemIO.DirectoryInfo(_pluginDirectory); var pluginFiles = pluginDi.GetFiles("*.dll"); if (pluginFiles.Length > 0) { - if (!Directory.Exists(_loadedPluginsDirectoryPath)) + if (!SystemIO.Directory.Exists(_loadedPluginsDirectoryPath)) { - Directory.CreateDirectory(_loadedPluginsDirectoryPath); + SystemIO.Directory.CreateDirectory(_loadedPluginsDirectoryPath); } } @@ -254,14 +412,14 @@ namespace PepperDash.Essentials filePath = _loadedPluginsDirectoryPath + Global.DirectorySeparator + pluginFile.Name; // Check if there is a previous file in the loadedPlugins directory and delete - if (File.Exists(filePath)) + if (SystemIO.File.Exists(filePath)) { Debug.LogMessage(LogEventLevel.Information, "Found existing file in loadedPlugins: {0} Deleting and moving new file to replace it", filePath); - File.Delete(filePath); + SystemIO.File.Delete(filePath); } // Move the file - File.Move(pluginFile.FullName, filePath); + SystemIO.File.Move(pluginFile.FullName, filePath); Debug.LogMessage(LogEventLevel.Verbose, "Moved {0} to {1}", pluginFile.FullName, filePath); } else @@ -284,9 +442,9 @@ namespace PepperDash.Essentials /// static void UnzipAndMoveCplzArchives() { - Debug.LogMessage(LogEventLevel.Information, "Looking for .cplz archives from user folder..."); - //var di = new DirectoryInfo(_pluginDirectory); - //var zFiles = di.GetFiles("*.cplz"); + Debug.LogMessage(LogEventLevel.Information, "Looking for .cplz archives from plugins folder..."); + var di = new SystemIO.DirectoryInfo(_pluginDirectory); + var zFiles = di.GetFiles("*.cplz"); //// Find cplz files at the root of the user folder. Makes development/testing easier for VC-4, and helps with mistakes by end users @@ -300,16 +458,16 @@ namespace PepperDash.Essentials if (cplzFiles.Length > 0) { - if (!Directory.Exists(_loadedPluginsDirectoryPath)) + if (!SystemIO.Directory.Exists(_loadedPluginsDirectoryPath)) { - Directory.CreateDirectory(_loadedPluginsDirectoryPath); + SystemIO.Directory.CreateDirectory(_loadedPluginsDirectoryPath); } } foreach (var zfi in cplzFiles) { - Directory.CreateDirectory(_tempDirectory); - var tempDi = new DirectoryInfo(_tempDirectory); + SystemIO.Directory.CreateDirectory(_tempDirectory); + var tempDi = new SystemIO.DirectoryInfo(_tempDirectory); Debug.LogMessage(LogEventLevel.Information, "Found cplz: {0}. Unzipping into temp plugins directory", zfi.FullName); var result = CrestronZIP.Unzip(zfi.FullName, tempDi.FullName); @@ -327,14 +485,14 @@ namespace PepperDash.Essentials filePath = _loadedPluginsDirectoryPath + Global.DirectorySeparator + tempFile.Name; // Check if there is a previous file in the loadedPlugins directory and delete - if (File.Exists(filePath)) + if (SystemIO.File.Exists(filePath)) { Debug.LogMessage(LogEventLevel.Information, "Found existing file in loadedPlugins: {0} Deleting and moving new file to replace it", filePath); - File.Delete(filePath); + SystemIO.File.Delete(filePath); } // Move the file - File.Move(tempFile.FullName, filePath); + SystemIO.File.Move(tempFile.FullName, filePath); Debug.LogMessage(LogEventLevel.Verbose, "Moved {0} to {1}", tempFile.FullName, filePath); } else @@ -350,7 +508,7 @@ namespace PepperDash.Essentials } // Delete the .cplz and the temp directory - Directory.Delete(_tempDirectory, true); + SystemIO.Directory.Delete(_tempDirectory, true); zfi.Delete(); } @@ -363,16 +521,40 @@ namespace PepperDash.Essentials static void LoadPluginAssemblies() { Debug.LogMessage(LogEventLevel.Information, "Loading assemblies from loadedPlugins folder..."); - var pluginDi = new DirectoryInfo(_loadedPluginsDirectoryPath); + var pluginDi = new CrestronIO.DirectoryInfo(_loadedPluginsDirectoryPath); var pluginFiles = pluginDi.GetFiles("*.dll"); Debug.LogMessage(LogEventLevel.Verbose, "Found {0} plugin assemblies to load", pluginFiles.Length); + // First, check compatibility of all assemblies before loading any + var assemblyCompatibility = new Dictionary References)>(); + foreach (var pluginFile in pluginFiles) { - var loadedAssembly = LoadAssembly(pluginFile.FullName); - - LoadedPluginFolderAssemblies.Add(loadedAssembly); + string fileName = pluginFile.Name; + assemblyCompatibility[fileName] = IsPluginCompatibleWithNet8(pluginFile.FullName); + } + + // Now load compatible assemblies and track incompatible ones + foreach (var pluginFile in pluginFiles) + { + string fileName = pluginFile.Name; + var (isCompatible, reason, references) = assemblyCompatibility[fileName]; + + if (!isCompatible) + { + Debug.LogMessage(LogEventLevel.Warning, "Assembly '{0}' is not compatible with .NET 8: {1}", fileName, reason); + IncompatiblePlugins.Add(new IncompatiblePlugin(fileName, reason, null)); + continue; + } + + // Try to load the assembly + var loadedAssembly = LoadAssembly(pluginFile.FullName, null); + + if (loadedAssembly != null) + { + LoadedPluginFolderAssemblies.Add(loadedAssembly); + } } Debug.LogMessage(LogEventLevel.Information, "All Plugins Loaded."); @@ -384,8 +566,13 @@ namespace PepperDash.Essentials static void LoadCustomPluginTypes() { Debug.LogMessage(LogEventLevel.Information, "Loading Custom Plugin Types..."); + foreach (var loadedAssembly in LoadedPluginFolderAssemblies) { + // Skip if assembly is null (can happen if we had loading issues) + if (loadedAssembly == null || loadedAssembly.Assembly == null) + continue; + // iteratate this assembly's classes, looking for "LoadPlugin()" methods try { @@ -396,11 +583,49 @@ namespace PepperDash.Essentials types = assy.GetTypes(); Debug.LogMessage(LogEventLevel.Debug, $"Got types for assembly {assy.GetName().Name}"); } + catch (ReflectionTypeLoadException e) + { + Debug.LogMessage(LogEventLevel.Error, "Unable to get types for assembly {0}: {1}", + loadedAssembly.Name, e.Message); + + // Check if any of the loader exceptions are due to missing assemblies + foreach (var loaderEx in e.LoaderExceptions) + { + if (loaderEx is SystemIO.FileNotFoundException fileNotFoundEx) + { + string missingAssembly = fileNotFoundEx.FileName; + if (!string.IsNullOrEmpty(missingAssembly)) + { + Debug.LogMessage(LogEventLevel.Warning, "Assembly {0} requires missing dependency: {1}", + loadedAssembly.Name, missingAssembly); + + // Add to incompatible plugins with dependency information + IncompatiblePlugins.Add(new IncompatiblePlugin( + CrestronIO.Path.GetFileName(missingAssembly), + $"Missing dependency required by {loadedAssembly.Name}", + loadedAssembly.Name)); + } + } + } + + Debug.LogMessage(LogEventLevel.Verbose, e.StackTrace); + continue; + } catch (TypeLoadException e) { Debug.LogMessage(LogEventLevel.Error, "Unable to get types for assembly {0}: {1}", loadedAssembly.Name, e.Message); Debug.LogMessage(LogEventLevel.Verbose, e.StackTrace); + + // Add to incompatible plugins if this is likely a .NET 8 compatibility issue + if (e.Message.Contains("Could not load type") || + e.Message.Contains("Unable to load one or more of the requested types")) + { + IncompatiblePlugins.Add(new IncompatiblePlugin(loadedAssembly.Name, + $"Type loading error: {e.Message}", + null)); + } + continue; } @@ -425,22 +650,63 @@ namespace PepperDash.Essentials loadedAssembly.Name, e.Message, type.Name); continue; } - } } catch (Exception e) { Debug.LogMessage(LogEventLevel.Information, "Error Loading assembly {0}: {1}", - loadedAssembly.Name, e.Message); + loadedAssembly.Name, e.Message); Debug.LogMessage(LogEventLevel.Verbose, "{0}", e.StackTrace); + + // Add to incompatible plugins if this is likely a .NET 8 compatibility issue + if (e.Message.Contains("Could not load type") || + e.Message.Contains("Unable to load one or more of the requested types")) + { + IncompatiblePlugins.Add(new IncompatiblePlugin(loadedAssembly.Name, + $"Assembly loading error: {e.Message}", + null)); + } + continue; } } + + // Update incompatible plugins with dependency information + UpdateIncompatiblePluginDependencies(); + // plugin dll will be loaded. Any classes in plugin should have a static constructor // that registers that class with the Core.DeviceFactory Debug.LogMessage(LogEventLevel.Information, "Done Loading Custom Plugin Types."); } + /// + /// Updates incompatible plugins with information about which plugins depend on them + /// + private static void UpdateIncompatiblePluginDependencies(Dictionary> pluginDependencies) + { + // For each incompatible plugin + foreach (var incompatiblePlugin in IncompatiblePlugins) + { + // If it already has a requestedBy, skip it + if (incompatiblePlugin.TriggeredBy != "Direct load") + continue; + + // Find plugins that depend on this incompatible plugin + foreach (var plugin in pluginDependencies) + { + string pluginName = plugin.Key; + List dependencies = plugin.Value; + + // If this plugin depends on the incompatible plugin + if (dependencies.Contains(incompatiblePlugin.Name) || + dependencies.Any(d => d.StartsWith(incompatiblePlugin.Name + ","))) + { + incompatiblePlugin.UpdateTriggeringPlugin(pluginName); + break; + } + } + } + } /// /// Loads a /// @@ -517,7 +783,6 @@ namespace PepperDash.Essentials Debug.LogMessage(LogEventLevel.Information, "Loading legacy plugin: {0}", loadedAssembly.Name); loadPlugin.Invoke(null, null); - } /// @@ -527,7 +792,7 @@ namespace PepperDash.Essentials { Debug.LogMessage(LogEventLevel.Information, "Attempting to Load Plugins from {_pluginDirectory}", _pluginDirectory); - if (Directory.Exists(_pluginDirectory)) + if (SystemIO.Directory.Exists(_pluginDirectory)) { Debug.LogMessage(LogEventLevel.Information, "Plugins directory found, checking for plugins"); @@ -537,7 +802,7 @@ namespace PepperDash.Essentials // Deal with any .cplz files UnzipAndMoveCplzArchives(); - if (Directory.Exists(_loadedPluginsDirectoryPath)) + if (SystemIO.Directory.Exists(_loadedPluginsDirectoryPath)) { // Load the assemblies from the loadedPlugins folder into the AppDomain LoadPluginAssemblies(); @@ -545,9 +810,27 @@ namespace PepperDash.Essentials // Load the types from any custom plugin assemblies LoadCustomPluginTypes(); } + + // Report on incompatible plugins + if (IncompatiblePlugins.Count > 0) + { + Debug.LogMessage(LogEventLevel.Warning, "Found {0} incompatible plugins:", IncompatiblePlugins.Count); + foreach (var plugin in IncompatiblePlugins) + { + if (plugin.TriggeredBy != "Direct load") + { + Debug.LogMessage(LogEventLevel.Warning, " - {0}: {1} (Required by: {2})", + plugin.Name, plugin.Reason, plugin.TriggeredBy); + } + else + { + Debug.LogMessage(LogEventLevel.Warning, " - {0}: {1}", + plugin.Name, plugin.Reason); + } + } + } } } - } /// @@ -574,4 +857,52 @@ namespace PepperDash.Essentials Assembly = assembly; } } + + /// + /// Represents a plugin that was found to be incompatible with .NET 8 + /// + public class IncompatiblePlugin + { + [JsonProperty("name")] + public string Name { get; private set; } + + [JsonProperty("reason")] + public string Reason { get; private set; } + + [JsonProperty("triggeredBy")] + public string TriggeredBy { get; private set; } + + public IncompatiblePlugin(string name, string reason, string triggeredBy = null) + { + Name = name; + Reason = reason; + TriggeredBy = triggeredBy ?? "Direct load"; + } + + /// + /// Updates the plugin that triggered this incompatibility + /// + /// Name of the plugin that requires this incompatible plugin + public void UpdateTriggeringPlugin(string triggeringPlugin) + { + if (!string.IsNullOrEmpty(triggeringPlugin)) + { + TriggeredBy = triggeringPlugin; + } + } + } + + /// + /// Attribute to explicitly mark a plugin as .NET 8 compatible + /// + [AttributeUsage(AttributeTargets.Assembly)] + public class Net8CompatibleAttribute : Attribute + { + public bool IsCompatible { get; } + + public Net8CompatibleAttribute(bool isCompatible = true) + { + IsCompatible = isCompatible; + } + } } \ No newline at end of file