Skip to content

Commit

Permalink
Various plugin fixes (#5577)
Browse files Browse the repository at this point in the history
* Fix: Plugin updates do not work

* Offer install on disabled plugins when different version

This will:
* Clear any previous pending actions of a plugin if you click uninstall
* Show the plugin version that was disabled
* Show an update button on disabled plugins instead of install
* if a plugin is scheduled to be installed/updated, it will show which version was scheduled to be updated. If a newer version if available than the scheduled one, it will show an option to switch to that

* Ensure disabled plugins don't get loaded

* View fixes

---------

Co-authored-by: d11n <mail@dennisreimann.de>
  • Loading branch information
Kukks and dennisreimann committed Jan 18, 2024
1 parent 3eec9cb commit a753698
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 45 deletions.
2 changes: 1 addition & 1 deletion BTCPayServer/Controllers/UIServerController.Plugins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public class ListPluginsViewModel
public IEnumerable<PluginService.AvailablePlugin> Available { get; set; }
public (string command, string plugin)[] Commands { get; set; }
public bool CanShowRestart { get; set; }
public string[] Disabled { get; set; }
public Dictionary<string, Version> Disabled { get; set; }
public Dictionary<string, AvailablePlugin> DownloadedPluginsByIdentifier { get; set; } = new Dictionary<string, AvailablePlugin>();
}

Expand Down
6 changes: 4 additions & 2 deletions BTCPayServer/HostedServices/PluginUpdateFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,18 @@ public async Task Do(CancellationToken cancellationToken)
var remotePluginsList = remotePlugins
.GroupBy(plugin => plugin.Identifier)
.Select(group => group.OrderByDescending(plugin => plugin.Version).First())
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.Contains(pair.Name))
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.ContainsKey(pair.Name))
.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var notify = new HashSet<string>();
foreach (var pair in remotePluginsList)
{
if (dh.LastVersions.TryGetValue(pair.Key, out var lastVersion) && lastVersion >= pair.Value)
continue;
if (installedPlugins.TryGetValue(pair.Key, out var installedVersion) && installedVersion < pair.Value)
{
notify.Add(pair.Key);
if (disabledPlugins.Contains(pair.Key))
}
else if (disabledPlugins.TryGetValue(pair.Key, out var disabledVersion) && disabledVersion < pair.Value)
{
notify.Add(pair.Key);
}
Expand Down
64 changes: 48 additions & 16 deletions BTCPayServer/Plugins/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
pluginName = null;
return false;
}

public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection,
IConfiguration config, ILoggerFactory loggerFactory, ServiceProvider bootstrapServiceProvider)
{
void LoadPluginsFromAssemblies(Assembly systemAssembly1, HashSet<string> hashSet, HashSet<string> loadedPluginIdentifiers1,
void LoadPluginsFromAssemblies(Assembly systemAssembly1, HashSet<string> exclude, HashSet<string> loadedPluginIdentifiers1,
List<IBTCPayServerPlugin> btcPayServerPlugins)
{
// Load the referenced assembly plugins
Expand All @@ -73,7 +74,7 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
{
var assemblyName = assembly.GetName().Name;
bool isSystemPlugin = assembly == systemAssembly1;
if (!isSystemPlugin && hashSet.Contains(assemblyName))
if (!isSystemPlugin && exclude.Contains(assemblyName))
continue;

foreach (var plugin in GetPluginInstancesFromAssembly(assembly))
Expand Down Expand Up @@ -101,15 +102,15 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
Directory.CreateDirectory(pluginsFolder);
ExecuteCommands(pluginsFolder);

var disabledPlugins = GetDisabledPlugins(pluginsFolder);
var disabledPluginIdentifiers = GetDisabledPluginIdentifiers(pluginsFolder);
var systemAssembly = typeof(Program).Assembly;
LoadPluginsFromAssemblies(systemAssembly, disabledPlugins, loadedPluginIdentifiers, plugins);
LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);

if (ExecuteCommands(pluginsFolder, plugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version)))
{
plugins.Clear();
loadedPluginIdentifiers.Clear();
LoadPluginsFromAssemblies(systemAssembly, disabledPlugins, loadedPluginIdentifiers, plugins);
LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);
}

var pluginsToLoad = new List<(string PluginIdentifier, string PluginFilePath)>();
Expand All @@ -135,7 +136,7 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
var pluginFilePath = Path.Combine(directory, pluginIdentifier + ".dll");
if (!File.Exists(pluginFilePath))
continue;
if (disabledPlugins.Contains(pluginIdentifier))
if (disabledPluginIdentifiers.Contains(pluginIdentifier))
continue;
pluginsToLoad.Add((pluginIdentifier, pluginFilePath));
}
Expand Down Expand Up @@ -288,6 +289,31 @@ private static bool ExecuteCommands(string pluginsFolder, Dictionary<string, Ver

return remainingCommands.Count != pendingCommands.Length;
}
private static Dictionary<string, (Version, IBTCPayServerPlugin.PluginDependency[] Dependencies, bool Disabled)> TryGetInstalledInfo(
string pluginsFolder)
{
var disabled = GetDisabledPluginIdentifiers(pluginsFolder);
var installed = new Dictionary<string, (Version, IBTCPayServerPlugin.PluginDependency[] Dependencies, bool Disabled)>();
foreach (string pluginDir in Directory.EnumerateDirectories(pluginsFolder))
{
var plugin = Path.GetFileName(pluginDir);
var dirName = Path.Combine(pluginsFolder, plugin);
var isDisabled = disabled.Contains(plugin);
var manifestFileName = Path.Combine(dirName, plugin + ".json");
if (File.Exists(manifestFileName))
{
var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject<PluginService.AvailablePlugin>();
installed.TryAdd(pluginManifest.Identifier, (pluginManifest.Version, pluginManifest.Dependencies, isDisabled));
}
else if (isDisabled)
{
// Disabled plugin might not have a manifest, but we still need to include
// it in the list, so that it can be shown on the Manage Plugins page
installed.TryAdd(plugin, (null, null, true));
}
}
return installed;
}

