Various plugin fixes (#5577)

* 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>
This commit is contained in:
Andrew Camilleri
2024-01-18 09:15:16 +01:00
committed by GitHub
parent 3eec9cb0bb
commit a753698ae7
5 changed files with 97 additions and 45 deletions

View File

@@ -55,7 +55,7 @@ namespace BTCPayServer.Controllers
public IEnumerable<PluginService.AvailablePlugin> Available { get; set; } public IEnumerable<PluginService.AvailablePlugin> Available { get; set; }
public (string command, string plugin)[] Commands { get; set; } public (string command, string plugin)[] Commands { get; set; }
public bool CanShowRestart { 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>(); public Dictionary<string, AvailablePlugin> DownloadedPluginsByIdentifier { get; set; } = new Dictionary<string, AvailablePlugin>();
} }

View File

@@ -87,7 +87,7 @@ namespace BTCPayServer.HostedServices
var remotePluginsList = remotePlugins var remotePluginsList = remotePlugins
.GroupBy(plugin => plugin.Identifier) .GroupBy(plugin => plugin.Identifier)
.Select(group => group.OrderByDescending(plugin => plugin.Version).First()) .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); .ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var notify = new HashSet<string>(); var notify = new HashSet<string>();
foreach (var pair in remotePluginsList) foreach (var pair in remotePluginsList)
@@ -95,8 +95,10 @@ namespace BTCPayServer.HostedServices
if (dh.LastVersions.TryGetValue(pair.Key, out var lastVersion) && lastVersion >= pair.Value) if (dh.LastVersions.TryGetValue(pair.Key, out var lastVersion) && lastVersion >= pair.Value)
continue; continue;
if (installedPlugins.TryGetValue(pair.Key, out var installedVersion) && installedVersion < pair.Value) if (installedPlugins.TryGetValue(pair.Key, out var installedVersion) && installedVersion < pair.Value)
{
notify.Add(pair.Key); 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); notify.Add(pair.Key);
} }

View File

