2026-09-13 14:17:25 +02:00
using AIStudio.Models.Plugins ;
2025-03-22 21:12:14 +01:00
using AIStudio.Settings ;
2026-04-10 17:11:05 +02:00
using AIStudio.Settings.DataModel ;
2025-03-22 21:12:14 +01:00
namespace AIStudio.Tools.PluginSystem ;
2025-03-29 18:40:17 +01:00
public static partial class PluginFactory
2025-03-22 21:12:14 +01:00
{
2025-04-12 21:13:33 +02:00
private static readonly ILogger LOG = Program . LOGGER_FACTORY . CreateLogger ( nameof ( PluginFactory ));
2026-06-10 21:01:27 +02:00
private static SettingsManager SettingsManagerAccess => Program . SERVICE_PROVIDER . GetRequiredService < SettingsManager >();
2026-02-19 20:43:47 +01:00
2025-04-12 21:13:33 +02:00
private static string DATA_DIR = string . Empty ;
private static string PLUGINS_ROOT = string . Empty ;
private static string INTERNAL_PLUGINS_ROOT = string . Empty ;
2026-08-08 18:35:46 +02:00
/// <summary>
2026-08-28 14:06:24 +02:00
/// The directory the config server downloads the plugins of an organization into.
2026-08-08 18:35:46 +02:00
/// </summary>
/// <remarks>
/// This is not the home of configuration plugins in general: a local configuration plugin can
/// live in any directory below the plugins root. Only the IT department of an organization
2026-08-28 14:06:24 +02:00
/// deploys plugins here, each deployment in a directory named after its configuration ID.<br/><br/>
/// A deployment is not limited to a configuration, even though the directory name says so. An
/// organization serves one archive per configuration ID and uses it for every kind of plugin:
/// assistants, languages, themes, and whatever else follows. Those plugins live in
/// subdirectories, each with its own plugin.lua and its own plugin ID, and only the
/// configuration plugin itself carries the configuration ID as its ID. Everything below such a
/// deployment belongs to the organization, whatever its type is and however deeply it is nested.
2026-08-08 18:35:46 +02:00
/// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string . Empty ;
2026-08-09 19:01:58 +02:00
/// <summary>
2026-08-28 14:06:24 +02:00
/// The directory administrators use to try a deployment out before their organization rolls it out.
2026-08-09 19:01:58 +02:00
/// </summary>
/// <remarks>
/// Everything stored here acts on behalf of the organization, so that a test behaves like the
2026-08-28 14:06:24 +02:00
/// later rollout, including the approval of assistant plugins and the protection against changes
/// through the user interface. It takes every kind of plugin, exactly like a real deployment, so
/// the directory structure of the later archive can be reproduced one to one. In exchange, the
/// directory is emptied on every start: a test lives for one session only.<br/><br/>
/// A test therefore ends by restarting AI Studio, or by removing the files again. Whoever builds
/// enterprise plugins places them here by hand in the first place, so both ways are open to them
/// anyway, and neither weakens what the directory grants a plugin.
2026-08-09 19:01:58 +02:00
/// </remarks>
private static string ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = string . Empty ;
2025-06-09 14:06:54 +02:00
private static string HOT_RELOAD_LOCK_FILE = string . Empty ;
2025-04-12 21:13:33 +02:00
private static FileSystemWatcher HOT_RELOAD_WATCHER = null !;
2026-08-09 19:01:58 +02:00
/// <summary>
/// How many test configurations were removed while AI Studio was starting.
/// </summary>
/// <remarks>
/// The user interface reports this: an administrator who placed a test configuration and restarted
/// AI Studio would otherwise face an empty directory without any explanation.
/// </remarks>
public static int RemovedTestConfigurationsAtStartup { get ; private set ; }
2026-02-19 20:43:47 +01:00
public static ILanguagePlugin BaseLanguage { get ; private set ; } = NoPluginLanguage . INSTANCE ;
public static bool IsInitialized { get ; private set ; }
2025-04-07 19:36:24 +02:00
2026-02-07 22:59:41 +01:00
/// <summary>
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
/// </summary>
public static EnterpriseEncryption ? EnterpriseEncryption { get ; private set ; }
/// <summary>
/// Initializes the enterprise encryption service by reading the encryption secret
2026-03-31 13:02:59 +02:00
/// from the effective enterprise source.
2026-02-07 22:59:41 +01:00
/// </summary>
/// <param name="rustService">The Rust service to use for reading the encryption secret.</param>
public static async Task InitializeEnterpriseEncryption ( Services . RustService rustService )
{
var encryptionSecret = await rustService . EnterpriseEnvConfigEncryptionSecret ();
2026-03-31 13:02:59 +02:00
InitializeEnterpriseEncryption ( encryptionSecret );
}
/// <summary>
/// Initializes the enterprise encryption service using a prefetched secret value.
/// </summary>
/// <param name="encryptionSecret">The base64-encoded enterprise encryption secret.</param>
public static void InitializeEnterpriseEncryption ( string? encryptionSecret )
{
LOG . LogInformation ( "Initializing enterprise encryption service..." );
2026-02-07 22:59:41 +01:00
var enterpriseEncryptionLogger = Program . LOGGER_FACTORY . CreateLogger < EnterpriseEncryption >();
EnterpriseEncryption = new EnterpriseEncryption ( enterpriseEncryptionLogger , encryptionSecret );
if ( EnterpriseEncryption . IsAvailable )
LOG . LogInformation ( "Enterprise encryption service is available." );
else
LOG . LogWarning ( "Enterprise encryption service is not available (no secret configured)." );
}
2025-04-12 21:13:33 +02:00
/// <summary>
/// Set up the plugin factory. We will read the data directory from the settings manager.
/// Afterward, we will create the plugins directory and the internal plugin directory.
/// </summary>
2025-04-24 09:50:03 +02:00
public static bool Setup ()
2025-04-07 19:36:24 +02:00
{
2026-02-19 20:43:47 +01:00
if ( IsInitialized )
2025-04-24 09:50:03 +02:00
return false ;
2025-04-23 14:07:22 +02:00
2025-06-02 20:08:25 +02:00
LOG . LogInformation ( "Initializing plugin factory..." );
2025-04-12 21:13:33 +02:00
DATA_DIR = SettingsManager . DataDirectory !;
PLUGINS_ROOT = Path . Join ( DATA_DIR , "plugins" );
2025-06-09 14:06:54 +02:00
HOT_RELOAD_LOCK_FILE = Path . Join ( PLUGINS_ROOT , ".lock" );
2025-04-12 21:13:33 +02:00
INTERNAL_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".internal" );
2026-08-08 18:35:46 +02:00
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".config" );
2026-08-09 19:01:58 +02:00
ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".config-tests" );
2025-04-07 19:36:24 +02:00
if (! Directory . Exists ( PLUGINS_ROOT ))
Directory . CreateDirectory ( PLUGINS_ROOT );
2026-08-09 19:01:58 +02:00
ClearTestConfigurationPlugins ();
2025-04-07 19:36:24 +02:00
HOT_RELOAD_WATCHER = new ( PLUGINS_ROOT );
2026-02-19 20:43:47 +01:00
IsInitialized = true ;
2025-06-02 20:08:25 +02:00
LOG . LogInformation ( "Plugin factory initialized successfully." );
2025-04-24 09:50:03 +02:00
return true ;
2025-04-07 19:36:24 +02:00
}
2025-06-09 14:06:54 +02:00
2026-08-08 18:35:46 +02:00
/// <summary>
/// Checks whether a plugin directory belongs to the enterprise configuration area.
/// </summary>
/// <remarks>
/// Only the IT department of an organization deploys plugins there: the config server downloads
2026-08-28 14:06:24 +02:00
/// each deployment into a directory named after its configuration ID, and a plugin of any type
/// may sit in a subdirectory of it. We decide by path on purpose. The Lua field
/// DEPLOYED_USING_CONFIG_SERVER is self-declared, so any plugin could claim to be deployed by an
/// organization, and one an organization did deploy could deny it.
2026-08-08 18:35:46 +02:00
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
2026-08-09 19:01:58 +02:00
public static bool IsEnterpriseConfigurationPath ( string? pluginPath ) => IsPathInside ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT , pluginPath );
/// <summary>
/// Checks whether a plugin directory belongs to the test configuration area.
/// </summary>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the test configuration directory.</returns>
public static bool IsEnterpriseTestConfigurationPath ( string? pluginPath ) => IsPathInside ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT , pluginPath );
/// <summary>
2026-08-28 14:06:24 +02:00
/// Checks whether a plugin belongs to an organization, either deployed by a configuration server
/// or staged for a test.
2026-08-09 19:01:58 +02:00
/// </summary>
/// <remarks>
2026-08-28 14:06:24 +02:00
/// This is the criterion for everything an organization owns, and it holds for every plugin type:
/// a configuration speaking for the organization when it approves assistant plugins or claims a
/// setting, and the protection of a plugin against the user, e.g. against deletion or editing
/// through the user interface.<br/><br/>
/// A test deployment is protected just like a real one, so that a test shows what colleagues will
/// see later. Administrators end a test by restarting AI Studio or by removing the files they
/// placed, which is why they do not need the user interface to get rid of it.<br/><br/>
/// Plugins an organization rolls out past these directories, e.g. through an MDM solution, carry
/// no path to prove it. Those declare DEPLOYED_USING_CONFIG_SERVER instead, which is read into
/// the IsManagedByConfigServer property of a plugin's metadata. Check that property in addition
/// to this method wherever a plugin is protected against the user.
2026-08-09 19:01:58 +02:00
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory belongs to the enterprise or the test configuration area.</returns>
public static bool IsOrganizationConfigurationPath ( string? pluginPath ) => IsEnterpriseConfigurationPath ( pluginPath ) || IsEnterpriseTestConfigurationPath ( pluginPath );
2026-08-28 14:06:24 +02:00
/// <summary>
/// Determines which deployed configuration a plugin below the enterprise configuration directory
/// belongs to.
/// </summary>
/// <remarks>
/// A configuration server downloads each configuration into a directory named after its ID. That
/// archive may carry more than the configuration itself: organizations deploy assistant plugins
/// and other plugin types alongside it, each in its own subdirectory. We therefore look at the
/// topmost directory below the enterprise configuration directory instead of the directory the
/// plugin lives in, which for such a plugin is a nested one.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="configurationId">The ID of the configuration the plugin was deployed with.</param>
/// <returns>True when the plugin is nested in a directory named after a configuration ID.</returns>
public static bool TryGetDeployedConfigurationId ( string? pluginPath , out Guid configurationId )
{
configurationId = Guid . Empty ;
if (! IsEnterpriseConfigurationPath ( pluginPath ))
return false ;
try
{
var root = Path . GetFullPath ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT );
var relativePath = Path . GetRelativePath ( root , Path . GetFullPath ( pluginPath !));
var deploymentDirectory = relativePath . Split ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar )[ 0 ];
return Guid . TryParse ( deploymentDirectory , out configurationId ) && configurationId != Guid . Empty ;
}
catch ( Exception e )
{
LOG . LogWarning ( e , $"Was not able to determine the deployed configuration ID for the plugin directory '{pluginPath}'." );
return false ;
}
}
2026-08-09 19:01:58 +02:00
/// <summary>
/// Ranks how much say a configuration plugin has, based on where it is stored. The higher rank
/// wins when two configuration plugins claim the same plugin ID.
/// </summary>
/// <remarks>
/// A test configuration outranks a deployed one on purpose: an administrator tries out the next
/// version of a configuration under the ID it will have later. Local configuration plugins rank
/// lowest, so nobody can push aside what an organization deployed.
/// </remarks>
private static int GetConfigurationAuthority ( string? pluginPath )
{
if ( IsEnterpriseTestConfigurationPath ( pluginPath ))
return 2 ;
return IsEnterpriseConfigurationPath ( pluginPath ) ? 1 : 0 ;
}
/// <summary>
/// Empties the test configuration directory.
/// </summary>
/// <remarks>
/// A test configuration carries the rights of an organization configuration without anybody having
/// deployed it. It must therefore never outlive the session it was placed in, and administrators
/// get a predictable lifetime instead of a configuration which is swept away at some point.
/// </remarks>
private static void ClearTestConfigurationPlugins ()
2026-08-08 18:35:46 +02:00
{
2026-08-09 19:01:58 +02:00
RemovedTestConfigurationsAtStartup = 0 ;
try
{
if ( Directory . Exists ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT ))
{
var removedTestConfigurations = Directory . EnumerateDirectories ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT ). Count ();
Directory . Delete ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT , true );
RemovedTestConfigurationsAtStartup = removedTestConfigurations ;
if ( removedTestConfigurations > 0 )
LOG . LogWarning ( $"Removed {removedTestConfigurations} test configuration(s) from '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'. Test configurations are valid for one session only." );
}
Directory . CreateDirectory ( ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT );
}
catch ( Exception e )
{
LOG . LogError ( e , $"Failed to empty the test configuration directory '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'." );
}
}
/// <summary>
/// Checks whether a plugin directory is stored below the plugins directory of AI Studio.
/// </summary>
/// <remarks>
/// Everything that removes or replaces plugin files checks this first, so a plugin directory
/// which points somewhere else can never be touched.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the plugins directory.</returns>
public static bool IsInsidePluginsRoot ( string? pluginPath ) => IsPathInside ( PLUGINS_ROOT , pluginPath );
/// <summary>
/// Checks whether a plugin directory is the plugins directory itself.
/// </summary>
/// <remarks>
/// A `plugin.lua` placed directly in the plugins directory makes that directory the plugin
/// directory. Removing or replacing such a plugin means touching its directory, which would take
/// every other plugin with it.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is the plugins directory.</returns>
public static bool IsPluginsRoot ( string? pluginPath )
{
if ( string . IsNullOrWhiteSpace ( pluginPath ) || string . IsNullOrWhiteSpace ( PLUGINS_ROOT ))
2026-08-08 18:35:46 +02:00
return false ;
try
{
2026-08-09 19:01:58 +02:00
var root = Path . GetFullPath ( PLUGINS_ROOT ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar );
var pluginDirectory = Path . GetFullPath ( pluginPath ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar );
return string . Equals ( root , pluginDirectory , StringComparison . OrdinalIgnoreCase );
}
catch ( Exception e )
{
LOG . LogWarning ( e , $"Was not able to check whether the plugin directory '{pluginPath}' is the plugins directory. Treating it as the plugins directory." );
return true ;
}
}
private static bool IsPathInside ( string rootDirectory , string? pluginPath )
{
if ( string . IsNullOrWhiteSpace ( pluginPath ) || string . IsNullOrWhiteSpace ( rootDirectory ))
return false ;
try
{
var root = Path . GetFullPath ( rootDirectory ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) + Path . DirectorySeparatorChar ;
2026-08-08 18:35:46 +02:00
var pluginDirectory = Path . GetFullPath ( pluginPath ). TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) + Path . DirectorySeparatorChar ;
2026-08-09 19:01:58 +02:00
return pluginDirectory . StartsWith ( root , StringComparison . OrdinalIgnoreCase );
2026-08-08 18:35:46 +02:00
}
catch ( Exception e )
{
2026-08-09 19:01:58 +02:00
LOG . LogWarning ( e , $"Was not able to check whether the plugin directory '{pluginPath}' is nested in '{rootDirectory}'. Treating it as unrelated." );
2026-08-08 18:35:46 +02:00
return false ;
}
}
/// <summary>
/// Checks whether a configuration plugin was deployed by the IT department of an organization.
/// </summary>
/// <remarks>
/// A plugin which is deployed but could not be loaded still counts: it might be broken, e.g. due
/// to invalid Lua code or an incomplete download, but it was not removed. Everything it manages
/// stays under the control of the organization until the plugin is gone for good.
/// </remarks>
/// <param name="configPluginId">The ID of the configuration plugin.</param>
/// <returns>True when the plugin belongs to an organization, false when it is local or unknown.</returns>
public static bool IsEnterpriseConfigurationPlugin ( Guid configPluginId )
{
if ( configPluginId == Guid . Empty || ! IsInitialized )
return false ;
if ( AVAILABLE_PLUGINS . Any ( plugin => plugin . Id == configPluginId && plugin . Type is PluginType . CONFIGURATION && IsEnterpriseConfigurationPath ( plugin . LocalPath )))
return true ;
return Directory . Exists ( Path . Join ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT , configPluginId . ToString ()));
}
2026-08-09 19:01:58 +02:00
/// <summary>
/// Checks whether a configuration plugin speaks for an organization: either deployed by its IT
/// department, or staged as a test configuration.
/// </summary>
/// <remarks>
/// A test configuration is only ever loaded, never merely present: it is emptied on every start,
/// so there is no unloadable leftover to account for.
/// </remarks>
/// <param name="configPluginId">The ID of the configuration plugin.</param>
/// <returns>True when the plugin speaks for an organization, false when it is local or unknown.</returns>
public static bool IsOrganizationConfigurationPlugin ( Guid configPluginId )
{
if ( configPluginId == Guid . Empty || ! IsInitialized )
return false ;
if ( IsEnterpriseConfigurationPlugin ( configPluginId ))
return true ;
return AVAILABLE_PLUGINS . Any ( plugin => plugin . Id == configPluginId && plugin . Type is PluginType . CONFIGURATION && IsEnterpriseTestConfigurationPath ( plugin . LocalPath ));
}
2026-08-15 19:55:42 +02:00
/// <summary>
/// Counts how many operations currently write to the plugins directory.
/// </summary>
/// <remarks>
/// Downloading an organization's configuration and installing a plugin can run at the same
/// time. Without counting, whichever finishes first would unlock hot reloading while the other
/// is still writing.
/// </remarks>
private static int HOT_RELOAD_LOCK_COUNT ;
private static readonly SemaphoreSlim HOT_RELOAD_LOCK_SEMAPHORE = new ( 1 , 1 );
/// <summary>
/// Holds back hot reloading while the caller writes to the plugins directory.
/// </summary>
/// <remarks>
/// Every caller has to release the lock again, so wrap the write in a try-finally block. Hot
/// reloading resumes once the last caller has released it.
/// </remarks>
public static async Task LockHotReloadAsync ()
2025-06-09 14:06:54 +02:00
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-06-09 14:06:54 +02:00
{
LOG . LogError ( "PluginFactory is not initialized." );
return ;
}
2026-08-15 19:55:42 +02:00
await HOT_RELOAD_LOCK_SEMAPHORE . WaitAsync ();
2025-06-09 14:06:54 +02:00
try
{
2026-08-15 19:55:42 +02:00
if ( HOT_RELOAD_LOCK_COUNT ++ > 0 )
2025-06-09 14:06:54 +02:00
return ;
2026-08-15 19:55:42 +02:00
2025-06-09 14:06:54 +02:00
await File . WriteAllTextAsync ( HOT_RELOAD_LOCK_FILE , DateTime . UtcNow . ToString ( "o" ));
}
catch ( Exception e )
{
LOG . LogError ( e , "An error occurred while trying to lock hot reloading." );
}
2026-08-15 19:55:42 +02:00
finally
{
HOT_RELOAD_LOCK_SEMAPHORE . Release ();
}
2025-06-09 14:06:54 +02:00
}
2026-08-15 19:55:42 +02:00
/// <summary>
/// Releases the hot reload lock of one caller, see LockHotReloadAsync.
/// </summary>
public static void UnlockHotReload ()
2025-06-09 14:06:54 +02:00
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-06-09 14:06:54 +02:00
{
LOG . LogError ( "PluginFactory is not initialized." );
return ;
}
2026-08-15 19:55:42 +02:00
HOT_RELOAD_LOCK_SEMAPHORE . Wait ();
2025-06-09 14:06:54 +02:00
try
{
2026-08-15 19:55:42 +02:00
//
// The count can be zero when the reload gave up waiting and removed the lock file
// itself. We must not go negative, because that would keep the next lock from ever
// writing the file again:
//
if ( HOT_RELOAD_LOCK_COUNT > 0 )
HOT_RELOAD_LOCK_COUNT --;
if ( HOT_RELOAD_LOCK_COUNT > 0 )
return ;
2025-06-09 14:06:54 +02:00
if ( File . Exists ( HOT_RELOAD_LOCK_FILE ))
File . Delete ( HOT_RELOAD_LOCK_FILE );
else
LOG . LogWarning ( "Hot reload lock file does not exist. Nothing to unlock." );
}
catch ( Exception e )
{
LOG . LogError ( e , "An error occurred while trying to unlock hot reloading." );
}
2026-08-15 19:55:42 +02:00
finally
{
HOT_RELOAD_LOCK_SEMAPHORE . Release ();
}
2025-06-09 14:06:54 +02:00
}
2025-03-29 18:40:17 +01:00
2025-03-30 20:34:30 +02:00
public static void Dispose ()
{
2026-02-19 20:43:47 +01:00
if (! IsInitialized )
2025-04-12 21:13:33 +02:00
return ;
2026-08-15 19:55:42 +02:00
2025-03-30 20:34:30 +02:00
HOT_RELOAD_WATCHER . Dispose ();
2026-08-15 19:55:42 +02:00
HOT_RELOAD_DEBOUNCE_TIMER . Dispose ();
2025-03-30 20:34:30 +02:00
}
2026-04-10 17:11:05 +02:00
public static IReadOnlyList < DataMandatoryInfo > GetMandatoryInfos ()
{
2026-09-13 14:17:25 +02:00
return ResolveLivePluginContent < PluginConfiguration , DataMandatoryInfo >( "mandatory info" , plugin => plugin . MandatoryInfos ). ToList ();
2026-04-10 17:11:05 +02:00
}
2026-06-20 20:28:22 +02:00
public static IReadOnlyList < DataIntroduction > GetIntroductions ()
{
2026-09-13 14:17:25 +02:00
return ResolveLivePluginContent < PluginConfiguration , DataIntroduction >( "introduction" , plugin => plugin . Introductions )
2026-06-20 20:28:22 +02:00
. OrderBy ( introduction => introduction . Index )
2026-08-25 08:39:29 +02:00
. ThenBy ( introduction => introduction . Id , StringComparer . Ordinal )
2026-06-20 20:28:22 +02:00
. ToList ();
}
2026-08-25 08:39:29 +02:00
/// <summary>
2026-09-13 14:17:25 +02:00
/// Collects what the running model plugins declare about models.
2026-08-25 08:39:29 +02:00
/// </summary>
/// <remarks>
2026-09-13 14:17:25 +02:00
/// A declaration is identified by its pattern, so two plugins claiming exactly the same model
/// names are a collision like any other and are settled the same way. Two plugins describing
/// different models never meet, and both are heard.
/// </remarks>
/// <returns>The declarations of all model plugins, with every pattern resolved to one winner.</returns>
public static IReadOnlyList < ModelDeclaration > GetModelDeclarations ()
{
return ResolveLivePluginContent < PluginModels , ModelDeclaration >( "model declaration" , plugin => plugin . Declarations ). ToList ();
}
/// <summary>
/// Collects live content from all running plugins of one kind, so that each content ID appears exactly once.
/// </summary>
/// <remarks>
/// The IDs of live content are chosen by whoever writes the plugin, so two plugins may use the
/// same ID. We resolve such a collision the same way a collision on a setting is resolved: a
/// plugin which acts on behalf of the organization wins, so nobody can push aside what an
/// organization deployed. Among plugins of the same origin, the declared priority decides, and
/// when even that is equal, the plugin which started later wins.<br/><br/>
2026-08-25 08:39:29 +02:00
/// Duplicates are not merely a cosmetic problem: the home page keys its panels by the introduction
2026-09-13 14:17:25 +02:00
/// ID, the acceptance of a mandatory info is stored per ID as well, and two model declarations
/// claiming the same names would tie in the matching engine, which only a person can settle.
2026-08-25 08:39:29 +02:00
/// </remarks>
/// <param name="contentKind">The kind of content, used to report a collision in the log.</param>
2026-09-13 14:17:25 +02:00
/// <param name="selector">Selects the content of one plugin.</param>
/// <typeparam name="TPlugin">The kind of plugin providing the content.</typeparam>
2026-08-25 08:39:29 +02:00
/// <typeparam name="T">The type of the live plugin content.</typeparam>
2026-09-13 14:17:25 +02:00
/// <returns>The content of all those plugins, with every ID resolved to one winner.</returns>
private static IEnumerable < T > ResolveLivePluginContent < TPlugin , T >( string contentKind , Func < TPlugin , IEnumerable < T >> selector ) where TPlugin : PluginBase , ILivePluginContentSource where T : ILivePluginContent
2026-08-25 08:39:29 +02:00
{
var contentById = new Dictionary < string , ( T Content , int Authority , int Priority )>( StringComparer . Ordinal );
2026-09-13 14:17:25 +02:00
foreach ( var plugin in RUNNING_PLUGINS . OfType < TPlugin >())
2026-08-25 08:39:29 +02:00
{
var authority = GetConfigurationAuthority ( plugin . PluginPath );
foreach ( var content in selector ( plugin ))
{
if ( contentById . TryGetValue ( content . Id , out var currentWinner ))
{
//
// The candidate needs the higher authority to take over. Within the same
// authority, the higher priority wins, and an equal priority falls back to the
// start order, where the plugin processed later wins:
//
var isTakingOver = authority > currentWinner . Authority || ( authority == currentWinner . Authority && plugin . Priority >= currentWinner . Priority );
var winnerPluginId = isTakingOver ? content . EnterpriseConfigurationPluginId : currentWinner . Content . EnterpriseConfigurationPluginId ;
var ignoredPluginId = isTakingOver ? currentWinner . Content . EnterpriseConfigurationPluginId : content . EnterpriseConfigurationPluginId ;
if ( winnerPluginId == ignoredPluginId )
2026-09-13 14:17:25 +02:00
LOG . LogWarning ( $"The plugin '{winnerPluginId}' defines the {contentKind} ID '{content.Id}' more than once. Using its last definition and ignoring the earlier one. Please use each ID only once." );
2026-08-25 08:39:29 +02:00
else
{
var reason = isTakingOver
? DescribeConfigurationPrecedence ( authority , plugin . Priority , currentWinner . Authority , currentWinner . Priority )
: DescribeConfigurationPrecedence ( currentWinner . Authority , currentWinner . Priority , authority , plugin . Priority );
2026-09-13 14:17:25 +02:00
LOG . LogWarning ( $"Multiple plugins define the {contentKind} ID '{content.Id}'. Using the one from the plugin '{winnerPluginId}' and ignoring the one from the plugin '{ignoredPluginId}', because {reason}." );
2026-08-25 08:39:29 +02:00
}
if (! isTakingOver )
continue ;
}
contentById [ content . Id ] = ( content , authority , plugin . Priority );
}
}
return contentById . Values . Select ( entry => entry . Content );
}
/// <summary>
2026-09-13 14:17:25 +02:00
/// Explains in one phrase why one plugin won a collision against another.
2026-08-25 08:39:29 +02:00
/// </summary>
/// <remarks>
/// Administrators read this in the log while they are testing their configuration. Naming the
/// deciding rule saves them from guessing why their change had no effect.
/// </remarks>
private static string DescribeConfigurationPrecedence ( int winnerAuthority , int winnerPriority , int ignoredAuthority , int ignoredPriority )
{
if ( winnerAuthority != ignoredAuthority )
2026-09-13 14:17:25 +02:00
return "a plugin which acts on behalf of your organization takes precedence over a locally placed one" ;
2026-08-25 08:39:29 +02:00
if ( winnerPriority != ignoredPriority )
return $"it declares the higher priority ({winnerPriority} instead of {ignoredPriority})" ;
2026-09-13 14:17:25 +02:00
return $"both declare the same priority ({winnerPriority}), so the plugin which started later wins" ;
2026-08-25 08:39:29 +02:00
}
2025-03-22 21:12:14 +01:00
}