private static bool DependenciesMet(string pluginsFolder, string plugin, Dictionary<string, Version> installed)
{
Expand All @@ -299,20 +325,20 @@ private static bool DependenciesMet(string pluginsFolder, string plugin, Diction
}

private static bool ExecuteCommand((string command, string extension) command, string pluginsFolder,
bool ignoreOrder = false, Dictionary<string, Version> installed = null)
bool ignoreOrder, Dictionary<string, Version> installed)
{
var dirName = Path.Combine(pluginsFolder, command.extension);
switch (command.command)
{
case "update":
if (!DependenciesMet(pluginsFolder, command.extension, installed))
return false;
ExecuteCommand(("delete", command.extension), pluginsFolder, true);
ExecuteCommand(("install", command.extension), pluginsFolder, true);
ExecuteCommand(("delete", command.extension), pluginsFolder, true, installed);
ExecuteCommand(("install", command.extension), pluginsFolder, true, installed);
break;

case "delete":
ExecuteCommand(("enable", command.extension), pluginsFolder, true);
ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed);
if (File.Exists(dirName))
{
File.Delete(dirName);
Expand All @@ -335,7 +361,7 @@ private static bool DependenciesMet(string pluginsFolder, string plugin, Diction
if (!DependenciesMet(pluginsFolder, command.extension, installed))
return false;

ExecuteCommand(("enable", command.extension), pluginsFolder, true);
ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed);
if (File.Exists(fileName))
{
ZipFile.ExtractToDirectory(fileName, dirName, true);
Expand Down Expand Up @@ -426,12 +452,18 @@ public static void DisablePlugin(string pluginDir, string plugin)
QueueCommands(pluginDir, ("disable", plugin));
}

public static HashSet<string> GetDisabledPlugins(string pluginsFolder)
// Loads the list of disabled plugins from the file
private static HashSet<string> GetDisabledPluginIdentifiers(string pluginsFolder)
{
var disabledPath = Path.Combine(pluginsFolder, "disabled");
return File.Exists(disabledPath) ? File.ReadAllLines(disabledPath).ToHashSet() : [];
}

// List of disabled plugins with additional info, like the disabled version and its dependencies
public static Dictionary<string, Version> GetDisabledPlugins(string pluginsFolder)
{
var disabledFilePath = Path.Combine(pluginsFolder, "disabled");
return File.Exists(disabledFilePath)
? File.ReadLines(disabledFilePath).ToHashSet()
: [];
return TryGetInstalledInfo(pluginsFolder).Where(pair => pair.Value.Disabled)
.ToDictionary(pair => pair.Key, pair => pair.Value.Item1);
}

public static bool DependencyMet(IBTCPayServerPlugin.PluginDependency dependency,
Expand Down
5 changes: 3 additions & 2 deletions BTCPayServer/Plugins/PluginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public async Task UploadPlugin(IFormFile plugin)
public void UninstallPlugin(string plugin)
{
var dest = _dataDirectories.Value.PluginDir;
PluginManager.CancelCommands(dest, plugin);
PluginManager.QueueCommands(dest, ("delete", plugin));
}

Expand Down Expand Up @@ -155,9 +156,9 @@ public void CancelCommands(string plugin)
PluginManager.CancelCommands(_dataDirectories.Value.PluginDir, plugin);
}

public string[] GetDisabledPlugins()
public Dictionary<string, Version> GetDisabledPlugins()
{
return PluginManager.GetDisabledPlugins(_dataDirectories.Value.PluginDir).ToArray();
return PluginManager.GetDisabledPlugins(_dataDirectories.Value.PluginDir);
}
}
}
65 changes: 41 additions & 24 deletions BTCPayServer/Views/UIServer/ListPlugins.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
Layout = "_Layout";
ViewData.SetActivePage(ServerNavPages.Plugins);
var installed = Model.Installed.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);

