diff --git a/PlugHub.Shared/Interfaces/Plugins/IPluginResourceInclusion.cs b/PlugHub.Shared/Interfaces/Plugins/IPluginResourceInclusion.cs new file mode 100644 index 0000000..729c466 --- /dev/null +++ b/PlugHub.Shared/Interfaces/Plugins/IPluginResourceInclusion.cs @@ -0,0 +1,50 @@ +using Avalonia.Controls; +using Avalonia.Styling; +using PlugHub.Shared.Attributes; +using PlugHub.Shared.Models.Plugins; + +namespace PlugHub.Shared.Interfaces.Plugins +{ + /// + /// 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. + /// + /// Unique identifier for the plugin providing this descriptor. + /// Unique identifier for the descriptor. + /// Version of the descriptor. + /// URI of the AXAML resource to be loaded as a ResourceInclude. + /// Base URI for resolving relative resource paths (defaults to plugin's base URI). + /// Optional delegate that creates one or more or instances at runtime. + /// Descriptors that should be applied after this one to maintain order. + /// Descriptors that should be applied before this one to maintain order. + /// Descriptors that this descriptor explicitly depends on. + /// Descriptors with which this descriptor cannot coexist. + public record PluginResourceIncludeDescriptor( + Guid PluginID, + Guid DescriptorID, + string Version, + string? ResourceUri = null, + string? BaseUri = null, + Func? Factory = null, + IEnumerable? LoadBefore = null, + IEnumerable? LoadAfter = null, + IEnumerable? DependsOn = null, + IEnumerable? ConflictsWith = null + ) : PluginDescriptor(PluginID, DescriptorID, Version, LoadBefore, LoadAfter, DependsOn, ConflictsWith); + + /// + /// 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. + /// + [DescriptorProvider("GetResourceIncludeDescriptors", false)] + public interface IPluginResourceInclusion : IPlugin + { + /// + /// Returns a collection of descriptors defining Avalonia resource dictionaries + /// (AXAML files containing styles, themes, or other resources) offered by this plugin. + /// + IEnumerable GetResourceIncludeDescriptors(); + } +} \ No newline at end of file diff --git a/PlugHub/App.axaml.cs b/PlugHub/App.axaml.cs index c988ad1..bfb99c7 100644 --- a/PlugHub/App.axaml.cs +++ b/PlugHub/App.axaml.cs @@ -239,15 +239,7 @@ private static void ConfigureTheme(IConfigService configService, TokenSet tokenS ArgumentNullException.ThrowIfNull(configService); ArgumentNullException.ThrowIfNull(tokenSet); - IEnumerable styleIncludeProviders = serviceProvider.GetServices(); ILogger logger = serviceProvider.GetRequiredService>(); - IPluginResolver pluginResolver = serviceProvider.GetRequiredService(); - - IReadOnlyList orderedDescriptors = - pluginResolver.ResolveAndOrder(styleIncludeProviders); - - HashSet loadedResourceDictionaries = []; - HashSet loadedFactoryTypes = []; IConfigAccessorFor configAccessor = configService.GetAccessor(owner: tokenSet.Owner); IConfigAccessorFor envAccessor = configService.GetAccessor(owner: tokenSet.Owner); @@ -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, @@ -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 logger) + { + IEnumerable? providers = serviceProvider?.GetServices(); + IPluginResolver? resolver = serviceProvider?.GetRequiredService(); + + IReadOnlyList? descriptors = resolver?.ResolveAndOrder(providers); + + HashSet loadedUris = []; + HashSet 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 logger) + { + if (Application.Current?.Styles is null) + return; + + IEnumerable? providers = serviceProvider?.GetServices(); + IPluginResolver? resolver = serviceProvider?.GetRequiredService(); + + IReadOnlyList? descriptors = resolver?.ResolveAndOrder(providers); + + HashSet loadedFactories = []; + HashSet 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) @@ -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