mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Merge pull request #2520 from flooxo/local_install
Feature: Add support for local plugin installation
This commit is contained in:
commit
1bbe0302fa
8 changed files with 156 additions and 35 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Flow.Launcher.Core.ExternalPlugins
|
namespace Flow.Launcher.Core.ExternalPlugins
|
||||||
{
|
{
|
||||||
|
|
@ -13,9 +13,11 @@ namespace Flow.Launcher.Core.ExternalPlugins
|
||||||
public string Website { get; set; }
|
public string Website { get; set; }
|
||||||
public string UrlDownload { get; set; }
|
public string UrlDownload { get; set; }
|
||||||
public string UrlSourceCode { get; set; }
|
public string UrlSourceCode { get; set; }
|
||||||
|
public string LocalInstallPath { get; set; }
|
||||||
public string IcoPath { get; set; }
|
public string IcoPath { get; set; }
|
||||||
public DateTime? LatestReleaseDate { get; set; }
|
public DateTime? LatestReleaseDate { get; set; }
|
||||||
public DateTime? DateAdded { get; set; }
|
public DateTime? DateAdded { get; set; }
|
||||||
|
|
||||||
|
public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,8 @@ namespace Flow.Launcher.Core.Plugin
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update a plugin to new version, from a zip file. Will Delete zip after updating.
|
/// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url,
|
||||||
|
/// unless it's a local path installation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void UpdatePlugin(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
|
public static void UpdatePlugin(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
|
||||||
{
|
{
|
||||||
|
|
@ -390,11 +391,11 @@ namespace Flow.Launcher.Core.Plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Install a plugin. Will Delete zip after updating.
|
/// Install a plugin. By default will remove the zip file if installation is from url, unless it's a local path installation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void InstallPlugin(UserPlugin plugin, string zipFilePath)
|
public static void InstallPlugin(UserPlugin plugin, string zipFilePath)
|
||||||
{
|
{
|
||||||
InstallPlugin(plugin, zipFilePath, true);
|
InstallPlugin(plugin, zipFilePath, checkModified: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -420,7 +421,9 @@ namespace Flow.Launcher.Core.Plugin
|
||||||
// Unzip plugin files to temp folder
|
// Unzip plugin files to temp folder
|
||||||
var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||||
System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath);
|
System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath);
|
||||||
File.Delete(zipFilePath);
|
|
||||||
|
if(!plugin.IsFromLocalInstallPath)
|
||||||
|
File.Delete(zipFilePath);
|
||||||
|
|
||||||
var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath);
|
var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
#pragma warning disable IDE0005
|
#pragma warning disable IDE0005
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
#pragma warning restore IDE0005
|
#pragma warning restore IDE0005
|
||||||
|
|
@ -200,6 +201,24 @@ namespace Flow.Launcher.Plugin.SharedCommands
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///<summary>
|
||||||
|
/// This checks whether a given string is a zip file path.
|
||||||
|
/// By default does not check if the zip file actually exist on disk, can do so by
|
||||||
|
/// setting checkFileExists = true.
|
||||||
|
///</summary>
|
||||||
|
public static bool IsZipFilePath(string querySearchString, bool checkFileExists = false)
|
||||||
|
{
|
||||||
|
if (IsLocationPathString(querySearchString) && querySearchString.Split('.').Last() == "zip")
|
||||||
|
{
|
||||||
|
if (checkFileExists)
|
||||||
|
return FileExists(querySearchString);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
///<summary>
|
///<summary>
|
||||||
/// This checks whether a given string is a directory path or network location string.
|
/// This checks whether a given string is a directory path or network location string.
|
||||||
/// It does not check if location actually exists.
|
/// It does not check if location actually exists.
|
||||||
|
|
|
||||||
|
|
@ -191,11 +191,15 @@ namespace Flow.Launcher.Test.Plugins
|
||||||
[TestCase(@"\c:\", false)]
|
[TestCase(@"\c:\", false)]
|
||||||
[TestCase(@"cc:\", false)]
|
[TestCase(@"cc:\", false)]
|
||||||
[TestCase(@"\\\SomeNetworkLocation\", false)]
|
[TestCase(@"\\\SomeNetworkLocation\", false)]
|
||||||
|
[TestCase(@"\\SomeNetworkLocation\", true)]
|
||||||
[TestCase("RandomFile", false)]
|
[TestCase("RandomFile", false)]
|
||||||
[TestCase(@"c:\>*", true)]
|
[TestCase(@"c:\>*", true)]
|
||||||
[TestCase(@"c:\>", true)]
|
[TestCase(@"c:\>", true)]
|
||||||
[TestCase(@"c:\SomeLocation\SomeOtherLocation\>", true)]
|
[TestCase(@"c:\SomeLocation\SomeOtherLocation\>", true)]
|
||||||
[TestCase(@"c:\SomeLocation\SomeOtherLocation", true)]
|
[TestCase(@"c:\SomeLocation\SomeOtherLocation", true)]
|
||||||
|
[TestCase(@"c:\SomeLocation\SomeOtherLocation\SomeFile.exe", true)]
|
||||||
|
[TestCase(@"\\SomeNetworkLocation\SomeFile.exe", true)]
|
||||||
|
|
||||||
public void WhenGivenQuerySearchString_ThenShouldIndicateIfIsLocationPathString(string querySearchString, bool expectedResult)
|
public void WhenGivenQuerySearchString_ThenShouldIndicateIfIsLocationPathString(string querySearchString, bool expectedResult)
|
||||||
{
|
{
|
||||||
// When, Given
|
// When, Given
|
||||||
|
|
|
||||||
BIN
Plugins/Flow.Launcher.Plugin.PluginsManager/Images/zipfolder.png
Normal file
BIN
Plugins/Flow.Launcher.Plugin.PluginsManager/Images/zipfolder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -26,7 +26,6 @@
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_prompt">{0} by {1} {2}{3}Would you like to update this plugin? After the update Flow will automatically restart.</system:String>
|
<system:String x:Key="plugin_pluginsmanager_update_prompt">{0} by {1} {2}{3}Would you like to update this plugin? After the update Flow will automatically restart.</system:String>
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_prompt_no_restart">{0} by {1} {2}{2}Would you like to update this plugin?</system:String>
|
<system:String x:Key="plugin_pluginsmanager_update_prompt_no_restart">{0} by {1} {2}{2}Would you like to update this plugin?</system:String>
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_title">Plugin Update</system:String>
|
<system:String x:Key="plugin_pluginsmanager_update_title">Plugin Update</system:String>
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_exists">This plugin has an update, would you like to see it?</system:String>
|
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_alreadyexists">This plugin is already installed</system:String>
|
<system:String x:Key="plugin_pluginsmanager_update_alreadyexists">This plugin is already installed</system:String>
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_failed_title">Plugin Manifest Download Failed</system:String>
|
<system:String x:Key="plugin_pluginsmanager_update_failed_title">Plugin Manifest Download Failed</system:String>
|
||||||
<system:String x:Key="plugin_pluginsmanager_update_failed_subtitle">Please check if you can connect to github.com. This error means you may not be able to install or update plugins.</system:String>
|
<system:String x:Key="plugin_pluginsmanager_update_failed_subtitle">Please check if you can connect to github.com. This error means you may not be able to install or update plugins.</system:String>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@ using Flow.Launcher.Core.Plugin;
|
||||||
using Flow.Launcher.Infrastructure;
|
using Flow.Launcher.Infrastructure;
|
||||||
using Flow.Launcher.Infrastructure.Http;
|
using Flow.Launcher.Infrastructure.Http;
|
||||||
using Flow.Launcher.Infrastructure.Logger;
|
using Flow.Launcher.Infrastructure.Logger;
|
||||||
using Flow.Launcher.Infrastructure.UserSettings;
|
|
||||||
using Flow.Launcher.Plugin.SharedCommands;
|
using Flow.Launcher.Plugin.SharedCommands;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
|
@ -99,13 +97,12 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
if (Context.API.GetAllPlugins()
|
if (Context.API.GetAllPlugins()
|
||||||
.Any(x => x.Metadata.ID == plugin.ID && x.Metadata.Version.CompareTo(plugin.Version) < 0))
|
.Any(x => x.Metadata.ID == plugin.ID && x.Metadata.Version.CompareTo(plugin.Version) < 0))
|
||||||
{
|
{
|
||||||
if (MessageBox.Show(Context.API.GetTranslation("plugin_pluginsmanager_update_exists"),
|
var updateDetail = !plugin.IsFromLocalInstallPath ? plugin.Name : plugin.LocalInstallPath;
|
||||||
Context.API.GetTranslation("plugin_pluginsmanager_update_title"),
|
|
||||||
MessageBoxButton.YesNo) == MessageBoxResult.Yes)
|
Context
|
||||||
Context
|
.API
|
||||||
.API
|
.ChangeQuery(
|
||||||
.ChangeQuery(
|
$"{Context.CurrentPluginMetadata.ActionKeywords.FirstOrDefault()} {Settings.UpdateCommand} {updateDetail}");
|
||||||
$"{Context.CurrentPluginMetadata.ActionKeywords.FirstOrDefault()} {Settings.UpdateCommand} {plugin.Name}");
|
|
||||||
|
|
||||||
var mainWindow = Application.Current.MainWindow;
|
var mainWindow = Application.Current.MainWindow;
|
||||||
mainWindow.Show();
|
mainWindow.Show();
|
||||||
|
|
@ -147,12 +144,17 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(filePath))
|
if (!plugin.IsFromLocalInstallPath)
|
||||||
{
|
{
|
||||||
File.Delete(filePath);
|
if (File.Exists(filePath))
|
||||||
}
|
File.Delete(filePath);
|
||||||
|
|
||||||
await Http.DownloadAsync(plugin.UrlDownload, filePath).ConfigureAwait(false);
|
await Http.DownloadAsync(plugin.UrlDownload, filePath).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
filePath = plugin.LocalInstallPath;
|
||||||
|
}
|
||||||
|
|
||||||
Install(plugin, filePath);
|
Install(plugin, filePath);
|
||||||
}
|
}
|
||||||
|
|
@ -193,24 +195,38 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
{
|
{
|
||||||
await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly);
|
await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly);
|
||||||
|
|
||||||
|
var pluginFromLocalPath = null as UserPlugin;
|
||||||
|
var updateFromLocalPath = false;
|
||||||
|
|
||||||
|
if (FilesFolders.IsZipFilePath(search, checkFileExists: true))
|
||||||
|
{
|
||||||
|
pluginFromLocalPath = Utilities.GetPluginInfoFromZip(search);
|
||||||
|
pluginFromLocalPath.LocalInstallPath = search;
|
||||||
|
updateFromLocalPath = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateSource = !updateFromLocalPath
|
||||||
|
? PluginsManifest.UserPlugins
|
||||||
|
: new List<UserPlugin> { pluginFromLocalPath };
|
||||||
|
|
||||||
var resultsForUpdate = (
|
var resultsForUpdate = (
|
||||||
from existingPlugin in Context.API.GetAllPlugins()
|
from existingPlugin in Context.API.GetAllPlugins()
|
||||||
join pluginFromManifest in PluginsManifest.UserPlugins
|
join pluginUpdateSource in updateSource
|
||||||
on existingPlugin.Metadata.ID equals pluginFromManifest.ID
|
on existingPlugin.Metadata.ID equals pluginUpdateSource.ID
|
||||||
where String.Compare(existingPlugin.Metadata.Version, pluginFromManifest.Version,
|
where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version,
|
||||||
StringComparison.InvariantCulture) <
|
StringComparison.InvariantCulture) <
|
||||||
0 // if current version precedes manifest version
|
0 // if current version precedes version of the plugin from update source (e.g. PluginsManifest)
|
||||||
&& !PluginManager.PluginModified(existingPlugin.Metadata.ID)
|
&& !PluginManager.PluginModified(existingPlugin.Metadata.ID)
|
||||||
select
|
select
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
pluginFromManifest.Name,
|
pluginUpdateSource.Name,
|
||||||
pluginFromManifest.Author,
|
pluginUpdateSource.Author,
|
||||||
CurrentVersion = existingPlugin.Metadata.Version,
|
CurrentVersion = existingPlugin.Metadata.Version,
|
||||||
NewVersion = pluginFromManifest.Version,
|
NewVersion = pluginUpdateSource.Version,
|
||||||
existingPlugin.Metadata.IcoPath,
|
existingPlugin.Metadata.IcoPath,
|
||||||
PluginExistingMetadata = existingPlugin.Metadata,
|
PluginExistingMetadata = existingPlugin.Metadata,
|
||||||
PluginNewUserPlugin = pluginFromManifest
|
PluginNewUserPlugin = pluginUpdateSource
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
if (!resultsForUpdate.Any())
|
if (!resultsForUpdate.Any())
|
||||||
|
|
@ -261,13 +277,21 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
|
|
||||||
_ = Task.Run(async delegate
|
_ = Task.Run(async delegate
|
||||||
{
|
{
|
||||||
if (File.Exists(downloadToFilePath))
|
if (!x.PluginNewUserPlugin.IsFromLocalInstallPath)
|
||||||
{
|
{
|
||||||
File.Delete(downloadToFilePath);
|
if (File.Exists(downloadToFilePath))
|
||||||
}
|
{
|
||||||
|
File.Delete(downloadToFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
await Http.DownloadAsync(x.PluginNewUserPlugin.UrlDownload, downloadToFilePath)
|
await Http.DownloadAsync(x.PluginNewUserPlugin.UrlDownload, downloadToFilePath)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
downloadToFilePath = x.PluginNewUserPlugin.LocalInstallPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
PluginManager.UpdatePlugin(x.PluginExistingMetadata, x.PluginNewUserPlugin,
|
PluginManager.UpdatePlugin(x.PluginExistingMetadata, x.PluginNewUserPlugin,
|
||||||
downloadToFilePath);
|
downloadToFilePath);
|
||||||
|
|
@ -396,7 +420,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
results = results.Prepend(updateAllResult);
|
results = results.Prepend(updateAllResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Search(results, search);
|
return !updateFromLocalPath ? Search(results, search) : results.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool PluginExists(string id)
|
internal bool PluginExists(string id)
|
||||||
|
|
@ -470,6 +494,42 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
return new List<Result> { result };
|
return new List<Result> { result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal List<Result> InstallFromLocalPath(string localPath)
|
||||||
|
{
|
||||||
|
var plugin = Utilities.GetPluginInfoFromZip(localPath);
|
||||||
|
|
||||||
|
plugin.LocalInstallPath = localPath;
|
||||||
|
|
||||||
|
return new List<Result>
|
||||||
|
{
|
||||||
|
new Result
|
||||||
|
{
|
||||||
|
Title = $"{plugin.Name} by {plugin.Author}",
|
||||||
|
SubTitle = plugin.Description,
|
||||||
|
IcoPath = plugin.IcoPath,
|
||||||
|
Action = e =>
|
||||||
|
{
|
||||||
|
if (Settings.WarnFromUnknownSource)
|
||||||
|
{
|
||||||
|
if (!InstallSourceKnown(plugin.Website)
|
||||||
|
&& MessageBox.Show(string.Format(
|
||||||
|
Context.API.GetTranslation("plugin_pluginsmanager_install_unknown_source_warning"),
|
||||||
|
Environment.NewLine),
|
||||||
|
Context.API.GetTranslation(
|
||||||
|
"plugin_pluginsmanager_install_unknown_source_warning_title"),
|
||||||
|
MessageBoxButton.YesNo) == MessageBoxResult.No)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Application.Current.MainWindow.Hide();
|
||||||
|
_ = InstallOrUpdateAsync(plugin);
|
||||||
|
|
||||||
|
return ShouldHideWindow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private bool InstallSourceKnown(string url)
|
private bool InstallSourceKnown(string url)
|
||||||
{
|
{
|
||||||
var author = url.Split('/')[3];
|
var author = url.Split('/')[3];
|
||||||
|
|
@ -489,6 +549,9 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
&& search.Split('.').Last() == zip)
|
&& search.Split('.').Last() == zip)
|
||||||
return InstallFromWeb(search);
|
return InstallFromWeb(search);
|
||||||
|
|
||||||
|
if (FilesFolders.IsZipFilePath(search, checkFileExists: true))
|
||||||
|
return InstallFromLocalPath(search);
|
||||||
|
|
||||||
var results =
|
var results =
|
||||||
PluginsManifest
|
PluginsManifest
|
||||||
.UserPlugins
|
.UserPlugins
|
||||||
|
|
@ -522,10 +585,13 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
if (!File.Exists(downloadedFilePath))
|
if (!File.Exists(downloadedFilePath))
|
||||||
throw new FileNotFoundException($"Plugin {plugin.ID} zip file not found at {downloadedFilePath}",
|
throw new FileNotFoundException($"Plugin {plugin.ID} zip file not found at {downloadedFilePath}",
|
||||||
downloadedFilePath);
|
downloadedFilePath);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
PluginManager.InstallPlugin(plugin, downloadedFilePath);
|
PluginManager.InstallPlugin(plugin, downloadedFilePath);
|
||||||
File.Delete(downloadedFilePath);
|
|
||||||
|
if (!plugin.IsFromLocalInstallPath)
|
||||||
|
File.Delete(downloadedFilePath);
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException e)
|
catch (FileNotFoundException e)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
using ICSharpCode.SharpZipLib.Zip;
|
using Flow.Launcher.Core.ExternalPlugins;
|
||||||
|
using Flow.Launcher.Infrastructure.UserSettings;
|
||||||
|
using ICSharpCode.SharpZipLib.Zip;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace Flow.Launcher.Plugin.PluginsManager
|
namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
{
|
{
|
||||||
|
|
@ -55,5 +60,28 @@ namespace Flow.Launcher.Plugin.PluginsManager
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static UserPlugin GetPluginInfoFromZip(string filePath)
|
||||||
|
{
|
||||||
|
var plugin = null as UserPlugin;
|
||||||
|
|
||||||
|
using (ZipArchive archive = System.IO.Compression.ZipFile.OpenRead(filePath))
|
||||||
|
{
|
||||||
|
var pluginJsonPath = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json").ToString();
|
||||||
|
ZipArchiveEntry pluginJsonEntry = archive.GetEntry(pluginJsonPath);
|
||||||
|
|
||||||
|
if (pluginJsonEntry != null)
|
||||||
|
{
|
||||||
|
using (StreamReader reader = new StreamReader(pluginJsonEntry.Open()))
|
||||||
|
{
|
||||||
|
string pluginJsonContent = reader.ReadToEnd();
|
||||||
|
plugin = JsonConvert.DeserializeObject<UserPlugin>(pluginJsonContent);
|
||||||
|
plugin.IcoPath = "Images\\zipfolder.png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue