diff --git a/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml b/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml index 4f6016b18..ab63ed95f 100644 --- a/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml +++ b/src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml @@ -85,6 +85,20 @@ lib/net10.0/Ivy.Plugin.Abstractions.dll true + + CP0006 + E:Ivy.Plugins.IPluginManager.PluginLoadFailed + lib/net10.0/Ivy.Plugin.Abstractions.dll + lib/net10.0/Ivy.Plugin.Abstractions.dll + true + + + CP0006 + E:Ivy.Plugins.IPluginManager.PluginRemoved + lib/net10.0/Ivy.Plugin.Abstractions.dll + lib/net10.0/Ivy.Plugin.Abstractions.dll + true + CP0006 M:Ivy.Plugins.IPluginManager.BuildPluginConfigurationView(System.String,Ivy.Plugins.IIvyPluginConfig) diff --git a/src/Ivy.Plugin.Abstractions/IPluginManager.cs b/src/Ivy.Plugin.Abstractions/IPluginManager.cs index 8dd271cdc..9fc08f901 100644 --- a/src/Ivy.Plugin.Abstractions/IPluginManager.cs +++ b/src/Ivy.Plugin.Abstractions/IPluginManager.cs @@ -20,7 +20,9 @@ public interface IPluginManager bool ReconfigurePlugin(string pluginId); event Action? PluginLoaded; + event Action? PluginLoadFailed; event Action? PluginUnloaded; + event Action? PluginRemoved; event Action? PluginReloaded; event Action? PluginActivated; event Action? PluginDeactivated; diff --git a/src/Ivy.Plugin.Abstractions/PluginIcon.cs b/src/Ivy.Plugin.Abstractions/PluginIcon.cs index b853ef58f..b868cfcb7 100644 --- a/src/Ivy.Plugin.Abstractions/PluginIcon.cs +++ b/src/Ivy.Plugin.Abstractions/PluginIcon.cs @@ -14,16 +14,10 @@ public record PluginIcon /// Creates an icon from a URL (e.g. PNG, SVG, or any image URL). /// public static PluginIcon Url(string url) => new() { Kind = PluginIconKind.Url, Value = url }; - - /// - /// Creates an icon from a file bundled with the plugin (relative path within the plugin directory). - /// - public static PluginIcon File(string relativePath) => new() { Kind = PluginIconKind.File, Value = relativePath }; } public enum PluginIconKind { Named = 0, Url = 1, - File = 2, } diff --git a/src/Ivy.Test/Plugins/PluginStateServiceTests.cs b/src/Ivy.Test/Plugins/PluginStateServiceTests.cs index c724c4ac3..e3fd4e72d 100644 --- a/src/Ivy.Test/Plugins/PluginStateServiceTests.cs +++ b/src/Ivy.Test/Plugins/PluginStateServiceTests.cs @@ -95,7 +95,9 @@ private class FakePluginManager : IPluginManager public List ActivePluginIds { get; set; } = []; public event Action? PluginLoaded; + public event Action? PluginLoadFailed; public event Action? PluginUnloaded; + public event Action? PluginRemoved; public event Action? PluginReloaded; public event Action? PluginActivated; public event Action? PluginDeactivated; @@ -121,7 +123,9 @@ private class FakePluginManager : IPluginManager public object? BuildPluginConfigurationView(string pluginId, IIvyPluginConfig config) => null; public void RaisePluginLoaded(string pluginId) => PluginLoaded?.Invoke(pluginId); + public void RaisePluginLoadFailed(string pluginId) => PluginLoadFailed?.Invoke(pluginId); public void RaisePluginUnloaded(string pluginId) => PluginUnloaded?.Invoke(pluginId); + public void RaisePluginRemoved(string pluginId) => PluginRemoved?.Invoke(pluginId); public void RaisePluginReloaded(string pluginId) => PluginReloaded?.Invoke(pluginId); public void RaisePluginActivated(string pluginId) => PluginActivated?.Invoke(pluginId); public void RaisePluginDeactivated(string pluginId) => PluginDeactivated?.Invoke(pluginId); diff --git a/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs b/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs index 25713bfd7..4aece35a4 100644 --- a/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs +++ b/src/Ivy/Abstractions/IIvyExtendedPluginContext.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; namespace Ivy.Plugins; @@ -9,7 +9,6 @@ public interface IIvyExtendedPluginContext : IIvyPluginContext void AddApp(AppDescriptor descriptor); void AddAppsFromAssembly(Assembly assembly); - // ASP.NET pipeline - void UseWebApplication(Action configure); - void UseWebApplicationBuilder(Action configure); + // HTTP endpoints + void UseEndpoints(string slug, Action configure); } diff --git a/src/Ivy/Build/Ivy.ExternalWidget.targets b/src/Ivy/Build/Ivy.ExternalWidget.targets index fe4c1c17b..31c293c60 100644 --- a/src/Ivy/Build/Ivy.ExternalWidget.targets +++ b/src/Ivy/Build/Ivy.ExternalWidget.targets @@ -15,13 +15,13 @@ - + - + diff --git a/src/Ivy/CompatibilitySuppressions.xml b/src/Ivy/CompatibilitySuppressions.xml index a1859cbc1..d17fa9402 100644 --- a/src/Ivy/CompatibilitySuppressions.xml +++ b/src/Ivy/CompatibilitySuppressions.xml @@ -120,6 +120,20 @@ lib/net10.0/Ivy.dll true + + CP0002 + M:Ivy.Core.Plugins.PluginContextBase.UseWebApplication(System.Action{Microsoft.AspNetCore.Builder.WebApplication}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + + + CP0002 + M:Ivy.Core.Plugins.PluginContextBase.UseWebApplicationBuilder(System.Action{Microsoft.AspNetCore.Builder.WebApplicationBuilder}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + CP0002 M:Ivy.Core.Plugins.PluginLoader.SetConfiguration(Microsoft.Extensions.Configuration.IConfiguration) @@ -148,6 +162,20 @@ lib/net10.0/Ivy.dll true + + CP0002 + M:Ivy.Plugins.IIvyExtendedPluginContext.UseWebApplication(System.Action{Microsoft.AspNetCore.Builder.WebApplication}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + + + CP0002 + M:Ivy.Plugins.IIvyExtendedPluginContext.UseWebApplicationBuilder(System.Action{Microsoft.AspNetCore.Builder.WebApplicationBuilder}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + CP0002 M:Ivy.Server.UsePlugins(System.String,System.Version,System.Func{Ivy.Server,Microsoft.AspNetCore.Builder.WebApplicationBuilder,Ivy.Core.Plugins.PluginContextBase},System.Collections.Generic.IEnumerable{System.String},System.Boolean,System.Boolean) @@ -155,6 +183,13 @@ lib/net10.0/Ivy.dll true + + CP0006 + M:Ivy.Plugins.IIvyExtendedPluginContext.UseEndpoints(System.String,System.Action{Microsoft.AspNetCore.Routing.IEndpointRouteBuilder}) + lib/net10.0/Ivy.dll + lib/net10.0/Ivy.dll + true + CP0011 F:Ivy.Icons.AArrowDown diff --git a/src/Ivy/Core/Plugins/PluginContext.cs b/src/Ivy/Core/Plugins/PluginContext.cs index 21b799e1f..37aacb162 100644 --- a/src/Ivy/Core/Plugins/PluginContext.cs +++ b/src/Ivy/Core/Plugins/PluginContext.cs @@ -1,8 +1,10 @@ using System.Reflection; +using System.Text.RegularExpressions; using Ivy.Core.Apps; +using Ivy.Core.Plugins.Routing; using Ivy.Plugins; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace Ivy.Core.Plugins; @@ -31,10 +33,13 @@ protected PluginContextBase(AppRepository appRepository, IReadOnlySet re ReservedPaths = reservedPaths; Builder = builder; } - private readonly List> _appActions = []; + private readonly AggregatePluginServiceProvider _aggregateProvider = new(); private readonly Dictionary _pluginStates = new(); private readonly ReaderWriterLockSlim _lock = new(); + private readonly Dictionary _slugToPluginId = new(); + private DynamicPluginEndpointDataSource? _endpointDataSource; + private WebApplication? _app; private string? _currentPluginId; protected string? CurrentPluginId => _currentPluginId; @@ -93,26 +98,58 @@ public void AddAppsFromAssembly(Assembly assembly) state.AppFactories.Add(factory); } + public void UseEndpoints(string slug, Action configure) + { + ValidateSlug(slug); + var pluginId = _currentPluginId + ?? throw new InvalidOperationException("UseEndpoints can only be called during plugin configuration."); - public void UseWebApplication(Action configure) - { _lock.EnterWriteLock(); try { - _appActions.Add(configure); - if (_currentPluginId is not null && _pluginStates.TryGetValue(_currentPluginId, out var state)) - state.AppActions.Add(configure); + if (_slugToPluginId.TryGetValue(slug, out var existingId) && existingId != pluginId) + throw new InvalidOperationException($"Endpoint slug '{slug}' is already claimed by plugin '{existingId}'."); + _slugToPluginId[slug] = pluginId; + + if (_pluginStates.TryGetValue(pluginId, out var state)) + state.EndpointSlug = slug; } finally { _lock.ExitWriteLock(); } + + if (_endpointDataSource is null || _app is null) + throw new InvalidOperationException("UseEndpoints cannot be called before the application is built."); + + var pluginDir = _pluginStates.TryGetValue(pluginId, out var ps) ? ps.Directory : null; + var builder = new PluginEndpointRouteBuilder(_app.Services, _app); + + // Use ASP.NET's MapGroup for correct prefix handling across all endpoint types + var group = builder.MapGroup($"/ivy/plugins/{slug}"); + + // Wrap the group to provide plugin directory for MapStaticAssets + IEndpointRouteBuilder target = pluginDir is not null + ? new PluginEndpointRouteBuilderWithDirectory(group, pluginDir) + : group; + + configure(target); + + var endpoints = builder.CollectEndpoints(); + _endpointDataSource.AddEndpoints(slug, endpoints); } - public void UseWebApplicationBuilder(Action configure) + private static readonly Regex SlugPattern = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled); + + private static void ValidateSlug(string slug) { - configure(Builder); + if (string.IsNullOrWhiteSpace(slug)) + throw new ArgumentException("Endpoint slug cannot be empty.", nameof(slug)); + if (!SlugPattern.IsMatch(slug)) + throw new ArgumentException( + $"Endpoint slug '{slug}' is invalid. Must be lowercase alphanumeric with optional hyphens, not starting or ending with a hyphen.", + nameof(slug)); } public void BuildServiceProvider() @@ -174,8 +211,12 @@ internal virtual void RemovePluginContributions(string pluginId) // Collect app IDs before removing factories so we can reload affected sessions affectedAppIds = GetAppIdsFromFactories(state.AppFactories); - foreach (var a in state.AppActions) - _appActions.Remove(a); + // Remove dynamic endpoints + if (state.EndpointSlug is not null) + { + _endpointDataSource?.RemoveEndpoints(state.EndpointSlug); + _slugToPluginId.Remove(state.EndpointSlug); + } foreach (var f in state.AppFactories) AppRepository.RemoveFactory(f); @@ -242,53 +283,19 @@ private static HashSet GetAppIdsFromFactories(List internal IReadOnlyDictionary PluginStates => _pluginStates; + internal void SetEndpointDataSource(DynamicPluginEndpointDataSource dataSource) + { + _endpointDataSource = dataSource; + } + public void Apply(WebApplication app) { - List> actions; - _lock.EnterReadLock(); - try { actions = _appActions.ToList(); } - finally { _lock.ExitReadLock(); } + _app = app; - foreach (var action in actions) - action(app); + // Register the dynamic endpoint data source for plugin routes + _endpointDataSource ??= new DynamicPluginEndpointDataSource(); + ((IEndpointRouteBuilder)app).DataSources.Add(_endpointDataSource); - app.MapGet("/ivy/plugins/{pluginId}/assets/{**filePath}", (string pluginId, string filePath) => - { - _lock.EnterReadLock(); - try - { - if (!_pluginStates.TryGetValue(pluginId, out var state)) - return Results.NotFound(); - - if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath)) - return Results.NotFound(); - - var pluginDir = Path.GetFullPath(state.Directory); - var fullPath = Path.GetFullPath(Path.Join(pluginDir, filePath)); - - if (!fullPath.StartsWith(pluginDir + Path.DirectorySeparatorChar)) - return Results.NotFound(); - - if (!File.Exists(fullPath)) - return Results.NotFound(); - - var contentType = Path.GetExtension(fullPath).ToLowerInvariant() switch - { - ".svg" => "image/svg+xml", - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".ico" => "image/x-icon", - _ => "application/octet-stream" - }; - return Results.File(fullPath, contentType); - } - finally - { - _lock.ExitReadLock(); - } - }); } } diff --git a/src/Ivy/Core/Plugins/PluginLoader.cs b/src/Ivy/Core/Plugins/PluginLoader.cs index fe67e968a..2a961e2e4 100644 --- a/src/Ivy/Core/Plugins/PluginLoader.cs +++ b/src/Ivy/Core/Plugins/PluginLoader.cs @@ -26,18 +26,22 @@ public class PluginLoader : IPluginManager private Version? _hostVersion; public event Action? PluginLoaded; + public event Action? PluginLoadFailed; public event Action? PluginUnloaded; + public event Action? PluginRemoved; public event Action? PluginReloaded; public event Action? PluginActivated; public event Action? PluginDeactivated; internal bool BuildSourcePlugins { get; } + internal bool DeferPluginLoads { get; } - internal PluginLoader(string pluginsDirectory, ILogger logger, IEnumerable? sharedAssemblyNames = null, bool buildSourcePlugins = false) + internal PluginLoader(string pluginsDirectory, ILogger logger, IEnumerable? sharedAssemblyNames = null, bool buildSourcePlugins = false, bool deferPluginLoads = false) { _pluginsDirectory = pluginsDirectory; _logger = logger; BuildSourcePlugins = buildSourcePlugins; + DeferPluginLoads = deferPluginLoads; _sharedAssemblyNames = new HashSet(sharedAssemblyNames ?? []) { "Ivy.Plugin.Abstractions", @@ -81,8 +85,7 @@ public void DiscoverAndLoad(Version hostVersion, IServiceProvider serviceProvide { if (!Directory.Exists(directory)) { - _logger.LogWarning("Referenced plugin directory does not exist: {Directory}", directory); - RecordFailure(directory, $"Directory does not exist: {directory}"); + RecordFailure(directory, "Directory does not exist"); continue; } @@ -236,6 +239,11 @@ public void DiscoverAndLoad(Version hostVersion, IServiceProvider serviceProvide return (instance, pluginAssembly, loadContext, directory, shadowDir); } + internal void SetHostVersion(Version version) + { + _hostVersion = version; + } + internal void SetPluginConfigFactory(IIvyPluginConfigFactory factory) { _configFactory = factory; @@ -244,6 +252,56 @@ internal void SetPluginConfigFactory(IIvyPluginConfigFactory factory) internal void SetServiceProviderFactory(Func factory) { _serviceProviderFactory = factory; + if (DeferPluginLoads) + LoadDeferredPluginsAsync(); + } + + private void LoadDeferredPluginsAsync() + { + _ = Task.Run(() => + { + try + { + _logger.LogInformation("Loading plugins in background..."); + var directories = DiscoverPluginDirectories(); + foreach (var directory in directories) + { + try + { + LoadPlugin(directory); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.LogError(ex, "Error loading deferred plugin from {Directory}", directory); + } + } + _logger.LogInformation("Background plugin loading complete. {Count} plugin(s) loaded.", Plugins.Count); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _logger.LogError(ex, "Error during background plugin loading"); + } + }); + } + + private List DiscoverPluginDirectories() + { + var directories = new List(); + if (!Directory.Exists(_pluginsDirectory)) return directories; + + // Referenced plugins first (explicit references take priority) + var referencesFilePath = Path.Combine(_pluginsDirectory, PluginReferencesWatcher.FileName); + var referencedPaths = PluginReferencesWatcher.ParseReferencesFile(referencesFilePath, _pluginsDirectory, _logger); + foreach (var directory in referencedPaths.Where(Directory.Exists)) + { + directories.Add(directory); + } + + // Then subdirectories of the plugins directory + foreach (var directory in Directory.GetDirectories(_pluginsDirectory)) + directories.Add(directory); + + return directories; } public void Configure(PluginContextBase context) @@ -354,10 +412,14 @@ public bool LoadPlugin(string pluginPath) if (BuildSourcePlugins && SourcePluginBuilder.IsSourcePlugin(pluginPath)) { if (!SourcePluginBuilder.BuildSync(pluginPath, _logger)) + { + RecordFailure(pluginPath, "Build failed"); return false; + } } string? loadedPluginId = null; + string? loadFailureReason = null; PluginStatus loadedStatus = PluginStatus.Active; _lock.EnterWriteLock(); try @@ -365,8 +427,9 @@ public bool LoadPlugin(string pluginPath) var loaded = LoadPluginFromDirectory(pluginPath, _serviceProviderFactory(), out var loadFailReason); if (loaded is null) { - _logger.LogError("Failed to load plugin from {Path}: {Reason}", pluginPath, loadFailReason ?? "unknown"); - return false; + RecordFailure(pluginPath, loadFailReason ?? "unknown", fireEvent: false); + loadFailureReason = loadFailReason; + goto done; } var manifest = loaded.Value.Instance.Manifest; @@ -374,7 +437,7 @@ public bool LoadPlugin(string pluginPath) if (_plugins.Any(p => p.Instance.Manifest.Id == manifest.Id)) { _logger.LogError("Plugin '{Id}' is already loaded.", manifest.Id); - return false; + goto done; } if (manifest.MinimumHostVersion is { } minVersion && _hostVersion < minVersion) @@ -382,7 +445,9 @@ public bool LoadPlugin(string pluginPath) _logger.LogError( "Plugin '{Id}' requires host version {Required} but current is {Current}.", manifest.Id, minVersion, _hostVersion); - return false; + loadFailureReason = $"Requires host version {minVersion} but current is {_hostVersion}"; + RecordFailure(pluginPath, loadFailureReason, fireEvent: false); + goto done; } var plugin = new LoadedPlugin(loaded.Value.Instance, loaded.Value.Assembly, loaded.Value.Context, loaded.Value.Directory, loaded.Value.ShadowDirectory); @@ -417,12 +482,25 @@ public bool LoadPlugin(string pluginPath) // Configure context — plugin has valid config if (_pluginContext is not null) { - _pluginContext.SetCurrentPlugin(manifest.Id, pluginPath); - _pluginContext.SetPluginConfig(CreatePluginConfig(plugin.Instance)); - plugin.Instance.Configure(_pluginContext); - _pluginContext.ClearCurrentPlugin(); - _pluginContext.ClearPluginConfig(); - _pluginContext.BuildPluginServiceProvider(manifest.Id, plugin.Services); + try + { + _pluginContext.SetCurrentPlugin(manifest.Id, pluginPath); + _pluginContext.SetPluginConfig(CreatePluginConfig(plugin.Instance)); + plugin.Instance.Configure(_pluginContext); + _pluginContext.ClearCurrentPlugin(); + _pluginContext.ClearPluginConfig(); + _pluginContext.BuildPluginServiceProvider(manifest.Id, plugin.Services); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + _pluginContext.ClearCurrentPlugin(); + _pluginContext.ClearPluginConfig(); + plugin.LoadContext.Unload(); + DeleteShadowDirectory(plugin.ShadowDirectory); + RecordFailure(pluginPath, ex, fireEvent: false); + loadFailureReason = $"Exception during load: {ex.Message}"; + goto done; + } } plugin.Status = PluginStatus.Active; @@ -452,10 +530,13 @@ public bool LoadPlugin(string pluginPath) _pluginContext!.RefreshApps(appIds); } + done: // Fire event outside the lock to avoid deadlocks — subscribers may call // GetActivePluginIds() which needs a read lock. if (loadedPluginId is not null) PluginLoaded?.Invoke(loadedPluginId); + else if (loadFailureReason is not null) + PluginLoadFailed?.Invoke(Path.GetFileName(pluginPath)); return loadedPluginId is not null; } @@ -880,6 +961,34 @@ public IReadOnlyList GetUnloadedPlugins() } } + internal void ForgetPlugin(string pluginId) + { + _lock.EnterWriteLock(); + try + { + _knownPlugins.Remove(pluginId); + } + finally + { + _lock.ExitWriteLock(); + } + PluginRemoved?.Invoke(pluginId); + } + + internal void RemoveFailedPlugin(string directory) + { + _lock.EnterWriteLock(); + try + { + _failedPlugins.Remove(directory); + } + finally + { + _lock.ExitWriteLock(); + } + PluginRemoved?.Invoke(Path.GetFileName(directory)); + } + private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); private void InvokeShutdownHook(IIvyPlugin pluginInstance, string pluginId, PluginShutdownReason reason) @@ -972,33 +1081,39 @@ public async Task ShutdownAllAsync() await Task.WhenAll(tasks); } - private void LogPluginLoadFailure(string directory, Exception ex) + private void RecordFailure(string directory, Exception ex, bool fireEvent = true) + => RecordFailure(directory, $"Exception during load: {ex.Message}", fireEvent); + + private void RecordFailure(string directory, string reason, bool fireEvent = true) { - _logger.LogError(ex, "Failed to load plugin from {Directory}. Skipping.", directory); + _logger.LogError("Failed to load plugin from {Directory}: {Reason}", directory, reason); - _lock.EnterWriteLock(); - try + var writeLockHeld = _lock.IsWriteLockHeld; + if (writeLockHeld) { - _failedPlugins[directory] = ( - $"Exception during load: {ex.Message}", - DateTime.UtcNow); + _failedPlugins[directory] = (reason, DateTime.UtcNow); } - finally + else { - _lock.ExitWriteLock(); + _lock.EnterWriteLock(); + try + { + _failedPlugins[directory] = (reason, DateTime.UtcNow); + } + finally + { + _lock.ExitWriteLock(); + } } - } - private void RecordFailure(string directory, string reason) - { - _lock.EnterWriteLock(); - try + if (fireEvent) { - _failedPlugins[directory] = (reason, DateTime.UtcNow); - } - finally - { - _lock.ExitWriteLock(); + if (writeLockHeld) + { + throw new InvalidOperationException("Cannot fire PluginLoadFailed event while holding write lock"); + } + + PluginLoadFailed?.Invoke(Path.GetFileName(directory)); } } @@ -1033,8 +1148,6 @@ private void TryLoadCandidate( return; } - ValidatePluginIcon(manifest, directory); - // Reject duplicates — if a plugin with this ID was already discovered, skip it. var existingIndex = candidates.FindIndex(c => c.Instance.Manifest.Id == manifest.Id); if (existingIndex >= 0) @@ -1052,23 +1165,23 @@ private void TryLoadCandidate( } catch (IOException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (UnauthorizedAccessException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (BadImageFormatException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (ReflectionTypeLoadException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } catch (InvalidOperationException ex) { - LogPluginLoadFailure(directory, ex); + RecordFailure(directory, ex); } } @@ -1085,27 +1198,6 @@ internal void AddTestPlugin(IIvyPlugin instance, string directory) } } - private void ValidatePluginIcon(PluginManifest manifest, string directory) - { - if (manifest.Icon is { Kind: PluginIconKind.File } icon) - { - if (string.IsNullOrWhiteSpace(icon.Value) || Path.IsPathRooted(icon.Value)) - { - _logger.LogWarning( - "Plugin '{Id}' has an invalid icon path '{Path}' (must be relative).", - manifest.Id, icon.Value); - return; - } - - var fullPath = Path.GetFullPath(Path.Join(directory, icon.Value)); - if (!File.Exists(fullPath)) - { - _logger.LogWarning( - "Plugin '{Id}' references icon file '{Path}' which does not exist in '{Directory}'.", - manifest.Id, icon.Value, directory); - } - } - } internal List ValidatePluginConfiguration( PluginConfigurationSchema schema, diff --git a/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs b/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs index 62ac23fbb..ad446a012 100644 --- a/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs +++ b/src/Ivy/Core/Plugins/PluginReferencesWatcher.cs @@ -123,12 +123,17 @@ private void ProcessReferencesFileChange() try { _pluginManager.UnloadPlugin(pluginId); + loader.ForgetPlugin(pluginId); } catch (InvalidOperationException ex) { _logger.LogError(ex, "Failed to unload plugin {PluginId}", pluginId); } } + else + { + loader.RemoveFailedPlugin(dir); + } } } diff --git a/src/Ivy/Core/Plugins/PluginReloadScheduler.cs b/src/Ivy/Core/Plugins/PluginReloadScheduler.cs index 0aeb3efbf..eea43dca3 100644 --- a/src/Ivy/Core/Plugins/PluginReloadScheduler.cs +++ b/src/Ivy/Core/Plugins/PluginReloadScheduler.cs @@ -44,8 +44,8 @@ public void ScheduleLoad(string pluginDirectory) _logger.LogInformation("Loading plugin from: {Directory}", pluginDirectory); try { - _pluginManager.LoadPlugin(pluginDirectory); - _reloadCooldowns[pluginDirectory] = DateTime.UtcNow + _cooldownPeriod; + if (_pluginManager.LoadPlugin(pluginDirectory)) + _reloadCooldowns[pluginDirectory] = DateTime.UtcNow + _cooldownPeriod; } catch (InvalidOperationException ex) { diff --git a/src/Ivy/Core/Plugins/PluginState.cs b/src/Ivy/Core/Plugins/PluginState.cs index c3a560024..734eb5ced 100644 --- a/src/Ivy/Core/Plugins/PluginState.cs +++ b/src/Ivy/Core/Plugins/PluginState.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; namespace Ivy.Core.Plugins; @@ -8,8 +7,8 @@ internal class PluginState public string PluginId { get; } public string Directory { get; } public ServiceCollection PluginServices { get; } = new(); + public string? EndpointSlug { get; set; } - public List> AppActions { get; } = []; public List> AppFactories { get; } = []; public PluginState(string pluginId, string directory) diff --git a/src/Ivy/Core/Plugins/PluginStateService.cs b/src/Ivy/Core/Plugins/PluginStateService.cs index fccf6dae1..5e3eb79ec 100644 --- a/src/Ivy/Core/Plugins/PluginStateService.cs +++ b/src/Ivy/Core/Plugins/PluginStateService.cs @@ -13,7 +13,9 @@ public PluginStateService(IPluginManager pluginManager) _pluginManager = pluginManager; _pluginManager.PluginLoaded += OnPluginChanged; + _pluginManager.PluginLoadFailed += OnPluginChanged; _pluginManager.PluginUnloaded += OnPluginChanged; + _pluginManager.PluginRemoved += OnPluginChanged; _pluginManager.PluginReloaded += OnPluginChanged; _pluginManager.PluginActivated += OnPluginChanged; _pluginManager.PluginDeactivated += OnPluginChanged; @@ -27,7 +29,9 @@ public IReadOnlyList GetActivePluginIds() => public void Dispose() { _pluginManager.PluginLoaded -= OnPluginChanged; + _pluginManager.PluginLoadFailed -= OnPluginChanged; _pluginManager.PluginUnloaded -= OnPluginChanged; + _pluginManager.PluginRemoved -= OnPluginChanged; _pluginManager.PluginReloaded -= OnPluginChanged; _pluginManager.PluginActivated -= OnPluginChanged; _pluginManager.PluginDeactivated -= OnPluginChanged; diff --git a/src/Ivy/Core/Plugins/PluginWatcher.cs b/src/Ivy/Core/Plugins/PluginWatcher.cs index 9665350f5..4320bd655 100644 --- a/src/Ivy/Core/Plugins/PluginWatcher.cs +++ b/src/Ivy/Core/Plugins/PluginWatcher.cs @@ -108,12 +108,17 @@ private void OnDeleted(object sender, FileSystemEventArgs e) try { _pluginManager.UnloadPlugin(pluginId); + loader.ForgetPlugin(pluginId); } catch (Exception ex) { _logger.LogError(ex, "Failed to unload plugin {PluginId}", pluginId); } } + else + { + loader.RemoveFailedPlugin(e.FullPath); + } } } diff --git a/src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs b/src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs new file mode 100644 index 000000000..70de4580e --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/DynamicPluginEndpointDataSource.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +namespace Ivy.Core.Plugins.Routing; + +internal sealed class DynamicPluginEndpointDataSource : EndpointDataSource +{ + private readonly object _lock = new(); + private readonly Dictionary> _pluginEndpoints = new(); + private CancellationTokenSource _cts = new(); + private IReadOnlyList? _cachedEndpoints; + + public override IReadOnlyList Endpoints + { + get + { + lock (_lock) + { + _cachedEndpoints ??= _pluginEndpoints.Values.SelectMany(e => e).ToList(); + return _cachedEndpoints; + } + } + } + + public override IChangeToken GetChangeToken() + { + return new CancellationChangeToken(_cts.Token); + } + + public void AddEndpoints(string slug, IReadOnlyList endpoints) + { + lock (_lock) + { + _pluginEndpoints[slug] = endpoints; + InvalidateCache(); + } + } + + public void RemoveEndpoints(string slug) + { + lock (_lock) + { + if (_pluginEndpoints.Remove(slug)) + InvalidateCache(); + } + } + + public bool HasSlug(string slug) + { + lock (_lock) + { + return _pluginEndpoints.ContainsKey(slug); + } + } + + private void InvalidateCache() + { + _cachedEndpoints = null; + var oldCts = _cts; + _cts = new CancellationTokenSource(); + oldCts.Cancel(); + oldCts.Dispose(); + } +} diff --git a/src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs b/src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs new file mode 100644 index 000000000..88d0a34da --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/PluginEndpointExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticFiles; + +namespace Ivy.Core.Plugins.Routing; + +public static class PluginEndpointExtensions +{ + private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new(); + + /// + /// Serves static files from the plugin directory at the given sub-path. + /// Must be called on an inside a UseEndpoints callback. + /// + public static IEndpointRouteBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, string subPath) + { + var directory = endpoints is PluginEndpointRouteBuilderWithDirectory withDir + ? withDir.PluginDirectory + : throw new InvalidOperationException( + "MapStaticAssets can only be called on an endpoint builder provided by UseEndpoints."); + + return MapStaticAssets(endpoints, subPath, directory); + } + + public static IEndpointRouteBuilder MapStaticAssets(this IEndpointRouteBuilder endpoints, string subPath, string directory) + { + var normalizedSubPath = subPath.TrimStart('/').TrimEnd('/'); + var baseDir = Path.GetFullPath(directory); + + endpoints.MapGet($"{normalizedSubPath}/{{**filePath}}", (string filePath) => + { + if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath)) + return Results.NotFound(); + + var fullPath = Path.GetFullPath(Path.Join(baseDir, filePath)); + if (!fullPath.StartsWith(baseDir + Path.DirectorySeparatorChar)) + return Results.NotFound(); + + if (!File.Exists(fullPath)) + return Results.NotFound(); + + if (!ContentTypeProvider.TryGetContentType(fullPath, out var contentType)) + contentType = "application/octet-stream"; + + return Results.File(fullPath, contentType); + }); + + return endpoints; + } +} diff --git a/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs new file mode 100644 index 000000000..32de30cb6 --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilder.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Ivy.Core.Plugins.Routing; + +/// +/// Collects endpoint data sources registered by a plugin. +/// Prefixing is handled by calling MapGroup on this builder before +/// passing the group to the plugin's configure callback. +/// +internal sealed class PluginEndpointRouteBuilder : IEndpointRouteBuilder +{ + private readonly IApplicationBuilder _appBuilder; + + public PluginEndpointRouteBuilder(IServiceProvider serviceProvider, IApplicationBuilder appBuilder) + { + ServiceProvider = serviceProvider; + _appBuilder = appBuilder; + } + + public IServiceProvider ServiceProvider { get; } + public ICollection DataSources { get; } = new List(); + + public IApplicationBuilder CreateApplicationBuilder() => _appBuilder.New(); + + /// + /// Collects all endpoints from registered data sources. + /// Prefixing is already applied by the RouteGroupBuilder returned from MapGroup. + /// + internal IReadOnlyList CollectEndpoints() + { + return DataSources.SelectMany(ds => ds.Endpoints).ToList(); + } +} diff --git a/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs new file mode 100644 index 000000000..cc93c8f7a --- /dev/null +++ b/src/Ivy/Core/Plugins/Routing/PluginEndpointRouteBuilderWithDirectory.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Ivy.Core.Plugins.Routing; + +internal sealed class PluginEndpointRouteBuilderWithDirectory : IEndpointRouteBuilder +{ + private readonly IEndpointRouteBuilder _inner; + + public string PluginDirectory { get; } + + public PluginEndpointRouteBuilderWithDirectory(IEndpointRouteBuilder inner, string pluginDirectory) + { + _inner = inner; + PluginDirectory = pluginDirectory; + } + + public IServiceProvider ServiceProvider => _inner.ServiceProvider; + public ICollection DataSources => _inner.DataSources; + public IApplicationBuilder CreateApplicationBuilder() => _inner.CreateApplicationBuilder(); +} diff --git a/src/Ivy/Core/Plugins/SourcePluginBuilder.cs b/src/Ivy/Core/Plugins/SourcePluginBuilder.cs index 5afb42df7..7b028cbbf 100644 --- a/src/Ivy/Core/Plugins/SourcePluginBuilder.cs +++ b/src/Ivy/Core/Plugins/SourcePluginBuilder.cs @@ -26,8 +26,7 @@ public SourcePluginBuilder(ILogger logger) public static bool IsSourcePlugin(string directory) { return Directory.Exists(directory) && - Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly).Any() && - Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Any(); + Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly).Any(); } public static bool IsSourceFile(string filePath) diff --git a/src/Ivy/Ivy.csproj b/src/Ivy/Ivy.csproj index 3e5d16fdf..73a120cc4 100644 --- a/src/Ivy/Ivy.csproj +++ b/src/Ivy/Ivy.csproj @@ -105,13 +105,13 @@ - + - + diff --git a/src/Ivy/Server.cs b/src/Ivy/Server.cs index e92e6f198..268edfa15 100644 --- a/src/Ivy/Server.cs +++ b/src/Ivy/Server.cs @@ -556,16 +556,23 @@ public Server UsePlugins( Func? contextFactory = null, IEnumerable? sharedAssemblyNames = null, bool enableHotReload = true, - bool buildSourcePlugins = false) + bool buildSourcePlugins = false, + bool deferPluginLoads = false) { using var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); var logger = loggerFactory.CreateLogger(); - var loader = new PluginLoader(pluginsDirectory, logger, sharedAssemblyNames, buildSourcePlugins); + var loader = new PluginLoader(pluginsDirectory, logger, sharedAssemblyNames, buildSourcePlugins, deferPluginLoads); - using var bootstrapProvider = Services.BuildServiceProvider(); - loader.DiscoverAndLoad( - hostVersion ?? Assembly.GetEntryAssembly()!.GetName().Version!, - bootstrapProvider); + var effectiveHostVersion = hostVersion ?? Assembly.GetEntryAssembly()!.GetName().Version!; + if (!deferPluginLoads) + { + using var bootstrapProvider = Services.BuildServiceProvider(); + loader.DiscoverAndLoad(effectiveHostVersion, bootstrapProvider); + } + else + { + loader.SetHostVersion(effectiveHostVersion); + } loader.SetPluginConfigFactory(configFactory); configFactory.SetPluginManager(loader); @@ -764,8 +771,6 @@ public Server UsePlugins( }); } - // Plugin Configure runs before Build so UseWebApplicationBuilder actions - // apply directly to the builder. UseWebApplication actions are deferred to Apply. PluginContextBase? pluginContext = null; if (_pluginLoader != null) { diff --git a/src/frontend/src/lib/__tests__/shortcutRegistry.test.ts b/src/frontend/src/lib/__tests__/shortcutRegistry.test.ts index 076585598..453220850 100644 --- a/src/frontend/src/lib/__tests__/shortcutRegistry.test.ts +++ b/src/frontend/src/lib/__tests__/shortcutRegistry.test.ts @@ -202,12 +202,21 @@ describe("shortcutRegistry", () => { expect(_getRegistrySize()).toBe(0); }); - it("should install listener on first registration and remove on last unregister", () => { - registerShortcut(makeRegistration({ id: "btn-lazy" })); + it("keeps listener installed after all shortcuts are unregistered", () => { + registerShortcut(makeRegistration({ id: "temp" })); + unregisterShortcut("temp"); expect(_isListenerInstalled()).toBe(true); - unregisterShortcut("btn-lazy"); - expect(_isListenerInstalled()).toBe(false); + // Backspace still prevented + const event = new KeyboardEvent("keydown", { + key: "Backspace", + code: "Backspace", + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, "preventDefault"); + window.dispatchEvent(event); + expect(spy).toHaveBeenCalled(); }); it("should match shift modifier correctly", () => { diff --git a/src/frontend/src/lib/__tests__/useShortcut.test.ts b/src/frontend/src/lib/__tests__/useShortcut.test.ts index e3fc30983..059c248b3 100644 --- a/src/frontend/src/lib/__tests__/useShortcut.test.ts +++ b/src/frontend/src/lib/__tests__/useShortcut.test.ts @@ -67,7 +67,9 @@ describe("useShortcut", () => { root = createRoot(container); expect(_getRegistrySize()).toBe(0); - expect(_isListenerInstalled()).toBe(false); + // The keydown listener is permanent (installed at module load) so Backspace + // back-navigation prevention stays active even with no shortcuts registered. + expect(_isListenerInstalled()).toBe(true); }); it("should not register when disabled", () => { diff --git a/src/frontend/src/lib/shortcutRegistry.ts b/src/frontend/src/lib/shortcutRegistry.ts index c6243972a..5604ed571 100644 --- a/src/frontend/src/lib/shortcutRegistry.ts +++ b/src/frontend/src/lib/shortcutRegistry.ts @@ -113,12 +113,6 @@ function installListener() { listenerInstalled = true; } -function removeListener() { - if (!listenerInstalled) return; - window.removeEventListener("keydown", handleKeyDown); - listenerInstalled = false; -} - export function registerShortcut(registration: ShortcutRegistration): void { registry.set(registration.id, registration); installListener(); @@ -142,7 +136,6 @@ export function unregisterShortcut(id: string): void { registry.delete(id); recentShortcuts.delete(id); if (registry.size === 0) { - removeListener(); if (sweepTimerId !== null) { clearInterval(sweepTimerId); sweepTimerId = null; @@ -167,7 +160,10 @@ export function _resetForTesting(): void { clearInterval(sweepTimerId); sweepTimerId = null; } - removeListener(); + if (listenerInstalled) { + window.removeEventListener("keydown", handleKeyDown); + listenerInstalled = false; + } // Reinstall listener to match module-load state (for backspace prevention) installListener(); } diff --git a/src/frontend/src/widgets/dataTables/DataTableWidget.test.ts b/src/frontend/src/widgets/dataTables/DataTableWidget.test.ts index 40e652bb3..7a86305c7 100644 --- a/src/frontend/src/widgets/dataTables/DataTableWidget.test.ts +++ b/src/frontend/src/widgets/dataTables/DataTableWidget.test.ts @@ -9,6 +9,14 @@ import * as path from "path"; describe("DataTableWidget - container style for height modes", () => { const source = fs.readFileSync(path.resolve(__dirname, "./DataTableWidget.tsx"), "utf-8"); + // Isolate the `if (height === "Full") { ... }` branch so assertions about the + // Full-height container don't accidentally match the explicit-height `else` branch. + const fullBranch = (() => { + const start = source.indexOf('if (height === "Full")'); + const elseIndex = source.indexOf("} else {", start); + return start >= 0 && elseIndex > start ? source.slice(start, elseIndex) : ""; + })(); + it('should set display flex on outer container when height is "Full"', () => { expect(source).toContain('containerStyle.display = "flex"'); expect(source).toContain('containerStyle.flexDirection = "column"'); @@ -28,8 +36,15 @@ describe("DataTableWidget - container style for height modes", () => { expect(source).toContain("containerStyle.minHeight = minHeight"); }); - it("should set maxHeight to 100% so tables shrink with the viewport", () => { - expect(source).toContain('containerStyle.maxHeight = "100%"'); + it('should not clamp the "Full" height branch with a percentage maxHeight', () => { + // Regression guard for issue #1695 / PR #4485: a percentage max-height + // collapses the table to its min-height inside the scrolling app host. + // flexGrow fills the flex parent, so the Full branch must not set maxHeight. + expect(fullBranch).not.toBe(""); + expect(fullBranch).not.toContain('containerStyle.maxHeight = "100%"'); + // The fill behaviour and responsive lower bound must remain in place. + expect(fullBranch).toContain("containerStyle.flexGrow = 1"); + expect(fullBranch).toContain("containerStyle.minHeight = minHeight"); }); it("should apply getHeight for explicit pixel heights", () => { diff --git a/src/frontend/src/widgets/dataTables/DataTableWidget.tsx b/src/frontend/src/widgets/dataTables/DataTableWidget.tsx index 9053fb823..2a367bab7 100644 --- a/src/frontend/src/widgets/dataTables/DataTableWidget.tsx +++ b/src/frontend/src/widgets/dataTables/DataTableWidget.tsx @@ -110,7 +110,8 @@ export const DataTable: React.FC = ({ containerStyle.flexGrow = 1; containerStyle.flexShrink = 1; containerStyle.minHeight = minHeight; - containerStyle.maxHeight = "100%"; + // no maxHeight — flexGrow fills the flex parent; a percentage max-height + // collapses the table inside the scrolling app host (issue #1695 / PR #4485). } else { containerStyle.display = "flex"; containerStyle.flexDirection = "column"; diff --git a/src/frontend/src/widgets/layouts/tabs/components/Variants.tsx b/src/frontend/src/widgets/layouts/tabs/components/Variants.tsx index 5646390e8..1025c7569 100644 --- a/src/frontend/src/widgets/layouts/tabs/components/Variants.tsx +++ b/src/frontend/src/widgets/layouts/tabs/components/Variants.tsx @@ -370,6 +370,9 @@ export const TabsVariant: React.FC = ({ return (
{group.runs.map((item) => (
  • 1 ? "ml-4" : undefined} > {renderInlineContent(item.run, item.index, events, eventHandler, id)} @@ -188,7 +188,7 @@ function renderGroup( return (
      {group.runs.map((item) => ( -
    1. +
    2. {renderInlineContent(item.run, item.index, events, eventHandler, id)}
    3. ))} @@ -200,7 +200,7 @@ function renderGroup( return group.runs.map((item) => { const run = item.run; const index = item.index; - const key = `run-${index}-${run.content.length}`; + const key = `run-${index}-${(run.content || "").length}`; if (run.lineBreak) { return
      ; } diff --git a/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj b/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj index 972868c22..4d3af23bf 100644 --- a/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj +++ b/src/plugins/plugins/Ivy.Plugin.Example.AppProvider/Ivy.Plugin.Example.AppProvider.csproj @@ -4,6 +4,15 @@ net10.0 enable enable + Ivy.Plugin.Example.AppProvider + Example App Provider + Ivy Interactive + Ivy Interactive + Sample plugin demonstrating custom app registration in Ivy + Apache-2.0 + https://github.com/Ivy-Interactive/Ivy-Framework + https://github.com/Ivy-Interactive/Ivy-Framework + git diff --git a/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj b/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj index 9b8d86193..be9b915f6 100644 --- a/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj +++ b/src/plugins/plugins/Ivy.Plugin.HelloWorld/Ivy.Plugin.HelloWorld.csproj @@ -4,6 +4,15 @@ net10.0 enable enable + Ivy.Plugin.HelloWorld + Hello World + Ivy Interactive + Ivy Interactive + Sample plugin demonstrating the Ivy plugin system + Apache-2.0 + https://github.com/Ivy-Interactive/Ivy-Framework + https://github.com/Ivy-Interactive/Ivy-Framework + git