From cbb7c9c19a795a7206014af3461bc8f23d6bddb7 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Fri, 30 Oct 2026 16:39:12 +0300 Subject: [PATCH 01/12] [Tests] Update the Revit test runner and TUnit --- Directory.Packages.props | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5d641ef..71811c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,13 +23,13 @@ - - - - - - - + + + + + + + @@ -44,7 +44,7 @@ - + From 1807a7f0d755f2e49dd2c862a44d62bc09ac0b00 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Fri, 30 Oct 2026 16:39:12 +0300 Subject: [PATCH 02/12] [Events] Run an external event created outside a Revit callback as the add-in of its handler --- CHANGELOG.md | 6 + README.md | 11 + .../Applications/ExternalApplication.cs | 4 + .../External/Commands/ExternalCommand.cs | 4 + .../External/Events/AsyncExternalEvent.cs | 6 +- .../External/Events/AsyncExternalEvent{T}.cs | 6 +- .../AsyncRequestExternalEvent{T,TResult}.cs | 6 +- .../AsyncRequestExternalEvent{TResult}.cs | 6 +- .../External/Events/ExternalEvent.cs | 8 +- .../External/Events/ExternalEventHandler.cs | 19 +- .../External/Events/ExternalEvent{T}.cs | 4 +- .../Internal/AddInContext.cs | 229 ++++++++++++++++++ .../ExternalEventHandlerTests.cs | 171 +++++++++++++ .../TestAddIn.cs | 16 ++ 14 files changed, 476 insertions(+), 20 deletions(-) create mode 100644 source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs create mode 100644 tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs create mode 100644 tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0239b86..216f878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2027.0.4 + +### External Events + +- An external event created outside a Revit callback, such as on a background thread, in an event handler of a modeless window, or by the first access to a generated `[ExternalEvent]` property, runs its handler as the add-in that uses it. The handler can write Extensible Storage entities with vendor write access. `ActiveAddInId` returns the add-in inside the handler. Previously, Revit ran such a handler with no add-in and refused vendor-restricted operations. + # 2027.0.3 ### External Events diff --git a/README.md b/README.md index 79241ce..932a32b 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,17 @@ which is particularly useful when working with modeless windows. Events do not need to be created inside the Revit API context before use — the Toolkit handles all the initialization for you, so you can create them anywhere in your code and on any thread. +Each handler runs as the add-in that uses the event. +The add-in decides what the handler may do, such as writing an Extensible Storage entity with vendor write access. +`ActiveAddInId` returns the add-in inside the handler. + +Revit binds an event to the add-in that is active when the event is created. +No add-in is active outside a Revit callback, such as on a background thread or in an event handler of a modeless window. +For an event created there, the Toolkit uses the add-in that was active the first time the assembly of the handler ran an `ExternalApplication` or an `ExternalCommand`, or created an external event inside a Revit callback. + +An add-in that implements `IExternalApplication` directly gives the Toolkit no such callback. +Create one external event in `OnStartup` to bind the events the add-in creates later outside a Revit callback. + Use `Raise()` when the caller does not need to wait, or `RaiseAsync()` when it needs completion or a return value. For generated event properties, see the [ExternalEvent attribute](#externalevent-attribute). diff --git a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs index 4226c1c..0bb7ce9 100644 --- a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs +++ b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using Autodesk.Revit.UI; using Nice3point.Revit.Toolkit.Helpers; +using Nice3point.Revit.Toolkit.Internal; #if NET using System.Runtime.Loader; #endif @@ -46,7 +47,9 @@ public Result OnStartup(UIControlledApplication application) Application = application; var currentType = GetType(); + AddInContext.Capture(currentType.Assembly); #if NET + if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -59,6 +62,7 @@ public Result OnStartup(UIControlledApplication application) OnStartup(); } #else + using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { OnStartup(); diff --git a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs index 7700b29..007f2ce 100644 --- a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs +++ b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using Autodesk.Revit.UI; using Nice3point.Revit.Toolkit.Helpers; +using Nice3point.Revit.Toolkit.Internal; #if NET using System.Runtime.Loader; #endif @@ -129,7 +130,9 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme JournalData = commandData.JournalData; var currentType = GetType(); + AddInContext.Capture(currentType.Assembly); #if NET + if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -142,6 +145,7 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme Execute(); } #else + using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { Execute(); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs index 5ea18a3..d5a52db 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent.cs @@ -28,7 +28,7 @@ public AsyncExternalEvent(Action handler) : this(handler, ExternalEventOptions.N /// The execution logic. /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -56,7 +56,7 @@ public AsyncExternalEvent(Action handler) : this(handler, Externa /// The execution logic that receives the current . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -84,7 +84,7 @@ public AsyncExternalEvent(Action handler) : th /// The execution logic that receives the current and the token passed to . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs index 8aef493..507201b 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncExternalEvent{T}.cs @@ -31,7 +31,7 @@ public AsyncExternalEvent(Action handler) : this(handler, ExternalEventOption /// The execution logic that receives an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -59,7 +59,7 @@ public AsyncExternalEvent(Action handler) : this(handler, Exte /// The execution logic that receives the current and an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -87,7 +87,7 @@ public AsyncExternalEvent(Action handler) : /// The execution logic that receives the current , an argument of type , and the token passed to . /// The options to use to configure the external event. /// Thrown if is . - public AsyncExternalEvent(Action handler, ExternalEventOptions options) + public AsyncExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs index e6c2484..49ebea3 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{T,TResult}.cs @@ -32,7 +32,7 @@ public AsyncRequestExternalEvent(Func handler) : this(handler, Exter /// The execution logic that receives an argument of type and returns a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -55,7 +55,7 @@ public AsyncRequestExternalEvent(Func handler) : this /// The execution logic that receives the current and an argument of type , returning a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -78,7 +78,7 @@ public AsyncRequestExternalEvent(FuncThe execution logic that receives the current , an argument of type , and the token passed to , returning a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs index 356d89c..9c1a7bf 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AsyncRequestExternalEvent{TResult}.cs @@ -29,7 +29,7 @@ public AsyncRequestExternalEvent(Func handler) : this(handler, External /// The execution logic that returns a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -52,7 +52,7 @@ public AsyncRequestExternalEvent(Func handler) : this(ha /// The execution logic that receives the current and returns a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -75,7 +75,7 @@ public AsyncRequestExternalEvent(Func /// The execution logic that receives the current and the token passed to , returning a result of type . /// The options to use to configure the external event. /// Thrown if is . - public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) + public AsyncRequestExternalEvent(Func handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs index a8f82d6..875cc9b 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent.cs @@ -21,7 +21,7 @@ public class ExternalEvent : ExternalEventHandler, IExternalEvent /// /// The execution logic. /// Thrown if is . - public ExternalEvent(Action handler) + public ExternalEvent(Action handler) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -34,7 +34,7 @@ public ExternalEvent(Action handler) /// The execution logic. /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -47,7 +47,7 @@ public ExternalEvent(Action handler, ExternalEventOptions options) /// /// The execution logic that receives the current . /// Thrown if is . - public ExternalEvent(Action handler) + public ExternalEvent(Action handler) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -60,7 +60,7 @@ public ExternalEvent(Action handler) /// The execution logic that receives the current . /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs index a958244..e59c310 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs @@ -1,4 +1,5 @@ using Autodesk.Revit.UI; +using Nice3point.Revit.Toolkit.Internal; // ReSharper disable once CheckNamespace namespace Nice3point.Revit.Toolkit.External; @@ -17,11 +18,25 @@ public abstract class ExternalEventHandler : IExternalEventHandler /// Initializes a new instance of the class /// and creates the underlying Revit external event. /// - protected ExternalEventHandler() + /// + /// The handler runs as the add-in that is active when the event is created. + /// An event created outside a Revit callback, such as on a background thread or in an event handler of a modeless window, runs as the add-in of the assembly that declares the handler type. + /// The add-in of an assembly is the add-in that is active the first time the assembly runs an or an , or creates an external event inside a Revit callback. + /// + protected ExternalEventHandler() : this(handler: null) + { + } + + /// + /// Initializes a new instance of the class + /// and creates the underlying Revit external event for the add-in of the code that declares the handler delegate. + /// + /// The delegate that implements the execution logic. + private protected ExternalEventHandler(Delegate? handler) { using (RevitContext.BeginApiContextScope()) { - _externalEvent = Autodesk.Revit.UI.ExternalEvent.Create(this); + _externalEvent = AddInContext.CreateExternalEvent(this, handler); } } diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs index 68b48c5..823719f 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEvent{T}.cs @@ -31,7 +31,7 @@ public ExternalEvent(Action handler) : this(handler, ExternalEventOptions.Non /// The execution logic that receives an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); @@ -54,7 +54,7 @@ public ExternalEvent(Action handler) : this(handler, ExternalE /// The execution logic that receives the current and an argument of type . /// The options to use to configure the external event. /// Thrown if is . - public ExternalEvent(Action handler, ExternalEventOptions options) + public ExternalEvent(Action handler, ExternalEventOptions options) : base(handler) { ArgumentNullException.ThrowIfNull(handler); diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs new file mode 100644 index 0000000..6c0c3dc --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -0,0 +1,229 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Autodesk.Revit.UI; +#if NET +using System.Runtime.Loader; +#endif + +namespace Nice3point.Revit.Toolkit.Internal; + +/// +/// Binds an external event created outside a Revit callback to the add-in captured for its handler. +/// +/// +/// Revit binds an external event to the add-in on top of the add-in data stack of the creating thread. +/// The stack is thread-local and empty outside a Revit callback, where Revit binds the event to no add-in. +/// A native AddInDataScope pushes a copy of the captured add-in data onto the stack of the current thread for the creation of the event. +/// A captured copy of the add-in data lives as long as the process. +/// The binding turns off when the Revit version exposes no member of the expected signature. +/// +internal static unsafe class AddInContext +{ + private static readonly bool IsSupported; + private static readonly bool IsShared; + private static readonly int AddInDataSize; + private static readonly int AddInDataScopeSize; + private static readonly delegate* GetAddInDataStack; + private static readonly delegate* IsAddInDataStackEmpty; + private static readonly delegate* GetActiveAddInData; + private static readonly delegate* EnterAddInDataScope; + private static readonly delegate* ExitAddInDataScope; + + private static readonly Lock CaptureLock = new(); + private static readonly ConditionalWeakTable SharedAddIns = new(); + private static IntPtr _isolatedAddInData; + + static AddInContext() + { + var uiAssembly = typeof(UIApplication).Assembly; + var foundationReference = uiAssembly.GetReferencedAssemblies().FirstOrDefault(static reference => reference.Name == "RevitAPIFoundation"); + if (foundationReference is null) + { + return; + } + + var uiModule = uiAssembly.ManifestModule; + var foundationModule = Assembly.Load(foundationReference).ManifestModule; + var addInDataType = uiModule.GetType("AddInData"); + var addInDataScopeType = uiModule.GetType("AddInDataScope"); + var addInDataStackType = foundationModule.GetType("ArrStack"); + if (addInDataType is null || addInDataScopeType is null || addInDataStackType is null) + { + return; + } + + var addInDataPointerType = addInDataType.MakePointerType(); + var addInDataScopePointerType = addInDataScopeType.MakePointerType(); + var getAddInDataStack = FindGlobalMethod(foundationModule, "getAddInDataStack"); + var isAddInDataStackEmpty = FindGlobalMethod(foundationModule, "ArrStack.empty", addInDataStackType.MakePointerType()); + var getActiveAddInData = FindGlobalMethod(uiModule, "getActiveAddInData", addInDataPointerType); + var enterAddInDataScope = FindGlobalMethod(uiModule, "AddInDataScope.{ctor}", addInDataScopePointerType, addInDataPointerType); + var exitAddInDataScope = FindGlobalMethod(uiModule, "AddInDataScope.{dtor}", addInDataScopePointerType); + if (getAddInDataStack is null || isAddInDataStackEmpty is null || getActiveAddInData is null || enterAddInDataScope is null || exitAddInDataScope is null) + { + return; + } + + // The function pointer of a global method is the managed entry of its native call stub. + GetAddInDataStack = (delegate*)getAddInDataStack.MethodHandle.GetFunctionPointer(); + IsAddInDataStackEmpty = (delegate*)isAddInDataStackEmpty.MethodHandle.GetFunctionPointer(); + GetActiveAddInData = (delegate*)getActiveAddInData.MethodHandle.GetFunctionPointer(); + EnterAddInDataScope = (delegate*)enterAddInDataScope.MethodHandle.GetFunctionPointer(); + ExitAddInDataScope = (delegate*)exitAddInDataScope.MethodHandle.GetFunctionPointer(); + + // The native sizes differ between Revit versions. + AddInDataSize = addInDataType.StructLayoutAttribute?.Size ?? 0; + AddInDataScopeSize = addInDataScopeType.StructLayoutAttribute?.Size ?? 0; + + // A toolkit in the AssemblyLoadContext of an isolated add-in runs for that add-in alone. +#if NET + IsShared = AssemblyLoadContext.GetLoadContext(typeof(AddInContext).Assembly) == AssemblyLoadContext.Default; +#else + IsShared = true; +#endif + IsSupported = AddInDataSize > 0 && AddInDataScopeSize > 0; + } + + /// + /// Captures the add-in of the current Revit callback as the add-in of the assembly. + /// + /// The assembly whose code the callback runs. + /// + /// The first captured add-in of an assembly stays. + /// The call captures nothing outside a Revit callback. + /// + internal static void Capture(Assembly assembly) + { + if (!IsSupported) + { + return; + } + + CaptureOwner(IsShared ? assembly : null); + } + + /// + /// Creates the Revit external event of the handler, bound to the add-in the handler belongs to. + /// + /// The external event handler. + /// The delegate the handler invokes, or when the handler type implements the execution logic. + /// The Revit external event. + /// + /// Inside a Revit callback, Revit binds the event to the active add-in. + /// The call captures the active add-in for the handler assembly. + /// Outside any callback, the event receives the captured add-in of the assembly of , or of the handler type. + /// + internal static ExternalEvent CreateExternalEvent(IExternalEventHandler handler, Delegate? source) + { + if (!IsSupported) + { + return ExternalEvent.Create(handler); + } + + var owner = FindOwner(handler, source); + if (IsAddInActive()) + { + CaptureOwner(owner); + return ExternalEvent.Create(handler); + } + + if (!TryGetAddInData(owner, out var addInData)) + { + return ExternalEvent.Create(handler); + } + + // The native scope keeps its state in its own memory: the buffer stays in this frame until the destructor runs. + var scope = stackalloc byte[AddInDataScopeSize]; + EnterAddInDataScope(scope, (void*)addInData); + try + { + return ExternalEvent.Create(handler); + } + finally + { + ExitAddInDataScope(scope); + } + } + + private static void CaptureOwner(Assembly? owner) + { + if (TryGetAddInData(owner, out _) || !IsAddInActive()) + { + return; + } + + lock (CaptureLock) + { + if (TryGetAddInData(owner, out _)) + { + return; + } + + var addInData = Marshal.AllocHGlobal(AddInDataSize); + GetActiveAddInData((void*)addInData); + if (owner is null) + { + Volatile.Write(ref _isolatedAddInData, addInData); + } + else + { + SharedAddIns.Add(owner, new CapturedAddIn(addInData)); + } + } + } + + private static bool TryGetAddInData(Assembly? owner, out IntPtr addInData) + { + if (owner is null) + { + addInData = Volatile.Read(ref _isolatedAddInData); + return addInData != IntPtr.Zero; + } + + if (SharedAddIns.TryGetValue(owner, out var capturedAddIn)) + { + addInData = capturedAddIn.AddInData; + return true; + } + + addInData = IntPtr.Zero; + return false; + } + + // Revit registers an external event for an add-in only when the add-in data stack holds one. + // The active add-in data can't tell an empty stack: it carries the null GUID before Revit 2027 and a fixed non-null GUID since. + private static bool IsAddInActive() + { + return !IsAddInDataStackEmpty(GetAddInDataStack()); + } + + // An isolated toolkit keeps one add-in for every assembly. + // A shared toolkit keys the add-in by the assembly of the execution logic. + // The delegate target is the closure, the lambda cache, or the instance of the consumer code. + // The target comes first: Delegate.Method runs a reflection lookup and allocates on every new delegate instance. + private static Assembly? FindOwner(IExternalEventHandler handler, Delegate? source) + { + if (!IsShared) + { + return null; + } + + if (source is null) + { + return handler.GetType().Assembly; + } + + return source.Target?.GetType().Assembly ?? source.Method.Module.Assembly; + } + + private static MethodInfo? FindGlobalMethod(Module module, string name, params Type[] parameterTypes) + { + return module.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static, binder: null, CallingConventions.Any, parameterTypes, modifiers: null); + } + + private sealed class CapturedAddIn(IntPtr addInData) + { + public IntPtr AddInData { get; } = addInData; + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs new file mode 100644 index 0000000..76c8792 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -0,0 +1,171 @@ +using System.Diagnostics.CodeAnalysis; +using System.Windows.Threading; +using Autodesk.Revit.DB.ExtensibleStorage; +using Autodesk.Revit.UI; +using Nice3point.Revit.Toolkit.External; +using Nice3point.TUnit.Revit; +using InvalidOperationException = Autodesk.Revit.Exceptions.InvalidOperationException; + +namespace Nice3point.Revit.Toolkit.Tests; + +[SuppressMessage("ReSharper", "MethodSupportsCancellation")] +public sealed class ExternalEventHandlerTests : RevitApiUiTest +{ + private const string WrittenResult = "Written"; + private static readonly Guid AddInId = new(TestAddIn.Id); + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedInsideAddInCallback_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + var externalEvent = new AsyncRequestExternalEvent(ReadActiveAddInId); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedInsideAddInCallbackAndRaisedFromThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + var externalEvent = new AsyncRequestExternalEvent(ReadActiveAddInId); + + // Act + var handlerAddInId = await Task.Run(externalEvent.RaiseAsync, cancellationToken).WaitAsync(cancellationToken); + + // Assert + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedOnThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var (creationAddInId, externalEvent) = await Task.Run(() => (ReadActiveAddInId(), new AsyncRequestExternalEvent(ReadActiveAddInId)), cancellationToken); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(creationAddInId).IsNotEqualTo(AddInId).Because("the add-in is not active on a thread-pool thread"); + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + } + + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_CreatedInDispatcherCallback_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var dispatcher = Dispatcher.CurrentDispatcher; + var (creationAddInId, externalEvent) = await dispatcher.InvokeAsync(() => (ReadActiveAddInId(), new AsyncRequestExternalEvent(ReadActiveAddInId))).Task.WaitAsync(cancellationToken); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(creationAddInId).IsNotEqualTo(AddInId).Because("the add-in is not active in a dispatcher callback outside a Revit callback"); + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + } + + [Test] + [Timeout(10_000)] + public async Task Raise_HandlerTypeCreatedOnThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var handler = await Task.Run(() => new ActiveAddInHandler(), cancellationToken); + + // Act + handler.Raise(); + var handlerAddInId = await handler.Execution.Task.WaitAsync(cancellationToken); + + // Assert + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + + [Test] + [Timeout(30_000)] + public async Task RaiseAsync_CreatedOnThreadPool_WritesAnEntityOfTheVendorSchema(CancellationToken cancellationToken) + { + // Arrange + CreateExternalEventInsideAddInCallback(); + var externalEvent = await Task.Run(() => new AsyncRequestExternalEvent(WriteVendorEntity), cancellationToken); + + // Act + var result = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(WrittenResult); + } + + // The toolkit captures the add-in of the test assembly from an external event created inside a Revit callback. + private static void CreateExternalEventInsideAddInCallback() + { + _ = new External.ExternalEvent(static () => { }); + } + + private static Guid? ReadActiveAddInId() + { + return RevitApiContext.Application.ActiveAddInId?.GetGUID(); + } + + // Writes an entity of a schema that grants write access to the vendor of the test add-in alone. + // Returns the written result, or the message Revit refuses the write with. + private static string WriteVendorEntity(UIApplication uiApplication) + { + var schemaId = Guid.NewGuid(); + var schemaBuilder = new SchemaBuilder(schemaId); + schemaBuilder.SetSchemaName($"ToolkitTests{schemaId:N}"); + schemaBuilder.SetVendorId(TestAddIn.VendorId); + schemaBuilder.SetReadAccessLevel(AccessLevel.Public); + schemaBuilder.SetWriteAccessLevel(AccessLevel.Vendor); + schemaBuilder.AddSimpleField("Value", typeof(string)); + var schema = schemaBuilder.Finish(); + + var document = uiApplication.Application.NewProjectDocument(UnitSystem.Metric); + try + { + using var transaction = new Transaction(document, "Write entity"); + transaction.Start(); + + var entity = new Entity(schema); + entity.Set("Value", WrittenResult); + document.ProjectInformation.SetEntity(entity); + + transaction.Commit(); + return WrittenResult; + } + catch (InvalidOperationException exception) + { + return exception.Message; + } + finally + { + document.Close(false); + } + } + + private sealed class ActiveAddInHandler : ExternalEventHandler + { + public TaskCompletionSource Execution { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override void Execute(UIApplication uiApplication) + { + Execution.TrySetResult(uiApplication.ActiveAddInId?.GetGUID()); + } + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs b/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs new file mode 100644 index 0000000..24cb5a6 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs @@ -0,0 +1,16 @@ +using Nice3point.Revit.Injector.Attributes; +using Nice3point.Revit.Toolkit.Tests; + +[assembly: RevitClient(Id = TestAddIn.Id, Name = TestAddIn.Name, VendorId = TestAddIn.VendorId)] + +namespace Nice3point.Revit.Toolkit.Tests; + +/// +/// The add-in Revit attributes every API call of the tests to. +/// +internal static class TestAddIn +{ + public const string Id = "4C2E8A61-7F3B-4D95-B0A8-9E1D6C5F2B47"; + public const string Name = "Toolkit Tests"; + public const string VendorId = "Nice3point"; +} From 69ac70968ed38c10d6b2ae555d20dd672cfdbd01 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Fri, 30 Oct 2026 21:41:25 +0300 Subject: [PATCH 03/12] [Events] Revise the add-in binding to the writing and code standards --- CHANGELOG.md | 2 +- README.md | 11 +- .../Applications/ExternalApplication.cs | 3 +- .../External/Commands/ExternalCommand.cs | 3 +- .../External/Events/ExternalEventHandler.cs | 24 ++- .../Internal/AddInContext.cs | 204 ++++++++---------- .../ExternalEventHandlerTests.cs | 27 ++- .../TestAddIn.cs | 2 +- 8 files changed, 137 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 216f878..417c2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### External Events -- An external event created outside a Revit callback, such as on a background thread, in an event handler of a modeless window, or by the first access to a generated `[ExternalEvent]` property, runs its handler as the add-in that uses it. The handler can write Extensible Storage entities with vendor write access. `ActiveAddInId` returns the add-in inside the handler. Previously, Revit ran such a handler with no add-in and refused vendor-restricted operations. +- An external event created outside a Revit callback, such as on a background thread, in an event handler of a modeless window, or by the first access to a generated `[ExternalEvent]` property, runs its handler as the add-in of the assembly whose code the event runs. The handler can write Extensible Storage entities with vendor write access. `UIApplication.ActiveAddInId` returns the ID of that add-in inside the handler. Previously, Revit ran such a handler with no add-in and refused vendor-restricted operations. # 2027.0.3 diff --git a/README.md b/README.md index 932a32b..863e99d 100644 --- a/README.md +++ b/README.md @@ -198,15 +198,16 @@ which is particularly useful when working with modeless windows. Events do not need to be created inside the Revit API context before use — the Toolkit handles all the initialization for you, so you can create them anywhere in your code and on any thread. -Each handler runs as the add-in that uses the event. -The add-in decides what the handler may do, such as writing an Extensible Storage entity with vendor write access. -`ActiveAddInId` returns the add-in inside the handler. +Each handler runs as the add-in of the handler assembly. +The handler assembly is the assembly whose code the event runs: the lambda or method passed to the event, or the class derived from `ExternalEventHandler`. +Revit checks the permissions of that add-in, such as vendor write access to an Extensible Storage schema. +`UIApplication.ActiveAddInId` returns the ID of that add-in inside the handler. Revit binds an event to the add-in that is active when the event is created. No add-in is active outside a Revit callback, such as on a background thread or in an event handler of a modeless window. -For an event created there, the Toolkit uses the add-in that was active the first time the assembly of the handler ran an `ExternalApplication` or an `ExternalCommand`, or created an external event inside a Revit callback. +For an event created outside a Revit callback, the Toolkit uses the add-in that was active the first time the handler assembly ran an `ExternalApplication` or an `ExternalCommand`, or created an external event inside a Revit callback. -An add-in that implements `IExternalApplication` directly gives the Toolkit no such callback. +An add-in that implements `IExternalApplication` directly runs no `ExternalApplication` or `ExternalCommand` of the Toolkit. Create one external event in `OnStartup` to bind the events the add-in creates later outside a Revit callback. Use `Raise()` when the caller does not need to wait, or `RaiseAsync()` when it needs completion or a return value. diff --git a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs index 0bb7ce9..e564af8 100644 --- a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs +++ b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs @@ -48,8 +48,8 @@ public Result OnStartup(UIControlledApplication application) var currentType = GetType(); AddInContext.Capture(currentType.Assembly); -#if NET +#if NET if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -62,7 +62,6 @@ public Result OnStartup(UIControlledApplication application) OnStartup(); } #else - using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { OnStartup(); diff --git a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs index 007f2ce..2847664 100644 --- a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs +++ b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs @@ -131,8 +131,8 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme var currentType = GetType(); AddInContext.Capture(currentType.Assembly); -#if NET +#if NET if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -145,7 +145,6 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme Execute(); } #else - using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { Execute(); diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs index e59c310..34e51a1 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs @@ -5,9 +5,14 @@ namespace Nice3point.Revit.Toolkit.External; /// -/// A base class wrapping the Revit and -/// boilerplate to simplify external event handler implementation. +/// A base class wrapping the Revit and boilerplate to simplify external event handler implementation. /// +/// +/// The handler runs as the add-in that is active when the event is created. +/// An event created outside a Revit callback, such as on a background thread or in an event handler of a modeless window, runs as the add-in of the handler assembly. +/// The handler assembly is the assembly whose code the event runs: the delegate passed to a toolkit event, or the type derived from . +/// The add-in of the handler assembly is the add-in that is active the first time the assembly runs an or an , or creates an external event inside a Revit callback. +/// [PublicAPI] public abstract class ExternalEventHandler : IExternalEventHandler { @@ -15,23 +20,22 @@ public abstract class ExternalEventHandler : IExternalEventHandler private string? _identifier; /// - /// Initializes a new instance of the class - /// and creates the underlying Revit external event. + /// Initializes a new instance of the class. /// /// - /// The handler runs as the add-in that is active when the event is created. - /// An event created outside a Revit callback, such as on a background thread or in an event handler of a modeless window, runs as the add-in of the assembly that declares the handler type. - /// The add-in of an assembly is the add-in that is active the first time the assembly runs an or an , or creates an external event inside a Revit callback. + /// The constructor creates the underlying Revit external event, bound to the add-in of the assembly of the derived type. /// protected ExternalEventHandler() : this(handler: null) { } /// - /// Initializes a new instance of the class - /// and creates the underlying Revit external event for the add-in of the code that declares the handler delegate. + /// Initializes a new instance of the class. /// - /// The delegate that implements the execution logic. + /// The delegate that implements the execution logic, or when the derived type implements it. + /// + /// The constructor creates the underlying Revit external event, bound to the add-in of the handler assembly. + /// private protected ExternalEventHandler(Delegate? handler) { using (RevitContext.BeginApiContextScope()) diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index 6c0c3dc..7012911 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -9,14 +9,14 @@ namespace Nice3point.Revit.Toolkit.Internal; /// -/// Binds an external event created outside a Revit callback to the add-in captured for its handler. +/// Provides the captured add-in of the handler assembly to an external event created outside a Revit callback. /// /// /// Revit binds an external event to the add-in on top of the add-in data stack of the creating thread. /// The stack is thread-local and empty outside a Revit callback, where Revit binds the event to no add-in. /// A native AddInDataScope pushes a copy of the captured add-in data onto the stack of the current thread for the creation of the event. -/// A captured copy of the add-in data lives as long as the process. -/// The binding turns off when the Revit version exposes no member of the expected signature. +/// The context never frees a captured copy of the add-in data. +/// A Revit version without a member of the expected signature creates every event without a scope. /// internal static unsafe class AddInContext { @@ -30,59 +30,47 @@ internal static unsafe class AddInContext private static readonly delegate* EnterAddInDataScope; private static readonly delegate* ExitAddInDataScope; + private static readonly Assembly ToolkitAssembly = typeof(AddInContext).Assembly; private static readonly Lock CaptureLock = new(); - private static readonly ConditionalWeakTable SharedAddIns = new(); - private static IntPtr _isolatedAddInData; + private static readonly ConditionalWeakTable> CapturedAddIns = new(); static AddInContext() { - var uiAssembly = typeof(UIApplication).Assembly; - var foundationReference = uiAssembly.GetReferencedAssemblies().FirstOrDefault(static reference => reference.Name == "RevitAPIFoundation"); - if (foundationReference is null) - { - return; - } - - var uiModule = uiAssembly.ManifestModule; - var foundationModule = Assembly.Load(foundationReference).ManifestModule; - var addInDataType = uiModule.GetType("AddInData"); - var addInDataScopeType = uiModule.GetType("AddInDataScope"); - var addInDataStackType = foundationModule.GetType("ArrStack"); - if (addInDataType is null || addInDataScopeType is null || addInDataStackType is null) - { - return; - } - - var addInDataPointerType = addInDataType.MakePointerType(); - var addInDataScopePointerType = addInDataScopeType.MakePointerType(); - var getAddInDataStack = FindGlobalMethod(foundationModule, "getAddInDataStack"); - var isAddInDataStackEmpty = FindGlobalMethod(foundationModule, "ArrStack.empty", addInDataStackType.MakePointerType()); - var getActiveAddInData = FindGlobalMethod(uiModule, "getActiveAddInData", addInDataPointerType); - var enterAddInDataScope = FindGlobalMethod(uiModule, "AddInDataScope.{ctor}", addInDataScopePointerType, addInDataPointerType); - var exitAddInDataScope = FindGlobalMethod(uiModule, "AddInDataScope.{dtor}", addInDataScopePointerType); - if (getAddInDataStack is null || isAddInDataStackEmpty is null || getActiveAddInData is null || enterAddInDataScope is null || exitAddInDataScope is null) - { - return; - } - - // The function pointer of a global method is the managed entry of its native call stub. - GetAddInDataStack = (delegate*)getAddInDataStack.MethodHandle.GetFunctionPointer(); - IsAddInDataStackEmpty = (delegate*)isAddInDataStackEmpty.MethodHandle.GetFunctionPointer(); - GetActiveAddInData = (delegate*)getActiveAddInData.MethodHandle.GetFunctionPointer(); - EnterAddInDataScope = (delegate*)enterAddInDataScope.MethodHandle.GetFunctionPointer(); - ExitAddInDataScope = (delegate*)exitAddInDataScope.MethodHandle.GetFunctionPointer(); - - // The native sizes differ between Revit versions. - AddInDataSize = addInDataType.StructLayoutAttribute?.Size ?? 0; - AddInDataScopeSize = addInDataScopeType.StructLayoutAttribute?.Size ?? 0; - // A toolkit in the AssemblyLoadContext of an isolated add-in runs for that add-in alone. #if NET - IsShared = AssemblyLoadContext.GetLoadContext(typeof(AddInContext).Assembly) == AssemblyLoadContext.Default; + IsShared = AssemblyLoadContext.GetLoadContext(ToolkitAssembly) == AssemblyLoadContext.Default; #else IsShared = true; #endif - IsSupported = AddInDataSize > 0 && AddInDataScopeSize > 0; + + try + { + var uiAssembly = typeof(UIApplication).Assembly; + var foundationReference = uiAssembly.GetReferencedAssemblies().First(static reference => reference.Name == "RevitAPIFoundation"); + var uiModule = uiAssembly.ManifestModule; + var foundationModule = Assembly.Load(foundationReference).ManifestModule; + + var addInDataType = uiModule.GetType("AddInData", throwOnError: true, ignoreCase: false)!; + var addInDataScopeType = uiModule.GetType("AddInDataScope", throwOnError: true, ignoreCase: false)!; + var addInDataStackType = foundationModule.GetType("ArrStack", throwOnError: true, ignoreCase: false)!; + var addInDataPointerType = addInDataType.MakePointerType(); + var addInDataScopePointerType = addInDataScopeType.MakePointerType(); + + GetAddInDataStack = (delegate*)GetGlobalFunction(foundationModule, "getAddInDataStack"); + IsAddInDataStackEmpty = (delegate*)GetGlobalFunction(foundationModule, "ArrStack.empty", addInDataStackType.MakePointerType()); + GetActiveAddInData = (delegate*)GetGlobalFunction(uiModule, "getActiveAddInData", addInDataPointerType); + EnterAddInDataScope = (delegate*)GetGlobalFunction(uiModule, "AddInDataScope.{ctor}", addInDataScopePointerType, addInDataPointerType); + ExitAddInDataScope = (delegate*)GetGlobalFunction(uiModule, "AddInDataScope.{dtor}", addInDataScopePointerType); + + // The native sizes differ between Revit versions. + AddInDataSize = addInDataType.StructLayoutAttribute?.Size ?? 0; + AddInDataScopeSize = addInDataScopeType.StructLayoutAttribute?.Size ?? 0; + IsSupported = AddInDataSize > 0 && AddInDataScopeSize > 0; + } + catch (Exception) + { + IsSupported = false; + } } /// @@ -90,8 +78,8 @@ static AddInContext() /// /// The assembly whose code the callback runs. /// - /// The first captured add-in of an assembly stays. - /// The call captures nothing outside a Revit callback. + /// A later call leaves the captured add-in of the assembly unchanged. + /// Outside a Revit callback, the call captures nothing. /// internal static void Capture(Assembly assembly) { @@ -100,45 +88,46 @@ internal static void Capture(Assembly assembly) return; } - CaptureOwner(IsShared ? assembly : null); + CaptureAddIn(IsShared ? assembly : ToolkitAssembly); } /// - /// Creates the Revit external event of the handler, bound to the add-in the handler belongs to. + /// Creates the Revit external event of the event handler, bound to the add-in of the handler assembly. /// - /// The external event handler. - /// The delegate the handler invokes, or when the handler type implements the execution logic. + /// The external event handler. + /// The delegate that implements the execution logic, or when the type of implements it. /// The Revit external event. /// + /// The handler assembly is the assembly of , or of the type of . /// Inside a Revit callback, Revit binds the event to the active add-in. /// The call captures the active add-in for the handler assembly. - /// Outside any callback, the event receives the captured add-in of the assembly of , or of the handler type. + /// Outside a Revit callback, the call binds the event to the captured add-in of the handler assembly. /// - internal static ExternalEvent CreateExternalEvent(IExternalEventHandler handler, Delegate? source) + internal static ExternalEvent CreateExternalEvent(IExternalEventHandler eventHandler, Delegate? handler) { if (!IsSupported) { - return ExternalEvent.Create(handler); + return ExternalEvent.Create(eventHandler); } - var owner = FindOwner(handler, source); + var owner = FindOwner(eventHandler, handler); if (IsAddInActive()) { - CaptureOwner(owner); - return ExternalEvent.Create(handler); + CaptureAddIn(owner); + return ExternalEvent.Create(eventHandler); } - if (!TryGetAddInData(owner, out var addInData)) + if (!CapturedAddIns.TryGetValue(owner, out var capturedAddIn)) { - return ExternalEvent.Create(handler); + return ExternalEvent.Create(eventHandler); } - // The native scope keeps its state in its own memory: the buffer stays in this frame until the destructor runs. + // The native scope stores its state in the buffer, and the destructor reads it back. var scope = stackalloc byte[AddInDataScopeSize]; - EnterAddInDataScope(scope, (void*)addInData); + EnterAddInDataScope(scope, (void*)capturedAddIn.Value); try { - return ExternalEvent.Create(handler); + return ExternalEvent.Create(eventHandler); } finally { @@ -146,84 +135,79 @@ internal static ExternalEvent CreateExternalEvent(IExternalEventHandler handler, } } - private static void CaptureOwner(Assembly? owner) + private static void CaptureAddIn(Assembly owner) { - if (TryGetAddInData(owner, out _) || !IsAddInActive()) + if (CapturedAddIns.TryGetValue(owner, out _)) + { + return; + } + + if (!IsAddInActive()) { return; } lock (CaptureLock) { - if (TryGetAddInData(owner, out _)) + if (CapturedAddIns.TryGetValue(owner, out _)) { return; } var addInData = Marshal.AllocHGlobal(AddInDataSize); GetActiveAddInData((void*)addInData); - if (owner is null) - { - Volatile.Write(ref _isolatedAddInData, addInData); - } - else - { - SharedAddIns.Add(owner, new CapturedAddIn(addInData)); - } + CapturedAddIns.Add(owner, new StrongBox(addInData)); } } - private static bool TryGetAddInData(Assembly? owner, out IntPtr addInData) - { - if (owner is null) - { - addInData = Volatile.Read(ref _isolatedAddInData); - return addInData != IntPtr.Zero; - } - - if (SharedAddIns.TryGetValue(owner, out var capturedAddIn)) - { - addInData = capturedAddIn.AddInData; - return true; - } - - addInData = IntPtr.Zero; - return false; - } - - // Revit registers an external event for an add-in only when the add-in data stack holds one. - // The active add-in data can't tell an empty stack: it carries the null GUID before Revit 2027 and a fixed non-null GUID since. + /// + /// Determines whether the add-in data stack of the current thread contains an add-in. + /// + /// if the current thread runs inside a Revit callback of an add-in; otherwise, . + /// + /// Revit registers an external event for an add-in only when the add-in data stack contains one. + /// The active add-in data does not indicate an empty stack: it contains the null GUID before Revit 2027 and a fixed non-null GUID since. + /// private static bool IsAddInActive() { return !IsAddInDataStackEmpty(GetAddInDataStack()); } - // An isolated toolkit keeps one add-in for every assembly. - // A shared toolkit keys the add-in by the assembly of the execution logic. - // The delegate target is the closure, the lambda cache, or the instance of the consumer code. - // The target comes first: Delegate.Method runs a reflection lookup and allocates on every new delegate instance. - private static Assembly? FindOwner(IExternalEventHandler handler, Delegate? source) + /// + /// Finds the assembly the captured add-in of the event handler is stored under. + /// + /// The external event handler. + /// The delegate that implements the execution logic, or when the type of implements it. + /// The handler assembly for a shared toolkit, or the toolkit assembly for an isolated toolkit. + /// + /// An isolated toolkit stores one add-in for every assembly under its own assembly. + /// The delegate target is the closure, the lambda cache, or the instance of the consumer code. + /// The target comes first: runs a reflection lookup and allocates on every new delegate instance. + /// + private static Assembly FindOwner(IExternalEventHandler eventHandler, Delegate? handler) { if (!IsShared) { - return null; + return ToolkitAssembly; } - if (source is null) + if (handler is null) { - return handler.GetType().Assembly; + return eventHandler.GetType().Assembly; } - return source.Target?.GetType().Assembly ?? source.Method.Module.Assembly; + return handler.Target?.GetType().Assembly ?? handler.Method.Module.Assembly; } - private static MethodInfo? FindGlobalMethod(Module module, string name, params Type[] parameterTypes) + private static nint GetGlobalFunction(Module module, string name, params Type[] parameterTypes) { - return module.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static, binder: null, CallingConventions.Any, parameterTypes, modifiers: null); - } + var method = module.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static, binder: null, CallingConventions.Any, parameterTypes, modifiers: null); + if (method is null) + { + throw new MissingMethodException(module.Name, name); + } - private sealed class CapturedAddIn(IntPtr addInData) - { - public IntPtr AddInData { get; } = addInData; + // The function pointer of a global method is the managed entry of its native call stub. + return method.MethodHandle.GetFunctionPointer(); } } diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs index 76c8792..1d2a275 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -8,7 +8,7 @@ namespace Nice3point.Revit.Toolkit.Tests; -[SuppressMessage("ReSharper", "MethodSupportsCancellation")] +[SuppressMessage("ReSharper", "MethodSupportsCancellation", Justification = "The tests raise the event through the overload without a token and bound the wait with WaitAsync.")] public sealed class ExternalEventHandlerTests : RevitApiUiTest { private const string WrittenResult = "Written"; @@ -48,7 +48,7 @@ public async Task RaiseAsync_CreatedOnThreadPool_RunsTheHandlerAsTheAddIn(Cancel { // Arrange CreateExternalEventInsideAddInCallback(); - var (creationAddInId, externalEvent) = await Task.Run(() => (ReadActiveAddInId(), new AsyncRequestExternalEvent(ReadActiveAddInId)), cancellationToken); + var (creationAddInId, externalEvent) = await Task.Run(CreateActiveAddInIdEvent, cancellationToken); // Act var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); @@ -68,7 +68,8 @@ public async Task RaiseAsync_CreatedInDispatcherCallback_RunsTheHandlerAsTheAddI // Arrange CreateExternalEventInsideAddInCallback(); var dispatcher = Dispatcher.CurrentDispatcher; - var (creationAddInId, externalEvent) = await dispatcher.InvokeAsync(() => (ReadActiveAddInId(), new AsyncRequestExternalEvent(ReadActiveAddInId))).Task.WaitAsync(cancellationToken); + var creation = dispatcher.InvokeAsync(CreateActiveAddInIdEvent); + var (creationAddInId, externalEvent) = await creation.Task.WaitAsync(cancellationToken); // Act var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); @@ -87,7 +88,7 @@ public async Task Raise_HandlerTypeCreatedOnThreadPool_RunsTheHandlerAsTheAddIn( { // Arrange CreateExternalEventInsideAddInCallback(); - var handler = await Task.Run(() => new ActiveAddInHandler(), cancellationToken); + var handler = await Task.Run(static () => new ActiveAddInHandler(), cancellationToken); // Act handler.Raise(); @@ -103,7 +104,7 @@ public async Task RaiseAsync_CreatedOnThreadPool_WritesAnEntityOfTheVendorSchema { // Arrange CreateExternalEventInsideAddInCallback(); - var externalEvent = await Task.Run(() => new AsyncRequestExternalEvent(WriteVendorEntity), cancellationToken); + var externalEvent = await Task.Run(static () => new AsyncRequestExternalEvent(WriteVendorEntity), cancellationToken); // Act var result = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); @@ -112,19 +113,29 @@ public async Task RaiseAsync_CreatedOnThreadPool_WritesAnEntityOfTheVendorSchema await Assert.That(result).IsEqualTo(WrittenResult); } - // The toolkit captures the add-in of the test assembly from an external event created inside a Revit callback. + /// + /// Captures the add-in of the test assembly through an external event created inside a Revit callback. + /// private static void CreateExternalEventInsideAddInCallback() { _ = new External.ExternalEvent(static () => { }); } + private static (Guid? CreationAddInId, AsyncRequestExternalEvent ExternalEvent) CreateActiveAddInIdEvent() + { + return (ReadActiveAddInId(), new AsyncRequestExternalEvent(ReadActiveAddInId)); + } + private static Guid? ReadActiveAddInId() { return RevitApiContext.Application.ActiveAddInId?.GetGUID(); } - // Writes an entity of a schema that grants write access to the vendor of the test add-in alone. - // Returns the written result, or the message Revit refuses the write with. + /// + /// Writes an entity of a schema that grants write access to the vendor of the test add-in alone. + /// + /// The current . + /// The written result, or the message Revit refuses the write with. private static string WriteVendorEntity(UIApplication uiApplication) { var schemaId = Guid.NewGuid(); diff --git a/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs b/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs index 24cb5a6..4238044 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs @@ -6,7 +6,7 @@ namespace Nice3point.Revit.Toolkit.Tests; /// -/// The add-in Revit attributes every API call of the tests to. +/// Represents the Revit add-in the test runner registers for the test assembly. /// internal static class TestAddIn { From 5f0432c2ac80bce5c1a41c037387a00140015a33 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Fri, 30 Oct 2026 21:54:58 +0300 Subject: [PATCH 04/12] [Events] Remove the add-in binding section from the readme and rename the test configuration --- README.md | 12 ------------ .../External/Applications/ExternalApplication.cs | 3 ++- .../External/Commands/ExternalCommand.cs | 3 ++- .../Internal/AddInContext.cs | 2 +- .../ExternalEventHandlerTests.cs | 6 +++--- .../Nice3point.Revit.Toolkit.Tests/TestAddIn.cs | 16 ---------------- .../TestsConfiguration.cs | 13 +++++++++++++ 7 files changed, 21 insertions(+), 34 deletions(-) delete mode 100644 tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs create mode 100644 tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs diff --git a/README.md b/README.md index 863e99d..79241ce 100644 --- a/README.md +++ b/README.md @@ -198,18 +198,6 @@ which is particularly useful when working with modeless windows. Events do not need to be created inside the Revit API context before use — the Toolkit handles all the initialization for you, so you can create them anywhere in your code and on any thread. -Each handler runs as the add-in of the handler assembly. -The handler assembly is the assembly whose code the event runs: the lambda or method passed to the event, or the class derived from `ExternalEventHandler`. -Revit checks the permissions of that add-in, such as vendor write access to an Extensible Storage schema. -`UIApplication.ActiveAddInId` returns the ID of that add-in inside the handler. - -Revit binds an event to the add-in that is active when the event is created. -No add-in is active outside a Revit callback, such as on a background thread or in an event handler of a modeless window. -For an event created outside a Revit callback, the Toolkit uses the add-in that was active the first time the handler assembly ran an `ExternalApplication` or an `ExternalCommand`, or created an external event inside a Revit callback. - -An add-in that implements `IExternalApplication` directly runs no `ExternalApplication` or `ExternalCommand` of the Toolkit. -Create one external event in `OnStartup` to bind the events the add-in creates later outside a Revit callback. - Use `Raise()` when the caller does not need to wait, or `RaiseAsync()` when it needs completion or a return value. For generated event properties, see the [ExternalEvent attribute](#externalevent-attribute). diff --git a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs index e564af8..0bb7ce9 100644 --- a/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs +++ b/source/Nice3point.Revit.Toolkit/External/Applications/ExternalApplication.cs @@ -48,8 +48,8 @@ public Result OnStartup(UIControlledApplication application) var currentType = GetType(); AddInContext.Capture(currentType.Assembly); - #if NET + if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -62,6 +62,7 @@ public Result OnStartup(UIControlledApplication application) OnStartup(); } #else + using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { OnStartup(); diff --git a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs index 2847664..007f2ce 100644 --- a/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs +++ b/source/Nice3point.Revit.Toolkit/External/Commands/ExternalCommand.cs @@ -131,8 +131,8 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme var currentType = GetType(); AddInContext.Capture(currentType.Assembly); - #if NET + if (AssemblyLoadContext.GetLoadContext(currentType.Assembly) == AssemblyLoadContext.Default) { using (ResolveHelper.BeginAssemblyResolveScope(currentType)) @@ -145,6 +145,7 @@ public Result Execute(ExternalCommandData commandData, ref string message, Eleme Execute(); } #else + using (ResolveHelper.BeginAssemblyResolveScope(currentType)) { Execute(); diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index 7012911..cf65e6b 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -36,8 +36,8 @@ internal static unsafe class AddInContext static AddInContext() { - // A toolkit in the AssemblyLoadContext of an isolated add-in runs for that add-in alone. #if NET + // Add-ins without dependency isolation share the toolkit loaded into the default AssemblyLoadContext. IsShared = AssemblyLoadContext.GetLoadContext(ToolkitAssembly) == AssemblyLoadContext.Default; #else IsShared = true; diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs index 1d2a275..fd646f1 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -12,7 +12,7 @@ namespace Nice3point.Revit.Toolkit.Tests; public sealed class ExternalEventHandlerTests : RevitApiUiTest { private const string WrittenResult = "Written"; - private static readonly Guid AddInId = new(TestAddIn.Id); + private static readonly Guid AddInId = new(TestsConfiguration.AddInId); [Test] [Timeout(10_000)] @@ -132,7 +132,7 @@ private static (Guid? CreationAddInId, AsyncRequestExternalEvent External } /// - /// Writes an entity of a schema that grants write access to the vendor of the test add-in alone. + /// Writes an entity of a schema with the vendor write access level and the vendor ID of the test add-in. /// /// The current . /// The written result, or the message Revit refuses the write with. @@ -141,7 +141,7 @@ private static string WriteVendorEntity(UIApplication uiApplication) var schemaId = Guid.NewGuid(); var schemaBuilder = new SchemaBuilder(schemaId); schemaBuilder.SetSchemaName($"ToolkitTests{schemaId:N}"); - schemaBuilder.SetVendorId(TestAddIn.VendorId); + schemaBuilder.SetVendorId(TestsConfiguration.VendorId); schemaBuilder.SetReadAccessLevel(AccessLevel.Public); schemaBuilder.SetWriteAccessLevel(AccessLevel.Vendor); schemaBuilder.AddSimpleField("Value", typeof(string)); diff --git a/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs b/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs deleted file mode 100644 index 4238044..0000000 --- a/tests/Nice3point.Revit.Toolkit.Tests/TestAddIn.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Nice3point.Revit.Injector.Attributes; -using Nice3point.Revit.Toolkit.Tests; - -[assembly: RevitClient(Id = TestAddIn.Id, Name = TestAddIn.Name, VendorId = TestAddIn.VendorId)] - -namespace Nice3point.Revit.Toolkit.Tests; - -/// -/// Represents the Revit add-in the test runner registers for the test assembly. -/// -internal static class TestAddIn -{ - public const string Id = "4C2E8A61-7F3B-4D95-B0A8-9E1D6C5F2B47"; - public const string Name = "Toolkit Tests"; - public const string VendorId = "Nice3point"; -} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs b/tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs new file mode 100644 index 0000000..1380390 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/TestsConfiguration.cs @@ -0,0 +1,13 @@ +using Nice3point.Revit.Injector.Attributes; +using Nice3point.Revit.Toolkit.Tests; + +[assembly: RevitClient(Id = TestsConfiguration.AddInId, Name = TestsConfiguration.AddInName, VendorId = TestsConfiguration.VendorId)] + +namespace Nice3point.Revit.Toolkit.Tests; + +internal static class TestsConfiguration +{ + public const string AddInId = "4C2E8A61-7F3B-4D95-B0A8-9E1D6C5F2B47"; + public const string AddInName = "Toolkit Tests"; + public const string VendorId = "Nice3point"; +} From 0c943b822362e56da8744d94862cdfe0a7f19518 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Fri, 30 Oct 2026 22:13:13 +0300 Subject: [PATCH 05/12] [Events] Describe the repeated add-in capture with a mechanical verb --- source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index cf65e6b..4afd2e5 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -78,7 +78,7 @@ static AddInContext() /// /// The assembly whose code the callback runs. /// - /// A later call leaves the captured add-in of the assembly unchanged. + /// A call for an assembly with a captured add-in returns immediately. /// Outside a Revit callback, the call captures nothing. /// internal static void Capture(Assembly assembly) From 0950839f7e7a0d491df3efb06a19f385a77be000 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Sat, 31 Oct 2026 14:01:22 +0300 Subject: [PATCH 06/12] [Events] Run the external events of project references and registered assemblies as the add-in --- CHANGELOG.md | 10 ++ README.md | 10 ++ .../AddInAssemblies/AddInAssemblyWriter.cs | 21 +++ .../AddInAssemblies/AddInProjectReferences.cs | 36 +++++ .../AddInAssemblyGenerator.cs | 40 +++++ .../Events/AddInAssemblyExtensions.cs | 52 +++++++ .../Attributes/AddInAssemblyAttribute.cs | 31 ++++ .../External/Events/ExternalEventHandler.cs | 2 + .../Internal/AddInContext.cs | 117 ++++++++++++++- .../Nice3point.Revit.Toolkit.csproj | 1 + .../build/Nice3point.Revit.Toolkit.targets | 17 +++ .../AddInAssemblyGeneratorTests.cs | 137 ++++++++++++++++++ .../ExternalEventHandlerTests.cs | 33 +++++ 13 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs create mode 100644 source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs create mode 100644 source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs create mode 100644 source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs create mode 100644 source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs create mode 100644 source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets create mode 100644 tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 417c2b5..e4c29e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ### External Events - An external event created outside a Revit callback, such as on a background thread, in an event handler of a modeless window, or by the first access to a generated `[ExternalEvent]` property, runs its handler as the add-in of the assembly whose code the event runs. The handler can write Extensible Storage entities with vendor write access. `UIApplication.ActiveAddInId` returns the ID of that add-in inside the handler. Previously, Revit ran such a handler with no add-in and refused vendor-restricted operations. +- A handler declared in a project reference of the add-in, transitive ones included, runs as the add-in. The build targets of the package list the project references, and the source generator declares them in the assembly of the add-in. +- New `RegisterAddInAssembly` extension methods for `UIControlledApplication` and `UIApplication` register an assembly as part of the active add-in. Call one inside a Revit callback for an assembly loaded at run time, or for the assembly of a raw `IExternalApplication`: + +```c# +public Result OnStartup(UIControlledApplication application) +{ + application.RegisterAddInAssembly(typeof(Application).Assembly); + return Result.Succeeded; +} +``` # 2027.0.3 diff --git a/README.md b/README.md index 79241ce..4574f3a 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,16 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs. `Raise(argument)` returns that result to the caller. The `RaiseAsync()` task fails with an `InvalidOperationException`. +A handler runs as the add-in that declares it, wherever the event is created. +The Toolkit finds the add-in of an `ExternalApplication` or `ExternalCommand` assembly and of its project references. +For an assembly loaded at run time, or for a raw `IExternalApplication`, register the assembly in `OnStartup`: + +```c# +application.RegisterAddInAssembly(typeof(Application).Assembly); +``` + +The preceding code binds the external events of the assembly and of its project references to the add-in. + #### ExternalEvent A synchronous external event that queues the handler via the Revit external event mechanism. diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs new file mode 100644 index 0000000..7ac1c06 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs @@ -0,0 +1,21 @@ +using Microsoft.CodeAnalysis.CSharp; +using Nice3point.Revit.Toolkit.SourceGenerators.CSharp; +using Nice3point.Revit.Toolkit.SourceGenerators.Models; + +namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; + +internal static class AddInAssemblyWriter +{ + public const string HintName = "AddInAssemblies.g.cs"; + + public static string GenerateSource(EquatableArray assemblyNames) + { + var writer = new CodeWriter(); + foreach (var assemblyName in assemblyNames) + { + writer.AppendLine($"[assembly: {AddInProjectReferences.AddInAssemblyAttribute}({SymbolDisplay.FormatLiteral(assemblyName, quote: true)})]"); + } + + return writer.ToString(); + } +} diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs new file mode 100644 index 0000000..bfa7d35 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Diagnostics; +using Nice3point.Revit.Toolkit.SourceGenerators.CSharp; +using Nice3point.Revit.Toolkit.SourceGenerators.Models; + +namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; + +/// +/// Reads the project references that the toolkit MSBuild targets pass to the compiler. +/// +internal static class AddInProjectReferences +{ + private const string PropertyName = "build_property.AddInProjectReferences"; + + public static readonly FullyQualifiedTypeName AddInAssemblyAttribute = "Nice3point.Revit.Toolkit.External.AddInAssemblyAttribute"; + + /// + /// Reads the assembly names of the project references from the global analyzer config options. + /// + /// The global analyzer config options of the compilation. + /// The distinct assembly names in ordinal order, or an empty array when the project has no project references. + public static EquatableArray Read(AnalyzerConfigOptions globalOptions) + { + if (!globalOptions.TryGetValue(PropertyName, out var value)) + { + return ImmutableArray.Empty; + } + + return value.Split(',') + .Select(static assemblyName => assemblyName.Trim()) + .Where(static assemblyName => assemblyName.Length > 0) + .Distinct(StringComparer.Ordinal) + .OrderBy(static assemblyName => assemblyName, StringComparer.Ordinal) + .ToImmutableArray(); + } +} diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs new file mode 100644 index 0000000..bc37bfc --- /dev/null +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs @@ -0,0 +1,40 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; + +namespace Nice3point.Revit.Toolkit.SourceGenerators; + +/// +/// Incremental source generator that declares an [assembly: AddInAssembly] attribute for every project reference of the project. +/// +/// +/// The toolkit MSBuild targets pass the project references, transitive ones included, as the AddInProjectReferences build property. +/// A compilation without the accessible AddInAssemblyAttribute type of the toolkit generates no source. +/// +[Generator(LanguageNames.CSharp)] +public sealed class AddInAssemblyGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var assemblyNames = context.AnalyzerConfigOptionsProvider + .Select(static (optionsProvider, _) => AddInProjectReferences.Read(optionsProvider.GlobalOptions)); + + var attributeAvailability = context.CompilationProvider + .Select(static (compilation, _) => compilation.GetTypeByMetadataName(AddInProjectReferences.AddInAssemblyAttribute.WithoutGlobalPrefix) is { } attributeType && + compilation.IsSymbolAccessibleWithin(attributeType, compilation.Assembly)); + + context.RegisterSourceOutput(assemblyNames.Combine(attributeAvailability), static (sourceProductionContext, generationInput) => + { + var (projectReferences, isAttributeAvailable) = generationInput; + if (!isAttributeAvailable || projectReferences.IsEmpty) + { + return; + } + + var generatedSource = AddInAssemblyWriter.GenerateSource(projectReferences); + sourceProductionContext.AddSource(AddInAssemblyWriter.HintName, SourceText.From(generatedSource, Encoding.UTF8)); + }); + } +} diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs new file mode 100644 index 0000000..a6b8a3d --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using Autodesk.Revit.UI; +using Nice3point.Revit.Toolkit.Internal; + +// ReSharper disable once CheckNamespace +namespace Nice3point.Revit.Toolkit.External; + +/// +/// Provides extension methods for and to register the assemblies of an add-in. +/// +[PublicAPI] +public static class AddInAssemblyExtensions +{ + /// + /// Defines extensions for . + /// + /// The application passed to the add-in at startup. + extension(UIControlledApplication application) + { + /// + /// Registers the assembly as part of the active add-in. + /// + /// The assembly that declares external event handlers of the add-in. + /// is . + /// The method is called outside a Revit callback. + /// + /// An external event whose handler is declared in the assembly runs as the active add-in, wherever the event is created. + /// The registration replaces the add-in the toolkit captured for the assembly earlier. + /// The project references the toolkit lists for the assembly at build time run as the same add-in. + /// A Revit version without the native add-in data members ignores the call. + /// + public void RegisterAddInAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + AddInContext.Register(assembly); + } + } + + /// + /// Defines extensions for . + /// + /// The application passed to a command or an event handler. + extension(UIApplication application) + { + /// + public void RegisterAddInAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + AddInContext.Register(assembly); + } + } +} diff --git a/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs b/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs new file mode 100644 index 0000000..9c78ee7 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; + +// ReSharper disable once CheckNamespace +namespace Nice3point.Revit.Toolkit.External; + +/// +/// Specifies an assembly whose external events run as the add-in of the assembly that declares the attribute. +/// +/// +/// The toolkit source generator declares the attribute for every project reference of a project that references the toolkit package. +/// The assembly is matched by name at its first external event created outside a Revit callback. +/// +[PublicAPI] +[EditorBrowsable(EditorBrowsableState.Never)] +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class AddInAssemblyAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The simple name of the assembly. + public AddInAssemblyAttribute(string assemblyName) + { + AssemblyName = assemblyName; + } + + /// + /// Gets the simple name of the assembly. + /// + public string AssemblyName { get; } +} diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs index 34e51a1..3bc13ac 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs @@ -12,6 +12,8 @@ namespace Nice3point.Revit.Toolkit.External; /// An event created outside a Revit callback, such as on a background thread or in an event handler of a modeless window, runs as the add-in of the handler assembly. /// The handler assembly is the assembly whose code the event runs: the delegate passed to a toolkit event, or the type derived from . /// The add-in of the handler assembly is the add-in that is active the first time the assembly runs an or an , or creates an external event inside a Revit callback. +/// A project reference of that assembly runs as the same add-in. +/// Another assembly, such as one loaded at run time or an assembly of a raw , runs as the add-in that registers for it. /// [PublicAPI] public abstract class ExternalEventHandler : IExternalEventHandler diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index 4afd2e5..5a01f20 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Autodesk.Revit.UI; +using AddInAssemblyAttribute = Nice3point.Revit.Toolkit.External.AddInAssemblyAttribute; #if NET using System.Runtime.Loader; #endif @@ -15,6 +16,7 @@ namespace Nice3point.Revit.Toolkit.Internal; /// Revit binds an external event to the add-in on top of the add-in data stack of the creating thread. /// The stack is thread-local and empty outside a Revit callback, where Revit binds the event to no add-in. /// A native AddInDataScope pushes a copy of the captured add-in data onto the stack of the current thread for the creation of the event. +/// A shared toolkit captures the add-in of an assembly for every assembly name its lists, at the first external event of the named assembly. /// The context never frees a captured copy of the add-in data. /// A Revit version without a member of the expected signature creates every event without a scope. /// @@ -33,6 +35,8 @@ internal static unsafe class AddInContext private static readonly Assembly ToolkitAssembly = typeof(AddInContext).Assembly; private static readonly Lock CaptureLock = new(); private static readonly ConditionalWeakTable> CapturedAddIns = new(); + private static readonly ConditionalWeakTable> UnmatchedAssemblies = new(); + private static readonly Dictionary ListedAddIns = new(StringComparer.Ordinal); static AddInContext() { @@ -91,6 +95,34 @@ internal static void Capture(Assembly assembly) CaptureAddIn(IsShared ? assembly : ToolkitAssembly); } + /// + /// Captures the add-in of the current Revit callback as the add-in of the assembly, and replaces the add-in captured for the assembly earlier. + /// + /// The assembly to capture the add-in for. + /// The current thread runs outside a Revit callback. + /// + /// The add-in replaces the earlier add-in of every assembly name the of the assembly lists. + /// + internal static void Register(Assembly assembly) + { + if (!IsSupported) + { + return; + } + + if (!IsAddInActive()) + { + throw new InvalidOperationException("No add-in is active on the current thread. Call the method inside a Revit callback, such as OnStartup or the Execute method of a command."); + } + + var owner = IsShared ? assembly : ToolkitAssembly; + lock (CaptureLock) + { + CapturedAddIns.Remove(owner); + StoreActiveAddIn(owner, replaceListedAddIns: true); + } + } + /// /// Creates the Revit external event of the event handler, bound to the add-in of the handler assembly. /// @@ -117,14 +149,14 @@ internal static ExternalEvent CreateExternalEvent(IExternalEventHandler eventHan return ExternalEvent.Create(eventHandler); } - if (!CapturedAddIns.TryGetValue(owner, out var capturedAddIn)) + if (!TryGetCapturedAddIn(owner, out var addInData)) { return ExternalEvent.Create(eventHandler); } // The native scope stores its state in the buffer, and the destructor reads it back. var scope = stackalloc byte[AddInDataScopeSize]; - EnterAddInDataScope(scope, (void*)capturedAddIn.Value); + EnterAddInDataScope(scope, (void*)addInData); try { return ExternalEvent.Create(eventHandler); @@ -154,9 +186,86 @@ private static void CaptureAddIn(Assembly owner) return; } - var addInData = Marshal.AllocHGlobal(AddInDataSize); - GetActiveAddInData((void*)addInData); + StoreActiveAddIn(owner, replaceListedAddIns: false); + } + } + + /// + /// Stores a copy of the active add-in data as the add-in of the assembly and of every assembly name its lists. + /// + /// The assembly to store the add-in under. + /// to replace the add-in of an assembly name that another assembly lists; otherwise, . + /// + /// The caller holds , and contains no entry for . + /// An isolated toolkit stores the add-in under its own assembly and reads no assembly names. + /// + private static void StoreActiveAddIn(Assembly owner, bool replaceListedAddIns) + { + var addInData = Marshal.AllocHGlobal(AddInDataSize); + GetActiveAddInData((void*)addInData); + CapturedAddIns.Add(owner, new StrongBox(addInData)); + + if (!IsShared) + { + return; + } + + foreach (var attribute in owner.GetCustomAttributes()) + { + if (replaceListedAddIns || !ListedAddIns.ContainsKey(attribute.AssemblyName)) + { + ListedAddIns[attribute.AssemblyName] = addInData; + } + } + } + + /// + /// Gets the captured add-in data of the assembly, or of the assembly name that a captured assembly lists. + /// + /// The assembly the captured add-in is stored under. + /// When this method returns, contains the pointer to the captured add-in data, if found. + /// if the assembly or its name has a captured add-in; otherwise, . + /// + /// The first miss of an assembly reads its name and stores the add-in of a listed name under the assembly. + /// An unmatched assembly stores the count of listed names, and a later miss reads its name again only after the count grows. + /// + private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) + { + if (CapturedAddIns.TryGetValue(owner, out var capturedAddIn)) + { + addInData = capturedAddIn.Value; + return true; + } + + addInData = 0; + if (!IsShared) + { + return false; + } + + lock (CaptureLock) + { + if (CapturedAddIns.TryGetValue(owner, out capturedAddIn)) + { + addInData = capturedAddIn.Value; + return true; + } + + if (UnmatchedAssemblies.TryGetValue(owner, out var listedNameCount) && listedNameCount.Value == ListedAddIns.Count) + { + return false; + } + + var assemblyName = owner.GetName().Name; + if (assemblyName is null || !ListedAddIns.TryGetValue(assemblyName, out addInData)) + { + UnmatchedAssemblies.Remove(owner); + UnmatchedAssemblies.Add(owner, new StrongBox(ListedAddIns.Count)); + return false; + } + CapturedAddIns.Add(owner, new StrongBox(addInData)); + return true; } } diff --git a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj index 901a560..4614d59 100644 --- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj +++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj @@ -56,6 +56,7 @@ + diff --git a/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets new file mode 100644 index 0000000..ce89c7b --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets @@ -0,0 +1,17 @@ + + + + + + + + + + + @(ReferencePath->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->'%(Filename)', ',') + + + + diff --git a/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs new file mode 100644 index 0000000..10e05fd --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Nice3point.Revit.Toolkit.SourceGenerators.Tests.Helpers; +using TUnit.Assertions.Enums; + +namespace Nice3point.Revit.Toolkit.SourceGenerators.Tests; + +public sealed class AddInAssemblyGeneratorTests +{ + private const string AttributeSource = """ + namespace Nice3point.Revit.Toolkit.External + { + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class AddInAssemblyAttribute : System.Attribute + { + public AddInAssemblyAttribute(string assemblyName) => AssemblyName = assemblyName; + + public string AssemblyName { get; } + } + } + """; + + [Test] + public async Task ProjectReferences_DeclareAnAttributePerAssemblyAsync() + { + var (driver, output) = await RunAsync(AttributeSource, projectReferences: "MyAddIn.UI,MyAddIn.Core"); + + await Assert.That(driver.GetRunResult().GeneratedTrees).Count().IsEqualTo(1); + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core", "MyAddIn.UI"], CollectionOrdering.Matching); + } + + [Test] + [Arguments(" MyAddIn.Core , MyAddIn.Core,,")] + [Arguments("MyAddIn.Core")] + public async Task RepeatedOrPaddedNames_DeclareEachAssemblyOnceAsync(string projectReferences) + { + var (_, output) = await RunAsync(AttributeSource, projectReferences); + + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core"]); + } + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" , ")] + public async Task NoProjectReferences_GeneratesNoSourceAsync(string? projectReferences) + { + var (driver, _) = await RunAsync(AttributeSource, projectReferences); + + await Assert.That(driver.GetRunResult().GeneratedTrees).IsEmpty(); + } + + [Test] + public async Task MissingAttributeType_GeneratesNoSourceAsync() + { + var (driver, _) = await RunAsync("public class Model { }", projectReferences: "MyAddIn.Core"); + + await Assert.That(driver.GetRunResult().GeneratedTrees).IsEmpty(); + } + + [Test] + public async Task UnrelatedSourceChange_ReusesGeneratedSourceAsync() + { + var (driver, output) = await RunAsync(AttributeSource, projectReferences: "MyAddIn.Core"); + var previousSource = driver.GetRunResult().GeneratedTrees.Single().GetText().ToString(); + var unrelatedTree = CSharpSyntaxTree.ParseText("public class Unrelated { }", (CSharpParseOptions)output.SyntaxTrees.First().Options); + + var updated = driver.RunGenerators(output.RemoveSyntaxTrees(driver.GetRunResult().GeneratedTrees).AddSyntaxTrees(unrelatedTree), TestContext.Current!.Execution.CancellationToken); + var outputStep = updated.GetRunResult().Results.Single().TrackedOutputSteps.Single().Value.Single(); + + await Assert.That(updated.GetRunResult().GeneratedTrees.Single().GetText().ToString()).IsEqualTo(previousSource); + await Assert.That(outputStep.Outputs.Single().Reason).IsEqualTo(IncrementalStepRunReason.Cached); + } + + private static async Task<(GeneratorDriver Driver, Compilation Output)> RunAsync([StringSyntax("C#")] string source, string? projectReferences) + { + var cancellationToken = TestContext.Current!.Execution.CancellationToken; + var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp14); + var references = await ReferenceAssemblies.Net.Net80.ResolveAsync(LanguageNames.CSharp, cancellationToken); + var input = CSharpCompilation.Create("Consumer", + [CSharpSyntaxTree.ParseText(source, parseOptions, cancellationToken: cancellationToken)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var globalOptions = projectReferences is null + ? ImmutableDictionary.Empty + : ImmutableDictionary.Empty.Add("build_property.AddInProjectReferences", projectReferences); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AddInAssemblyGenerator().AsSourceGenerator()], + parseOptions: parseOptions, + optionsProvider: new GlobalOptionsProvider(globalOptions), + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + + driver = driver.RunGeneratorsAndUpdateCompilation(input, out var output, out var diagnostics, cancellationToken); + + await Assert.That(diagnostics).IsEmpty(); + await GeneratorTest.AssertCompilesAsync(output); + return (driver, output); + } + + private static IEnumerable ReadListedAssemblyNames(Compilation compilation) + { + return compilation.Assembly.GetAttributes() + .Where(static attribute => attribute.AttributeClass?.Name == "AddInAssemblyAttribute") + .Select(static attribute => (string)attribute.ConstructorArguments[0].Value!); + } + + private sealed class GlobalOptionsProvider(ImmutableDictionary globalOptions) : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new DictionaryOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return DictionaryOptions.Empty; + } + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return DictionaryOptions.Empty; + } + } + + private sealed class DictionaryOptions(ImmutableDictionary options) : AnalyzerConfigOptions + { + public static readonly DictionaryOptions Empty = new(ImmutableDictionary.Empty); + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + return options.TryGetValue(key, out value); + } + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs index fd646f1..bcc84ed 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -113,6 +113,39 @@ public async Task RaiseAsync_CreatedOnThreadPool_WritesAnEntityOfTheVendorSchema await Assert.That(result).IsEqualTo(WrittenResult); } + [Test] + [Timeout(10_000)] + public async Task RaiseAsync_AssemblyRegisteredAndEventCreatedOnThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) + { + // Arrange + UiApplication.RegisterAddInAssembly(typeof(ExternalEventHandlerTests).Assembly); + var (creationAddInId, externalEvent) = await Task.Run(CreateActiveAddInIdEvent, cancellationToken); + + // Act + var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(creationAddInId).IsNotEqualTo(AddInId).Because("the add-in is not active on a thread-pool thread"); + await Assert.That(handlerAddInId).IsEqualTo(AddInId); + } + } + + [Test] + [Timeout(10_000)] + public async Task RegisterAddInAssembly_OnThreadPool_ThrowsInvalidOperation(CancellationToken cancellationToken) + { + // Arrange + var assembly = typeof(ExternalEventHandlerTests).Assembly; + + // Act + var registration = Task.Run(() => UiApplication.RegisterAddInAssembly(assembly), cancellationToken); + + // Assert + await Assert.That(() => registration).Throws(); + } + /// /// Captures the add-in of the test assembly through an external event created inside a Revit callback. /// From 7b11b4c72b6b3c4c03ef981420f4c58edfaa9a14 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Sat, 31 Oct 2026 14:35:34 +0300 Subject: [PATCH 07/12] [Events] Rewrite the add-in binding text for the users of the package --- CHANGELOG.md | 6 ++-- README.md | 8 ++--- .../AddInAssemblies/AddInProjectReferences.cs | 4 +-- .../AddInAssemblyGenerator.cs | 6 ++-- .../Events/AddInAssemblyExtensions.cs | 11 ++++--- .../Attributes/AddInAssemblyAttribute.cs | 5 ++-- .../External/Events/ExternalEventHandler.cs | 9 ++---- .../Internal/AddInContext.cs | 30 +++++++++++-------- .../Nice3point.Revit.Toolkit.csproj | 3 +- .../build/Nice3point.Revit.Toolkit.targets | 4 +-- 10 files changed, 42 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c29e7..27ed88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ### External Events -- An external event created outside a Revit callback, such as on a background thread, in an event handler of a modeless window, or by the first access to a generated `[ExternalEvent]` property, runs its handler as the add-in of the assembly whose code the event runs. The handler can write Extensible Storage entities with vendor write access. `UIApplication.ActiveAddInId` returns the ID of that add-in inside the handler. Previously, Revit ran such a handler with no add-in and refused vendor-restricted operations. -- A handler declared in a project reference of the add-in, transitive ones included, runs as the add-in. The build targets of the package list the project references, and the source generator declares them in the assembly of the add-in. -- New `RegisterAddInAssembly` extension methods for `UIControlledApplication` and `UIApplication` register an assembly as part of the active add-in. Call one inside a Revit callback for an assembly loaded at run time, or for the assembly of a raw `IExternalApplication`: +- External events always run as your add-in, even when created on a background thread or in a modeless window. Inside the handler, `ActiveAddInId` returns your add-in, and Extensible Storage schemas with vendor access accept the changes. Previously, Revit ran such a handler without an add-in. +- `ExternalApplication` and `ExternalCommand` set this up automatically, for the add-in project and every project it references. +- New `RegisterAddInAssembly()` method for the remaining cases: an `IExternalApplication` without the Toolkit base class, an assembly loaded at run time, or a library from a NuGet package. Call it in `OnStartup`: ```c# public Result OnStartup(UIControlledApplication application) diff --git a/README.md b/README.md index 4574f3a..377342b 100644 --- a/README.md +++ b/README.md @@ -210,16 +210,14 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs. `Raise(argument)` returns that result to the caller. The `RaiseAsync()` task fails with an `InvalidOperationException`. -A handler runs as the add-in that declares it, wherever the event is created. -The Toolkit finds the add-in of an `ExternalApplication` or `ExternalCommand` assembly and of its project references. -For an assembly loaded at run time, or for a raw `IExternalApplication`, register the assembly in `OnStartup`: +Each event runs as your add-in, on any thread. +`ExternalApplication` and `ExternalCommand` set this up automatically. +Without them, or for a library from a NuGet package or loaded at run time, register the assembly in `OnStartup`: ```c# application.RegisterAddInAssembly(typeof(Application).Assembly); ``` -The preceding code binds the external events of the assembly and of its project references to the add-in. - #### ExternalEvent A synchronous external event that queues the handler via the Revit external event mechanism. diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs index bfa7d35..70f58b5 100644 --- a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs @@ -6,7 +6,7 @@ namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; /// -/// Reads the project references that the toolkit MSBuild targets pass to the compiler. +/// Reads the project references listed by the package targets. /// internal static class AddInProjectReferences { @@ -18,7 +18,7 @@ internal static class AddInProjectReferences /// Reads the assembly names of the project references from the global analyzer config options. /// /// The global analyzer config options of the compilation. - /// The distinct assembly names in ordinal order, or an empty array when the project has no project references. + /// The distinct assembly names in ordinal order, or an empty array for a project without project references. public static EquatableArray Read(AnalyzerConfigOptions globalOptions) { if (!globalOptions.TryGetValue(PropertyName, out var value)) diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs index bc37bfc..aa0f258 100644 --- a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs @@ -6,11 +6,11 @@ namespace Nice3point.Revit.Toolkit.SourceGenerators; /// -/// Incremental source generator that declares an [assembly: AddInAssembly] attribute for every project reference of the project. +/// Incremental source generator that declares an [assembly: AddInAssembly] attribute for each project reference. /// /// -/// The toolkit MSBuild targets pass the project references, transitive ones included, as the AddInProjectReferences build property. -/// A compilation without the accessible AddInAssemblyAttribute type of the toolkit generates no source. +/// The package targets list the project references in the AddInProjectReferences build property, transitive ones included. +/// A compilation without access to AddInAssemblyAttribute generates no source. /// [Generator(LanguageNames.CSharp)] public sealed class AddInAssemblyGenerator : IIncrementalGenerator diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs index a6b8a3d..31e8032 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs @@ -6,7 +6,7 @@ namespace Nice3point.Revit.Toolkit.External; /// -/// Provides extension methods for and to register the assemblies of an add-in. +/// Provides extension methods for and to register add-in assemblies. /// [PublicAPI] public static class AddInAssemblyExtensions @@ -20,14 +20,13 @@ public static class AddInAssemblyExtensions /// /// Registers the assembly as part of the active add-in. /// - /// The assembly that declares external event handlers of the add-in. + /// The assembly with external event handlers of the add-in. /// is . /// The method is called outside a Revit callback. /// - /// An external event whose handler is declared in the assembly runs as the active add-in, wherever the event is created. - /// The registration replaces the add-in the toolkit captured for the assembly earlier. - /// The project references the toolkit lists for the assembly at build time run as the same add-in. - /// A Revit version without the native add-in data members ignores the call. + /// External events of the assembly run as the active add-in on any thread. + /// Project references of the assembly are registered too. + /// and register their assembly automatically. /// public void RegisterAddInAssembly(Assembly assembly) { diff --git a/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs b/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs index 9c78ee7..cc4b9f3 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs @@ -4,11 +4,10 @@ namespace Nice3point.Revit.Toolkit.External; /// -/// Specifies an assembly whose external events run as the add-in of the assembly that declares the attribute. +/// Specifies an assembly that runs its external events as the add-in of the declaring assembly. /// /// -/// The toolkit source generator declares the attribute for every project reference of a project that references the toolkit package. -/// The assembly is matched by name at its first external event created outside a Revit callback. +/// The Toolkit source generator declares the attribute for each project reference of the add-in. /// [PublicAPI] [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs index 3bc13ac..70cf5f0 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs @@ -8,12 +8,9 @@ namespace Nice3point.Revit.Toolkit.External; /// A base class wrapping the Revit and boilerplate to simplify external event handler implementation. /// /// -/// The handler runs as the add-in that is active when the event is created. -/// An event created outside a Revit callback, such as on a background thread or in an event handler of a modeless window, runs as the add-in of the handler assembly. -/// The handler assembly is the assembly whose code the event runs: the delegate passed to a toolkit event, or the type derived from . -/// The add-in of the handler assembly is the add-in that is active the first time the assembly runs an or an , or creates an external event inside a Revit callback. -/// A project reference of that assembly runs as the same add-in. -/// Another assembly, such as one loaded at run time or an assembly of a raw , runs as the add-in that registers for it. +/// The handler runs as the add-in that declares it, on any thread the event is created on. +/// and register their assembly with its project references automatically. +/// Any other assembly requires . /// [PublicAPI] public abstract class ExternalEventHandler : IExternalEventHandler diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index 5a01f20..35081bb 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -16,7 +16,8 @@ namespace Nice3point.Revit.Toolkit.Internal; /// Revit binds an external event to the add-in on top of the add-in data stack of the creating thread. /// The stack is thread-local and empty outside a Revit callback, where Revit binds the event to no add-in. /// A native AddInDataScope pushes a copy of the captured add-in data onto the stack of the current thread for the creation of the event. -/// A shared toolkit captures the add-in of an assembly for every assembly name its lists, at the first external event of the named assembly. +/// A shared toolkit binds the assemblies listed by to the add-in of the declaring assembly. +/// A listed assembly is matched by name at its first external event. /// The context never frees a captured copy of the add-in data. /// A Revit version without a member of the expected signature creates every event without a scope. /// @@ -80,7 +81,7 @@ static AddInContext() /// /// Captures the add-in of the current Revit callback as the add-in of the assembly. /// - /// The assembly whose code the callback runs. + /// The assembly that declares the callback. /// /// A call for an assembly with a captured add-in returns immediately. /// Outside a Revit callback, the call captures nothing. @@ -96,12 +97,12 @@ internal static void Capture(Assembly assembly) } /// - /// Captures the add-in of the current Revit callback as the add-in of the assembly, and replaces the add-in captured for the assembly earlier. + /// Captures the add-in of the current Revit callback for the assembly, replacing an earlier capture. /// /// The assembly to capture the add-in for. /// The current thread runs outside a Revit callback. /// - /// The add-in replaces the earlier add-in of every assembly name the of the assembly lists. + /// The assemblies listed by receive the same add-in. /// internal static void Register(Assembly assembly) { @@ -112,7 +113,7 @@ internal static void Register(Assembly assembly) if (!IsAddInActive()) { - throw new InvalidOperationException("No add-in is active on the current thread. Call the method inside a Revit callback, such as OnStartup or the Execute method of a command."); + throw new InvalidOperationException("No add-in is active on the current thread. Call the method in OnStartup or in the Execute method of a command."); } var owner = IsShared ? assembly : ToolkitAssembly; @@ -191,13 +192,15 @@ private static void CaptureAddIn(Assembly owner) } /// - /// Stores a copy of the active add-in data as the add-in of the assembly and of every assembly name its lists. + /// Stores a copy of the active add-in data for the assembly. /// /// The assembly to store the add-in under. - /// to replace the add-in of an assembly name that another assembly lists; otherwise, . + /// to overwrite a name listed by another assembly; otherwise, . /// - /// The caller holds , and contains no entry for . - /// An isolated toolkit stores the add-in under its own assembly and reads no assembly names. + /// The assemblies listed by receive the same copy. + /// An isolated toolkit reads no attributes. + /// The method runs under . + /// has no entry for . /// private static void StoreActiveAddIn(Assembly owner, bool replaceListedAddIns) { @@ -220,14 +223,15 @@ private static void StoreActiveAddIn(Assembly owner, bool replaceListedAddIns) } /// - /// Gets the captured add-in data of the assembly, or of the assembly name that a captured assembly lists. + /// Gets the captured add-in data of the assembly. /// /// The assembly the captured add-in is stored under. /// When this method returns, contains the pointer to the captured add-in data, if found. - /// if the assembly or its name has a captured add-in; otherwise, . + /// if the assembly has a captured add-in; otherwise, . /// - /// The first miss of an assembly reads its name and stores the add-in of a listed name under the assembly. - /// An unmatched assembly stores the count of listed names, and a later miss reads its name again only after the count grows. + /// On the first miss, the method looks up the assembly name among the listed names. + /// A match becomes the capture of the assembly. + /// A miss is cached until another assembly lists new names. /// private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) { diff --git a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj index 4614d59..d287c59 100644 --- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj +++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj @@ -56,7 +56,8 @@ - + + diff --git a/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets index ce89c7b..a973ddc 100644 --- a/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets +++ b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets @@ -1,11 +1,11 @@ + - - + From 9accbc9b53045b262e83179f3a2bcb59a777d9f3 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Sat, 31 Oct 2026 14:43:22 +0300 Subject: [PATCH 08/12] [Docs] Name the effect of the add-in binding in the readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 377342b..26fb291 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,8 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs. `Raise(argument)` returns that result to the caller. The `RaiseAsync()` task fails with an `InvalidOperationException`. -Each event runs as your add-in, on any thread. +Revit treats your add-in as the active add-in inside the event handler, even when the event is created on a background thread or in a modeless window. +`ActiveAddInId` returns your add-in, and Extensible Storage schemas with vendor access accept the changes. `ExternalApplication` and `ExternalCommand` set this up automatically. Without them, or for a library from a NuGet package or loaded at run time, register the assembly in `OnStartup`: From b0109962d7460610bd3fda0f5bd7f6dc4acd0c73 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Sat, 31 Oct 2026 14:53:29 +0300 Subject: [PATCH 09/12] [Docs] Describe the add-in binding through the active add-in ID --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26fb291..a0c1f3c 100644 --- a/README.md +++ b/README.md @@ -210,8 +210,8 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs. `Raise(argument)` returns that result to the caller. The `RaiseAsync()` task fails with an `InvalidOperationException`. -Revit treats your add-in as the active add-in inside the event handler, even when the event is created on a background thread or in a modeless window. -`ActiveAddInId` returns your add-in, and Extensible Storage schemas with vendor access accept the changes. +Inside the event handler, `UIApplication.ActiveAddInId` returns the ID of your add-in, even when the event is created on a background thread or in a modeless window. +Extensible Storage schemas with vendor write access accept the changes of the handler. `ExternalApplication` and `ExternalCommand` set this up automatically. Without them, or for a library from a NuGet package or loaded at run time, register the assembly in `OnStartup`: From 3da6841dc327239763b06c3fa7b8979432037133 Mon Sep 17 00:00:00 2001 From: Nice3point Date: Sat, 31 Oct 2026 14:53:29 +0300 Subject: [PATCH 10/12] [Events] Match listed assemblies case-insensitively within the default load context --- .../AddInAssemblies/AddInProjectReferences.cs | 2 +- .../Internal/AddInContext.cs | 63 ++++++---- .../Internal/ListedAddInAssemblies.cs | 62 +++++++++ .../AddInAssemblyGeneratorTests.cs | 3 +- .../ExternalEventHandlerTests.cs | 6 + .../ListedAddInAssembliesTests.cs | 118 ++++++++++++++++++ 6 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs create mode 100644 tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs index 70f58b5..b393384 100644 --- a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs @@ -29,7 +29,7 @@ public static EquatableArray Read(AnalyzerConfigOptions globalOptions) return value.Split(',') .Select(static assemblyName => assemblyName.Trim()) .Where(static assemblyName => assemblyName.Length > 0) - .Distinct(StringComparer.Ordinal) + .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static assemblyName => assemblyName, StringComparer.Ordinal) .ToImmutableArray(); } diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index 35081bb..b017daa 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -36,8 +36,7 @@ internal static unsafe class AddInContext private static readonly Assembly ToolkitAssembly = typeof(AddInContext).Assembly; private static readonly Lock CaptureLock = new(); private static readonly ConditionalWeakTable> CapturedAddIns = new(); - private static readonly ConditionalWeakTable> UnmatchedAssemblies = new(); - private static readonly Dictionary ListedAddIns = new(StringComparer.Ordinal); + private static readonly ListedAddInAssemblies ListedAssemblies = new(); static AddInContext() { @@ -208,20 +207,51 @@ private static void StoreActiveAddIn(Assembly owner, bool replaceListedAddIns) GetActiveAddInData((void*)addInData); CapturedAddIns.Add(owner, new StrongBox(addInData)); - if (!IsShared) + if (IsShared && IsInDefaultContext(owner)) { - return; + ListedAssemblies.Add(ReadListedAssemblyNames(owner), addInData, replaceListedAddIns); } + } - foreach (var attribute in owner.GetCustomAttributes()) + /// + /// Reads the assembly names listed by the of the assembly. + /// + /// The assembly to read the attributes of. + /// The listed assembly names, or an empty array when the attributes can't be read. + /// + /// An attribute type from a missing assembly fails the read. + /// A failed read lists no names: the capture never fails the startup of an add-in. + /// + private static string[] ReadListedAssemblyNames(Assembly assembly) + { + try { - if (replaceListedAddIns || !ListedAddIns.ContainsKey(attribute.AssemblyName)) - { - ListedAddIns[attribute.AssemblyName] = addInData; - } + return assembly.GetCustomAttributes().Select(static attribute => attribute.AssemblyName).ToArray(); + } + catch (Exception) + { + return []; } } + /// + /// Determines whether the assembly is loaded into the default AssemblyLoadContext. + /// + /// The assembly to check. + /// if the assembly is loaded into the default context; otherwise, . + /// + /// A shared toolkit matches names for the default context only. + /// An isolated add-in can load its own assembly with a listed name. + /// + private static bool IsInDefaultContext(Assembly assembly) + { +#if NET + return AssemblyLoadContext.GetLoadContext(assembly) == AssemblyLoadContext.Default; +#else + return true; +#endif + } + /// /// Gets the captured add-in data of the assembly. /// @@ -229,9 +259,8 @@ private static void StoreActiveAddIn(Assembly owner, bool replaceListedAddIns) /// When this method returns, contains the pointer to the captured add-in data, if found. /// if the assembly has a captured add-in; otherwise, . /// - /// On the first miss, the method looks up the assembly name among the listed names. + /// On a miss, the method looks up the assembly name among the listed names. /// A match becomes the capture of the assembly. - /// A miss is cached until another assembly lists new names. /// private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) { @@ -242,7 +271,7 @@ private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) } addInData = 0; - if (!IsShared) + if (!IsShared || !IsInDefaultContext(owner)) { return false; } @@ -255,16 +284,8 @@ private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) return true; } - if (UnmatchedAssemblies.TryGetValue(owner, out var listedNameCount) && listedNameCount.Value == ListedAddIns.Count) - { - return false; - } - - var assemblyName = owner.GetName().Name; - if (assemblyName is null || !ListedAddIns.TryGetValue(assemblyName, out addInData)) + if (!ListedAssemblies.TryMatch(owner, out addInData)) { - UnmatchedAssemblies.Remove(owner); - UnmatchedAssemblies.Add(owner, new StrongBox(ListedAddIns.Count)); return false; } diff --git a/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs new file mode 100644 index 0000000..124becf --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Nice3point.Revit.Toolkit.Internal; + +/// +/// Maps the assembly names listed by to the add-in of the listing assembly. +/// +/// +/// Assembly names are compared case-insensitively, as the runtime compares simple names. +/// The type is not thread-safe. +/// +internal sealed class ListedAddInAssemblies +{ + private readonly Dictionary _addIns = new(StringComparer.OrdinalIgnoreCase); + private readonly ConditionalWeakTable> _unmatchedAssemblies = new(); + + /// + /// Lists the assembly names under the add-in. + /// + /// The simple names of the assemblies. + /// The pointer to the add-in data. + /// to overwrite a name listed earlier; otherwise, . + public void Add(IEnumerable assemblyNames, nint addInData, bool replace) + { + foreach (var assemblyName in assemblyNames) + { + if (replace || !_addIns.ContainsKey(assemblyName)) + { + _addIns[assemblyName] = addInData; + } + } + } + + /// + /// Finds the add-in listed for the name of the assembly. + /// + /// The assembly to match. + /// When this method returns, contains the pointer to the add-in data, if found. + /// if the name of the assembly is listed; otherwise, . + /// + /// A miss is cached until a call to lists a new name. + /// + public bool TryMatch(Assembly assembly, out nint addInData) + { + addInData = 0; + if (_unmatchedAssemblies.TryGetValue(assembly, out var listedNameCount) && listedNameCount.Value == _addIns.Count) + { + return false; + } + + var assemblyName = assembly.GetName().Name; + if (assemblyName is not null && _addIns.TryGetValue(assemblyName, out addInData)) + { + return true; + } + + _unmatchedAssemblies.Remove(assembly); + _unmatchedAssemblies.Add(assembly, new StrongBox(_addIns.Count)); + return false; + } +} diff --git a/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs index 10e05fd..204e60f 100644 --- a/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs +++ b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs @@ -35,8 +35,9 @@ public async Task ProjectReferences_DeclareAnAttributePerAssemblyAsync() [Test] [Arguments(" MyAddIn.Core , MyAddIn.Core,,")] + [Arguments("MyAddIn.Core,myaddin.core")] [Arguments("MyAddIn.Core")] - public async Task RepeatedOrPaddedNames_DeclareEachAssemblyOnceAsync(string projectReferences) + public async Task RepeatedPaddedOrCaseVariantNames_DeclareEachAssemblyOnceAsync(string projectReferences) { var (_, output) = await RunAsync(AttributeSource, projectReferences); diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs index bcc84ed..8b5c695 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -146,6 +146,12 @@ public async Task RegisterAddInAssembly_OnThreadPool_ThrowsInvalidOperation(Canc await Assert.That(() => registration).Throws(); } + [Test] + public async Task RegisterAddInAssembly_NullAssembly_ThrowsArgumentNull() + { + await Assert.That(() => UiApplication.RegisterAddInAssembly(null!)).Throws(); + } + /// /// Captures the add-in of the test assembly through an external event created inside a Revit callback. /// diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs new file mode 100644 index 0000000..d9fce30 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using Nice3point.Revit.Toolkit.Internal; +using Nice3point.TUnit.Revit; + +namespace Nice3point.Revit.Toolkit.Tests; + +public sealed class ListedAddInAssembliesTests : RevitApiUiTest +{ + private const nint FirstAddIn = 1; + private const nint SecondAddIn = 2; + private static readonly Assembly ListedAssembly = typeof(ListedAddInAssembliesTests).Assembly; + private static readonly string ListedName = ListedAssembly.GetName().Name!; + + [Test] + public async Task TryMatch_ListedName_ReturnsTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName], FirstAddIn, replace: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatched).IsTrue(); + await Assert.That(addInData).IsEqualTo(FirstAddIn); + } + } + + [Test] + public async Task TryMatch_ListedNameInAnotherCase_ReturnsTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName.ToUpperInvariant()], FirstAddIn, replace: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatched).IsTrue().Because("the runtime compares simple assembly names case-insensitively"); + await Assert.That(addInData).IsEqualTo(FirstAddIn); + } + } + + [Test] + public async Task TryMatch_UnlistedName_ReturnsFalse() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replace: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatched).IsFalse(); + await Assert.That(addInData).IsEqualTo(0); + } + } + + [Test] + public async Task TryMatch_NameListedAfterAMiss_ReturnsTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replace: false); + var isMatchedBeforeListing = listedAssemblies.TryMatch(ListedAssembly, out _); + listedAssemblies.Add([ListedName], SecondAddIn, replace: false); + + // Act + var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); + + // Assert + using (Assert.Multiple()) + { + await Assert.That(isMatchedBeforeListing).IsFalse(); + await Assert.That(isMatched).IsTrue().Because("a new listed name invalidates the cached miss"); + await Assert.That(addInData).IsEqualTo(SecondAddIn); + } + } + + [Test] + public async Task Add_NameListedEarlierWithoutReplace_KeepsTheFirstAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName], FirstAddIn, replace: false); + + // Act + listedAssemblies.Add([ListedName], SecondAddIn, replace: false); + + // Assert + listedAssemblies.TryMatch(ListedAssembly, out var addInData); + await Assert.That(addInData).IsEqualTo(FirstAddIn); + } + + [Test] + public async Task Add_NameListedEarlierWithReplace_ReplacesTheAddIn() + { + // Arrange + var listedAssemblies = new ListedAddInAssemblies(); + listedAssemblies.Add([ListedName], FirstAddIn, replace: false); + + // Act + listedAssemblies.Add([ListedName], SecondAddIn, replace: true); + + // Assert + listedAssemblies.TryMatch(ListedAssembly, out var addInData); + await Assert.That(addInData).IsEqualTo(SecondAddIn); + } +} From faee8da6f5936c3b9b4575984d75a61021f8105d Mon Sep 17 00:00:00 2001 From: Nice3point Date: Sat, 31 Oct 2026 15:10:20 +0300 Subject: [PATCH 11/12] [Events] Declare the add-in assembly attribute in the consumer assembly and publish captures atomically --- AGENTS.md | 4 +- .../AddInAssemblies/AddInAssemblyWriter.cs | 45 +++++- .../AddInAssemblies/AddInProjectReferences.cs | 5 +- .../AddInAssemblyGenerator.cs | 13 +- .../Events/AddInAssemblyExtensions.cs | 2 + .../Attributes/AddInAssemblyAttribute.cs | 30 ---- .../External/Events/ExternalEventHandler.cs | 3 +- .../Internal/AddInContext.cs | 129 +++++++++++------- .../Internal/ListedAddInAssemblies.cs | 8 +- .../build/Nice3point.Revit.Toolkit.targets | 8 +- .../AddInAssemblyGeneratorTests.cs | 58 ++++---- .../ExternalEventHandlerTests.cs | 21 +-- .../ListedAddInAssembliesTests.cs | 18 +-- 13 files changed, 185 insertions(+), 159 deletions(-) delete mode 100644 source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs diff --git a/AGENTS.md b/AGENTS.md index 3ea03d1..c5fabe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ The same package bundles Roslyn tooling: an incremental source generator that em * Never break the public surface. Deprecate a renamed member with `[Obsolete]` with a JetBrains `[CodeTemplate]` auto-conversion; the obsolete member forwards to the replacement and never throws or changes behavior. * Mark a member Revit invokes but consumers must not call `[EditorBrowsable(EditorBrowsableState.Never)]`. * The Roslyn tooling is public surface. It targets `netstandard2.0`, dual-targets Roslyn (the default with the `.Roslyn414` twins), uses stable `RVTTK####` diagnostic ids, and needs an `AnalyzerReleases.Unshipped.md` entry per new or changed rule. Never rename a generated member or renumber a shipped id. +* The `AddInAssemblyGenerator` runs in every project with project references. Its output compiles with C# 7.3 and declares its own attribute type. The runtime matches `Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute` by full name: never rename it. +* The MSBuild targets in `source/Nice3point.Revit.Toolkit/build` are public surface. Their targets, items, and properties take the `_RevitToolkit` prefix. * Every type compiles under every supported configuration. * A change ships with a test covering the toolkit's custom logic: Revit behavior runs on the Revit thread, and the analyzers, fixers, and generator run against the Roslyn testing harness. * Confirm an unfamiliar Revit or .NET API before use through official docs or `gh` (`gh api`, `gh search code`). @@ -20,7 +22,7 @@ The same package bundles Roslyn tooling: an incremental source generator that em ## Repository map -* `source/Nice3point.Revit.Toolkit` it packs the runtime library and the tooling into one NuGet package for users. +* `source/Nice3point.Revit.Toolkit` it packs the runtime library and the tooling into one NuGet package for users. Its `build/` folder contains the MSBuild targets NuGet imports into a consumer project. * `source/Nice3point.Revit.Toolkit.Analyzers`, `Nice3point.Revit.Toolkit.Analyzers.CodeFixers`, and `Nice3point.Revit.Toolkit.SourceGenerators` hold the analyzers, code fixers, and source generator, each with a `.Roslyn414` twin for the older Roslyn. * `tests/` — `Nice3point.Revit.Toolkit.Tests` runs inside a Revit process; the analyzer, fixer, and generator test projects run against the compiler. * `build/` — the ModularPipelines build. diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs index 7ac1c06..ea20b6e 100644 --- a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs @@ -1,21 +1,58 @@ +using System.Text; using Microsoft.CodeAnalysis.CSharp; -using Nice3point.Revit.Toolkit.SourceGenerators.CSharp; using Nice3point.Revit.Toolkit.SourceGenerators.Models; namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; +/// +/// Writes the assembly attributes and the internal attribute type for the listed project references. +/// +/// +/// Each assembly declares its own attribute type. +/// The attributes resolve whichever toolkit version Revit loads into the shared context. +/// The source compiles with C# 7.3, the default language version of a .NET Framework project. +/// internal static class AddInAssemblyWriter { public const string HintName = "AddInAssemblies.g.cs"; + private const string GeneratorName = "Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblyGenerator"; + private const string AttributeNamespace = "Nice3point.Revit.Toolkit.Generated"; + private const string AttributeName = "AddInAssemblyAttribute"; + + private static readonly string AssemblyVersion = typeof(AddInAssemblyGenerator).Assembly.GetName().Version?.ToString() ?? "1.0.0"; + public static string GenerateSource(EquatableArray assemblyNames) { - var writer = new CodeWriter(); + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#pragma warning disable"); + builder.AppendLine(); + foreach (var assemblyName in assemblyNames) { - writer.AppendLine($"[assembly: {AddInProjectReferences.AddInAssemblyAttribute}({SymbolDisplay.FormatLiteral(assemblyName, quote: true)})]"); + builder.AppendLine($"[assembly: global::{AttributeNamespace}.{AttributeName}({SymbolDisplay.FormatLiteral(assemblyName, quote: true)})]"); } - return writer.ToString(); + builder.AppendLine($$""" + + namespace {{AttributeNamespace}} + { + [global::System.CodeDom.Compiler.GeneratedCode("{{GeneratorName}}", "{{AssemblyVersion}}")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, AllowMultiple = true)] + internal sealed class {{AttributeName}} : global::System.Attribute + { + public {{AttributeName}}(string assemblyName) + { + AssemblyName = assemblyName; + } + + public string AssemblyName { get; } + } + } + """); + + return builder.ToString(); } } diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs index b393384..9ae3e7f 100644 --- a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis.Diagnostics; -using Nice3point.Revit.Toolkit.SourceGenerators.CSharp; using Nice3point.Revit.Toolkit.SourceGenerators.Models; namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; @@ -10,9 +9,7 @@ namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies; /// internal static class AddInProjectReferences { - private const string PropertyName = "build_property.AddInProjectReferences"; - - public static readonly FullyQualifiedTypeName AddInAssemblyAttribute = "Nice3point.Revit.Toolkit.External.AddInAssemblyAttribute"; + private const string PropertyName = "build_property._RevitToolkitAddInProjectReferences"; /// /// Reads the assembly names of the project references from the global analyzer config options. diff --git a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs index aa0f258..9ee9bc1 100644 --- a/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs +++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs @@ -9,8 +9,8 @@ namespace Nice3point.Revit.Toolkit.SourceGenerators; /// Incremental source generator that declares an [assembly: AddInAssembly] attribute for each project reference. /// /// -/// The package targets list the project references in the AddInProjectReferences build property, transitive ones included. -/// A compilation without access to AddInAssemblyAttribute generates no source. +/// The package targets list the project references in a build property, transitive ones included. +/// A project without project references generates no source. /// [Generator(LanguageNames.CSharp)] public sealed class AddInAssemblyGenerator : IIncrementalGenerator @@ -21,14 +21,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var assemblyNames = context.AnalyzerConfigOptionsProvider .Select(static (optionsProvider, _) => AddInProjectReferences.Read(optionsProvider.GlobalOptions)); - var attributeAvailability = context.CompilationProvider - .Select(static (compilation, _) => compilation.GetTypeByMetadataName(AddInProjectReferences.AddInAssemblyAttribute.WithoutGlobalPrefix) is { } attributeType && - compilation.IsSymbolAccessibleWithin(attributeType, compilation.Assembly)); - - context.RegisterSourceOutput(assemblyNames.Combine(attributeAvailability), static (sourceProductionContext, generationInput) => + context.RegisterSourceOutput(assemblyNames, static (sourceProductionContext, projectReferences) => { - var (projectReferences, isAttributeAvailable) = generationInput; - if (!isAttributeAvailable || projectReferences.IsEmpty) + if (projectReferences.IsEmpty) { return; } diff --git a/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs index 31e8032..b8c909b 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs @@ -27,6 +27,8 @@ public static class AddInAssemblyExtensions /// External events of the assembly run as the active add-in on any thread. /// Project references of the assembly are registered too. /// and register their assembly automatically. + /// An external event created before the registration runs as the add-in Revit bound it to at creation. + /// On a Revit version without the expected native API, the method does nothing. /// public void RegisterAddInAssembly(Assembly assembly) { diff --git a/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs b/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs deleted file mode 100644 index cc4b9f3..0000000 --- a/source/Nice3point.Revit.Toolkit/External/Events/Attributes/AddInAssemblyAttribute.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel; - -// ReSharper disable once CheckNamespace -namespace Nice3point.Revit.Toolkit.External; - -/// -/// Specifies an assembly that runs its external events as the add-in of the declaring assembly. -/// -/// -/// The Toolkit source generator declares the attribute for each project reference of the add-in. -/// -[PublicAPI] -[EditorBrowsable(EditorBrowsableState.Never)] -[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] -public sealed class AddInAssemblyAttribute : Attribute -{ - /// - /// Initializes a new instance of the class. - /// - /// The simple name of the assembly. - public AddInAssemblyAttribute(string assemblyName) - { - AssemblyName = assemblyName; - } - - /// - /// Gets the simple name of the assembly. - /// - public string AssemblyName { get; } -} diff --git a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs index 70cf5f0..3a00185 100644 --- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs +++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs @@ -10,7 +10,8 @@ namespace Nice3point.Revit.Toolkit.External; /// /// The handler runs as the add-in that declares it, on any thread the event is created on. /// and register their assembly with its project references automatically. -/// Any other assembly requires . +/// An assembly that creates an external event inside a Revit callback registers itself. +/// Other assemblies, for example a library from a NuGet package, are registered with . /// [PublicAPI] public abstract class ExternalEventHandler : IExternalEventHandler diff --git a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs index b017daa..0b58e21 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs @@ -2,7 +2,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Autodesk.Revit.UI; -using AddInAssemblyAttribute = Nice3point.Revit.Toolkit.External.AddInAssemblyAttribute; #if NET using System.Runtime.Loader; #endif @@ -16,9 +15,9 @@ namespace Nice3point.Revit.Toolkit.Internal; /// Revit binds an external event to the add-in on top of the add-in data stack of the creating thread. /// The stack is thread-local and empty outside a Revit callback, where Revit binds the event to no add-in. /// A native AddInDataScope pushes a copy of the captured add-in data onto the stack of the current thread for the creation of the event. -/// A shared toolkit binds the assemblies listed by to the add-in of the declaring assembly. +/// A shared toolkit binds the assemblies listed by the generated AddInAssemblyAttribute to the add-in of the declaring assembly. /// A listed assembly is matched by name at its first external event. -/// The context never frees a captured copy of the add-in data. +/// The context never frees a published copy of the add-in data. /// A Revit version without a member of the expected signature creates every event without a scope. /// internal static unsafe class AddInContext @@ -33,10 +32,13 @@ internal static unsafe class AddInContext private static readonly delegate* EnterAddInDataScope; private static readonly delegate* ExitAddInDataScope; + private const string AddInAssemblyAttributeName = "Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute"; + private static readonly Assembly ToolkitAssembly = typeof(AddInContext).Assembly; - private static readonly Lock CaptureLock = new(); private static readonly ConditionalWeakTable> CapturedAddIns = new(); - private static readonly ListedAddInAssemblies ListedAssemblies = new(); + private static readonly ConditionalWeakTable>.CreateValueCallback CreateEmptyCapture = static _ => new StrongBox(); + private static readonly Lock ListedAddInAssembliesLock = new(); + private static readonly ListedAddInAssemblies ListedAddInAssemblies = new(); static AddInContext() { @@ -101,7 +103,7 @@ internal static void Capture(Assembly assembly) /// The assembly to capture the add-in for. /// The current thread runs outside a Revit callback. /// - /// The assemblies listed by receive the same add-in. + /// The assemblies listed by the generated AddInAssemblyAttribute receive the same add-in. /// internal static void Register(Assembly assembly) { @@ -116,11 +118,13 @@ internal static void Register(Assembly assembly) } var owner = IsShared ? assembly : ToolkitAssembly; - lock (CaptureLock) - { - CapturedAddIns.Remove(owner); - StoreActiveAddIn(owner, replaceListedAddIns: true); - } + var addInData = CopyActiveAddInData(); + + // A concurrent event can read the replaced copy, and the context never frees it. + var capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture); + Volatile.Write(ref capturedAddIn.Value, addInData); + + ListAddInAssemblies(owner, addInData, replaceListedNames: true); } /// @@ -167,9 +171,17 @@ internal static ExternalEvent CreateExternalEvent(IExternalEventHandler eventHan } } + /// + /// Captures the active add-in for the assembly without a captured add-in. + /// + /// The assembly to store the add-in under. + /// + /// The first copy published by concurrent captures wins. + /// The method frees a copy that loses. + /// private static void CaptureAddIn(Assembly owner) { - if (CapturedAddIns.TryGetValue(owner, out _)) + if (CapturedAddIns.TryGetValue(owner, out var capturedAddIn) && Volatile.Read(ref capturedAddIn.Value) != IntPtr.Zero) { return; } @@ -179,46 +191,63 @@ private static void CaptureAddIn(Assembly owner) return; } - lock (CaptureLock) + capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture); + var addInData = CopyActiveAddInData(); + if (Interlocked.CompareExchange(ref capturedAddIn.Value, addInData, IntPtr.Zero) != IntPtr.Zero) { - if (CapturedAddIns.TryGetValue(owner, out _)) - { - return; - } - - StoreActiveAddIn(owner, replaceListedAddIns: false); + Marshal.FreeHGlobal(addInData); + return; } + + ListAddInAssemblies(owner, addInData, replaceListedNames: false); } /// - /// Stores a copy of the active add-in data for the assembly. + /// Copies the active add-in data into a new native buffer. /// - /// The assembly to store the add-in under. - /// to overwrite a name listed by another assembly; otherwise, . - /// - /// The assemblies listed by receive the same copy. - /// An isolated toolkit reads no attributes. - /// The method runs under . - /// has no entry for . - /// - private static void StoreActiveAddIn(Assembly owner, bool replaceListedAddIns) + /// The pointer to the copy. + private static nint CopyActiveAddInData() { var addInData = Marshal.AllocHGlobal(AddInDataSize); GetActiveAddInData((void*)addInData); - CapturedAddIns.Add(owner, new StrongBox(addInData)); + return addInData; + } - if (IsShared && IsInDefaultContext(owner)) + /// + /// Lists the assemblies named by the generated AddInAssemblyAttribute of the assembly under its add-in. + /// + /// The assembly that declares the attributes. + /// The pointer to the add-in data of the assembly. + /// to overwrite a name listed by another assembly; otherwise, . + /// + /// An isolated toolkit lists no names. + /// + private static void ListAddInAssemblies(Assembly owner, nint addInData, bool replaceListedNames) + { + if (!IsShared || !IsInDefaultContext(owner)) + { + return; + } + + var assemblyNames = ReadListedAssemblyNames(owner); + if (assemblyNames.Length == 0) + { + return; + } + + lock (ListedAddInAssembliesLock) { - ListedAssemblies.Add(ReadListedAssemblyNames(owner), addInData, replaceListedAddIns); + ListedAddInAssemblies.Add(assemblyNames, addInData, replaceListedNames); } } /// - /// Reads the assembly names listed by the of the assembly. + /// Reads the assembly names listed by the generated AddInAssemblyAttribute of the assembly. /// /// The assembly to read the attributes of. /// The listed assembly names, or an empty array when the attributes can't be read. /// + /// The source generator declares the attribute type in each assembly, and the method matches it by full name. /// An attribute type from a missing assembly fails the read. /// A failed read lists no names: the capture never fails the startup of an add-in. /// @@ -226,7 +255,11 @@ private static string[] ReadListedAssemblyNames(Assembly assembly) { try { - return assembly.GetCustomAttributes().Select(static attribute => attribute.AssemblyName).ToArray(); + return assembly.GetCustomAttributesData() + .Where(static attribute => attribute.AttributeType.FullName == AddInAssemblyAttributeName) + .Select(static attribute => attribute.ConstructorArguments[0].Value) + .OfType() + .ToArray(); } catch (Exception) { @@ -260,14 +293,17 @@ private static bool IsInDefaultContext(Assembly assembly) /// if the assembly has a captured add-in; otherwise, . /// /// On a miss, the method looks up the assembly name among the listed names. - /// A match becomes the capture of the assembly. + /// A match becomes the capture of the assembly, unless a concurrent capture publishes first. /// private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) { if (CapturedAddIns.TryGetValue(owner, out var capturedAddIn)) { - addInData = capturedAddIn.Value; - return true; + addInData = Volatile.Read(ref capturedAddIn.Value); + if (addInData != 0) + { + return true; + } } addInData = 0; @@ -276,22 +312,19 @@ private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData) return false; } - lock (CaptureLock) + nint listedAddInData; + lock (ListedAddInAssembliesLock) { - if (CapturedAddIns.TryGetValue(owner, out capturedAddIn)) - { - addInData = capturedAddIn.Value; - return true; - } - - if (!ListedAssemblies.TryMatch(owner, out addInData)) + if (!ListedAddInAssemblies.TryMatch(owner, out listedAddInData)) { return false; } - - CapturedAddIns.Add(owner, new StrongBox(addInData)); - return true; } + + capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture); + Interlocked.CompareExchange(ref capturedAddIn.Value, listedAddInData, IntPtr.Zero); + addInData = Volatile.Read(ref capturedAddIn.Value); + return true; } /// diff --git a/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs index 124becf..eebed1d 100644 --- a/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs +++ b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs @@ -4,7 +4,7 @@ namespace Nice3point.Revit.Toolkit.Internal; /// -/// Maps the assembly names listed by to the add-in of the listing assembly. +/// Maps the assembly names listed by the generated AddInAssemblyAttribute to the add-in of the listing assembly. /// /// /// Assembly names are compared case-insensitively, as the runtime compares simple names. @@ -20,12 +20,12 @@ internal sealed class ListedAddInAssemblies /// /// The simple names of the assemblies. /// The pointer to the add-in data. - /// to overwrite a name listed earlier; otherwise, . - public void Add(IEnumerable assemblyNames, nint addInData, bool replace) + /// to overwrite a name listed earlier; otherwise, . + public void Add(IEnumerable assemblyNames, nint addInData, bool replaceListedNames) { foreach (var assemblyName in assemblyNames) { - if (replace || !_addIns.ContainsKey(assemblyName)) + if (replaceListedNames || !_addIns.ContainsKey(assemblyName)) { _addIns[assemblyName] = addInData; } diff --git a/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets index a973ddc..c4fdc99 100644 --- a/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets +++ b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets @@ -2,15 +2,15 @@ - + - - + - @(ReferencePath->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->'%(Filename)', ',') + <_RevitToolkitAddInProjectReferences>@(ReferencePath->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->'%(Filename)', ',') diff --git a/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs index 204e60f..95478e2 100644 --- a/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs +++ b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs @@ -11,23 +11,13 @@ namespace Nice3point.Revit.Toolkit.SourceGenerators.Tests; public sealed class AddInAssemblyGeneratorTests { - private const string AttributeSource = """ - namespace Nice3point.Revit.Toolkit.External - { - [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] - public sealed class AddInAssemblyAttribute : System.Attribute - { - public AddInAssemblyAttribute(string assemblyName) => AssemblyName = assemblyName; - - public string AssemblyName { get; } - } - } - """; + private const string Source = "public class Model { }"; + private const string AttributeName = "Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute"; [Test] public async Task ProjectReferences_DeclareAnAttributePerAssemblyAsync() { - var (driver, output) = await RunAsync(AttributeSource, projectReferences: "MyAddIn.UI,MyAddIn.Core"); + var (driver, output) = await RunAsync(Source, projectReferences: "MyAddIn.UI,MyAddIn.Core"); await Assert.That(driver.GetRunResult().GeneratedTrees).Count().IsEqualTo(1); await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core", "MyAddIn.UI"], CollectionOrdering.Matching); @@ -39,7 +29,7 @@ public async Task ProjectReferences_DeclareAnAttributePerAssemblyAsync() [Arguments("MyAddIn.Core")] public async Task RepeatedPaddedOrCaseVariantNames_DeclareEachAssemblyOnceAsync(string projectReferences) { - var (_, output) = await RunAsync(AttributeSource, projectReferences); + var (_, output) = await RunAsync(Source, projectReferences); await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core"]); } @@ -50,23 +40,36 @@ public async Task RepeatedPaddedOrCaseVariantNames_DeclareEachAssemblyOnceAsync( [Arguments(" , ")] public async Task NoProjectReferences_GeneratesNoSourceAsync(string? projectReferences) { - var (driver, _) = await RunAsync(AttributeSource, projectReferences); + var (driver, _) = await RunAsync(Source, projectReferences); await Assert.That(driver.GetRunResult().GeneratedTrees).IsEmpty(); } [Test] - public async Task MissingAttributeType_GeneratesNoSourceAsync() + [Arguments(LanguageVersion.CSharp7_3)] + [Arguments(LanguageVersion.CSharp14)] + public async Task LanguageVersion_GeneratesCompilableSourceAsync(LanguageVersion languageVersion) { - var (driver, _) = await RunAsync("public class Model { }", projectReferences: "MyAddIn.Core"); + var (_, output) = await RunAsync(Source, projectReferences: "MyAddIn.Core", languageVersion: languageVersion); - await Assert.That(driver.GetRunResult().GeneratedTrees).IsEmpty(); + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Core"]).Because("a .NET Framework project compiles with C# 7.3 by default"); + } + + [Test] + public async Task ReferenceWithVisibleInternals_DeclaresTheAttributeWithoutConflictsAsync() + { + const string librarySource = """[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Consumer")]"""; + var (_, library) = await RunAsync(librarySource, projectReferences: "MyAddIn.Core", assemblyName: "MyAddIn.Services"); + + var (_, output) = await RunAsync(Source, projectReferences: "MyAddIn.Services", additionalReference: library.ToMetadataReference()); + + await Assert.That(ReadListedAssemblyNames(output)).IsEquivalentTo(["MyAddIn.Services"]); } [Test] public async Task UnrelatedSourceChange_ReusesGeneratedSourceAsync() { - var (driver, output) = await RunAsync(AttributeSource, projectReferences: "MyAddIn.Core"); + var (driver, output) = await RunAsync(Source, projectReferences: "MyAddIn.Core"); var previousSource = driver.GetRunResult().GeneratedTrees.Single().GetText().ToString(); var unrelatedTree = CSharpSyntaxTree.ParseText("public class Unrelated { }", (CSharpParseOptions)output.SyntaxTrees.First().Options); @@ -77,19 +80,24 @@ public async Task UnrelatedSourceChange_ReusesGeneratedSourceAsync() await Assert.That(outputStep.Outputs.Single().Reason).IsEqualTo(IncrementalStepRunReason.Cached); } - private static async Task<(GeneratorDriver Driver, Compilation Output)> RunAsync([StringSyntax("C#")] string source, string? projectReferences) + private static async Task<(GeneratorDriver Driver, Compilation Output)> RunAsync( + [StringSyntax("C#")] string source, + string? projectReferences, + string assemblyName = "Consumer", + LanguageVersion languageVersion = LanguageVersion.CSharp14, + MetadataReference? additionalReference = null) { var cancellationToken = TestContext.Current!.Execution.CancellationToken; - var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp14); + var parseOptions = new CSharpParseOptions(languageVersion); var references = await ReferenceAssemblies.Net.Net80.ResolveAsync(LanguageNames.CSharp, cancellationToken); - var input = CSharpCompilation.Create("Consumer", + var input = CSharpCompilation.Create(assemblyName, [CSharpSyntaxTree.ParseText(source, parseOptions, cancellationToken: cancellationToken)], - references, + additionalReference is null ? references : references.Add(additionalReference), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var globalOptions = projectReferences is null ? ImmutableDictionary.Empty - : ImmutableDictionary.Empty.Add("build_property.AddInProjectReferences", projectReferences); + : ImmutableDictionary.Empty.Add("build_property._RevitToolkitAddInProjectReferences", projectReferences); GeneratorDriver driver = CSharpGeneratorDriver.Create( [new AddInAssemblyGenerator().AsSourceGenerator()], @@ -107,7 +115,7 @@ [new AddInAssemblyGenerator().AsSourceGenerator()], private static IEnumerable ReadListedAssemblyNames(Compilation compilation) { return compilation.Assembly.GetAttributes() - .Where(static attribute => attribute.AttributeClass?.Name == "AddInAssemblyAttribute") + .Where(static attribute => attribute.AttributeClass?.ToDisplayString() == AttributeName) .Select(static attribute => (string)attribute.ConstructorArguments[0].Value!); } diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs index 8b5c695..1d64524 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs @@ -113,25 +113,6 @@ public async Task RaiseAsync_CreatedOnThreadPool_WritesAnEntityOfTheVendorSchema await Assert.That(result).IsEqualTo(WrittenResult); } - [Test] - [Timeout(10_000)] - public async Task RaiseAsync_AssemblyRegisteredAndEventCreatedOnThreadPool_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken) - { - // Arrange - UiApplication.RegisterAddInAssembly(typeof(ExternalEventHandlerTests).Assembly); - var (creationAddInId, externalEvent) = await Task.Run(CreateActiveAddInIdEvent, cancellationToken); - - // Act - var handlerAddInId = await externalEvent.RaiseAsync().WaitAsync(cancellationToken); - - // Assert - using (Assert.Multiple()) - { - await Assert.That(creationAddInId).IsNotEqualTo(AddInId).Because("the add-in is not active on a thread-pool thread"); - await Assert.That(handlerAddInId).IsEqualTo(AddInId); - } - } - [Test] [Timeout(10_000)] public async Task RegisterAddInAssembly_OnThreadPool_ThrowsInvalidOperation(CancellationToken cancellationToken) @@ -149,7 +130,7 @@ public async Task RegisterAddInAssembly_OnThreadPool_ThrowsInvalidOperation(Canc [Test] public async Task RegisterAddInAssembly_NullAssembly_ThrowsArgumentNull() { - await Assert.That(() => UiApplication.RegisterAddInAssembly(null!)).Throws(); + await Assert.That(() => UiApplication.RegisterAddInAssembly(null!)).Throws(); } /// diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs index d9fce30..f37aa42 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs @@ -16,7 +16,7 @@ public async Task TryMatch_ListedName_ReturnsTheAddIn() { // Arrange var listedAssemblies = new ListedAddInAssemblies(); - listedAssemblies.Add([ListedName], FirstAddIn, replace: false); + listedAssemblies.Add([ListedName], FirstAddIn, replaceListedNames: false); // Act var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); @@ -34,7 +34,7 @@ public async Task TryMatch_ListedNameInAnotherCase_ReturnsTheAddIn() { // Arrange var listedAssemblies = new ListedAddInAssemblies(); - listedAssemblies.Add([ListedName.ToUpperInvariant()], FirstAddIn, replace: false); + listedAssemblies.Add([ListedName.ToUpperInvariant()], FirstAddIn, replaceListedNames: false); // Act var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); @@ -52,7 +52,7 @@ public async Task TryMatch_UnlistedName_ReturnsFalse() { // Arrange var listedAssemblies = new ListedAddInAssemblies(); - listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replace: false); + listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replaceListedNames: false); // Act var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); @@ -70,9 +70,9 @@ public async Task TryMatch_NameListedAfterAMiss_ReturnsTheAddIn() { // Arrange var listedAssemblies = new ListedAddInAssemblies(); - listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replace: false); + listedAssemblies.Add(["MyAddIn.Core"], FirstAddIn, replaceListedNames: false); var isMatchedBeforeListing = listedAssemblies.TryMatch(ListedAssembly, out _); - listedAssemblies.Add([ListedName], SecondAddIn, replace: false); + listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: false); // Act var isMatched = listedAssemblies.TryMatch(ListedAssembly, out var addInData); @@ -91,10 +91,10 @@ public async Task Add_NameListedEarlierWithoutReplace_KeepsTheFirstAddIn() { // Arrange var listedAssemblies = new ListedAddInAssemblies(); - listedAssemblies.Add([ListedName], FirstAddIn, replace: false); + listedAssemblies.Add([ListedName], FirstAddIn, replaceListedNames: false); // Act - listedAssemblies.Add([ListedName], SecondAddIn, replace: false); + listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: false); // Assert listedAssemblies.TryMatch(ListedAssembly, out var addInData); @@ -106,10 +106,10 @@ public async Task Add_NameListedEarlierWithReplace_ReplacesTheAddIn() { // Arrange var listedAssemblies = new ListedAddInAssemblies(); - listedAssemblies.Add([ListedName], FirstAddIn, replace: false); + listedAssemblies.Add([ListedName], FirstAddIn, replaceListedNames: false); // Act - listedAssemblies.Add([ListedName], SecondAddIn, replace: true); + listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: true); // Assert listedAssemblies.TryMatch(ListedAssembly, out var addInData); From 0467c1623a0fb9536435defd889f5b64b32999e0 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 31 Oct 2026 18:17:29 +0300 Subject: [PATCH 12/12] Update Nice3point.Revit.Toolkit.csproj --- .../Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj index d287c59..f0eb7b5 100644 --- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj +++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj @@ -56,7 +56,6 @@ - @@ -66,4 +65,4 @@ - \ No newline at end of file +