Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions PlugHub.Shared/Interfaces/Plugins/IPluginResourceInclusion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Avalonia.Controls;
using Avalonia.Styling;
using PlugHub.Shared.Attributes;
using PlugHub.Shared.Models.Plugins;

namespace PlugHub.Shared.Interfaces.Plugins
{
/// <summary>
/// Describes a plugin component that provides Avalonia resource dictionaries (AXAML files)
/// that must be loaded before the main UI initializes.
/// Declares all dependency and ordering relationships for conflict-free, deterministic resource loading.
/// </summary>
/// <param name="PluginID">Unique identifier for the plugin providing this descriptor.</param>
/// <param name="DescriptorID">Unique identifier for the descriptor.</param>
/// <param name="Version">Version of the descriptor.</param>
/// <param name="ResourceUri">URI of the AXAML resource to be loaded as a ResourceInclude.</param>
/// <param name="BaseUri">Base URI for resolving relative resource paths (defaults to plugin's base URI).</param>
/// <param name="Factory">Optional delegate that creates one or more <see cref="IResourceDictionary"/> or <see cref="IResourceProvider"/> instances at runtime.</param>
/// <param name="LoadBefore">Descriptors that should be applied after this one to maintain order.</param>
/// <param name="LoadAfter">Descriptors that should be applied before this one to maintain order.</param>
/// <param name="DependsOn">Descriptors that this descriptor explicitly depends on.</param>
/// <param name="ConflictsWith">Descriptors with which this descriptor cannot coexist.</param>
public record PluginResourceIncludeDescriptor(
Guid PluginID,
Guid DescriptorID,
string Version,
string? ResourceUri = null,
string? BaseUri = null,
Func<IResourceDictionary>? Factory = null,
IEnumerable<PluginDescriptorReference>? LoadBefore = null,
IEnumerable<PluginDescriptorReference>? LoadAfter = null,
IEnumerable<PluginDescriptorReference>? DependsOn = null,
IEnumerable<PluginDescriptorReference>? ConflictsWith = null
) : PluginDescriptor(PluginID, DescriptorID, Version, LoadBefore, LoadAfter, DependsOn, ConflictsWith);

/// <summary>
/// Interface for plugins that supply Avalonia resource dictionaries.
/// Provides descriptors for AXAML resource files (styles, themes, control templates, etc.)
/// that need to be loaded during application bootstrap.
/// </summary>
[DescriptorProvider("GetResourceIncludeDescriptors", false)]
public interface IPluginResourceInclusion : IPlugin
{
/// <summary>
/// Returns a collection of descriptors defining Avalonia resource dictionaries
/// (AXAML files containing styles, themes, or other resources) offered by this plugin.
/// </summary>
IEnumerable<PluginResourceIncludeDescriptor> GetResourceIncludeDescriptors();
}
}
115 changes: 87 additions & 28 deletions PlugHub/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,7 @@ private static void ConfigureTheme(IConfigService configService, TokenSet tokenS
ArgumentNullException.ThrowIfNull(configService);
ArgumentNullException.ThrowIfNull(tokenSet);

IEnumerable<IPluginStyleInclusion> styleIncludeProviders = serviceProvider.GetServices<IPluginStyleInclusion>();
ILogger<App> logger = serviceProvider.GetRequiredService<ILogger<App>>();
IPluginResolver pluginResolver = serviceProvider.GetRequiredService<IPluginResolver>();

IReadOnlyList<PluginStyleIncludeDescriptor> orderedDescriptors =
pluginResolver.ResolveAndOrder<IPluginStyleInclusion, PluginStyleIncludeDescriptor>(styleIncludeProviders);

HashSet<string> loadedResourceDictionaries = [];
HashSet<Type> loadedFactoryTypes = [];

IConfigAccessorFor<AppConfig> configAccessor = configService.GetAccessor<AppConfig>(owner: tokenSet.Owner);
IConfigAccessorFor<AppEnv> envAccessor = configService.GetAccessor<AppEnv>(owner: tokenSet.Owner);
Expand All @@ -264,9 +256,6 @@ private static void ConfigureTheme(IConfigService configService, TokenSet tokenS
? appEnv.SystemTheme!
: appConfig.SystemTheme;

if (!useDefaultTheme)
return;

ThemeVariant requested = systemThemeStr?.Trim().ToLowerInvariant() switch
{
"light" => ThemeVariant.Light,
Expand All @@ -277,51 +266,121 @@ private static void ConfigureTheme(IConfigService configService, TokenSet tokenS
if (Application.Current != null)
Application.Current.RequestedThemeVariant = requested;

styles.Add(new StyleInclude(new Uri("avares://PlugHub/"))
if (useDefaultTheme)
{
Source = new Uri("avares://PlugHub/Styles/Icons.axaml")
});
styles.Add(new FluentAvaloniaTheme
{
PreferSystemTheme = preferSystemTheme,
PreferUserAccentColor = preferUserAccentColor
});
}

styles.Add(new FluentAvaloniaTheme
resources.MergedDictionaries.Add(new ResourceInclude(new Uri("avares://PlugHub/"))
{
PreferSystemTheme = preferSystemTheme,
PreferUserAccentColor = preferUserAccentColor
Source = new Uri("avares://PlugHub/Styles/Generic.axaml")
});

resources.MergedDictionaries.Add(new ResourceInclude(new Uri("avares://PlugHub/"))
AddPluginResources(resources, logger);

styles.Add(new StyleInclude(new Uri("avares://PlugHub/"))
{
Source = new Uri("avares://PlugHub/Styles/Generic.axaml")
Source = new Uri("avares://PlugHub/Styles/Icons.axaml")
});

AddPluginStyles(styles, logger);
}

private static void AddPluginResources(IResourceDictionary resources, ILogger<App> logger)
{
IEnumerable<IPluginResourceInclusion>? providers = serviceProvider?.GetServices<IPluginResourceInclusion>();
IPluginResolver? resolver = serviceProvider?.GetRequiredService<IPluginResolver>();

IReadOnlyList<PluginResourceIncludeDescriptor>? descriptors = resolver?.ResolveAndOrder<IPluginResourceInclusion, PluginResourceIncludeDescriptor>(providers);

HashSet<string> loadedUris = [];
HashSet<Type> loadedFactories = [];

foreach (PluginStyleIncludeDescriptor descriptor in orderedDescriptors)
foreach (PluginResourceIncludeDescriptor descriptor in descriptors ?? [])
{
if (Application.Current?.Styles is null)
continue;
try
{
if (descriptor.Factory is not null)
{
IResourceDictionary? dict = descriptor.Factory();

if (dict is not null && loadedFactories.Add(dict.GetType()))
{
resources.MergedDictionaries.Add(dict);
}
else
{
logger.LogDebug("[App] Skipped duplicate resource factory of type {Type}", dict?.GetType().FullName);
}
}
else if (!string.IsNullOrEmpty(descriptor.ResourceUri) && loadedUris.Add(descriptor.ResourceUri))
{
Uri baseUri = string.IsNullOrEmpty(descriptor.BaseUri)
? new Uri("avares://PlugHub/")
: new Uri(descriptor.BaseUri);

ResourceInclude include = new(baseUri)
{
Source = new Uri(descriptor.ResourceUri)
};

resources.MergedDictionaries.Add(include);
}
}
catch (Exception ex)
{
logger.LogError(ex, "[App] Failed to load resource from {Source}", descriptor.ResourceUri ?? descriptor.Factory?.Method?.Name ?? "unknown");
}
}

logger.LogInformation("[App] Added {UriCount} URI-based resource dictionaries and {FactoryCount} factory dictionaries", loadedUris.Count, loadedFactories.Count);
}
private static void AddPluginStyles(Styles styles, ILogger<App> logger)
{
if (Application.Current?.Styles is null)
return;

IEnumerable<IPluginStyleInclusion>? providers = serviceProvider?.GetServices<IPluginStyleInclusion>();
IPluginResolver? resolver = serviceProvider?.GetRequiredService<IPluginResolver>();

IReadOnlyList<PluginStyleIncludeDescriptor>? descriptors = resolver?.ResolveAndOrder<IPluginStyleInclusion, PluginStyleIncludeDescriptor>(providers);

HashSet<Type> loadedFactories = [];
HashSet<string> loadedIncludes = [];

foreach (PluginStyleIncludeDescriptor descriptor in descriptors ?? [])
{
try
{
if (descriptor.Factory is not null)
{
IStyle style = descriptor.Factory();

if (loadedFactoryTypes.Add(style.GetType()))
Application.Current.Styles.Add(style);
if (loadedFactories.Add(style.GetType()))
{
styles.Add(style);
}
else
{
logger.LogDebug("[App] Skipped duplicate factory style of type {StyleType}", style.GetType().FullName);
}
}
else if (!string.IsNullOrEmpty(descriptor.ResourceUri) && loadedResourceDictionaries.Add(descriptor.ResourceUri))
else if (!string.IsNullOrEmpty(descriptor.ResourceUri) && loadedIncludes.Add(descriptor.ResourceUri))
{
Uri baseUri = string.IsNullOrEmpty(descriptor.BaseUri)
? new Uri("avares://PlugHub/")
: new Uri(descriptor.BaseUri);

StyleInclude styleInclude = new(baseUri)
StyleInclude include = new(baseUri)
{
Source = new Uri(descriptor.ResourceUri)
};

Application.Current.Styles.Add(styleInclude);
styles.Add(include);
}
}
catch (Exception ex)
Expand All @@ -330,7 +389,7 @@ private static void ConfigureTheme(IConfigService configService, TokenSet tokenS
}
}

logger.LogInformation("[App] PluginsStyleIncludes completed: Added {ResourceCount} unique resource dictionaries and {FactoryCount} unique factory styles.", loadedResourceDictionaries.Count, loadedFactoryTypes.Count);
logger.LogInformation("[App] Added {FactoryCount} factory styles and {IncludeCount} style includes", loadedFactories.Count, loadedIncludes.Count);
}

#endregion
Expand Down