Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2def575
Detect source plugins with only a .csproj (no .cs files required)
zachwolfe Jun 30, 2026
6b7bfb0
Don't set reload cooldown when plugin load fails
zachwolfe Jun 30, 2026
2ac7842
Clean up failed plugins when their directory is deleted
zachwolfe Jun 30, 2026
223a374
Fire PluginLoadFailed event so UI updates when a plugin fails to load
zachwolfe Jun 30, 2026
4e27565
Consolidate plugin failure recording into RecordFailure
zachwolfe Jun 30, 2026
3baed5c
Forget plugin from _knownPlugins when its directory is deleted
zachwolfe Jun 30, 2026
09ee6fc
Add PluginRemoved event for when a plugin is fully forgotten from the…
zachwolfe Jun 30, 2026
0c983f6
Forget plugins when their reference is removed from plugin-references…
zachwolfe Jun 30, 2026
fb4df13
Catch exceptions during plugin Configure() and record as load failure
zachwolfe Jun 30, 2026
89a7055
Add deferPluginLoads option to load plugins asynchronously after serv…
zachwolfe Jul 1, 2026
c3703be
Merge remote-tracking branch 'origin/development' into plugin-system-…
zachwolfe Jul 1, 2026
a959f35
Add missing PluginLoadFailed and PluginRemoved events to FakePluginMa…
zachwolfe Jul 1, 2026
370effc
Add UseEndpoints to plugin context for scoped, hot-reloadable HTTP ro…
zachwolfe Jul 7, 2026
caa0c1c
Merge remote-tracking branch 'origin/development' into plugin-system-…
zachwolfe Jul 9, 2026
386f602
Remove UseWebApplication and UseWebApplicationBuilder from plugin int…
zachwolfe Jul 10, 2026
cc0f2b9
Fix formatting and add exception filters to generic catch clauses
zachwolfe Jul 10, 2026
a3b2518
Add API compatibility suppressions for plugin context changes
zachwolfe Jul 10, 2026
1eb500f
Potential fix for pull request finding 'Missed opportunity to use Where'
zachwolfe Jul 10, 2026
86d44de
Remove unnecessary using System.Linq directive
zachwolfe Jul 10, 2026
741bf13
Add API compatibility suppressions for new IPluginManager events
zachwolfe Jul 10, 2026
cef735f
Merge remote-tracking branch 'origin/development' into plugin-system-…
zachwolfe Jul 18, 2026
0996b26
Remove PluginIconKind.File — unused and replaced by tendril.json conv…
zachwolfe Jul 18, 2026
6536789
Add NuGet metadata to sample plugins (HelloWorld, AppProvider)
zachwolfe Jul 18, 2026
badd16c
Remove unused using Microsoft.AspNetCore.Http directive
zachwolfe Jul 18, 2026
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
14 changes: 14 additions & 0 deletions src/Ivy.Plugin.Abstractions/CompatibilitySuppressions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@
<Right>lib/net10.0/Ivy.Plugin.Abstractions.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>E:Ivy.Plugins.IPluginManager.PluginLoadFailed</Target>
<Left>lib/net10.0/Ivy.Plugin.Abstractions.dll</Left>
<Right>lib/net10.0/Ivy.Plugin.Abstractions.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>E:Ivy.Plugins.IPluginManager.PluginRemoved</Target>
<Left>lib/net10.0/Ivy.Plugin.Abstractions.dll</Left>
<Right>lib/net10.0/Ivy.Plugin.Abstractions.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Ivy.Plugins.IPluginManager.BuildPluginConfigurationView(System.String,Ivy.Plugins.IIvyPluginConfig)</Target>
Expand Down
2 changes: 2 additions & 0 deletions src/Ivy.Plugin.Abstractions/IPluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public interface IPluginManager
bool ReconfigurePlugin(string pluginId);

event Action<string>? PluginLoaded;
event Action<string>? PluginLoadFailed;
event Action<string>? PluginUnloaded;
event Action<string>? PluginRemoved;
event Action<string>? PluginReloaded;
event Action<string>? PluginActivated;
event Action<string>? PluginDeactivated;
Expand Down
6 changes: 0 additions & 6 deletions src/Ivy.Plugin.Abstractions/PluginIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,10 @@ public record PluginIcon
/// Creates an icon from a URL (e.g. PNG, SVG, or any image URL).
/// </summary>
public static PluginIcon Url(string url) => new() { Kind = PluginIconKind.Url, Value = url };

/// <summary>
/// Creates an icon from a file bundled with the plugin (relative path within the plugin directory).
/// </summary>
public static PluginIcon File(string relativePath) => new() { Kind = PluginIconKind.File, Value = relativePath };
}

