2025-03-22 21:12:14 +01:00
using Lua ;
// ReSharper disable MemberCanBePrivate.Global
namespace AIStudio.Tools.PluginSystem ;
/// <summary>
/// Represents the base of any AI Studio plugin.
/// </summary>
2026-08-23 21:15:37 +02:00
public abstract partial class PluginBase : IPluginMetadata , IDisposable
2025-03-22 21:12:14 +01:00
{
2025-05-04 14:59:30 +02:00
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( PluginBase ). Namespace , nameof ( PluginBase ));
2025-03-22 21:12:14 +01:00
private readonly IReadOnlyCollection < string > baseIssues ;
2026-04-09 10:08:37 +02:00
protected readonly LuaState State ;
2025-03-22 21:12:14 +01:00
2026-04-09 10:08:37 +02:00
protected readonly List < string > PluginIssues = [];
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2026-08-25 12:46:10 +02:00
public string IconDataUrl { get ; }
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public PluginType Type { get ; }
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public Guid Id { get ; }
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public string Name { get ; } = string . Empty ;
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public string Description { get ; } = string . Empty ;
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public PluginVersion Version { get ; }
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public string [] Authors { get ; } = [];
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public string SupportContact { get ; } = string . Empty ;
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public string SourceURL { get ; } = string . Empty ;
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public PluginCategory [] Categories { get ; } = [];
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public PluginTargetGroup [] TargetGroups { get ; } = [];
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
2025-03-22 21:12:14 +01:00
public bool IsMaintained { get ; }
2025-03-29 18:40:17 +01:00
/// <inheritdoc />
public string DeprecationMessage { get ; } = string . Empty ;
/// <inheritdoc />
public bool IsInternal { get ; }
2026-04-09 10:01:24 +02:00
/// <summary>
/// The absolute path to the plugin directory (where `plugin.lua` lives).
/// </summary>
public string PluginPath { get ; internal set ; } = string . Empty ;
2025-03-29 18:40:17 +01:00
2025-03-22 21:12:14 +01:00
/// <summary>
/// The issues that occurred during the initialization of this plugin.
/// </summary>
2026-04-09 10:08:37 +02:00
public IEnumerable < string > Issues => this . baseIssues . Concat ( this . PluginIssues );
2025-03-22 21:12:14 +01:00
/// <summary>
/// True, when the plugin is valid.
/// </summary>
/// <remarks>
/// False means that there were issues during the initialization of the plugin.
/// Please check the Issues property for more information.
/// </remarks>
2026-04-09 10:08:37 +02:00
public bool IsValid => this is not NoPlugin && this . baseIssues . Count == 0 && this . PluginIssues . Count == 0 ;
2025-03-22 21:12:14 +01:00
2025-03-29 18:40:17 +01:00
protected PluginBase ( bool isInternal , LuaState state , PluginType type , string parseError = "" )
2025-03-22 21:12:14 +01:00
{
2026-04-09 10:08:37 +02:00
this . State = state ;
2025-03-22 21:12:14 +01:00
this . Type = type ;
var issues = new List < string >();
if (! string . IsNullOrWhiteSpace ( parseError ))
issues . Add ( parseError );
2025-03-29 18:40:17 +01:00
2026-06-02 16:32:09 +02:00
if ( this is NoPlugin or NoPluginLanguage )
{
this . IsInternal = isInternal ;
2026-08-25 12:46:10 +02:00
this . IconDataUrl = string . Empty ;
2026-06-02 16:32:09 +02:00
this . baseIssues = issues ;
return ;
}
2025-03-29 18:40:17 +01:00
// Notice: when no icon is specified, the default icon will be used.
2026-08-25 12:46:10 +02:00
this . TryInitIconDataUrl ( out _ , out var iconDataUrl );
this . IconDataUrl = iconDataUrl ;
2025-03-22 21:12:14 +01:00
if ( this . TryInitId ( out var issue , out var id ))
2025-03-29 18:40:17 +01:00
{
2025-03-22 21:12:14 +01:00
this . Id = id ;
2025-03-29 18:40:17 +01:00
this . IsInternal = isInternal ;
}
2025-03-22 21:12:14 +01:00
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitName ( out issue , out var name ))
this . Name = name ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitDescription ( out issue , out var description ))
this . Description = description ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitVersion ( out issue , out var version ))
this . Version = version ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitAuthors ( out issue , out var authors ))
this . Authors = authors ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitSupportContact ( out issue , out var contact ))
this . SupportContact = contact ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitSourceURL ( out issue , out var url ))
this . SourceURL = url ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitCategories ( out issue , out var categories ))
this . Categories = categories ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitTargetGroups ( out issue , out var targetGroups ))
this . TargetGroups = targetGroups ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitIsMaintained ( out issue , out var isMaintained ))
this . IsMaintained = isMaintained ;
else if ( this is not NoPlugin )
issues . Add ( issue );
if ( this . TryInitDeprecationMessage ( out issue , out var deprecationMessage ))
this . DeprecationMessage = deprecationMessage ;
else if ( this is not NoPlugin )
issues . Add ( issue );
this . baseIssues = issues ;
}
#region Initialization - related methods
/// <summary>
/// Tries to read the ID of the plugin.
/// </summary>
/// <param name="message">The error message, when the ID could not be read.</param>
/// <param name="id">The read ID.</param>
/// <returns>True, when the ID could be read successfully.</returns>
private bool TryInitId ( out string message , out Guid id )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "ID" ]. TryRead < string >( out var idText ))
2025-03-22 21:12:14 +01:00
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field ID does not exist or is not a valid string." );
2025-03-22 21:12:14 +01:00
id = Guid . Empty ;
return false ;
}
if (! Guid . TryParse ( idText , out id ))
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field ID is not a valid GUID / UUID. The ID must be formatted in the 8-4-4-4-12 format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." );
2025-03-22 21:12:14 +01:00
id = Guid . Empty ;
return false ;
}
if ( id == Guid . Empty )
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field ID is empty. The ID must be formatted in the 8-4-4-4-12 format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the name of the plugin.
/// </summary>
/// <param name="message">The error message, when the name could not be read.</param>
/// <param name="name">The read name.</param>
/// <returns>True, when the name could be read successfully.</returns>
private bool TryInitName ( out string message , out string name )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "NAME" ]. TryRead ( out name ))
2025-03-22 21:12:14 +01:00
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field NAME does not exist or is not a valid string." );
2025-03-22 21:12:14 +01:00
name = string . Empty ;
return false ;
}
if ( string . IsNullOrWhiteSpace ( name ))
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field NAME is empty. The name must be a non-empty string." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the description of the plugin.
/// </summary>
/// <param name="message">The error message, when the description could not be read.</param>
/// <param name="description">The read description.</param>
/// <returns>True, when the description could be read successfully.</returns>
private bool TryInitDescription ( out string message , out string description )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "DESCRIPTION" ]. TryRead ( out description ))
2025-03-22 21:12:14 +01:00
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field DESCRIPTION does not exist or is not a valid string." );
2025-03-22 21:12:14 +01:00
description = string . Empty ;
return false ;
}
if ( string . IsNullOrWhiteSpace ( description ))
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field DESCRIPTION is empty. The description must be a non-empty string." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the version of the plugin.
/// </summary>
/// <param name="message">The error message, when the version could not be read.</param>
/// <param name="version">The read version.</param>
/// <returns>True, when the version could be read successfully.</returns>
private bool TryInitVersion ( out string message , out PluginVersion version )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "VERSION" ]. TryRead < string >( out var versionText ))
2025-03-22 21:12:14 +01:00
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field VERSION does not exist or is not a valid string." );
2025-03-22 21:12:14 +01:00
version = PluginVersion . NONE ;
return false ;
}
if (! PluginVersion . TryParse ( versionText , out version ))
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X)." );
2025-03-22 21:12:14 +01:00
version = PluginVersion . NONE ;
return false ;
}
if ( version == PluginVersion . NONE )
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field VERSION is empty. The version number must be formatted as string in the major.minor.patch format (X.X.X)." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the authors of the plugin.
/// </summary>
/// <param name="message">The error message, when the authors could not be read.</param>
/// <param name="authors">The read authors.</param>
/// <returns>True, when the authors could be read successfully.</returns>
private bool TryInitAuthors ( out string message , out string [] authors )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "AUTHORS" ]. TryRead < LuaTable >( out var authorsTable ))
2025-03-22 21:12:14 +01:00
{
authors = [];
2025-05-04 14:59:30 +02:00
message = TB ( "The table AUTHORS does not exist or is using an invalid syntax." );
2025-03-22 21:12:14 +01:00
return false ;
}
var authorList = new List < string >();
foreach ( var author in authorsTable . GetArraySpan ())
if ( author . TryRead < string >( out var authorName ))
authorList . Add ( authorName );
authors = authorList . ToArray ();
if ( authorList . Count == 0 )
{
2025-05-04 14:59:30 +02:00
message = TB ( "The table AUTHORS is empty. At least one author must be specified." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the support contact for the plugin.
/// </summary>
/// <param name="message">The error message, when the support contact could not be read.</param>
/// <param name="contact">The read support contact.</param>
/// <returns>True, when the support contact could be read successfully.</returns>
private bool TryInitSupportContact ( out string message , out string contact )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "SUPPORT_CONTACT" ]. TryRead ( out contact ))
2025-03-22 21:12:14 +01:00
{
contact = string . Empty ;
2025-05-04 14:59:30 +02:00
message = TB ( "The field SUPPORT_CONTACT does not exist or is not a valid string." );
2025-03-22 21:12:14 +01:00
return false ;
}
if ( string . IsNullOrWhiteSpace ( contact ))
{
2025-05-04 14:59:30 +02:00
message = TB ( "The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Try to read the source URL of the plugin.
/// </summary>
/// <param name="message">The error message, when the source URL could not be read.</param>
/// <param name="url">The read source URL.</param>
/// <returns>True, when the source URL could be read successfully.</returns>
private bool TryInitSourceURL ( out string message , out string url )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "SOURCE_URL" ]. TryRead ( out url ))
2025-03-22 21:12:14 +01:00
{
url = string . Empty ;
2025-05-04 14:59:30 +02:00
message = TB ( "The field SOURCE_URL does not exist or is not a valid string." );
2025-03-22 21:12:14 +01:00
return false ;
}
2026-02-10 15:23:56 +01:00
url = url . Trim ();
if (! Uri . TryCreate ( url , UriKind . Absolute , out var sourceUri ))
2025-03-22 21:12:14 +01:00
{
url = string . Empty ;
2026-02-10 15:23:56 +01:00
message = TB ( "The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'." );
2025-03-22 21:12:14 +01:00
return false ;
}
2026-02-10 15:23:56 +01:00
var isHttp = sourceUri . Scheme . Equals ( Uri . UriSchemeHttp , StringComparison . OrdinalIgnoreCase );
var isHttps = sourceUri . Scheme . Equals ( Uri . UriSchemeHttps , StringComparison . OrdinalIgnoreCase );
var isMailTo = sourceUri . Scheme . Equals ( Uri . UriSchemeMailto , StringComparison . OrdinalIgnoreCase );
if (! isHttp && ! isHttps && ! isMailTo )
{
url = string . Empty ;
message = TB ( "The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'." );
return false ;
}
if ( isMailTo )
{
var recipient = ExtractMailtoRecipient ( url );
if ( string . IsNullOrWhiteSpace ( recipient ))
{
url = string . Empty ;
message = TB ( "The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient." );
return false ;
}
}
url = sourceUri . ToString ();
2025-03-22 21:12:14 +01:00
message = string . Empty ;
return true ;
}
2026-02-10 15:23:56 +01:00
private static string ExtractMailtoRecipient ( string rawUrl )
{
var separatorIndex = rawUrl . IndexOf ( ':' );
if ( separatorIndex < 0 || separatorIndex + 1 >= rawUrl . Length )
return string . Empty ;
var schemeSpecificPart = rawUrl [( separatorIndex + 1 )..];
var queryStart = schemeSpecificPart . IndexOf ( '?' );
var recipient = queryStart >= 0
? schemeSpecificPart [.. queryStart ]
: schemeSpecificPart ;
return recipient . Trim ();
}
2025-03-22 21:12:14 +01:00
/// <summary>
/// Tries to read the categories of the plugin.
/// </summary>
/// <param name="message">The error message, when the categories could not be read.</param>
/// <param name="categories">The read categories.</param>
/// <returns>True, when the categories could be read successfully.</returns>
private bool TryInitCategories ( out string message , out PluginCategory [] categories )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "CATEGORIES" ]. TryRead < LuaTable >( out var categoriesTable ))
2025-03-22 21:12:14 +01:00
{
categories = [];
2025-05-04 14:59:30 +02:00
message = TB ( "The table CATEGORIES does not exist or is using an invalid syntax." );
2025-03-22 21:12:14 +01:00
return false ;
}
var categoryList = new List < PluginCategory >();
foreach ( var luaCategory in categoriesTable . GetArraySpan ())
if ( luaCategory . TryRead < string >( out var categoryName ))
if ( Enum . TryParse < PluginCategory >( categoryName , out var category ) && category != PluginCategory . NONE )
categoryList . Add ( category );
categories = categoryList . ToArray ();
if ( categoryList . Count == 0 )
{
2025-05-04 14:59:30 +02:00
message = string . Format ( TB ( "The table CATEGORIES is empty. At least one category is necessary. Valid categories are: {0}." ), CommonTools . GetAllEnumValues ( PluginCategory . NONE ));
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the intended target groups for the plugin.
/// </summary>
/// <param name="message">The error message, when the target groups could not be read.</param>
/// <param name="targetGroups">The read target groups.</param>
/// <returns>True, when the target groups could be read successfully.</returns>
private bool TryInitTargetGroups ( out string message , out PluginTargetGroup [] targetGroups )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "TARGET_GROUPS" ]. TryRead < LuaTable >( out var targetGroupsTable ))
2025-03-22 21:12:14 +01:00
{
targetGroups = [];
2025-05-04 14:59:30 +02:00
message = TB ( "The table TARGET_GROUPS does not exist or is using an invalid syntax." );
2025-03-22 21:12:14 +01:00
return false ;
}
var targetGroupList = new List < PluginTargetGroup >();
foreach ( var luaTargetGroup in targetGroupsTable . GetArraySpan ())
if ( luaTargetGroup . TryRead < string >( out var targetGroupName ))
if ( Enum . TryParse < PluginTargetGroup >( targetGroupName , out var targetGroup ) && targetGroup != PluginTargetGroup . NONE )
targetGroupList . Add ( targetGroup );
targetGroups = targetGroupList . ToArray ();
if ( targetGroups . Length == 0 )
{
2025-05-04 14:59:30 +02:00
message = string . Format ( TB ( "The table TARGET_GROUPS is empty or is not a valid table of strings. Valid target groups are: {0}." ), CommonTools . GetAllEnumValues ( PluginTargetGroup . NONE ));
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the maintenance status of the plugin.
/// </summary>
/// <param name="message">The error message, when the maintenance status could not be read.</param>
/// <param name="isMaintained">The read maintenance status.</param>
/// <returns>True, when the maintenance status could be read successfully.</returns>
private bool TryInitIsMaintained ( out string message , out bool isMaintained )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "IS_MAINTAINED" ]. TryRead ( out isMaintained ))
2025-03-22 21:12:14 +01:00
{
isMaintained = false ;
2025-05-04 14:59:30 +02:00
message = TB ( "The field IS_MAINTAINED does not exist or is not a valid boolean." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
/// <summary>
/// Tries to read the deprecation message of the plugin.
/// </summary>
/// <param name="message">The error message, when the deprecation message could not be read.</param>
/// <param name="deprecationMessage">The read deprecation message.</param>
/// <returns>True, when the deprecation message could be read successfully.</returns>
2025-03-29 18:40:17 +01:00
private bool TryInitDeprecationMessage ( out string message , out string deprecationMessage )
2025-03-22 21:12:14 +01:00
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "DEPRECATION_MESSAGE" ]. TryRead ( out deprecationMessage ))
2025-03-22 21:12:14 +01:00
{
2025-03-29 18:40:17 +01:00
deprecationMessage = string . Empty ;
2025-05-04 14:59:30 +02:00
message = TB ( "The field DEPRECATION_MESSAGE does not exist, is not a valid string. This message is optional: use an empty string to indicate that the plugin is not deprecated." );
2025-03-22 21:12:14 +01:00
return false ;
}
message = string . Empty ;
return true ;
}
2025-03-29 18:40:17 +01:00
2025-03-22 21:12:14 +01:00
/// <summary>
/// Tries to initialize the UI text content of the plugin.
/// </summary>
/// <param name="message">The error message, when the UI text content could not be read.</param>
/// <param name="pluginContent">The read UI text content.</param>
/// <returns>True, when the UI text content could be read successfully.</returns>
protected bool TryInitUITextContent ( out string message , out Dictionary < string , string > pluginContent )
{
2026-04-09 10:08:37 +02:00
if (! this . State . Environment [ "UI_TEXT_CONTENT" ]. TryRead < LuaTable >( out var textTable ))
2025-03-22 21:12:14 +01:00
{
2025-05-04 14:59:30 +02:00
message = TB ( "The UI_TEXT_CONTENT table does not exist or is not a valid table." );
2025-03-22 21:12:14 +01:00
pluginContent = [];
return false ;
}
this . ReadTextTable ( "root" , textTable , out pluginContent );
message = string . Empty ;
return true ;
}
/// <summary>
/// Reads a flat or hierarchical text table.
/// </summary>
/// <param name="parent">The parent key(s).</param>
/// <param name="table">The table to read.</param>
/// <param name="tableContent">The read table content.</param>
protected void ReadTextTable ( string parent , LuaTable table , out Dictionary < string , string > tableContent )
{
tableContent = [];
var lastKey = LuaValue . Nil ;
while ( table . TryGetNext ( lastKey , out var pair ))
{
var keyText = pair . Key . ToString ();
if ( pair . Value . TryRead < string >( out var value ))
tableContent [ $"{parent}::{keyText}" ] = value ;
else if ( pair . Value . TryRead < LuaTable >( out var t ))
{
this . ReadTextTable ( $"{parent}::{keyText}" , t , out var subContent );
foreach ( var ( k , v ) in subContent )
tableContent [ k ] = v ;
}
lastKey = pair . Key ;
}
}
#endregion
2026-08-23 21:15:37 +02:00
#region Implementation of IDisposable
/// <summary>
/// Releases the Lua runtime of this plugin.
/// </summary>
/// <remarks>
/// Every plugin owns a Lua state, which is an entire scripting runtime. Dropping a plugin
/// without disposing it leaves that runtime behind: before this existed, each hot reload added
/// another set of them for as long as the app was running.
/// </remarks>
public void Dispose () => this . State . Dispose ();
#endregion
2026-04-09 10:01:24 +02:00
}