var installedWithoutSystemPlugins = Model.Installed.Where(i => !i.SystemPlugin).ToList();
var availableAndNotInstalled = new List<PluginService.AvailablePlugin>();
var availableAndNotInstalledx = Model.Available
.Where(plugin => !installed.ContainsKey(plugin.Identifier))
.GroupBy(plugin => plugin.Identifier)
.ToList();

var availableAndNotInstalled = new List<PluginService.AvailablePlugin>();

foreach (var availableAndNotInstalledItem in availableAndNotInstalledx)
{
var ordered = availableAndNotInstalledItem.OrderByDescending(plugin => plugin.Version).ToArray();
Expand Down Expand Up @@ -76,12 +76,18 @@
<div class="mb-5">
<h3 class="mb-4">Disabled Plugins</h3>
<ul class="list-group list-group-flush d-inline-block">
@foreach (var d in Model.Disabled)
@foreach (var (plugin, version) in Model.Disabled)
{
<li class="list-group-item px-0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<span>@d</span>
<form asp-action="UnInstallPlugin" asp-route-plugin="@d">
<span>
@plugin
@if (version != null)
{
<span>({version})</span>
}
</span>
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin">
<button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button>
</form>
</div>
Expand Down Expand Up @@ -233,26 +239,27 @@
</div>
</div>
@{
var pendingAction = Model.Commands.Any(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var pendingAction = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command;
var exclusivePendingAction = true;

var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
}
<div class="card-footer border-0 pb-3 d-flex gap-2">
@if (pendingAction && updateAvailable)
@if (pendingAction is not null && updateAvailable)
{
var isUpdateAction = Model.Commands.Last(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command == "update";
if (isUpdateAction)
{
var version = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
exclusivePendingAction = version == x.Version;
exclusivePendingAction = versionOfPendingInstall == x.Version;
}
}
@if (pendingAction)
@if (pendingAction is not null)
{
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-outline-secondary">Cancel pending action</button>
<button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction @(versionOfPendingInstall is null? "": $"of {versionOfPendingInstall}")</button>
</form>
}
@if (!pendingAction || !exclusivePendingAction)
@if (pendingAction is null || !exclusivePendingAction)
{
@if (updateAvailable && x != null)
{
Expand Down Expand Up @@ -294,7 +301,7 @@
@foreach (var plugin in availableAndNotInstalled)
{
var recommended = BTCPayServerOptions.RecommendedPlugins.Any(id => string.Equals(id, plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var disabled = Model.Disabled?.Contains(plugin.Identifier) ?? false;
Model.Disabled.TryGetValue(plugin.Identifier, out var disabled);

<div class="col col-12 col-md-6 col-lg-12 col-xl-6 col-xxl-4 mb-4">
<div class="card h-100" id="@plugin.Identifier">
Expand All @@ -313,7 +320,11 @@
</div>
<h5 class="text-muted d-flex align-items-center mt-1 gap-2">
@plugin.Version
@if (disabled)
@if (disabled is { } && disabled != plugin.Version)
{
<div class="badge bg-light">Disabled (@disabled)</div>
}
else if (disabled is { } && disabled == plugin.Version)
{
<div class="badge bg-light">Disabled</div>
}
Expand Down Expand Up @@ -384,27 +395,33 @@
</div>
<div class="card-footer border-0 pb-3 d-flex gap-2">
@{
var pendingAction = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var version = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
var exclusivePendingAction = version == plugin.Version;
var res = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var pendingAction = res != default ? res.command : null;
var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
var exclusivePendingAction = pendingAction is not null && (pendingAction == "delete" || versionOfPendingInstall == plugin.Version);
}
@if (!pendingAction.Equals(default))
@if (pendingAction is not null)
{
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction.command</button>
<button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction @(versionOfPendingInstall is null? "": $"of {versionOfPendingInstall}")</button>
</form>
}
@if (pendingAction.Equals(default) || !exclusivePendingAction)
@if (pendingAction is null|| !exclusivePendingAction)
{
if (PluginManager.DependenciesMet(plugin.Dependencies, installed))
{
@* Don't show the "Install" button if plugin has been disabled *@
@if (!disabled)
@if (disabled is null)
{
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version">
<button type="submit" class="btn btn-primary">Install</button>
</form>
}
else if (disabled != plugin.Version)
{
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version" asp-route-update="true">
<button type="submit" class="btn btn-primary">Update</button>
</form>
}
}
else
{
Expand All @@ -413,7 +430,7 @@
</form>
}
}
@if (disabled)
@if (disabled is not null && pendingAction is null)
{
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button>
Expand Down

0 comments on commit a753698

Please sign in to comment.