Merge pull request #823 from Garulf/install-from-url

Detect URL and install using Plugin Manager
This commit is contained in:
Jeremy Wu 2021-11-29 06:23:19 +11:00 committed by GitHub
commit 4f5eb2b454
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 294 additions and 53 deletions

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
@ -45,8 +45,56 @@ namespace Flow.Launcher.Core.Plugin
}
}
}
return allPluginMetadata;
(List<PluginMetadata> uniqueList, List<PluginMetadata> 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<PluginMetadata>, List<PluginMetadata>) GetUniqueLatestPluginMetadata(List<PluginMetadata> allPluginMetadata)
{
var duplicate_list = new List<PluginMetadata>();
var unique_list = new List<PluginMetadata>();
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)

View file

@ -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<PluginMetadata>
{
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<PluginMetadata>
{
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);
}
}
}

View file

@ -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));
}
}
}

View file

@ -4,13 +4,16 @@
<!--Dialogues-->
<system:String x:Key="plugin_pluginsmanager_downloading_plugin">Downloading plugin</system:String>
<system:String x:Key="plugin_pluginsmanager_please_wait">Please wait...</system:String>
<system:String x:Key="plugin_pluginsmanager_download_success">Successfully downloaded</system:String>
<system:String x:Key="plugin_pluginsmanager_download_error">Error: Unable to download the plugin</system:String>
<system:String x:Key="plugin_pluginsmanager_uninstall_prompt">{0} by {1} {2}{3}Would you like to uninstall this plugin? After the uninstallation Flow will automatically restart.</system:String>
<system:String x:Key="plugin_pluginsmanager_install_prompt">{0} by {1} {2}{3}Would you like to install this plugin? After the installation Flow will automatically restart.</system:String>
<system:String x:Key="plugin_pluginsmanager_install_title">Plugin Install</system:String>
<system:String x:Key="plugin_pluginsmanager_install_from_web">Download and install {0}</system:String>
<system:String x:Key="plugin_pluginsmanager_uninstall_title">Plugin Uninstall</system:String>
<system:String x:Key="plugin_pluginsmanager_install_errormetadatafile">Install failed: unable to find the plugin.json metadata file from the new plugin</system:String>
<system:String x:Key="plugin_pluginsmanager_install_success_restart">Plugin successfully installed. Restarting Flow, please wait...</system:String>
<system:String x:Key="plugin_pluginsmanager_install_errormetadatafile">Unable to find the plugin.json metadata file from the extracted zip file.</system:String>
<system:String x:Key="plugin_pluginsmanager_install_error_duplicate">Error: A plugin which has the same or greater version with {0} already exists.</system:String>
<system:String x:Key="plugin_pluginsmanager_install_error_title">Error installing plugin</system:String>
<system:String x:Key="plugin_pluginsmanager_install_error_subtitle">Error occured while trying to install {0}</system:String>
<system:String x:Key="plugin_pluginsmanager_update_noresult_title">No update available</system:String>
@ -21,12 +24,15 @@
<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_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_install_unknown_source_warning_title">Installing from an unknown source</system:String>
<system:String x:Key="plugin_pluginsmanager_install_unknown_source_warning">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)</system:String>
<!--Controls-->
<!--Plugin Infos-->
<system:String x:Key="plugin_pluginsmanager_plugin_name">Plugins Manager</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_description">Management of installing, uninstalling or updating Flow Launcher plugins</system:String>
<system:String x:Key="plugin_pluginsmanager_unknown_author">Unknown Author</system:String>
<!--Context menu items-->
<system:String x:Key="plugin_pluginsmanager_plugin_contextmenu_openwebsite_title">Open website</system:String>
@ -36,6 +42,8 @@
<system:String x:Key="plugin_pluginsmanager_plugin_contextmenu_newissue_title">Suggest an enhancement or submit an issue</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_contextmenu_newissue_subtitle">Suggest an enhancement or submit an issue to the plugin developer</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_contextmenu_pluginsmanifest_title">Go to Flow's plugins repository</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_contextmenu_pluginsmanifest_subtitle">Visit the PluginsManifest repository to see comunity-made plugin submissions</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_contextmenu_pluginsmanifest_subtitle">Visit the PluginsManifest repository to see community-made plugin submissions</system:String>
<!--Settings menu items-->
<system:String x:Key="plugin_pluginsmanager_plugin_settings_unknown_source">Install from unknown source warning</system:String>
</ResourceDictionary>