@@ -60,10 +60,11 @@ namespace BTCPayServer.Plugins
pluginName = null; pluginName = null;
return false; return false;
} }
public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection, public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection,
IConfiguration config, ILoggerFactory loggerFactory, ServiceProvider bootstrapServiceProvider) 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) List<IBTCPayServerPlugin> btcPayServerPlugins)
{ {
// Load the referenced assembly plugins // Load the referenced assembly plugins
@@ -73,7 +74,7 @@ namespace BTCPayServer.Plugins
{ {
var assemblyName = assembly.GetName().Name; var assemblyName = assembly.GetName().Name;
bool isSystemPlugin = assembly == systemAssembly1; bool isSystemPlugin = assembly == systemAssembly1;
if (!isSystemPlugin && hashSet.Contains(assemblyName)) if (!isSystemPlugin && exclude.Contains(assemblyName))
continue; continue;
foreach (var plugin in GetPluginInstancesFromAssembly(assembly)) foreach (var plugin in GetPluginInstancesFromAssembly(assembly))
@@ -101,15 +102,15 @@ namespace BTCPayServer.Plugins
Directory.CreateDirectory(pluginsFolder); Directory.CreateDirectory(pluginsFolder);
ExecuteCommands(pluginsFolder); ExecuteCommands(pluginsFolder);
var disabledPlugins = GetDisabledPlugins(pluginsFolder); var disabledPluginIdentifiers = GetDisabledPluginIdentifiers(pluginsFolder);
var systemAssembly = typeof(Program).Assembly; 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))) if (ExecuteCommands(pluginsFolder, plugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version)))
{ {
plugins.Clear(); plugins.Clear();
loadedPluginIdentifiers.Clear(); loadedPluginIdentifiers.Clear();
LoadPluginsFromAssemblies(systemAssembly, disabledPlugins, loadedPluginIdentifiers, plugins); LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);
} }
var pluginsToLoad = new List<(string PluginIdentifier, string PluginFilePath)>(); var pluginsToLoad = new List<(string PluginIdentifier, string PluginFilePath)>();
@@ -135,7 +136,7 @@ namespace BTCPayServer.Plugins
var pluginFilePath = Path.Combine(directory, pluginIdentifier + ".dll"); var pluginFilePath = Path.Combine(directory, pluginIdentifier + ".dll");
if (!File.Exists(pluginFilePath)) if (!File.Exists(pluginFilePath))
continue; continue;
if (disabledPlugins.Contains(pluginIdentifier)) if (disabledPluginIdentifiers.Contains(pluginIdentifier))
continue; continue;
pluginsToLoad.Add((pluginIdentifier, pluginFilePath)); pluginsToLoad.Add((pluginIdentifier, pluginFilePath));
} }
@@ -288,6 +289,31 @@ namespace BTCPayServer.Plugins
return remainingCommands.Count != pendingCommands.Length; 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) private static bool DependenciesMet(string pluginsFolder, string plugin, Dictionary<string, Version> installed)
{ {
@@ -299,7 +325,7 @@ namespace BTCPayServer.Plugins
} }
private static bool ExecuteCommand((string command, string extension) command, string pluginsFolder, 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); var dirName = Path.Combine(pluginsFolder, command.extension);
switch (command.command) switch (command.command)
@@ -307,12 +333,12 @@ namespace BTCPayServer.Plugins
case "update": case "update":
if (!DependenciesMet(pluginsFolder, command.extension, installed)) if (!DependenciesMet(pluginsFolder, command.extension, installed))
return false; return false;
ExecuteCommand(("delete", command.extension), pluginsFolder, true); ExecuteCommand(("delete", command.extension), pluginsFolder, true, installed);
ExecuteCommand(("install", command.extension), pluginsFolder, true); ExecuteCommand(("install", command.extension), pluginsFolder, true, installed);
break; break;
case "delete": case "delete":
ExecuteCommand(("enable", command.extension), pluginsFolder, true); ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed);
if (File.Exists(dirName)) if (File.Exists(dirName))
{ {
File.Delete(dirName); File.Delete(dirName);
@@ -335,7 +361,7 @@ namespace BTCPayServer.Plugins
if (!DependenciesMet(pluginsFolder, command.extension, installed)) if (!DependenciesMet(pluginsFolder, command.extension, installed))
return false; return false;
ExecuteCommand(("enable", command.extension), pluginsFolder, true); ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed);
if (File.Exists(fileName)) if (File.Exists(fileName))
{ {
ZipFile.ExtractToDirectory(fileName, dirName, true); ZipFile.ExtractToDirectory(fileName, dirName, true);
@@ -426,12 +452,18 @@ namespace BTCPayServer.Plugins
QueueCommands(pluginDir, ("disable", 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 disabledFilePath = Path.Combine(pluginsFolder, "disabled"); var disabledPath = Path.Combine(pluginsFolder, "disabled");
return File.Exists(disabledFilePath) return File.Exists(disabledPath) ? File.ReadAllLines(disabledPath).ToHashSet() : [];
? File.ReadLines(disabledFilePath).ToHashSet() }
: [];
// List of disabled plugins with additional info, like the disabled version and its dependencies
public static Dictionary<string, Version> GetDisabledPlugins(string pluginsFolder)
{
return TryGetInstalledInfo(pluginsFolder).Where(pair => pair.Value.Disabled)
.ToDictionary(pair => pair.Key, pair => pair.Value.Item1);
} }
public static bool DependencyMet(IBTCPayServerPlugin.PluginDependency dependency, public static bool DependencyMet(IBTCPayServerPlugin.PluginDependency dependency,

View File

@@ -108,6 +108,7 @@ namespace BTCPayServer.Plugins
public void UninstallPlugin(string plugin) public void UninstallPlugin(string plugin)
{ {
var dest = _dataDirectories.Value.PluginDir; var dest = _dataDirectories.Value.PluginDir;
PluginManager.CancelCommands(dest, plugin);
PluginManager.QueueCommands(dest, ("delete", plugin)); PluginManager.QueueCommands(dest, ("delete", plugin));
} }
@@ -155,9 +156,9 @@ namespace BTCPayServer.Plugins
PluginManager.CancelCommands(_dataDirectories.Value.PluginDir, 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);
} }
} }
} }

