2025-03-22 20:12:14 +00:00
using AIStudio.Settings ;
2026-04-10 15:11:05 +00:00
using AIStudio.Settings.DataModel ;
2025-03-22 20:12:14 +00:00
namespace AIStudio.Tools.PluginSystem ;
2025-03-29 17:40:17 +00:00
public static partial class PluginFactory
2025-03-22 20:12:14 +00:00
{
2025-04-12 19:13:33 +00:00
private static readonly ILogger LOG = Program . LOGGER_FACTORY . CreateLogger ( nameof ( PluginFactory ) ) ;
2026-06-10 19:01:27 +00:00
private static SettingsManager SettingsManagerAccess = > Program . SERVICE_PROVIDER . GetRequiredService < SettingsManager > ( ) ;
2026-02-19 19:43:47 +00:00
2025-04-12 19:13:33 +00: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 16:35:46 +00:00
/// <summary>
/// The directory the config server downloads the configuration plugins of an organization into.
/// </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
/// deploys plugins here, each in a directory named after its configuration ID.
/// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string . Empty ;
2025-06-09 12:06:54 +00:00
private static string HOT_RELOAD_LOCK_FILE = string . Empty ;
2025-04-12 19:13:33 +00:00
private static FileSystemWatcher HOT_RELOAD_WATCHER = null ! ;
2026-02-19 19:43:47 +00:00
public static ILanguagePlugin BaseLanguage { get ; private set ; } = NoPluginLanguage . INSTANCE ;
public static bool IsInitialized { get ; private set ; }
2025-04-07 17:36:24 +00:00
2026-02-07 21:59:41 +00: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 11:02:59 +00:00
/// from the effective enterprise source.
2026-02-07 21:59:41 +00: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 11:02:59 +00: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 21:59:41 +00: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 19:13:33 +00: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 07:50:03 +00:00
public static bool Setup ( )
2025-04-07 17:36:24 +00:00
{
2026-02-19 19:43:47 +00:00
if ( IsInitialized )
2025-04-24 07:50:03 +00:00
return false ;
2025-04-23 12:07:22 +00:00
2025-06-02 18:08:25 +00:00
LOG . LogInformation ( "Initializing plugin factory..." ) ;
2025-04-12 19:13:33 +00:00
DATA_DIR = SettingsManager . DataDirectory ! ;
PLUGINS_ROOT = Path . Join ( DATA_DIR , "plugins" ) ;
2025-06-09 12:06:54 +00:00
HOT_RELOAD_LOCK_FILE = Path . Join ( PLUGINS_ROOT , ".lock" ) ;
2025-04-12 19:13:33 +00:00
INTERNAL_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".internal" ) ;
2026-08-08 16:35:46 +00:00
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path . Join ( PLUGINS_ROOT , ".config" ) ;
2025-04-12 19:13:33 +00:00
2025-04-07 17:36:24 +00:00
if ( ! Directory . Exists ( PLUGINS_ROOT ) )
Directory . CreateDirectory ( PLUGINS_ROOT ) ;
HOT_RELOAD_WATCHER = new ( PLUGINS_ROOT ) ;
2026-02-19 19:43:47 +00:00
IsInitialized = true ;
2025-06-02 18:08:25 +00:00
LOG . LogInformation ( "Plugin factory initialized successfully." ) ;
2025-04-24 07:50:03 +00:00
return true ;
2025-04-07 17:36:24 +00:00
}
2025-06-09 12:06:54 +00:00
2026-08-08 16:35:46 +00: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
/// them into a directory named after their configuration ID. 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.
/// </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-08 18:05:38 +00:00
public static bool IsEnterpriseConfigurationPath ( string? pluginPath ) = > IsPathInside ( ENTERPRISE_CONFIGURATION_PLUGINS_ROOT , pluginPath ) ;
/// <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 ) ;
2026-08-09 14:21:52 +00:00
/// <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 ) )
return false ;
try
{
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 ;
}
}
2026-08-08 18:05:38 +00:00
private static bool IsPathInside ( string rootDirectory , string? pluginPath )
2026-08-08 16:35:46 +00:00
{
2026-08-08 18:05:38 +00:00
if ( string . IsNullOrWhiteSpace ( pluginPath ) | | string . IsNullOrWhiteSpace ( rootDirectory ) )
2026-08-08 16:35:46 +00:00
return false ;
try
{
2026-08-08 18:05:38 +00:00
var root = Path . GetFullPath ( rootDirectory ) . TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) + Path . DirectorySeparatorChar ;
2026-08-08 16:35:46 +00:00
var pluginDirectory = Path . GetFullPath ( pluginPath ) . TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) + Path . DirectorySeparatorChar ;
2026-08-08 18:05:38 +00:00
return pluginDirectory . StartsWith ( root , StringComparison . OrdinalIgnoreCase ) ;
2026-08-08 16:35:46 +00:00
}
catch ( Exception e )
{
2026-08-08 18:05:38 +00: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 16:35:46 +00: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 ( ) ) ) ;
}
2025-06-09 12:06:54 +00:00
private static async Task LockHotReloadAsync ( )
{
2026-02-19 19:43:47 +00:00
if ( ! IsInitialized )
2025-06-09 12:06:54 +00:00
{
LOG . LogError ( "PluginFactory is not initialized." ) ;
return ;
}
try
{
if ( File . Exists ( HOT_RELOAD_LOCK_FILE ) )
{
LOG . LogWarning ( "Hot reload lock file already exists." ) ;
return ;
}
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." ) ;
}
}
private static void UnlockHotReload ( )
{
2026-02-19 19:43:47 +00:00
if ( ! IsInitialized )
2025-06-09 12:06:54 +00:00
{
LOG . LogError ( "PluginFactory is not initialized." ) ;
return ;
}
try
{
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." ) ;
}
}
2025-03-29 17:40:17 +00:00
2025-03-30 18:34:30 +00:00
public static void Dispose ( )
{
2026-02-19 19:43:47 +00:00
if ( ! IsInitialized )
2025-04-12 19:13:33 +00:00
return ;
2025-03-30 18:34:30 +00:00
HOT_RELOAD_WATCHER . Dispose ( ) ;
}
2026-04-10 15:11:05 +00:00
public static IReadOnlyList < DataMandatoryInfo > GetMandatoryInfos ( )
{
return RUNNING_PLUGINS
. OfType < PluginConfiguration > ( )
. SelectMany ( plugin = > plugin . MandatoryInfos )
. ToList ( ) ;
}
2026-06-20 18:28:22 +00:00
public static IReadOnlyList < DataIntroduction > GetIntroductions ( )
{
return RUNNING_PLUGINS
. OfType < PluginConfiguration > ( )
. SelectMany ( plugin = > plugin . Introductions )
. OrderBy ( introduction = > introduction . Index )
. ToList ( ) ;
}
2025-03-22 20:12:14 +00:00
}