View file

@ -4,7 +4,6 @@
<!--Dialogues-->
<system:String x:Key="plugin_pluginsmanager_downloading_plugin">Sťahovanie pluginu</system:String>
<system:String x:Key="plugin_pluginsmanager_please_wait">Čakajte, prosím…</system:String>
<system:String x:Key="plugin_pluginsmanager_download_success">Úspešne stiahnuté</system:String>
<system:String x:Key="plugin_pluginsmanager_uninstall_prompt">{0} od {1} {2}{3}Chcete odinštalovať tento plugin? Po odinštalovaní sa Flow automaticky reštartuje.</system:String>
<system:String x:Key="plugin_pluginsmanager_install_prompt">{0} by {1} {2}{3}Chcete nainštalovať tento plugin? Po nainštalovaní sa Flow automaticky reštartuje.</system:String>

View file

@ -4,7 +4,6 @@
<!--Dialogues-->
<system:String x:Key="plugin_pluginsmanager_downloading_plugin">下载插件</system:String>
<system:String x:Key="plugin_pluginsmanager_please_wait">请稍等...</system:String>
<system:String x:Key="plugin_pluginsmanager_download_success">下载完成</system:String>
<system:String x:Key="plugin_pluginsmanager_uninstall_prompt">{0} by {1} {2}{3} 您要卸载此插件吗? 卸载后Flow Launcher 将自动重启。</system:String>
<system:String x:Key="plugin_pluginsmanager_install_prompt">{0} by {1} {2}{3} 您要安装此插件吗? 安装后Flow Launcher 将自动重启</system:String>

View file

@ -55,7 +55,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
public async Task<List<Result>> 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 @@ namespace Flow.Launcher.Plugin.PluginsManager
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;

View file

@ -9,6 +9,8 @@ using System;
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
private Task _downloadManifestTask = Task.CompletedTask;
internal Task UpdateManifestAsync()
{
if (_downloadManifestTask.Status == TaskStatus.Running)
@ -138,13 +141,15 @@ namespace Flow.Launcher.Plugin.PluginsManager
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
}
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
.ToList();
}
internal List<Result> 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> { 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<List<Result>> RequestInstallOrUpdate(string searchName, CancellationToken token)
{
if (!PluginsManifest.UserPlugins.Any())
@ -311,7 +375,11 @@ namespace Flow.Launcher.Plugin.PluginsManager
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
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));
}
string newPluginPath = Path.Combine(DataLocation.PluginsDirectory, $"{plugin.Name}-{plugin.Version}");
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));
}
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
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 @@ namespace Flow.Launcher.Plugin.PluginsManager
return new List<Result>();
}
private bool SameOrLesserPluginVersionExists(string metadataPath)
{
var newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataPath));
return Context.API.GetAllPlugins()
.Any(x => x.Metadata.ID == newMetadata.ID
&& newMetadata.Version.CompareTo(x.Metadata.Version) <= 0);
}
}
}
}

View file

@ -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;
}
}

View file

@ -14,5 +14,11 @@ namespace Flow.Launcher.Plugin.PluginsManager.ViewModels
Context = context;
Settings = settings;
}
public bool WarnFromUnknownSource
{
get => Settings.WarnFromUnknownSource;
set => Settings.WarnFromUnknownSource = value;
}
}
}

View file

@ -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">
<Grid>
<Grid Margin="70 15 0 15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{DynamicResource plugin_pluginsmanager_plugin_settings_unknown_source}"
VerticalAlignment="Center"
FontSize="14"/>
<CheckBox Grid.Column="1" IsChecked="{Binding WarnFromUnknownSource}" />
</Grid>
</UserControl>

View file

@ -16,7 +16,7 @@ namespace Flow.Launcher.Plugin.PluginsManager.Views
this.viewModel = viewModel;
//RefreshView();
this.DataContext = viewModel;
}
}
}

View file

@ -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",