Compare commits

...

2 Commits

Author SHA1 Message Date
Jedd Morgan c0b38f0e12 feat(revit)!: Enable config for disabling API listening events (#1255)
.NET Build and Publish / build-connectors (push) Has been cancelled
.NET Build and Publish / deploy-installers (push) Has been cancelled
* Expose options to disable revit listening events

* fix mistake

* Disable change tracking for revit when document listener is disabled

* Check first
2026-01-21 19:10:34 +00:00
Jedd Morgan 4a88380fd2 Add logging for revitildlemanager reentry misuse (#1253) 2026-01-21 17:52:06 +00:00
6 changed files with 281 additions and 198 deletions
@@ -40,6 +40,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
private readonly ISendOperationManagerFactory _sendOperationManagerFactory;
private bool _isDocChangedSubscribed;
private EventHandler<Autodesk.Revit.DB.Events.DocumentChangedEventArgs>? _documentChangedHandler;
private readonly ConnectorConfig _config;
/// <summary>
/// Used internally to aggregate the changed objects' id. Note we're using a concurrent dictionary here as the expiry check method is not thread safe, and this was causing problems. See:
@@ -64,7 +65,8 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
LinkedModelHandler linkedModelHandler,
IThreadContext threadContext,
IRevitTask revitTask,
ISendOperationManagerFactory sendOperationManagerFactory
ISendOperationManagerFactory sendOperationManagerFactory,
IConfigStore configStore
)
: base("sendBinding", bridge)
{
@@ -81,6 +83,7 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
_linkedModelHandler = linkedModelHandler;
_threadContext = threadContext;
_sendOperationManagerFactory = sendOperationManagerFactory;
_config = configStore.GetConnectorConfig();
Commands = new SendBindingUICommands(bridge);
// TODO expiry events
@@ -98,7 +101,11 @@ internal sealed class RevitSendBinding : RevitBaseBinding, ISendBinding
private void OnModelCardsChanged(ModelCardsChangedEventArgs e)
{
if (e.ModelCards.Count > 0 && e.ModelCards.Any(m => m.TypeDiscriminator == nameof(SenderModelCard)))
if (
!_config.DocumentChangeListeningDisabled
&& e.ModelCards.Count > 0
&& e.ModelCards.Any(m => m.TypeDiscriminator == nameof(SenderModelCard))
)
{
SubscribeDocChanged();
}
@@ -1,5 +1,6 @@
using Speckle.Connectors.DUI.Bindings;
using Speckle.Connectors.DUI.Bridge;
using Speckle.Connectors.DUI.Settings;
using Speckle.Connectors.Revit.Plugin;
using Speckle.Converters.RevitShared.Helpers;
using Speckle.Sdk.Common;
@@ -18,26 +19,31 @@ internal sealed class SelectionBinding : RevitBaseBinding, ISelectionBinding, ID
RevitContext revitContext,
IBrowserBridge parent,
RevitIdleManager idleManager,
#if REVIT2022
ITopLevelExceptionHandler topLevelExceptionHandler,
IRevitTask revitTask
#endif
IRevitTask revitTask,
IConfigStore configStore
)
: base("selectionBinding", parent)
{
_revitContext = revitContext;
if (!configStore.GetConnectorConfig().SelectionChangeListeningDisabled)
{
#if REVIT2022
// NOTE: getting the selection data should be a fast function all, even for '000s of elements - and having a timer hitting it every 1s is ok.
_selectionTimer = new System.Timers.Timer(1000);
_selectionTimer.Elapsed += (_, _) => topLevelExceptionHandler.CatchUnhandled(OnSelectionChanged);
_selectionTimer.Start();
// NOTE: getting the selection data should be a fast function all, even for '000s of elements - and having a timer hitting it every 1s is ok.
_selectionTimer = new System.Timers.Timer(1000);
_selectionTimer.Elapsed += (_, _) => topLevelExceptionHandler.CatchUnhandled(OnSelectionChanged);
_selectionTimer.Start();
#else
revitTask.Run(
() =>
_revitContext.UIApplication.NotNull().SelectionChanged += (_, _) =>
idleManager.SubscribeToIdle(nameof(OnSelectionChanged), OnSelectionChanged)
);
revitTask.Run(
() =>
_revitContext.UIApplication.NotNull().SelectionChanged += (_, _) =>
idleManager.SubscribeToIdle(nameof(OnSelectionChanged), OnSelectionChanged)
);
#endif
}
}
private void OnSelectionChanged()
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Autodesk.Revit.DB;
using Microsoft.Extensions.Logging;
using Speckle.Connectors.Common.Builders;
@@ -7,6 +8,7 @@ using Speckle.Connectors.Common.Extensions;
using Speckle.Connectors.Common.Operations;
using Speckle.Connectors.Common.Threading;
using Speckle.Connectors.DUI.Exceptions;
using Speckle.Connectors.DUI.Settings;
using Speckle.Connectors.Revit.HostApp;
using Speckle.Converters.Common;
using Speckle.Converters.RevitShared.Helpers;
@@ -29,7 +31,8 @@ public class RevitRootObjectBuilder(
SendCollectionManager sendCollectionManager,
ILogger<RevitRootObjectBuilder> logger,
RevitToSpeckleCacheSingleton revitToSpeckleCacheSingleton,
LinkedModelHandler linkedModelHandler
LinkedModelHandler linkedModelHandler,
IConfigStore configStore
) : IRootObjectBuilder<DocumentToConvert>
{
public Task<RootObjectBuilderResult> Build(
@@ -42,6 +45,7 @@ public class RevitRootObjectBuilder(
() => Task.FromResult(BuildSync(documentElementContexts, projectId, onOperationProgressed, ct))
);
[SuppressMessage("Maintainability", "CA1506:Avoid excessive class coupling")]
private RootObjectBuilderResult BuildSync(
IReadOnlyList<DocumentToConvert> documentElementContexts,
string projectId,
@@ -134,6 +138,8 @@ public class RevitRootObjectBuilder(
var cacheHitCount = 0;
var skippedObjectCount = 0;
var config = configStore.GetConnectorConfig();
foreach (var atomicObjectByDocumentAndTransform in atomicObjectsByDocumentAndTransform)
{
string? modelDisplayName = null;
@@ -185,7 +191,11 @@ public class RevitRootObjectBuilder(
// TODO: Potential here to transform cached objects and NOT reconvert,
// TODO: we wont do !hasTransform here, and re-set application id before this
if (!hasTransform && sendConversionCache.TryGetValue(projectId, applicationId, out ObjectReference? value))
if (
!hasTransform
&& !config.DocumentChangeListeningDisabled //This is experimental
&& sendConversionCache.TryGetValue(projectId, applicationId, out ObjectReference? value)
)
{
converted = value;
cacheHitCount++;
@@ -1,5 +1,6 @@
using Autodesk.Revit.UI;
using Autodesk.Revit.UI.Events;
using Microsoft.Extensions.Logging;
using Speckle.Connectors.DUI.Bridge;
using Speckle.Converters.RevitShared.Helpers;
using Speckle.Sdk.Common;
@@ -17,13 +18,19 @@ namespace Speckle.Connectors.Revit.Plugin;
/// and low confidence in the reliability.
/// </remarks>
/// should be registered as singleton
public class RevitIdleManager(RevitContext revitContext, ITopLevelExceptionHandler topLevelExceptionHandler)
public class RevitIdleManager(
RevitContext revitContext,
ITopLevelExceptionHandler topLevelExceptionHandler,
ILogger<RevitIdleManager> logger
)
{
private readonly UIApplication _uiApplication = revitContext.UIApplication.NotNull();
private readonly Dictionary<string, Func<Task>> _calls = new();
private bool _hasSubscribed;
private bool _isExecutingIdle;
/// <summary>
/// Defers the invocation of an <paramref name="action"/> until next Revit idle tick (deduped by name).
/// The <paramref name="action"/> will be called only once.
@@ -52,6 +59,11 @@ public class RevitIdleManager(RevitContext revitContext, ITopLevelExceptionHandl
/// <inheritdoc cref="SubscribeToIdle(string, Action)"/>
public void SubscribeToIdle(string name, Func<Task> action)
{
if (_isExecutingIdle)
{
logger.LogWarning("SubscribeToIdle called while already executing idle events");
}
_calls[name] = action;
if (_hasSubscribed)
@@ -67,16 +79,34 @@ public class RevitIdleManager(RevitContext revitContext, ITopLevelExceptionHandl
{
topLevelExceptionHandler.CatchUnhandled(() =>
{
foreach (KeyValuePair<string, Func<Task>> kvp in _calls)
if (_isExecutingIdle)
{
topLevelExceptionHandler.FireAndForget(kvp.Value.Invoke);
logger.LogWarning("SubscribeToIdle called while already executing idle events");
}
_calls.Clear();
_uiApplication.Idling -= RevitAppOnIdle;
_isExecutingIdle = true;
try
{
try
{
foreach (KeyValuePair<string, Func<Task>> kvp in _calls)
{
topLevelExceptionHandler.FireAndForget(kvp.Value.Invoke);
}
}
finally
{
_calls.Clear();
}
}
finally
{
_uiApplication.Idling -= RevitAppOnIdle;
// setting last will delay entering re-subscription
_hasSubscribed = false;
_isExecutingIdle = false;
// setting last will delay entering re-subscription
_hasSubscribed = false;
}
});
}
}
@@ -1,10 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.Serialization;
using Speckle.Connectors.DUI.Bridge;
using Speckle.Connectors.DUI.Utils;
using Speckle.Connectors.DUI.Settings;
using Speckle.Connectors.Logging;
using Speckle.Sdk;
using Speckle.Sdk.SQLite;
namespace Speckle.Connectors.DUI.Bindings;
@@ -15,26 +12,10 @@ namespace Speckle.Connectors.DUI.Bindings;
/// ['Rhino', serialised config]
/// ['Revit', serialised config]
/// </summary>
public class ConfigBinding : IBinding
public class ConfigBinding(IConfigStore configStore, IBrowserBridge bridge) : IBinding
{
public string Name => "configBinding";
public IBrowserBridge Parent { get; }
private readonly ISqLiteJsonCacheManager _jsonCacheManager;
private readonly ISpeckleApplication _speckleApplication;
private readonly IJsonSerializer _serializer;
public ConfigBinding(
IJsonSerializer serializer,
ISpeckleApplication speckleApplication,
IBrowserBridge bridge,
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
)
{
Parent = bridge;
_jsonCacheManager = sqLiteJsonCacheManagerFactory.CreateForUser("DUI3Config"); // POC: maybe inject? (if we ever want to use a different storage for configs later down the line)
_speckleApplication = speckleApplication;
_serializer = serializer;
}
public IBrowserBridge Parent { get; } = bridge;
#pragma warning disable CA1024
public bool GetIsDevMode()
@@ -47,176 +28,32 @@ public class ConfigBinding : IBinding
#endif
}
public ConnectorConfig GetConfig()
{
var rawConfig = _jsonCacheManager.GetObject(_speckleApplication.HostApplication);
if (rawConfig is null)
{
return SeedConfig();
}
public ConnectorConfig GetConfig() => configStore.GetConnectorConfig();
try
{
var config = _serializer.Deserialize<ConnectorConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize config");
}
return config;
}
catch (SerializationException)
{
return SeedConfig();
}
}
private ConnectorConfig SeedConfig()
{
var cfg = new ConnectorConfig();
UpdateConfig(cfg);
return cfg;
}
public void UpdateConfig(ConnectorConfig config)
{
var str = _serializer.Serialize(config);
_jsonCacheManager.UpdateObject(_speckleApplication.HostApplication, str);
}
public void UpdateConfig(ConnectorConfig config) => configStore.UpdateConnectorConfig(config);
public void SetUserSelectedAccountId(string userSelectedAccountId)
{
var str = _serializer.Serialize(new AccountsConfig() { UserSelectedAccountId = userSelectedAccountId });
_jsonCacheManager.UpdateObject("accounts", str);
var config = new AccountsConfig() { UserSelectedAccountId = userSelectedAccountId };
configStore.UpdateAccountConfig(config);
}
// TODO: need to be replaced with `GetAccountsConfig` function after some amount of time to not confuse ourselves.
public AccountsConfig? GetUserSelectedAccountId()
{
var rawConfig = _jsonCacheManager.GetObject("accounts");
if (rawConfig is null)
{
return null;
}
public AccountsConfig? GetUserSelectedAccountId() => GetAccountsConfig();
try
{
var config = _serializer.Deserialize<AccountsConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize accounts config");
}
public GlobalConfig? GetGlobalConfig() => configStore.GetGlobalConfig();
return config;
}
catch (SerializationException)
{
return null;
}
}
public GlobalConfig? GetGlobalConfig()
{
var rawConfig = _jsonCacheManager.GetObject("global");
if (rawConfig is null)
{
return null;
}
try
{
var config = _serializer.Deserialize<GlobalConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize global config");
}
return config;
}
catch (SerializationException)
{
return null;
}
}
public AccountsConfig? GetAccountsConfig()
{
var rawConfig = _jsonCacheManager.GetObject("accounts");
if (rawConfig is null)
{
return null;
}
try
{
var config = _serializer.Deserialize<AccountsConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize accounts config");
}
return config;
}
catch (SerializationException)
{
return null;
}
}
public AccountsConfig? GetAccountsConfig() => configStore.GetAccountsConfig();
public void SetUserSelectedWorkspaceId(string workspaceId)
{
var str = _serializer.Serialize(new WorkspacesConfig() { UserSelectedWorkspaceId = workspaceId });
_jsonCacheManager.UpdateObject("workspaces", str);
var config = new WorkspacesConfig() { UserSelectedWorkspaceId = workspaceId };
configStore.UpdateWorkspacesConfig(config);
}
public WorkspacesConfig? GetWorkspacesConfig()
{
var rawConfig = _jsonCacheManager.GetObject("workspaces");
if (rawConfig is null)
{
return null;
}
try
{
var config = _serializer.Deserialize<WorkspacesConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize workspaces config");
}
return config;
}
catch (SerializationException)
{
return null;
}
}
public WorkspacesConfig? GetWorkspacesConfig() => configStore.GetWorkspacesConfig();
[SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Expose to UI")]
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Expose to UI")]
public string GetSessionId() => Consts.StaticSessionId;
}
/// <summary>
/// POC: A simple POCO for keeping track of settings. I see this as extensible in the future by each host application if and when we will need global per-app connector settings.
/// </summary>
public class ConnectorConfig
{
public bool DarkTheme { get; set; } = true;
}
public class GlobalConfig
{
public bool IsUpdateNotificationDisabled { get; set; }
}
public class AccountsConfig
{
public string? UserSelectedAccountId { get; set; }
}
public class WorkspacesConfig
{
public string? UserSelectedWorkspaceId { get; set; }
}
@@ -0,0 +1,193 @@
using System.Runtime.Serialization;
using Speckle.Connectors.DUI.Bindings;
using Speckle.Connectors.DUI.Utils;
using Speckle.InterfaceGenerator;
using Speckle.Sdk;
using Speckle.Sdk.SQLite;
namespace Speckle.Connectors.DUI.Settings;
/// <summary>
/// See <see cref="ConfigBinding"/>, as it was driving Dim nuts he couldn't swap to a dark theme.
/// How does it store configs? In a sqlite db called 'DUI3Config', we create a row for each host application:
/// [ hash, contents ]
/// ['Rhino', serialised config]
/// ['Revit', serialised config]
/// </summary>
/// <remarks>
/// We separated the business logic that's in this class from the <see cref="ConfigBinding"/> so that
/// <see cref="ConfigStore"/> can be injected into other bindings (you can't inject one binding into another)
/// </remarks>
[GenerateAutoInterface]
public sealed class ConfigStore : IConfigStore
{
private readonly ISqLiteJsonCacheManager _jsonCacheManager;
private readonly ISpeckleApplication _speckleApplication;
private readonly IJsonSerializer _serializer;
public ConfigStore(
IJsonSerializer serializer,
ISpeckleApplication speckleApplication,
ISqLiteJsonCacheManagerFactory sqLiteJsonCacheManagerFactory
)
{
_jsonCacheManager = sqLiteJsonCacheManagerFactory.CreateForUser("DUI3Config"); // POC: maybe inject? (if we ever want to use a different storage for configs later down the line)
_speckleApplication = speckleApplication;
_serializer = serializer;
}
public ConnectorConfig GetConnectorConfig()
{
var rawConfig = _jsonCacheManager.GetObject(_speckleApplication.HostApplication);
if (rawConfig is null)
{
return SeedConnectorConfig();
}
try
{
var config = _serializer.Deserialize<ConnectorConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize config");
}
return config;
}
catch (SerializationException)
{
return SeedConnectorConfig();
}
}
private ConnectorConfig SeedConnectorConfig()
{
var cfg = new ConnectorConfig();
UpdateConnectorConfig(cfg);
return cfg;
}
public void UpdateConnectorConfig(ConnectorConfig config)
{
var str = _serializer.Serialize(config);
_jsonCacheManager.UpdateObject(_speckleApplication.HostApplication, str);
}
public void UpdateAccountConfig(AccountsConfig accountsConfig)
{
var str = _serializer.Serialize(accountsConfig);
_jsonCacheManager.UpdateObject("accounts", str);
}
public GlobalConfig? GetGlobalConfig()
{
var rawConfig = _jsonCacheManager.GetObject("global");
if (rawConfig is null)
{
return null;
}
try
{
var config = _serializer.Deserialize<GlobalConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize global config");
}
return config;
}
catch (SerializationException)
{
return null;
}
}
public AccountsConfig? GetAccountsConfig()
{
var rawConfig = _jsonCacheManager.GetObject("accounts");
if (rawConfig is null)
{
return null;
}
try
{
var config = _serializer.Deserialize<AccountsConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize accounts config");
}
return config;
}
catch (SerializationException)
{
return null;
}
}
public WorkspacesConfig? GetWorkspacesConfig()
{
var rawConfig = _jsonCacheManager.GetObject("workspaces");
if (rawConfig is null)
{
return null;
}
try
{
var config = _serializer.Deserialize<WorkspacesConfig>(rawConfig);
if (config is null)
{
throw new SerializationException("Failed to deserialize workspaces config");
}
return config;
}
catch (SerializationException)
{
return null;
}
}
public void UpdateWorkspacesConfig(WorkspacesConfig workspacesConfig)
{
var str = _serializer.Serialize(workspacesConfig);
_jsonCacheManager.UpdateObject("workspaces", str);
}
}
/// <summary>
/// POC: A simple POCO for keeping track of settings. I see this as extensible in the future by each host application if and when we will need global per-app connector settings.
/// </summary>
public sealed class ConnectorConfig
{
public bool DarkTheme { get; init; } = true;
/// <remarks>
/// Only used by Revit Connector !!
/// We're exposing some settings to disable event listening inorder to debug app crash issues caused by Revit event handlers
/// Normal users are expected to have both enabled.
/// We can consider removing this in future once issues are resolved.
/// </remarks>
public bool SelectionChangeListeningDisabled { get; init; }
/// <inheritdoc cref="SelectionChangeListeningDisabled" />
public bool DocumentChangeListeningDisabled { get; init; }
}
public sealed class GlobalConfig
{
public bool IsUpdateNotificationDisabled { get; init; }
}
public sealed class AccountsConfig
{
public string? UserSelectedAccountId { get; init; }
}
public sealed class WorkspacesConfig
{
public string? UserSelectedWorkspaceId { get; init; }
}