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/CHANGELOG.md b/CHANGELOG.md
index 0239b86..27ed88e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+# 2027.0.4
+
+### External Events
+
+- 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)
+{
+ application.RegisterAddInAssembly(typeof(Application).Assembly);
+ return Result.Succeeded;
+}
+```
+
# 2027.0.3
### External Events
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 @@
-
+
diff --git a/README.md b/README.md
index 79241ce..a0c1f3c 100644
--- a/README.md
+++ b/README.md
@@ -210,6 +210,15 @@ 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`.
+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`:
+
+```c#
+application.RegisterAddInAssembly(typeof(Application).Assembly);
+```
+
#### 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..ea20b6e
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInAssemblyWriter.cs
@@ -0,0 +1,58 @@
+using System.Text;
+using Microsoft.CodeAnalysis.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 builder = new StringBuilder();
+ builder.AppendLine("// ");
+ builder.AppendLine("#pragma warning disable");
+ builder.AppendLine();
+
+ foreach (var assemblyName in assemblyNames)
+ {
+ builder.AppendLine($"[assembly: global::{AttributeNamespace}.{AttributeName}({SymbolDisplay.FormatLiteral(assemblyName, quote: true)})]");
+ }
+
+ 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
new file mode 100644
index 0000000..9ae3e7f
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblies/AddInProjectReferences.cs
@@ -0,0 +1,33 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Nice3point.Revit.Toolkit.SourceGenerators.Models;
+
+namespace Nice3point.Revit.Toolkit.SourceGenerators.AddInAssemblies;
+
+///
+/// Reads the project references listed by the package targets.
+///
+internal static class AddInProjectReferences
+{
+ private const string PropertyName = "build_property._RevitToolkitAddInProjectReferences";
+
+ ///
+ /// 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 for a project without 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.OrdinalIgnoreCase)
+ .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..9ee9bc1
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit.SourceGenerators/AddInAssemblyGenerator.cs
@@ -0,0 +1,35 @@
+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 each project reference.
+///
+///
+/// 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
+{
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var assemblyNames = context.AnalyzerConfigOptionsProvider
+ .Select(static (optionsProvider, _) => AddInProjectReferences.Read(optionsProvider.GlobalOptions));
+
+ context.RegisterSourceOutput(assemblyNames, static (sourceProductionContext, projectReferences) =>
+ {
+ if (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/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/AddInAssemblyExtensions.cs b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs
new file mode 100644
index 0000000..b8c909b
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit/External/Events/AddInAssemblyExtensions.cs
@@ -0,0 +1,53 @@
+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 add-in assemblies.
+///
+[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 with external event handlers of the add-in.
+ /// is .
+ /// The method is called outside a Revit callback.
+ ///
+ /// 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)
+ {
+ 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/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..3a00185 100644
--- a/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs
+++ b/source/Nice3point.Revit.Toolkit/External/Events/ExternalEventHandler.cs
@@ -1,12 +1,18 @@
using Autodesk.Revit.UI;
+using Nice3point.Revit.Toolkit.Internal;
// ReSharper disable once CheckNamespace
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 declares it, on any thread the event is created on.
+/// and register their assembly with its project references automatically.
+/// 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
{
@@ -14,14 +20,27 @@ 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.
///
- protected ExternalEventHandler()
+ ///
+ /// 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.
+ ///
+ /// 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())
{
- _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..0b58e21
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit/Internal/AddInContext.cs
@@ -0,0 +1,380 @@
+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;
+
+///
+/// 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 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 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
+{
+ 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 const string AddInAssemblyAttributeName = "Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute";
+
+ private static readonly Assembly ToolkitAssembly = typeof(AddInContext).Assembly;
+ private static readonly ConditionalWeakTable> CapturedAddIns = new();
+ private static readonly ConditionalWeakTable>.CreateValueCallback CreateEmptyCapture = static _ => new StrongBox();
+ private static readonly Lock ListedAddInAssembliesLock = new();
+ private static readonly ListedAddInAssemblies ListedAddInAssemblies = new();
+
+ static AddInContext()
+ {
+#if NET
+ // Add-ins without dependency isolation share the toolkit loaded into the default AssemblyLoadContext.
+ IsShared = AssemblyLoadContext.GetLoadContext(ToolkitAssembly) == AssemblyLoadContext.Default;
+#else
+ IsShared = true;
+#endif
+
+ 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;
+ }
+ }
+
+ ///
+ /// Captures the add-in of the current Revit callback as the add-in of the assembly.
+ ///
+ /// 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.
+ ///
+ internal static void Capture(Assembly assembly)
+ {
+ if (!IsSupported)
+ {
+ return;
+ }
+
+ CaptureAddIn(IsShared ? assembly : ToolkitAssembly);
+ }
+
+ ///
+ /// 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 assemblies listed by the generated AddInAssemblyAttribute receive the same add-in.
+ ///
+ 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 in OnStartup or in the Execute method of a command.");
+ }
+
+ var owner = IsShared ? assembly : ToolkitAssembly;
+ 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);
+ }
+
+ ///
+ /// Creates the Revit external event of the event handler, bound to the add-in of the handler assembly.
+ ///
+ /// 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 a Revit callback, the call binds the event to the captured add-in of the handler assembly.
+ ///
+ internal static ExternalEvent CreateExternalEvent(IExternalEventHandler eventHandler, Delegate? handler)
+ {
+ if (!IsSupported)
+ {
+ return ExternalEvent.Create(eventHandler);
+ }
+
+ var owner = FindOwner(eventHandler, handler);
+ if (IsAddInActive())
+ {
+ CaptureAddIn(owner);
+ return ExternalEvent.Create(eventHandler);
+ }
+
+ 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*)addInData);
+ try
+ {
+ return ExternalEvent.Create(eventHandler);
+ }
+ finally
+ {
+ ExitAddInDataScope(scope);
+ }
+ }
+
+ ///
+ /// 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 var capturedAddIn) && Volatile.Read(ref capturedAddIn.Value) != IntPtr.Zero)
+ {
+ return;
+ }
+
+ if (!IsAddInActive())
+ {
+ return;
+ }
+
+ capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture);
+ var addInData = CopyActiveAddInData();
+ if (Interlocked.CompareExchange(ref capturedAddIn.Value, addInData, IntPtr.Zero) != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(addInData);
+ return;
+ }
+
+ ListAddInAssemblies(owner, addInData, replaceListedNames: false);
+ }
+
+ ///
+ /// Copies the active add-in data into a new native buffer.
+ ///
+ /// The pointer to the copy.
+ private static nint CopyActiveAddInData()
+ {
+ var addInData = Marshal.AllocHGlobal(AddInDataSize);
+ GetActiveAddInData((void*)addInData);
+ return addInData;
+ }
+
+ ///
+ /// 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)
+ {
+ ListedAddInAssemblies.Add(assemblyNames, addInData, replaceListedNames);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private static string[] ReadListedAssemblyNames(Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetCustomAttributesData()
+ .Where(static attribute => attribute.AttributeType.FullName == AddInAssemblyAttributeName)
+ .Select(static attribute => attribute.ConstructorArguments[0].Value)
+ .OfType()
+ .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.
+ ///
+ /// 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 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, unless a concurrent capture publishes first.
+ ///
+ private static bool TryGetCapturedAddIn(Assembly owner, out nint addInData)
+ {
+ if (CapturedAddIns.TryGetValue(owner, out var capturedAddIn))
+ {
+ addInData = Volatile.Read(ref capturedAddIn.Value);
+ if (addInData != 0)
+ {
+ return true;
+ }
+ }
+
+ addInData = 0;
+ if (!IsShared || !IsInDefaultContext(owner))
+ {
+ return false;
+ }
+
+ nint listedAddInData;
+ lock (ListedAddInAssembliesLock)
+ {
+ if (!ListedAddInAssemblies.TryMatch(owner, out listedAddInData))
+ {
+ return false;
+ }
+ }
+
+ capturedAddIn = CapturedAddIns.GetValue(owner, CreateEmptyCapture);
+ Interlocked.CompareExchange(ref capturedAddIn.Value, listedAddInData, IntPtr.Zero);
+ addInData = Volatile.Read(ref capturedAddIn.Value);
+ return true;
+ }
+
+ ///
+ /// 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());
+ }
+
+ ///
+ /// 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 ToolkitAssembly;
+ }
+
+ if (handler is null)
+ {
+ return eventHandler.GetType().Assembly;
+ }
+
+ return handler.Target?.GetType().Assembly ?? handler.Method.Module.Assembly;
+ }
+
+ private static nint GetGlobalFunction(Module module, string name, params Type[] parameterTypes)
+ {
+ 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);
+ }
+
+ // The function pointer of a global method is the managed entry of its native call stub.
+ return method.MethodHandle.GetFunctionPointer();
+ }
+}
diff --git a/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs b/source/Nice3point.Revit.Toolkit/Internal/ListedAddInAssemblies.cs
new file mode 100644
index 0000000..eebed1d
--- /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 the generated AddInAssemblyAttribute 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 replaceListedNames)
+ {
+ foreach (var assemblyName in assemblyNames)
+ {
+ if (replaceListedNames || !_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/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj
index 901a560..f0eb7b5 100644
--- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj
+++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj
@@ -56,6 +56,7 @@
+
@@ -64,4 +65,4 @@
-
\ No newline at end of file
+
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..c4fdc99
--- /dev/null
+++ b/source/Nice3point.Revit.Toolkit/build/Nice3point.Revit.Toolkit.targets
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+ <_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
new file mode 100644
index 0000000..95478e2
--- /dev/null
+++ b/tests/Nice3point.Revit.Toolkit.SourceGenerators.Tests/AddInAssemblyGeneratorTests.cs
@@ -0,0 +1,146 @@
+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 Source = "public class Model { }";
+ private const string AttributeName = "Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute";
+
+ [Test]
+ public async Task ProjectReferences_DeclareAnAttributePerAssemblyAsync()
+ {
+ 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);
+ }
+
+ [Test]
+ [Arguments(" MyAddIn.Core , MyAddIn.Core,,")]
+ [Arguments("MyAddIn.Core,myaddin.core")]
+ [Arguments("MyAddIn.Core")]
+ public async Task RepeatedPaddedOrCaseVariantNames_DeclareEachAssemblyOnceAsync(string projectReferences)
+ {
+ var (_, output) = await RunAsync(Source, 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(Source, projectReferences);
+
+ await Assert.That(driver.GetRunResult().GeneratedTrees).IsEmpty();
+ }
+
+ [Test]
+ [Arguments(LanguageVersion.CSharp7_3)]
+ [Arguments(LanguageVersion.CSharp14)]
+ public async Task LanguageVersion_GeneratesCompilableSourceAsync(LanguageVersion languageVersion)
+ {
+ var (_, output) = await RunAsync(Source, projectReferences: "MyAddIn.Core", languageVersion: languageVersion);
+
+ 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(Source, 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,
+ string assemblyName = "Consumer",
+ LanguageVersion languageVersion = LanguageVersion.CSharp14,
+ MetadataReference? additionalReference = null)
+ {
+ var cancellationToken = TestContext.Current!.Execution.CancellationToken;
+ var parseOptions = new CSharpParseOptions(languageVersion);
+ var references = await ReferenceAssemblies.Net.Net80.ResolveAsync(LanguageNames.CSharp, cancellationToken);
+ var input = CSharpCompilation.Create(assemblyName,
+ [CSharpSyntaxTree.ParseText(source, parseOptions, cancellationToken: cancellationToken)],
+ additionalReference is null ? references : references.Add(additionalReference),
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ var globalOptions = projectReferences is null
+ ? ImmutableDictionary.Empty
+ : ImmutableDictionary.Empty.Add("build_property._RevitToolkitAddInProjectReferences", 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?.ToDisplayString() == AttributeName)
+ .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
new file mode 100644
index 0000000..1d64524
--- /dev/null
+++ b/tests/Nice3point.Revit.Toolkit.Tests/ExternalEventHandlerTests.cs
@@ -0,0 +1,202 @@
+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", 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";
+ private static readonly Guid AddInId = new(TestsConfiguration.AddInId);
+
+ [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(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 RaiseAsync_CreatedInDispatcherCallback_RunsTheHandlerAsTheAddIn(CancellationToken cancellationToken)
+ {
+ // Arrange
+ CreateExternalEventInsideAddInCallback();
+ var dispatcher = Dispatcher.CurrentDispatcher;
+ var creation = dispatcher.InvokeAsync(CreateActiveAddInIdEvent);
+ var (creationAddInId, externalEvent) = await creation.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(static () => 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(static () => new AsyncRequestExternalEvent(WriteVendorEntity), cancellationToken);
+
+ // Act
+ var result = await externalEvent.RaiseAsync().WaitAsync(cancellationToken);
+
+ // Assert
+ await Assert.That(result).IsEqualTo(WrittenResult);
+ }
+
+ [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();
+ }
+
+ [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.
+ ///
+ 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 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.
+ private static string WriteVendorEntity(UIApplication uiApplication)
+ {
+ var schemaId = Guid.NewGuid();
+ var schemaBuilder = new SchemaBuilder(schemaId);
+ schemaBuilder.SetSchemaName($"ToolkitTests{schemaId:N}");
+ schemaBuilder.SetVendorId(TestsConfiguration.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/ListedAddInAssembliesTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/ListedAddInAssembliesTests.cs
new file mode 100644
index 0000000..f37aa42
--- /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, replaceListedNames: 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, replaceListedNames: 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, replaceListedNames: 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, replaceListedNames: false);
+ var isMatchedBeforeListing = listedAssemblies.TryMatch(ListedAssembly, out _);
+ listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: 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, replaceListedNames: false);
+
+ // Act
+ listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: 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, replaceListedNames: false);
+
+ // Act
+ listedAssemblies.Add([ListedName], SecondAddIn, replaceListedNames: true);
+
+ // Assert
+ listedAssemblies.TryMatch(ListedAssembly, out var addInData);
+ await Assert.That(addInData).IsEqualTo(SecondAddIn);
+ }
+}
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";
+}