From 073e2f981f49892e17b04b73457f71789a27d5f4 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Wed, 17 Jun 2026 04:18:38 +0200 Subject: [PATCH] refactor: share metadata type scanner for plugin and WFA tasks --- .../Tasks/Services/MetadataTypeScanner.cs | 391 ++++++++++++++ .../Tasks/EnsurePluginAssemblyDataXml.cs | 330 ++---------- .../EnsureWorkflowActivityAssemblyDataXml.cs | 485 +++--------------- 3 files changed, 516 insertions(+), 690 deletions(-) create mode 100644 src/Dataverse/Tasks/Services/MetadataTypeScanner.cs diff --git a/src/Dataverse/Tasks/Services/MetadataTypeScanner.cs b/src/Dataverse/Tasks/Services/MetadataTypeScanner.cs new file mode 100644 index 0000000..4209af5 --- /dev/null +++ b/src/Dataverse/Tasks/Services/MetadataTypeScanner.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +/// +/// Reads type information from a .NET assembly using IL metadata only. +/// No types are actually loaded into the CLR, so there are no issues with assembly binding or locking files on disk. +/// +internal sealed class MetadataTypeScanner : IDisposable +{ + private readonly string _mainDllPath; + private readonly List _probeDirs; + private readonly List _disposables = new List(); + private readonly Dictionary _readerByFile = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _readerByAssembly = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public MetadataTypeScanner(string mainDllPath, IEnumerable probeDirs) + { + _mainDllPath = mainDllPath; + _probeDirs = probeDirs + .Where(d => !string.IsNullOrWhiteSpace(d) && Directory.Exists(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IEnumerable GetPublicTypes() + { + MetadataReader reader = OpenReaderForFile(_mainDllPath); + if (reader == null) + yield break; + + foreach (var handle in reader.TypeDefinitions) + { + var typeDef = reader.GetTypeDefinition(handle); + if ((typeDef.Attributes & TypeAttributes.VisibilityMask) != TypeAttributes.Public) + continue; + + yield return new ScannedType(this, reader, typeDef); + } + } + + + internal bool DerivesFromBaseType(MetadataReader reader, TypeDefinition typeDef, string baseSimpleName) + { + EntityHandle baseHandle = typeDef.BaseType; + int guard = 0; + + while (!baseHandle.IsNil && guard++ < 200) + { + if (baseHandle.Kind == HandleKind.TypeDefinition) + { + var td = reader.GetTypeDefinition((TypeDefinitionHandle)baseHandle); + if (reader.GetString(td.Name) == baseSimpleName) + return true; + + baseHandle = td.BaseType; + } + else if (baseHandle.Kind == HandleKind.TypeReference) + { + var tr = reader.GetTypeReference((TypeReferenceHandle)baseHandle); + + if (reader.GetString(tr.Name) == baseSimpleName) + return true; + + var resolved = ResolveTypeReference(reader, tr); + if (resolved == null) + return false; + + reader = resolved.Value.Reader; + baseHandle = resolved.Value.Definition.BaseType; + } + else + { + return false; + } + } + + return false; + } + + internal bool ImplementsInterface(MetadataReader reader, TypeDefinition typeDef, string interfaceNamespace, string interfaceName) + { + var visited = new HashSet(StringComparer.Ordinal); + + MetadataReader currentReader = reader; + TypeDefinition current = typeDef; + int guard = 0; + + while (guard++ < 200) + { + foreach (var implHandle in current.GetInterfaceImplementations()) + { + EntityHandle iface = currentReader.GetInterfaceImplementation(implHandle).Interface; + if (InterfaceMatchesOrInherits(currentReader, iface, interfaceNamespace, interfaceName, visited)) + return true; + } + + EntityHandle baseHandle = current.BaseType; + if (baseHandle.IsNil) + return false; + + var next = ResolveToTypeDef(currentReader, baseHandle); + if (next == null) + return false; + + currentReader = next.Value.Reader; + current = next.Value.Definition; + } + + return false; + } + + private bool InterfaceMatchesOrInherits(MetadataReader reader, EntityHandle ifaceHandle, string ns, string name, HashSet visited) + { + string ifaceNs, ifaceName; + if (TryGetTypeName(reader, ifaceHandle, out ifaceNs, out ifaceName)) + { + if (ifaceName == name && (ns == null || ifaceNs == ns)) + return true; + + if (!visited.Add(ifaceNs + "." + ifaceName)) + return false; + } + + // Follow interface inheritance (e.g. IMyPlugin : IPlugin). + var resolved = ResolveToTypeDef(reader, ifaceHandle); + if (resolved == null) + return false; + + foreach (var implHandle in resolved.Value.Definition.GetInterfaceImplementations()) + { + EntityHandle parent = resolved.Value.Reader.GetInterfaceImplementation(implHandle).Interface; + if (InterfaceMatchesOrInherits(resolved.Value.Reader, parent, ns, name, visited)) + return true; + } + + return false; + } + + internal CrmRegistrationInfo ReadCrmRegistration(MetadataReader reader, TypeDefinition typeDef) + { + foreach (var caHandle in typeDef.GetCustomAttributes()) + { + var ca = reader.GetCustomAttribute(caHandle); + if (GetAttributeTypeName(reader, ca) != "CrmPluginRegistrationAttribute") + continue; + + try + { + CustomAttributeValue value = ca.DecodeValue(StringTypeProvider.Instance); + + string name = value.FixedArguments.Length > 0 ? value.FixedArguments[0].Value as string : null; + string group = null; + foreach (var named in value.NamedArguments) + { + if (named.Name == "Group" && named.Value is string groupArg) + group = groupArg; + } + + return new CrmRegistrationInfo { Name = name, Group = group }; + } + catch + { + return null; + } + } + + return null; + } + + private (MetadataReader Reader, TypeDefinition Definition)? ResolveToTypeDef(MetadataReader reader, EntityHandle handle) + { + if (handle.Kind == HandleKind.TypeDefinition) + return (reader, reader.GetTypeDefinition((TypeDefinitionHandle)handle)); + + if (handle.Kind == HandleKind.TypeReference) + return ResolveTypeReference(reader, reader.GetTypeReference((TypeReferenceHandle)handle)); + + return null; + } + + private (MetadataReader Reader, TypeDefinition Definition)? ResolveTypeReference(MetadataReader reader, TypeReference tr) + { + string asmName = GetResolutionAssemblyName(reader, tr.ResolutionScope); + if (asmName == null) + return null; + + string ns = reader.GetString(tr.Namespace); + string name = reader.GetString(tr.Name); + + MetadataReader other = OpenReaderForAssembly(asmName); + if (other == null) + return null; + + foreach (var handle in other.TypeDefinitions) + { + var td = other.GetTypeDefinition(handle); + if (other.GetString(td.Name) == name && other.GetString(td.Namespace) == ns) + return (other, td); + } + + return null; + } + + private static string GetResolutionAssemblyName(MetadataReader reader, EntityHandle scope) + { + if (scope.Kind == HandleKind.AssemblyReference) + return reader.GetString(reader.GetAssemblyReference((AssemblyReferenceHandle)scope).Name); + + if (scope.Kind == HandleKind.TypeReference) + { + var parent = reader.GetTypeReference((TypeReferenceHandle)scope); + return GetResolutionAssemblyName(reader, parent.ResolutionScope); + } + + return null; + } + + private static bool TryGetTypeName(MetadataReader reader, EntityHandle handle, out string ns, out string name) + { + if (handle.Kind == HandleKind.TypeDefinition) + { + var td = reader.GetTypeDefinition((TypeDefinitionHandle)handle); + ns = reader.GetString(td.Namespace); + name = reader.GetString(td.Name); + return true; + } + + if (handle.Kind == HandleKind.TypeReference) + { + var tr = reader.GetTypeReference((TypeReferenceHandle)handle); + ns = reader.GetString(tr.Namespace); + name = reader.GetString(tr.Name); + return true; + } + + ns = null; + name = null; + return false; + } + + private static string GetAttributeTypeName(MetadataReader reader, CustomAttribute ca) + { + switch (ca.Constructor.Kind) + { + case HandleKind.MemberReference: + var memberRef = reader.GetMemberReference((MemberReferenceHandle)ca.Constructor); + if (memberRef.Parent.Kind == HandleKind.TypeReference) + return reader.GetString(reader.GetTypeReference((TypeReferenceHandle)memberRef.Parent).Name); + if (memberRef.Parent.Kind == HandleKind.TypeDefinition) + return reader.GetString(reader.GetTypeDefinition((TypeDefinitionHandle)memberRef.Parent).Name); + return ""; + + case HandleKind.MethodDefinition: + var methodDef = reader.GetMethodDefinition((MethodDefinitionHandle)ca.Constructor); + return reader.GetString(reader.GetTypeDefinition(methodDef.GetDeclaringType()).Name); + + default: + return ""; + } + } + + private MetadataReader OpenReaderForAssembly(string assemblyName) + { + MetadataReader cached; + if (_readerByAssembly.TryGetValue(assemblyName, out cached)) + return cached; + + MetadataReader reader = null; + foreach (var dir in _probeDirs) + { + var candidate = Path.Combine(dir, assemblyName + ".dll"); + if (File.Exists(candidate)) + { + reader = OpenReaderForFile(candidate); + if (reader != null) + break; + } + } + + _readerByAssembly[assemblyName] = reader; + return reader; + } + + private MetadataReader OpenReaderForFile(string path) + { + string fullPath = Path.GetFullPath(path); + + MetadataReader cached; + if (_readerByFile.TryGetValue(fullPath, out cached)) + return cached; + + MetadataReader reader = null; + try + { + // Read the whole file into memory, so the dll is never locked on disk. + var pe = new PEReader(ImmutableArray.Create(File.ReadAllBytes(fullPath))); + _disposables.Add(pe); + if (pe.HasMetadata) + reader = pe.GetMetadataReader(); + } + catch + { + reader = null; + } + + _readerByFile[fullPath] = reader; + return reader; + } + + public void Dispose() + { + foreach (var pe in _disposables) + { + try { pe.Dispose(); } + catch { } + } + + _disposables.Clear(); + } + + private sealed class StringTypeProvider : ICustomAttributeTypeProvider + { + public static readonly StringTypeProvider Instance = new StringTypeProvider(); + + public string GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode.ToString(); + + public string GetSystemType() => "System.Type"; + + public string GetSZArrayType(string elementType) => elementType + "[]"; + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + => reader.GetString(reader.GetTypeDefinition(handle).Name); + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + => reader.GetString(reader.GetTypeReference(handle).Name); + + public string GetTypeFromSerializedName(string name) => name; + + public PrimitiveTypeCode GetUnderlyingEnumType(string type) => PrimitiveTypeCode.Int32; + + public bool IsSystemType(string type) => type == "System.Type"; + } +} + +/// A top-level public type discovered by . +internal sealed class ScannedType +{ + private readonly MetadataTypeScanner _owner; + private readonly MetadataReader _reader; + private readonly TypeDefinition _definition; + + internal ScannedType(MetadataTypeScanner owner, MetadataReader reader, TypeDefinition definition) + { + _owner = owner; + _reader = reader; + _definition = definition; + + Namespace = reader.GetString(definition.Namespace); + Name = reader.GetString(definition.Name); + FullName = string.IsNullOrEmpty(Namespace) ? Name : Namespace + "." + Name; + IsAbstract = (definition.Attributes & TypeAttributes.Abstract) != 0; + IsInterface = (definition.Attributes & TypeAttributes.Interface) != 0; + } + + public string Namespace { get; } + public string Name { get; } + public string FullName { get; } + public bool IsAbstract { get; } + public bool IsInterface { get; } + + public bool DerivesFromBaseType(string baseSimpleName) + => _owner.DerivesFromBaseType(_reader, _definition, baseSimpleName); + + public bool ImplementsInterface(string interfaceNamespace, string interfaceName) + => _owner.ImplementsInterface(_reader, _definition, interfaceNamespace, interfaceName); + + public CrmRegistrationInfo TryGetCrmRegistration() + => _owner.ReadCrmRegistration(_reader, _definition); +} + +internal sealed class CrmRegistrationInfo +{ + public string Name { get; set; } + public string Group { get; set; } +} diff --git a/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs b/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs index 0a5dab0..2fadb1e 100644 --- a/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs +++ b/src/Dataverse/Tasks/Tasks/EnsurePluginAssemblyDataXml.cs @@ -1,16 +1,12 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Xml; using System.Xml.Linq; -using System.Collections.Generic; -using System.Threading; -#if NET6_0_OR_GREATER -using System.Runtime.Loader; -#endif public sealed class EnsurePluginAssemblyDataXml : Task { @@ -107,51 +103,62 @@ private void GeneratePluginAssemblyData(PluginProjectInfo info, string normalize if (!File.Exists(info.DllPath)) throw new FileNotFoundException("Build not found", info.DllPath); - string tempDllPath = CopyDllToTempFolder(info); + string publicKeyToken = GetPublicKeyTokenFromPath(info.DllPath); - HashSet probeDirs = BuildProbeDirectories(tempDllPath, info.ProjectDirectory); - ResolveEventHandler handler = CreateAssemblyResolveHandler(probeDirs); + var probeDirs = new List + { + Path.GetDirectoryName(info.DllPath), + Path.Combine(PluginRootPath, "bin", Configuration, TargetFramework), + info.ProjectDirectory + }; - AppDomain.CurrentDomain.AssemblyResolve += handler; + List classList = CollectPluginClasses(info.DllPath, probeDirs); + if (!classList.Any()) + throw new Exception("Plugins not found"); + + EnsureDirectoryForFile(info.XmlPath); + + XmlDocument pluginDoc = CreatePluginAssemblyDocument( + info.AssemblyName, + info.FileVersion, + publicKeyToken, + normalizedGuid, + classList, + info.CsprojFileName, + info.XmlPath, + info.RepositoryRoot + ); - try - { - TryAddSdkAssemblyProbe(probeDirs); - - Assembly pluginAssembly = LoadPluginAssembly(tempDllPath, info.AssemblyName, probeDirs); - string publicKeyToken = GetPublicKeyToken(pluginAssembly); - - List classList = GetPluginClassNames(pluginAssembly); - if (!classList.Any()) - throw new Exception("Plugins not found"); - - string xmlDir = EnsureDirectoryForFile(info.XmlPath); - - XmlDocument pluginDoc = CreatePluginAssemblyDocument( - info.AssemblyName, - info.FileVersion, - publicKeyToken, - normalizedGuid, - classList, - info.CsprojFileName, - info.XmlPath, - info.RepositoryRoot - ); + pluginDoc.Save(info.XmlPath); - pluginDoc.Save(info.XmlPath); + UpsertRootComponentIntoSolutionXml( + info.RepositoryRoot, + normalizedGuid, + info.AssemblyName, + info.FileVersion, + publicKeyToken + ); + } - UpsertRootComponentIntoSolutionXml( - info.RepositoryRoot, - normalizedGuid, - info.AssemblyName, - info.FileVersion, - publicKeyToken - ); - } - finally + private static List CollectPluginClasses(string dllPath, IEnumerable probeDirs) + { + var result = new List(); + + using (var scanner = new MetadataTypeScanner(dllPath, probeDirs)) { - AppDomain.CurrentDomain.AssemblyResolve -= handler; + foreach (var type in scanner.GetPublicTypes()) + { + if (type.IsInterface) + continue; + if (!type.ImplementsInterface("Microsoft.Xrm.Sdk", "IPlugin")) + continue; + + if (!string.IsNullOrWhiteSpace(type.FullName)) + result.Add(type.FullName); + } } + + return result; } private static string FindProjectFile(string pluginRootPath) @@ -244,71 +251,9 @@ private string ResolvePluginDllPath(string assemblyName) return BuildPluginDllPath(assemblyName); } - private HashSet BuildProbeDirectories(string dllPath, string projectDirectory) - { - string dllDir = Path.GetDirectoryName(dllPath); - if (string.IsNullOrEmpty(dllDir)) - throw new Exception("dll directory not resolved"); - - var probeDirs = new HashSet(StringComparer.OrdinalIgnoreCase); - probeDirs.Add(dllDir); - probeDirs.Add(Path.Combine(PluginRootPath, "bin", Configuration, TargetFramework)); - probeDirs.Add(projectDirectory); - - return probeDirs; - } - - private static ResolveEventHandler CreateAssemblyResolveHandler(HashSet probeDirs) + private static string GetPublicKeyTokenFromPath(string dllPath) { - return (sender, args) => - { - string name = null; - try - { - var an = new AssemblyName(args.Name); - name = an.Name; - } - catch { /* ignore */ } - - if (string.IsNullOrWhiteSpace(name)) - return null; - - foreach (var dir in probeDirs) - { - var candidate = Path.Combine(dir, name + ".dll"); - if (File.Exists(candidate)) - { - try - { - // Load from byte array to avoid file locking - var bytes = File.ReadAllBytes(candidate); - return Assembly.Load(bytes); - } - catch { /* ignore */ } - } - } - return null; - }; - } - - private void TryAddSdkAssemblyProbe(HashSet probeDirs) - { - string sdkPath = Path.Combine(PluginRootPath, "bin", Configuration, TargetFramework, "Microsoft.Xrm.Sdk.dll"); - - if (!File.Exists(sdkPath)) - return; - - TryLoadAssemblyNoThrow(sdkPath); - - string sdkDir = Path.GetDirectoryName(sdkPath); - - if (!string.IsNullOrEmpty(sdkDir)) - probeDirs.Add(sdkDir); - } - - private static string GetPublicKeyToken(Assembly pluginAssembly) - { - byte[] token = pluginAssembly.GetName().GetPublicKeyToken(); + byte[] token = AssemblyName.GetAssemblyName(dllPath).GetPublicKeyToken(); if (token == null || token.Length == 0) throw new Exception("Build not signed"); @@ -316,16 +261,6 @@ private static string GetPublicKeyToken(Assembly pluginAssembly) return BitConverter.ToString(token).Replace("-", "").ToLowerInvariant(); } - private static List GetPluginClassNames(Assembly pluginAssembly) - { - return GetPluginTypesSafe(pluginAssembly) - .Where(t => t.IsClass && t.IsPublic) - .Where(t => ImplementsInterfaceByName(t, "Microsoft.Xrm.Sdk.IPlugin")) - .Select(t => t.FullName) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .ToList(); - } - private static string EnsureDirectoryForFile(string filePath) { string dir = Path.GetDirectoryName(filePath); @@ -564,7 +499,6 @@ private static string NormalizeGuidBraces(string s) return s.Trim().Trim('{', '}'); } - private static string BuildRelativeDllPath(string xmlPath, string repoRoot, string assemblyName) { string xmlDir = Path.GetDirectoryName(xmlPath); @@ -597,77 +531,6 @@ private static string BuildAssemblyQualifiedTypeName(string className, string as return className + ", " + BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken); } - private static void TryLoadAssemblyNoThrow(string path) - { - try - { - // Load from byte array to avoid file locking - var bytes = File.ReadAllBytes(path); - Assembly.Load(bytes); - } - catch { /* ignore */ } - } - - private Assembly LoadPluginAssembly(string dllPath, string assemblyName, HashSet probeDirs) - { - var alreadyLoaded = FindLoadedAssembly(assemblyName); - if (alreadyLoaded != null) - return alreadyLoaded; - -#if NET6_0_OR_GREATER - try - { - var alc = new AssemblyLoadContext("PluginAssembly-" + Guid.NewGuid().ToString("N"), isCollectible: true); - alc.Resolving += (context, name) => - { - foreach (var dir in probeDirs) - { - var candidate = Path.Combine(dir, name.Name + ".dll"); - if (File.Exists(candidate)) - return context.LoadFromAssemblyPath(candidate); - } - return null; - }; - - var bytes = File.ReadAllBytes(dllPath); - var asm = alc.LoadFromStream(new MemoryStream(bytes)); - return asm; - } - catch (FileLoadException) - { - var loaded = FindLoadedAssembly(assemblyName); - if (loaded != null) - return loaded; - throw; - } -#else - try - { - // Load from byte array to avoid file locking - var bytes = File.ReadAllBytes(dllPath); - return Assembly.Load(bytes); - } - catch (FileLoadException) - { - var loaded = FindLoadedAssembly(assemblyName); - if (loaded != null) - return loaded; - throw; - } -#endif - } - - private static Assembly FindLoadedAssembly(string assemblyName) - { - return AppDomain.CurrentDomain - .GetAssemblies() - .FirstOrDefault(a => - { - var name = a.GetName(); - return name != null && string.Equals(name.Name, assemblyName, StringComparison.OrdinalIgnoreCase); - }); - } - private static string NormalizeGuid(string guidText) { if (string.IsNullOrWhiteSpace(guidText)) @@ -706,67 +569,6 @@ private static Tuple ReadProjectMetadata(string csprojPath, stri return Tuple.Create(assemblyName, fileVersion); } - private static IEnumerable GetPluginTypesSafe(Assembly asm) - { - try - { - return asm.GetTypes(); - } - catch (ReflectionTypeLoadException rtle) - { - return rtle.Types.Where(t => t != null).Cast(); - } - } - - private static bool ImplementsInterfaceByName(Type t, string interfaceFullName) - { - try - { - return t.GetInterfaces().Any(i => string.Equals(i.FullName, interfaceFullName, StringComparison.Ordinal)); - } - catch - { - return false; - } - } - - private static Dictionary LoadExistingPluginTypeMap(string xmlPath, XmlDocument targetDoc) - { - var result = new Dictionary(StringComparer.Ordinal); - - if (!File.Exists(xmlPath)) - return result; - - var existingDoc = new XmlDocument(); - existingDoc.Load(xmlPath); - - var pluginTypesNode = existingDoc.SelectSingleNode("//PluginAssembly/PluginTypes") as XmlElement; - if (pluginTypesNode == null) - return result; - - foreach (var node in pluginTypesNode.ChildNodes) - { - var el = node as XmlElement; - if (el == null) - continue; - - if (!string.Equals(el.Name, "PluginType", StringComparison.Ordinal)) - continue; - - string className = GetPluginTypeClassName(el); - if (string.IsNullOrWhiteSpace(className)) - continue; - - if (result.ContainsKey(className)) - continue; - - var imported = (XmlElement)targetDoc.ImportNode(el, true); - result[className] = imported; - } - - return result; - } - private static string GetPluginTypeClassName(XmlElement pluginTypeElement) { string aqn = pluginTypeElement.GetAttribute("AssemblyQualifiedName"); @@ -790,34 +592,6 @@ private static XmlElement CreatePluginTypeElement(XmlDocument doc) return doc.CreateElement("PluginType"); } - private string CopyDllToTempFolder(PluginProjectInfo info) - { - string tempDir = Path.Combine( - info.RepositoryRoot, - "obj", - Configuration, - TargetFramework, - "Temp" - ); - - Directory.CreateDirectory(tempDir); - - string tempDllPath = Path.Combine(tempDir, info.AssemblyName + ".dll"); - File.Copy(info.DllPath, tempDllPath, true); - - return tempDllPath; - } - - private static void CopyPluginAssembly(PluginProjectInfo info) - { - string destDir = Path.GetDirectoryName(info.XmlPath); - if (string.IsNullOrEmpty(destDir)) - throw new Exception("PluginAssembly data directory not resolved"); - - string destPath = Path.Combine(destDir, info.AssemblyName + ".dll"); - File.Copy(info.DllPath, destPath, true); - } - private sealed class PluginProjectInfo { public string RepositoryRoot { get; set; } = ""; diff --git a/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs b/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs index 72ca958..236e589 100644 --- a/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs +++ b/src/Dataverse/Tasks/Tasks/EnsureWorkflowActivityAssemblyDataXml.cs @@ -1,16 +1,12 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Xml; using System.Xml.Linq; -using System.Collections.Generic; -using System.Threading; -#if NET6_0_OR_GREATER -using System.Runtime.Loader; -#endif public sealed class EnsureWorkflowActivityAssemblyDataXml : Task { @@ -108,51 +104,83 @@ private void GenerateWorkflowActivityAssemblyData(WorkflowActivityProjectInfo in if (!File.Exists(info.DllPath)) throw new FileNotFoundException("Build not found", info.DllPath); - string tempDllPath = CopyDllToTempFolder(info); + string publicKeyToken = GetPublicKeyTokenFromPath(info.DllPath); - HashSet probeDirs = BuildProbeDirectories(tempDllPath, info.ProjectDirectory); - ResolveEventHandler handler = CreateAssemblyResolveHandler(probeDirs); + var probeDirs = new List + { + Path.GetDirectoryName(info.DllPath), + Path.Combine(WorkflowActivityRootPath, "bin", Configuration, TargetFramework), + info.ProjectDirectory + }; - AppDomain.CurrentDomain.AssemblyResolve += handler; + List classList = CollectWorkflowActivityClasses(info, probeDirs); - try - { - TryAddSdkAssemblyProbe(probeDirs); - - Assembly workflowActivityAssembly = LoadWorkflowActivityAssembly(tempDllPath, info.AssemblyName, probeDirs); - string publicKeyToken = GetPublicKeyToken(workflowActivityAssembly); - - List classList = GetWorkflowActivityClassInfos(workflowActivityAssembly, info.FileVersion); - if (!classList.Any()) - throw new Exception("WorkflowActivities not found in assembly " + info.AssemblyName); - - string xmlDir = EnsureDirectoryForFile(info.XmlPath); - - XmlDocument workflowActivityDoc = CreateWorkflowActivityAssemblyDocument( - info.AssemblyName, - info.FileVersion, - publicKeyToken, - normalizedGuid, - classList, - info.CsprojFileName, - info.XmlPath, - info.RepositoryRoot - ); + if (!classList.Any()) + throw new Exception("WorkflowActivities not found in assembly " + info.AssemblyName); - workflowActivityDoc.Save(info.XmlPath); + EnsureDirectoryForFile(info.XmlPath); - UpsertRootComponentIntoSolutionXml( - info.RepositoryRoot, - normalizedGuid, - info.AssemblyName, - info.FileVersion, - publicKeyToken - ); - } - finally + XmlDocument workflowActivityDoc = CreateWorkflowActivityAssemblyDocument( + info.AssemblyName, + info.FileVersion, + publicKeyToken, + normalizedGuid, + classList, + info.CsprojFileName, + info.XmlPath, + info.RepositoryRoot + ); + + workflowActivityDoc.Save(info.XmlPath); + + UpsertRootComponentIntoSolutionXml( + info.RepositoryRoot, + normalizedGuid, + info.AssemblyName, + info.FileVersion, + publicKeyToken + ); + } + + private List CollectWorkflowActivityClasses(WorkflowActivityProjectInfo info, IEnumerable probeDirs) + { + var result = new List(); + + using (var scanner = new MetadataTypeScanner(info.DllPath, probeDirs)) { - AppDomain.CurrentDomain.AssemblyResolve -= handler; + foreach (var type in scanner.GetPublicTypes()) + { + // A workflow activity is a concrete public class deriving from System.Activities.CodeActivity. + if (type.IsInterface || type.IsAbstract) + continue; + if (!type.DerivesFromBaseType("CodeActivity")) + continue; + + string displayName = type.Name; + string groupBase = !string.IsNullOrWhiteSpace(DefaultWorkflowActivityGroupName) + ? DefaultWorkflowActivityGroupName + : info.AssemblyName; + string groupName = groupBase + " (" + info.FileVersion + ")"; + + var registration = type.TryGetCrmRegistration(); + if (registration != null) + { + if (!string.IsNullOrWhiteSpace(registration.Name)) + displayName = registration.Name; + if (!string.IsNullOrWhiteSpace(registration.Group)) + groupName = registration.Group + " (" + info.FileVersion + ")"; + } + + result.Add(new WorkflowActivityTypeInfo + { + FullName = type.FullName, + DisplayName = displayName, + GroupName = groupName + }); + } } + + return result; } private static string FindProjectFile(string rootPath) @@ -245,122 +273,9 @@ private string ResolveWorkflowActivityDllPath(string assemblyName) return BuildWorkflowActivityDllPath(assemblyName); } - private HashSet BuildProbeDirectories(string dllPath, string projectDirectory) - { - string dllDir = Path.GetDirectoryName(dllPath); - if (string.IsNullOrEmpty(dllDir)) - throw new Exception("dll directory not resolved"); - - var probeDirs = new HashSet(StringComparer.OrdinalIgnoreCase); - probeDirs.Add(dllDir); - probeDirs.Add(Path.Combine(WorkflowActivityRootPath, "bin", Configuration, TargetFramework)); - probeDirs.Add(projectDirectory); - - return probeDirs; - } - - private static ResolveEventHandler CreateAssemblyResolveHandler(HashSet probeDirs) + private static string GetPublicKeyTokenFromPath(string dllPath) { - return (sender, args) => - { - string name = null; - try - { - var an = new AssemblyName(args.Name); - name = an.Name; - } - catch { /* ignore */ } - - if (string.IsNullOrWhiteSpace(name)) - return null; - - foreach (var dir in probeDirs) - { - var candidate = Path.Combine(dir, name + ".dll"); - if (File.Exists(candidate)) - { - try - { - var bytes = File.ReadAllBytes(candidate); - return Assembly.Load(bytes); - } - catch { /* ignore */ } - } - } - return null; - }; - } - - private void TryAddSdkAssemblyProbe(HashSet probeDirs) - { - string sdkPath = Path.Combine(WorkflowActivityRootPath, "bin", Configuration, TargetFramework, "Microsoft.Xrm.Sdk.dll"); - - if (!File.Exists(sdkPath)) - return; - - TryLoadAssemblyNoThrow(sdkPath); - - string sdkDir = Path.GetDirectoryName(sdkPath); - - if (!string.IsNullOrEmpty(sdkDir)) - probeDirs.Add(sdkDir); - - // Add .NET Framework reference assemblies for System.Activities - TryAddFrameworkAssemblyProbe(probeDirs); - } - - private void TryAddFrameworkAssemblyProbe(HashSet probeDirs) - { - // Probe the project's own build output directory for framework assemblies. - // After dotnet build/restore, NuGet reference assemblies (including System.Activities) - // are resolved into the output folder. This works cross-platform without hardcoded paths. - // The workflow activity project's bin folder may contain System.Activities - // from NuGet reference assemblies after restore - var buildOutputDir = Path.Combine(WorkflowActivityRootPath, "bin", Configuration, TargetFramework); - var possiblePaths = new List(); - if (Directory.Exists(buildOutputDir)) - possiblePaths.Add(buildOutputDir); - - foreach (var path in possiblePaths) - { - if (Directory.Exists(path)) - { - probeDirs.Add(path); - // Force load System.Activities before loading the workflow assembly - string systemActivitiesPath = Path.Combine(path, "System.Activities.dll"); - if (File.Exists(systemActivitiesPath)) - { - ForceLoadAssembly(systemActivitiesPath); - break; // Only load from first found location - } - } - } - } - - private void ForceLoadAssembly(string path) - { - try - { - // First check if already loaded - var name = AssemblyName.GetAssemblyName(path); - var existing = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => string.Equals(a.GetName().Name, name.Name, StringComparison.OrdinalIgnoreCase)); - - if (existing != null) - return; - - // Load from GAC/framework path - this ensures proper binding - Assembly.LoadFrom(path); - } - catch (Exception ex) - { - Log.LogMessage(MessageImportance.Low, "Failed to load " + path + ": " + ex.Message); - } - } - - private static string GetPublicKeyToken(Assembly assembly) - { - byte[] token = assembly.GetName().GetPublicKeyToken(); + byte[] token = AssemblyName.GetAssemblyName(dllPath).GetPublicKeyToken(); if (token == null || token.Length == 0) throw new Exception("Build not signed"); @@ -368,114 +283,6 @@ private static string GetPublicKeyToken(Assembly assembly) return BitConverter.ToString(token).Replace("-", "").ToLowerInvariant(); } - private List GetWorkflowActivityClassInfos(Assembly assembly, string fileVersion) - { - var result = new List(); - - foreach (var type in GetTypesSafe(assembly)) - { - if (!type.IsClass || !type.IsPublic || type.IsAbstract) - continue; - - if (!InheritsFromByName(type, "System.Activities.CodeActivity")) - continue; - - string fullName = type.FullName; - if (string.IsNullOrWhiteSpace(fullName)) - continue; - - string groupName = GetWorkflowActivityGroupName(type, fileVersion); - string displayName = GetWorkflowActivityDisplayName(type); - - result.Add(new WorkflowActivityTypeInfo - { - FullName = fullName, - GroupName = groupName, - DisplayName = displayName - }); - } - - return result; - } - - private string GetWorkflowActivityGroupName(Type type, string fileVersion) - { - // Try to get from CrmPluginRegistrationAttribute (Group parameter) - foreach (var attr in type.GetCustomAttributesData()) - { - if (attr.AttributeType.Name == "CrmPluginRegistrationAttribute") - { - // Look for Group named argument - foreach (var namedArg in attr.NamedArguments) - { - if (namedArg.MemberName == "Group" && namedArg.TypedValue.Value is string groupValue) - { - if (!string.IsNullOrWhiteSpace(groupValue)) - return groupValue + " (" + fileVersion + ")"; - } - } - } - } - - // Fallback to DefaultWorkflowActivityGroupName or assembly name - string baseName = !string.IsNullOrWhiteSpace(DefaultWorkflowActivityGroupName) - ? DefaultWorkflowActivityGroupName - : type.Assembly.GetName().Name; - - return baseName + " (" + fileVersion + ")"; - } - - private static string GetWorkflowActivityDisplayName(Type type) - { - // Try to get from CrmPluginRegistrationAttribute (Name parameter) - foreach (var attr in type.GetCustomAttributesData()) - { - if (attr.AttributeType.Name == "CrmPluginRegistrationAttribute") - { - // First constructor argument is usually the Name - if (attr.ConstructorArguments.Count > 0) - { - var nameValue = attr.ConstructorArguments[0].Value as string; - if (!string.IsNullOrWhiteSpace(nameValue)) - return nameValue; - } - } - } - - // Fallback to class name - return type.Name; - } - - private static bool InheritsFromByName(Type t, string baseClassName) - { - try - { - Type current = t.BaseType; - while (current != null) - { - // Check by FullName - if (string.Equals(current.FullName, baseClassName, StringComparison.Ordinal)) - return true; - - // Also check by Name only (in case namespace differs) - string simpleClassName = baseClassName; - int lastDot = baseClassName.LastIndexOf('.'); - if (lastDot >= 0) - simpleClassName = baseClassName.Substring(lastDot + 1); - - if (string.Equals(current.Name, simpleClassName, StringComparison.Ordinal)) - return true; - - current = current.BaseType; - } - } - catch - { - // If we can't inspect the type hierarchy, return false - } - return false; - } - private static string EnsureDirectoryForFile(string filePath) { string dir = Path.GetDirectoryName(filePath); @@ -752,85 +559,6 @@ private static string BuildAssemblyQualifiedTypeName(string className, string as return className + ", " + BuildAssemblyFullName(assemblyName, fileVersion, publicKeyToken); } - private static void TryLoadAssemblyNoThrow(string path) - { - try - { - var bytes = File.ReadAllBytes(path); - Assembly.Load(bytes); - } - catch { /* ignore */ } - } - - private Assembly LoadWorkflowActivityAssembly(string dllPath, string assemblyName, HashSet probeDirs) - { - // Always load fresh copy - don't reuse cached assemblies that may have been loaded - // without proper dependencies (causes intermittent ReflectionTypeLoadException) - -#if NET6_0_OR_GREATER - try - { - var alc = new AssemblyLoadContext("WorkflowActivityAssembly-" + Guid.NewGuid().ToString("N"), isCollectible: true); - alc.Resolving += (context, name) => - { - foreach (var dir in probeDirs) - { - var candidate = Path.Combine(dir, name.Name + ".dll"); - if (File.Exists(candidate)) - return context.LoadFromAssemblyPath(candidate); - } - return null; - }; - - var bytes = File.ReadAllBytes(dllPath); - var asm = alc.LoadFromStream(new MemoryStream(bytes)); - return asm; - } - catch (FileLoadException) - { - // Fallback to already loaded if fresh load fails - var loaded = FindLoadedAssembly(assemblyName); - if (loaded != null) - return loaded; - throw; - } -#else - try - { - // For .NET Framework, use Assembly.LoadFile to load into a separate context - // This avoids reusing cached assemblies that may be incomplete - return Assembly.LoadFile(dllPath); - } - catch (FileLoadException) - { - // Fallback to byte array loading - try - { - var bytes = File.ReadAllBytes(dllPath); - return Assembly.Load(bytes); - } - catch - { - var loaded = FindLoadedAssembly(assemblyName); - if (loaded != null) - return loaded; - throw; - } - } -#endif - } - - private static Assembly FindLoadedAssembly(string assemblyName) - { - return AppDomain.CurrentDomain - .GetAssemblies() - .FirstOrDefault(a => - { - var name = a.GetName(); - return name != null && string.Equals(name.Name, assemblyName, StringComparison.OrdinalIgnoreCase); - }); - } - private static string NormalizeGuid(string guidText) { if (string.IsNullOrWhiteSpace(guidText)) @@ -869,55 +597,6 @@ private static Tuple ReadProjectMetadata(string csprojPath, stri return Tuple.Create(assemblyName, fileVersion); } - private static IEnumerable GetTypesSafe(Assembly asm) - { - try - { - return asm.GetTypes(); - } - catch (ReflectionTypeLoadException rtle) - { - return rtle.Types.Where(t => t != null).Cast(); - } - } - - private static Dictionary LoadExistingPluginTypeMap(string xmlPath, XmlDocument targetDoc) - { - var result = new Dictionary(StringComparer.Ordinal); - - if (!File.Exists(xmlPath)) - return result; - - var existingDoc = new XmlDocument(); - existingDoc.Load(xmlPath); - - var pluginTypesNode = existingDoc.SelectSingleNode("//PluginAssembly/PluginTypes") as XmlElement; - if (pluginTypesNode == null) - return result; - - foreach (var node in pluginTypesNode.ChildNodes) - { - var el = node as XmlElement; - if (el == null) - continue; - - if (!string.Equals(el.Name, "PluginType", StringComparison.Ordinal)) - continue; - - string className = GetPluginTypeClassName(el); - if (string.IsNullOrWhiteSpace(className)) - continue; - - if (result.ContainsKey(className)) - continue; - - var imported = (XmlElement)targetDoc.ImportNode(el, true); - result[className] = imported; - } - - return result; - } - private static string GetPluginTypeClassName(XmlElement pluginTypeElement) { string aqn = pluginTypeElement.GetAttribute("AssemblyQualifiedName"); @@ -936,24 +615,6 @@ private static XmlElement CreatePluginTypeElement(XmlDocument doc) return doc.CreateElement("PluginType"); } - private string CopyDllToTempFolder(WorkflowActivityProjectInfo info) - { - string tempDir = Path.Combine( - info.RepositoryRoot, - "obj", - Configuration, - TargetFramework, - "Temp" - ); - - Directory.CreateDirectory(tempDir); - - string tempDllPath = Path.Combine(tempDir, info.AssemblyName + ".dll"); - File.Copy(info.DllPath, tempDllPath, true); - - return tempDllPath; - } - private sealed class WorkflowActivityProjectInfo { public string RepositoryRoot { get; set; } = "";