diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index ea2119e60bc..dd6517a7fd1 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.IO; @@ -45,8 +45,56 @@ public static List Parse(string[] pluginDirectories) } } } - - return allPluginMetadata; + + (List uniqueList, List duplicateList) = GetUniqueLatestPluginMetadata(allPluginMetadata); + + duplicateList + .ForEach( + x => Log.Warn("PluginConfig", + string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " + + "not loaded due to version not the highest of the duplicates", + x.Name, x.ID, x.Version), + "GetUniqueLatestPluginMetadata")); + + return uniqueList; + } + + internal static (List, List) GetUniqueLatestPluginMetadata(List allPluginMetadata) + { + var duplicate_list = new List(); + var unique_list = new List(); + + var duplicateGroups = allPluginMetadata.GroupBy(x => x.ID).Where(g => g.Count() > 1).Select(y => y).ToList(); + + foreach (var metadata in allPluginMetadata) + { + var duplicatesExist = false; + foreach (var group in duplicateGroups) + { + if (metadata.ID == group.Key) + { + duplicatesExist = true; + + // If metadata's version greater than each duplicate's version, CompareTo > 0 + var count = group.Where(x => metadata.Version.CompareTo(x.Version) > 0).Count(); + + // Only add if the meatadata's version is the highest of all duplicates in the group + if (count == group.Count() - 1) + { + unique_list.Add(metadata); + } + else + { + duplicate_list.Add(metadata); + } + } + } + + if (!duplicatesExist) + unique_list.Add(metadata); + } + + return (unique_list, duplicate_list); } private static PluginMetadata GetPluginMetadata(string pluginDirectory) diff --git a/Flow.Launcher.Test/PluginLoadTest.cs b/Flow.Launcher.Test/PluginLoadTest.cs new file mode 100644 index 00000000000..d6ba48f1905 --- /dev/null +++ b/Flow.Launcher.Test/PluginLoadTest.cs @@ -0,0 +1,92 @@ +using NUnit.Framework; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Plugin; +using System.Collections.Generic; +using System.Linq; + +namespace Flow.Launcher.Test +{ + [TestFixture] + class PluginLoadTest + { + [Test] + public void GivenDuplicatePluginMetadatasWhenLoadedThenShouldReturnOnlyUniqueList() + { + // Given + var duplicateList = new List + { + new PluginMetadata + { + ID = "CEA0TYUC6D3B4085823D60DC76F28855", + Version = "1.0.0" + }, + new PluginMetadata + { + ID = "CEA0TYUC6D3B4085823D60DC76F28855", + Version = "1.0.1" + }, + new PluginMetadata + { + ID = "CEA0TYUC6D3B4085823D60DC76F28855", + Version = "1.0.2" + }, + new PluginMetadata + { + ID = "CEA0TYUC6D3B4085823D60DC76F28855", + Version = "1.0.0" + }, + new PluginMetadata + { + ID = "CEA0TYUC6D3B4085823D60DC76F28855", + Version = "1.0.0" + }, + new PluginMetadata + { + ID = "ABC0TYUC6D3B7855823D60DC76F28855", + Version = "1.0.0" + }, + new PluginMetadata + { + ID = "ABC0TYUC6D3B7855823D60DC76F28855", + Version = "1.0.0" + } + }; + + // When + (var unique, var duplicates) = PluginConfig.GetUniqueLatestPluginMetadata(duplicateList); + + // Then + Assert.True(unique.FirstOrDefault().ID == "CEA0TYUC6D3B4085823D60DC76F28855" && unique.FirstOrDefault().Version == "1.0.2"); + Assert.True(unique.Count() == 1); + + Assert.False(duplicates.Any(x => x.Version == "1.0.2" && x.ID == "CEA0TYUC6D3B4085823D60DC76F28855")); + Assert.True(duplicates.Count() == 6); + } + + [Test] + public void GivenDuplicatePluginMetadatasWithNoUniquePluginWhenLoadedThenShouldReturnEmptyList() + { + // Given + var duplicateList = new List + { + new PluginMetadata + { + ID = "CEA0TYUC6D3B7855823D60DC76F28855", + Version = "1.0.0" + }, + new PluginMetadata + { + ID = "CEA0TYUC6D3B7855823D60DC76F28855", + Version = "1.0.0" + } + }; + + // When + (var unique, var duplicates) = PluginConfig.GetUniqueLatestPluginMetadata(duplicateList); + + // Then + Assert.True(unique.Count() == 0); + Assert.True(duplicates.Count() == 2); + } + } +} diff --git a/Flow.Launcher.Test/Plugins/PluginInitTest.cs b/Flow.Launcher.Test/Plugins/PluginInitTest.cs deleted file mode 100644 index 299a837ee0e..00000000000 --- a/Flow.Launcher.Test/Plugins/PluginInitTest.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NUnit.Framework; -using Flow.Launcher.Core.Plugin; -using Flow.Launcher.Infrastructure.Exception; - -namespace Flow.Launcher.Test.Plugins -{ - - [TestFixture] - public class PluginInitTest - { - [Test] - public void PublicAPIIsNullTest() - { - //Assert.Throws(typeof(Flow.LauncherFatalException), () => PluginManager.Initialize(null)); - } - } -} diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml index c36a1dcaac2..a15c50ccfea 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml @@ -4,13 +4,16 @@ Downloading plugin - Please wait... Successfully downloaded + Error: Unable to download the plugin {0} by {1} {2}{3}Would you like to uninstall this plugin? After the uninstallation Flow will automatically restart. {0} by {1} {2}{3}Would you like to install this plugin? After the installation Flow will automatically restart. Plugin Install + Download and install {0} Plugin Uninstall - Install failed: unable to find the plugin.json metadata file from the new plugin + Plugin successfully installed. Restarting Flow, please wait... + Unable to find the plugin.json metadata file from the extracted zip file. + Error: A plugin which has the same or greater version with {0} already exists. Error installing plugin Error occured while trying to install {0} No update available @@ -21,12 +24,15 @@ This plugin is already installed Plugin Manifest Download Failed Please check if you can connect to github.com. This error means you may not be able to install or update plugins. - + Installing from an unknown source + You are installing this plugin from an unknown source and it may contain potential risks!{0}{0}Please ensure you understand where this plugin is from and that it is safe.{0}{0}Would you like to continue still?{0}{0}(You can switch off this warning via settings) + Plugins Manager Management of installing, uninstalling or updating Flow Launcher plugins + Unknown Author Open website @@ -36,6 +42,8 @@ Suggest an enhancement or submit an issue Suggest an enhancement or submit an issue to the plugin developer Go to Flow's plugins repository - Visit the PluginsManifest repository to see comunity-made plugin submissions - + Visit the PluginsManifest repository to see community-made plugin submissions + + + Install from unknown source warning \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/sk.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/sk.xaml index 52cd784ee59..4f37dfe8364 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/sk.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/sk.xaml @@ -4,7 +4,6 @@ Sťahovanie pluginu - Čakajte, prosím… Úspešne stiahnuté {0} od {1} {2}{3}Chcete odinštalovať tento plugin? Po odinštalovaní sa Flow automaticky reštartuje. {0} by {1} {2}{3}Chcete nainštalovať tento plugin? Po nainštalovaní sa Flow automaticky reštartuje. diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/zh-cn.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/zh-cn.xaml index 6a060a7fe55..d74ab008ff9 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/zh-cn.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/zh-cn.xaml @@ -4,7 +4,6 @@ 下载插件 - 请稍等... 下载完成 {0} by {1} {2}{3} 您要卸载此插件吗? 卸载后,Flow Launcher 将自动重启。 {0} by {1} {2}{3} 您要安装此插件吗? 安装后,Flow Launcher 将自动重启 diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs index 89a8e0ff5a1..f445826fa8c 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs @@ -55,7 +55,7 @@ public List LoadContextMenus(Result selectedResult) public async Task> QueryAsync(Query query, CancellationToken token) { - var search = query.Search.ToLower(); + var search = query.Search; if (string.IsNullOrWhiteSpace(search)) return pluginManager.GetDefaultHotKeys(); @@ -70,9 +70,13 @@ public async Task> QueryAsync(Query query, CancellationToken token) return search switch { - var s when s.StartsWith(Settings.HotKeyInstall) => await pluginManager.RequestInstallOrUpdate(s, token), - var s when s.StartsWith(Settings.HotkeyUninstall) => pluginManager.RequestUninstall(s), - var s when s.StartsWith(Settings.HotkeyUpdate) => await pluginManager.RequestUpdate(s, token), + //search could be url, no need ToLower() when passed in + var s when s.StartsWith(Settings.HotKeyInstall, StringComparison.OrdinalIgnoreCase) + => await pluginManager.RequestInstallOrUpdate(search, token), + var s when s.StartsWith(Settings.HotkeyUninstall, StringComparison.OrdinalIgnoreCase) + => pluginManager.RequestUninstall(search), + var s when s.StartsWith(Settings.HotkeyUpdate, StringComparison.OrdinalIgnoreCase) + => await pluginManager.RequestUpdate(search, token), _ => pluginManager.GetDefaultHotKeys().Where(hotkey => { hotkey.Score = StringMatcher.FuzzySearch(search, hotkey.Title).Score; diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs index 9df1ca911ee..a2f580ee113 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -9,6 +9,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Windows; @@ -17,6 +19,8 @@ namespace Flow.Launcher.Plugin.PluginsManager { internal class PluginsManager { + const string zip = "zip"; + private PluginInitContext Context { get; set; } private Settings Settings { get; set; } @@ -47,7 +51,6 @@ internal PluginsManager(PluginInitContext context, Settings settings) private Task _downloadManifestTask = Task.CompletedTask; - internal Task UpdateManifestAsync() { if (_downloadManifestTask.Status == TaskStatus.Running) @@ -138,13 +141,15 @@ internal async Task InstallOrUpdate(UserPlugin plugin) MessageBoxButton.YesNo) == MessageBoxResult.No) return; - var filePath = Path.Combine(DataLocation.PluginsDirectory, $"{plugin.Name}-{plugin.Version}.zip"); + // at minimum should provide a name, but handle plugin that is not downloaded from plugins manifest and is a url download + var downloadFilename = string.IsNullOrEmpty(plugin.Version) + ? $"{plugin.Name}-{Guid.NewGuid()}.zip" + : $"{plugin.Name}-{plugin.Version}.zip"; + + var filePath = Path.Combine(DataLocation.PluginsDirectory, downloadFilename); try { - Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin"), - Context.API.GetTranslation("plugin_pluginsmanager_please_wait")); - await Http.DownloadAsync(plugin.UrlDownload, filePath).ConfigureAwait(false); Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin"), @@ -154,7 +159,11 @@ internal async Task InstallOrUpdate(UserPlugin plugin) } catch (Exception e) { - Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), + if (e is HttpRequestException) + MessageBox.Show(Context.API.GetTranslation("plugin_pluginsmanager_download_error"), + Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin")); + + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_error_subtitle"), plugin.Name)); @@ -163,6 +172,9 @@ internal async Task InstallOrUpdate(UserPlugin plugin) return; } + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_install_title"), + Context.API.GetTranslation("plugin_pluginsmanager_install_success_restart")); + Context.API.RestartApp(); } @@ -183,7 +195,7 @@ internal async ValueTask> RequestUpdate(string search, Cancellation if (autocompletedResults.Any()) return autocompletedResults; - var uninstallSearch = search.Replace(Settings.HotkeyUpdate, string.Empty).TrimStart(); + var uninstallSearch = search.Replace(Settings.HotkeyUpdate, string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart(); var resultsForUpdate = from existingPlugin in Context.API.GetAllPlugins() @@ -239,10 +251,6 @@ where existingPlugin.Metadata.Version.CompareTo(pluginFromManifest.Version) < Task.Run(async delegate { - Context.API.ShowMsg( - Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin"), - Context.API.GetTranslation("plugin_pluginsmanager_please_wait")); - await Http.DownloadAsync(x.PluginNewUserPlugin.UrlDownload, downloadToFilePath) .ConfigureAwait(false); @@ -302,6 +310,62 @@ internal List Search(IEnumerable results, string searchName) .ToList(); } + internal List InstallFromWeb(string url) + { + var filename = url.Split("/").Last(); + var name = filename.Split(string.Format(".{0}", zip)).First(); + + var plugin = new UserPlugin + { + ID = "", + Name = name, + Version = string.Empty, + Author = Context.API.GetTranslation("plugin_pluginsmanager_unknown_author"), + UrlDownload = url + }; + + var result = new Result + { + Title = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_from_web"), filename), + SubTitle = plugin.UrlDownload, + IcoPath = icoPath, + Action = e => + { + if (e.SpecialKeyState.CtrlPressed) + { + SearchWeb.NewTabInBrowser(plugin.UrlDownload); + return ShouldHideWindow; + } + + if (Settings.WarnFromUnknownSource) + { + if (!InstallSourceKnown(plugin.UrlDownload) + && 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(); + _ = InstallOrUpdate(plugin); + + return ShouldHideWindow; + } + }; + + return new List { result }; + } + + private bool InstallSourceKnown(string url) + { + var author = url.Split('/')[3]; + var acceptedSource = "https://github.com"; + var contructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author); + + return url.StartsWith(acceptedSource) && Context.API.GetAllPlugins().Any(x => x.Metadata.Website.StartsWith(contructedUrlPart)); + } + internal async ValueTask> RequestInstallOrUpdate(string searchName, CancellationToken token) { if (!PluginsManifest.UserPlugins.Any()) @@ -311,7 +375,11 @@ internal async ValueTask> RequestInstallOrUpdate(string searchName, token.ThrowIfCancellationRequested(); - var searchNameWithoutKeyword = searchName.Replace(Settings.HotKeyInstall, string.Empty).Trim(); + var searchNameWithoutKeyword = searchName.Replace(Settings.HotKeyInstall, string.Empty, StringComparison.OrdinalIgnoreCase).Trim(); + + if (Uri.IsWellFormedUriString(searchNameWithoutKeyword, UriKind.Absolute) + && searchNameWithoutKeyword.Split('.').Last() == zip) + return InstallFromWeb(searchNameWithoutKeyword); var results = PluginsManifest @@ -369,11 +437,26 @@ private void Install(UserPlugin plugin, string downloadedFilePath) if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) { - MessageBox.Show(Context.API.GetTranslation("plugin_pluginsmanager_install_errormetadatafile")); - return; + MessageBox.Show(Context.API.GetTranslation("plugin_pluginsmanager_install_errormetadatafile"), + Context.API.GetTranslation("plugin_pluginsmanager_install_error_title")); + + throw new FileNotFoundException ( + string.Format("Unable to find plugin.json from the extracted zip file, or this path {0} does not exist", pluginFolderPath)); + } + + if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) + { + MessageBox.Show(string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_error_duplicate"), plugin.Name), + Context.API.GetTranslation("plugin_pluginsmanager_install_error_title")); + + throw new InvalidOperationException( + string.Format("A plugin with the same ID and version already exists, " + + "or the version is greater than this downloaded plugin {0}", + plugin.Name)); } - string newPluginPath = Path.Combine(DataLocation.PluginsDirectory, $"{plugin.Name}-{plugin.Version}"); + var directory = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; + var newPluginPath = Path.Combine(DataLocation.PluginsDirectory, directory); FilesFolders.CopyAll(pluginFolderPath, newPluginPath); @@ -390,7 +473,7 @@ internal List RequestUninstall(string search) if (autocompletedResults.Any()) return autocompletedResults; - var uninstallSearch = search.Replace(Settings.HotkeyUninstall, string.Empty).TrimStart(); + var uninstallSearch = search.Replace(Settings.HotkeyUninstall, string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart(); var results = Context.API .GetAllPlugins() @@ -466,5 +549,13 @@ private List AutoCompleteReturnAllResults(string search, string hotkey, return new List(); } + + private bool SameOrLesserPluginVersionExists(string metadataPath) + { + var newMetadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); + return Context.API.GetAllPlugins() + .Any(x => x.Metadata.ID == newMetadata.ID + && newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); + } } -} \ No newline at end of file +} diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs index 9c5b0d29f6b..a951010c0b1 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs @@ -7,8 +7,11 @@ namespace Flow.Launcher.Plugin.PluginsManager internal class Settings { internal string HotKeyInstall { get; set; } = "install"; + internal string HotkeyUninstall { get; set; } = "uninstall"; internal string HotkeyUpdate { get; set; } = "update"; + + public bool WarnFromUnknownSource { get; set; } = true; } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs index 8ca4c614a45..11303f47256 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs @@ -14,5 +14,11 @@ public SettingsViewModel(PluginInitContext context, Settings settings) Context = context; Settings = settings; } + + public bool WarnFromUnknownSource + { + get => Settings.WarnFromUnknownSource; + set => Settings.WarnFromUnknownSource = value; + } } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml index 89d27f6ffea..d758030663d 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml @@ -3,10 +3,18 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:local="clr-namespace:Flow.Launcher.Plugin.PluginsManager.ViewModels" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> - - + + + + + + + + diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml.cs index 703682e074f..26668cc0529 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml.cs @@ -16,7 +16,7 @@ internal PluginsManagerSettings(SettingsViewModel viewModel) this.viewModel = viewModel; - //RefreshView(); + this.DataContext = viewModel; } } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json b/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json index eb90e7e8e1a..bf1ae5d8e55 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json @@ -6,7 +6,7 @@ "Name": "Plugins Manager", "Description": "Management of installing, uninstalling or updating Flow Launcher plugins", "Author": "Jeremy Wu", - "Version": "1.9.1", + "Version": "1.10.0", "Language": "csharp", "Website": "https://github.com/Flow-Launcher/Flow.Launcher", "ExecuteFileName": "Flow.Launcher.Plugin.PluginsManager.dll",