public enum PluginIconKind
{
Named = 0,
Url = 1,
File = 2,
}
4 changes: 4 additions & 0 deletions src/Ivy.Test/Plugins/PluginStateServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ private class FakePluginManager : IPluginManager
public List<string> ActivePluginIds { get; set; } = [];

public event Action<string>? PluginLoaded;
public event Action<string>? PluginLoadFailed;
public event Action<string>? PluginUnloaded;
public event Action<string>? PluginRemoved;
public event Action<string>? PluginReloaded;
public event Action<string>? PluginActivated;
public event Action<string>? PluginDeactivated;
Expand All @@ -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);
Expand Down
7 changes: 3 additions & 4 deletions src/Ivy/Abstractions/IIvyExtendedPluginContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;

namespace Ivy.Plugins;

Expand All @@ -9,7 +9,6 @@ public interface IIvyExtendedPluginContext : IIvyPluginContext
void AddApp(AppDescriptor descriptor);
void AddAppsFromAssembly(Assembly assembly);

// ASP.NET pipeline
void UseWebApplication(Action<WebApplication> configure);
void UseWebApplicationBuilder(Action<WebApplicationBuilder> configure);
// HTTP endpoints
void UseEndpoints(string slug, Action<IEndpointRouteBuilder> configure);
}
35 changes: 35 additions & 0 deletions src/Ivy/CompatibilitySuppressions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Ivy.Core.Plugins.PluginContextBase.UseWebApplication(System.Action{Microsoft.AspNetCore.Builder.WebApplication})</Target>
<Left>lib/net10.0/Ivy.dll</Left>
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Ivy.Core.Plugins.PluginContextBase.UseWebApplicationBuilder(System.Action{Microsoft.AspNetCore.Builder.WebApplicationBuilder})</Target>
<Left>lib/net10.0/Ivy.dll</Left>
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Ivy.Core.Plugins.PluginLoader.SetConfiguration(Microsoft.Extensions.Configuration.IConfiguration)</Target>
Expand Down Expand Up @@ -148,13 +162,34 @@
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Ivy.Plugins.IIvyExtendedPluginContext.UseWebApplication(System.Action{Microsoft.AspNetCore.Builder.WebApplication})</Target>
<Left>lib/net10.0/Ivy.dll</Left>
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Ivy.Plugins.IIvyExtendedPluginContext.UseWebApplicationBuilder(System.Action{Microsoft.AspNetCore.Builder.WebApplicationBuilder})</Target>
<Left>lib/net10.0/Ivy.dll</Left>
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>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)</Target>
<Left>lib/net10.0/Ivy.dll</Left>
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Ivy.Plugins.IIvyExtendedPluginContext.UseEndpoints(System.String,System.Action{Microsoft.AspNetCore.Routing.IEndpointRouteBuilder})</Target>
<Left>lib/net10.0/Ivy.dll</Left>
<Right>lib/net10.0/Ivy.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0011</DiagnosticId>
<Target>F:Ivy.Icons.AArrowDown</Target>
Expand Down
115 changes: 61 additions & 54 deletions src/Ivy/Core/Plugins/PluginContext.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,10 +33,13 @@ protected PluginContextBase(AppRepository appRepository, IReadOnlySet<string> re
ReservedPaths = reservedPaths;
Builder = builder;
}
private readonly List<Action<WebApplication>> _appActions = [];

private readonly AggregatePluginServiceProvider _aggregateProvider = new();
private readonly Dictionary<string, PluginState> _pluginStates = new();
private readonly ReaderWriterLockSlim _lock = new();
private readonly Dictionary<string, string> _slugToPluginId = new();
private DynamicPluginEndpointDataSource? _endpointDataSource;
private WebApplication? _app;
private string? _currentPluginId;
protected string? CurrentPluginId => _currentPluginId;

Expand Down Expand Up @@ -93,26 +98,58 @@ public void AddAppsFromAssembly(Assembly assembly)
state.AppFactories.Add(factory);
}

public void UseEndpoints(string slug, Action<IEndpointRouteBuilder> configure)
{
ValidateSlug(slug);

var pluginId = _currentPluginId
?? throw new InvalidOperationException("UseEndpoints can only be called during plugin configuration.");

public void UseWebApplication(Action<WebApplication> 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<WebApplicationBuilder> 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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -242,53 +283,19 @@ private static HashSet<string> GetAppIdsFromFactories(List<Func<AppDescriptor[]>

internal IReadOnlyDictionary<string, PluginState> PluginStates => _pluginStates;

internal void SetEndpointDataSource(DynamicPluginEndpointDataSource dataSource)
{
_endpointDataSource = dataSource;
}

public void Apply(WebApplication app)
{
List<Action<WebApplication>> 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();
}
});
}
}

Expand Down
Loading
Loading