View File

@@ -7,13 +7,13 @@
Layout = "_Layout"; Layout = "_Layout";
ViewData.SetActivePage(ServerNavPages.Plugins); ViewData.SetActivePage(ServerNavPages.Plugins);
var installed = Model.Installed.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version); 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 var availableAndNotInstalledx = Model.Available
.Where(plugin => !installed.ContainsKey(plugin.Identifier)) .Where(plugin => !installed.ContainsKey(plugin.Identifier))
.GroupBy(plugin => plugin.Identifier) .GroupBy(plugin => plugin.Identifier)
.ToList(); .ToList();
var availableAndNotInstalled = new List<PluginService.AvailablePlugin>();
foreach (var availableAndNotInstalledItem in availableAndNotInstalledx) foreach (var availableAndNotInstalledItem in availableAndNotInstalledx)
{ {
var ordered = availableAndNotInstalledItem.OrderByDescending(plugin => plugin.Version).ToArray(); var ordered = availableAndNotInstalledItem.OrderByDescending(plugin => plugin.Version).ToArray();
@@ -76,12 +76,18 @@
<div class="mb-5"> <div class="mb-5">
<h3 class="mb-4">Disabled Plugins</h3> <h3 class="mb-4">Disabled Plugins</h3>
<ul class="list-group list-group-flush d-inline-block"> <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"> <li class="list-group-item px-0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<span>@d</span> <span>
<form asp-action="UnInstallPlugin" asp-route-plugin="@d"> @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> <button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button>
</form> </form>
</div> </div>
@@ -233,26 +239,27 @@
</div> </div>
</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 exclusivePendingAction = true;
var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
} }
<div class="card-footer border-0 pb-3 d-flex gap-2"> <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"; var isUpdateAction = Model.Commands.Last(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command == "update";
if (isUpdateAction) if (isUpdateAction)
{ {
var version = PluginService.GetVersionOfPendingInstall(plugin.Identifier); exclusivePendingAction = versionOfPendingInstall == x.Version;
exclusivePendingAction = version == x.Version;
} }
} }
@if (pendingAction) @if (pendingAction is not null)
{ {
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier"> <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> </form>
} }
@if (!pendingAction || !exclusivePendingAction) @if (pendingAction is null || !exclusivePendingAction)
{ {
@if (updateAvailable && x != null) @if (updateAvailable && x != null)
{ {
@@ -294,7 +301,7 @@
@foreach (var plugin in availableAndNotInstalled) @foreach (var plugin in availableAndNotInstalled)
{ {
var recommended = BTCPayServerOptions.RecommendedPlugins.Any(id => string.Equals(id, plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)); 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="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"> <div class="card h-100" id="@plugin.Identifier">
@@ -313,7 +320,11 @@
</div> </div>
<h5 class="text-muted d-flex align-items-center mt-1 gap-2"> <h5 class="text-muted d-flex align-items-center mt-1 gap-2">
@plugin.Version @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> <div class="badge bg-light">Disabled</div>
} }
@@ -384,27 +395,33 @@
</div> </div>
<div class="card-footer border-0 pb-3 d-flex gap-2"> <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 res = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var version = PluginService.GetVersionOfPendingInstall(plugin.Identifier); var pendingAction = res != default ? res.command : null;
var exclusivePendingAction = version == plugin.Version; 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"> <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> </form>
} }
@if (pendingAction.Equals(default) || !exclusivePendingAction) @if (pendingAction is null|| !exclusivePendingAction)
{ {
if (PluginManager.DependenciesMet(plugin.Dependencies, installed)) if (PluginManager.DependenciesMet(plugin.Dependencies, installed))
{ {
@* Don't show the "Install" button if plugin has been disabled *@ @if (disabled is null)
@if (!disabled)
{ {
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version"> <form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version">
<button type="submit" class="btn btn-primary">Install</button> <button type="submit" class="btn btn-primary">Install</button>
</form> </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 else
{ {
@@ -413,7 +430,7 @@
</form> </form>
} }
} }
@if (disabled) @if (disabled is not null && pendingAction is null)
{ {
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier"> <form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button> <button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button>