diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 177c00fa2e4..5b3419041a6 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -99,3 +99,7 @@ pluginsmanager alreadyexists Softpedia img +Reloadable +metadatas +WMP +VSTHRD diff --git a/.github/actions/spelling/patterns.txt b/.github/actions/spelling/patterns.txt index 5ef8859fc27..f308ec5993a 100644 --- a/.github/actions/spelling/patterns.txt +++ b/.github/actions/spelling/patterns.txt @@ -133,3 +133,4 @@ \bPortuguês (Brasil)\b \bčeština\b \bPortuguês\b +\bIoc\b diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs new file mode 100644 index 00000000000..33963c01a5b --- /dev/null +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -0,0 +1,353 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin; + +/// +/// Class for installing, updating, and uninstalling plugins. +/// +public static class PluginInstaller +{ + private static readonly string ClassName = nameof(PluginInstaller); + + private static readonly Settings Settings = Ioc.Default.GetRequiredService(); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + + /// + /// Installs a plugin and restarts the application if required by settings. Prompts user for confirmation and handles download if needed. + /// + /// The plugin to install. + /// A Task representing the asynchronous install operation. + public static async Task InstallPluginAndCheckRestartAsync(UserPlugin newPlugin) + { + if (API.PluginModified(newPlugin.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), newPlugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return; + } + + if (API.ShowMsgBox( + string.Format( + API.GetTranslation("InstallPromptSubtitle"), + newPlugin.Name, newPlugin.Author, Environment.NewLine), + API.GetTranslation("InstallPromptTitle"), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + try + { + // 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(newPlugin.Version) + ? $"{newPlugin.Name}-{Guid.NewGuid()}.zip" + : $"{newPlugin.Name}-{newPlugin.Version}.zip"; + + var filePath = Path.Combine(Path.GetTempPath(), downloadFilename); + + using var cts = new CancellationTokenSource(); + + if (!newPlugin.IsFromLocalInstallPath) + { + await DownloadFileAsync( + $"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}", + newPlugin.UrlDownload, filePath, cts); + } + else + { + filePath = newPlugin.LocalInstallPath; + } + + // check if user cancelled download before installing plugin + if (cts.IsCancellationRequested) + { + return; + } + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"Plugin {newPlugin.ID} zip file not found at {filePath}", filePath); + } + + if (!API.InstallPlugin(newPlugin, filePath)) + { + return; + } + + if (!newPlugin.IsFromLocalInstallPath) + { + File.Delete(filePath); + } + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to install plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorInstallingPlugin")); + return; // do not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("installbtn"), + string.Format( + API.GetTranslation( + "InstallSuccessNoRestart"), + newPlugin.Name)); + } + } + + /// + /// Installs a plugin from a local zip file and restarts the application if required by settings. Validates the zip and prompts user for confirmation. + /// + /// The path to the plugin zip file. + /// A Task representing the asynchronous install operation. + public static async Task InstallPluginAndCheckRestartAsync(string filePath) + { + UserPlugin plugin; + try + { + using ZipArchive archive = ZipFile.OpenRead(filePath); + var pluginJsonEntry = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json") ?? + throw new FileNotFoundException("The zip file does not contain a plugin.json file."); + + using Stream stream = pluginJsonEntry.Open(); + plugin = JsonSerializer.Deserialize(stream); + plugin.IcoPath = "Images\\zipfolder.png"; + plugin.LocalInstallPath = filePath; + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to validate zip file", e); + API.ShowMsgError(API.GetTranslation("ZipFileNotHavePluginJson")); + return; + } + + if (API.PluginModified(plugin.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return; + } + + if (Settings.ShowUnknownSourceWarning) + { + if (!InstallSourceKnown(plugin.Website) + && API.ShowMsgBox(string.Format( + API.GetTranslation("InstallFromUnknownSourceSubtitle"), Environment.NewLine), + API.GetTranslation("InstallFromUnknownSourceTitle"), + MessageBoxButton.YesNo) == MessageBoxResult.No) + return; + } + + await InstallPluginAndCheckRestartAsync(plugin); + } + + /// + /// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings. + /// + /// The plugin metadata to uninstall. + /// A Task representing the asynchronous uninstall operation. + public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldPlugin) + { + if (API.PluginModified(oldPlugin.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), oldPlugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return; + } + + if (API.ShowMsgBox( + string.Format( + API.GetTranslation("UninstallPromptSubtitle"), + oldPlugin.Name, oldPlugin.Author, Environment.NewLine), + API.GetTranslation("UninstallPromptTitle"), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + var removePluginSettings = API.ShowMsgBox( + API.GetTranslation("KeepPluginSettingsSubtitle"), + API.GetTranslation("KeepPluginSettingsTitle"), + button: MessageBoxButton.YesNo) == MessageBoxResult.No; + + try + { + if (!await API.UninstallPluginAsync(oldPlugin, removePluginSettings)) + { + return; + } + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to uninstall plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorUninstallingPlugin")); + return; // don not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("uninstallbtn"), + string.Format( + API.GetTranslation( + "UninstallSuccessNoRestart"), + oldPlugin.Name)); + } + } + + /// + /// Updates a plugin to a new version and restarts the application if required by settings. Prompts user for confirmation and handles download if needed. + /// + /// The new plugin version to install. + /// The existing plugin metadata to update. + /// A Task representing the asynchronous update operation. + public static async Task UpdatePluginAndCheckRestartAsync(UserPlugin newPlugin, PluginMetadata oldPlugin) + { + if (API.ShowMsgBox( + string.Format( + API.GetTranslation("UpdatePromptSubtitle"), + oldPlugin.Name, oldPlugin.Author, Environment.NewLine), + API.GetTranslation("UpdatePromptTitle"), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + try + { + var filePath = Path.Combine(Path.GetTempPath(), $"{newPlugin.Name}-{newPlugin.Version}.zip"); + + using var cts = new CancellationTokenSource(); + + if (!newPlugin.IsFromLocalInstallPath) + { + await DownloadFileAsync( + $"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}", + newPlugin.UrlDownload, filePath, cts); + } + else + { + filePath = newPlugin.LocalInstallPath; + } + + // check if user cancelled download before installing plugin + if (cts.IsCancellationRequested) + { + return; + } + + if (!await API.UpdatePluginAsync(oldPlugin, newPlugin, filePath)) + { + return; + } + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to update plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin")); + return; // do not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("updatebtn"), + string.Format( + API.GetTranslation( + "UpdateSuccessNoRestart"), + newPlugin.Name)); + } + } + + /// + /// Downloads a file from a URL to a local path, optionally showing a progress box and handling cancellation. + /// + /// The title for the progress box. + /// The URL to download from. + /// The local file path to save to. + /// Cancellation token source for cancelling the download. + /// Whether to delete the file if it already exists. + /// Whether to show a progress box during download. + /// A Task representing the asynchronous download operation. + private static async Task DownloadFileAsync(string progressBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true) + { + if (deleteFile && File.Exists(filePath)) + File.Delete(filePath); + + if (showProgress) + { + var exceptionHappened = false; + await API.ShowProgressBoxAsync(progressBoxTitle, + async (reportProgress) => + { + if (reportProgress == null) + { + // when reportProgress is null, it means there is exception with the progress box + // so we record it with exceptionHappened and return so that progress box will close instantly + exceptionHappened = true; + return; + } + else + { + await API.HttpDownloadAsync(downloadUrl, filePath, reportProgress, cts.Token).ConfigureAwait(false); + } + }, cts.Cancel); + + // if exception happened while downloading and user does not cancel downloading, + // we need to redownload the plugin + if (exceptionHappened && (!cts.IsCancellationRequested)) + await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false); + } + else + { + await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false); + } + } + + /// + /// Determines if the plugin install source is a known/approved source (e.g., GitHub and matches an existing plugin author). + /// + /// The URL to check. + /// True if the source is known, otherwise false. + private static bool InstallSourceKnown(string url) + { + if (string.IsNullOrEmpty(url)) + return false; + + var pieces = url.Split('/'); + + if (pieces.Length < 4) + return false; + + var author = pieces[3]; + var acceptedHost = "github.com"; + var acceptedSource = "https://github.com"; + var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author); + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost) + return false; + + return API.GetAllPlugins().Any(x => + !string.IsNullOrEmpty(x.Metadata.Website) && + x.Metadata.Website.StartsWith(constructedUrlPart) + ); + } +} diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 444a44a0a81..60790abedcd 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -18,7 +18,7 @@ namespace Flow.Launcher.Core.Plugin { /// - /// The entry for managing Flow Launcher plugins + /// Class for co-ordinating and managing all plugin lifecycle. /// public static class PluginManager { @@ -561,33 +561,46 @@ public static bool PluginModified(string id) return ModifiedPlugins.Contains(id); } - public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) + public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) { - InstallPlugin(newVersion, zipFilePath, checkModified: false); - await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false); + if (PluginModified(existingVersion.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), existingVersion.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return false; + } + + var installSuccess = InstallPlugin(newVersion, zipFilePath, checkModified: false); + if (!installSuccess) return false; + + var uninstallSuccess = await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false); + if (!uninstallSuccess) return false; + ModifiedPlugins.Add(existingVersion.ID); + return true; } - public static void InstallPlugin(UserPlugin plugin, string zipFilePath) + public static bool InstallPlugin(UserPlugin plugin, string zipFilePath) { - InstallPlugin(plugin, zipFilePath, checkModified: true); + return InstallPlugin(plugin, zipFilePath, checkModified: true); } - public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false) + public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false) { - await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true); + return await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true); } #endregion #region Internal functions - internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) + internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { - // Distinguish exception from installing same or less version - throw new ArgumentException($"Plugin {plugin.Name} {plugin.ID} has been modified.", nameof(plugin)); + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return false; } // Unzip plugin files to temp folder @@ -605,12 +618,16 @@ internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool c if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) { - throw new FileNotFoundException($"Unable to find plugin.json from the extracted zip file, or this path {pluginFolderPath} does not exist"); + API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name), + string.Format(API.GetTranslation("fileNotFoundMessage"), pluginFolderPath)); + return false; } if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) { - throw new InvalidOperationException($"A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin {plugin.Name}"); + API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name), + API.GetTranslation("pluginExistAlreadyMessage")); + return false; } var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; @@ -654,13 +671,17 @@ internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool c { ModifiedPlugins.Add(plugin.ID); } + + return true; } - internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) + internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { - throw new ArgumentException($"Plugin {plugin.Name} has been modified"); + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return false; } if (removePluginSettings || removePluginFromSettings) @@ -729,6 +750,8 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool remo { ModifiedPlugins.Add(plugin.ID); } + + return true; } #endregion diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 2dbdf0bf8a9..d55daf17551 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -203,6 +203,9 @@ public bool ShowHistoryResultsForHomePage public int MaxHistoryResultsToShowForHomePage { get; set; } = 5; + public bool AutoRestartAfterChanging { get; set; } = false; + public bool ShowUnknownSourceWarning { get; set; } = true; + public int CustomExplorerIndex { get; set; } = 0; [JsonIgnore] diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 1827354d0ba..cfa813d3f2b 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -23,8 +23,8 @@ public interface IPublicAPI /// /// query text /// - /// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one. - /// Set this to to force Flow Launcher requerying + /// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one. + /// Set this to to force Flow Launcher re-querying /// void ChangeQuery(string query, bool requery = false); @@ -49,7 +49,7 @@ public interface IPublicAPI /// /// Text to save on clipboard /// When true it will directly copy the file/folder from the path specified in text - /// Whether to show the default notification from this method after copy is done. + /// Whether to show the default notification from this method after copy is done. /// It will show file/folder/text is copied successfully. /// Turn this off to show your own notification after copy is done.> public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true); @@ -65,7 +65,7 @@ public interface IPublicAPI void SavePluginSettings(); /// - /// Reloads any Plugins that have the + /// Reloads any Plugins that have the /// IReloadable implemented. It refeshes /// Plugin's in memory data with new content /// added by user. @@ -97,7 +97,7 @@ public interface IPublicAPI /// Show the MainWindow when hiding /// void ShowMainWindow(); - + /// /// Focus the query text box in the main window /// @@ -115,7 +115,7 @@ public interface IPublicAPI bool IsMainWindowVisible(); /// - /// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off. + /// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off. /// event VisibilityChangedEventHandler VisibilityChanged; @@ -171,7 +171,7 @@ public interface IPublicAPI string GetTranslation(string key); /// - /// Get all loaded plugins + /// Get all loaded plugins /// /// List GetAllPlugins(); @@ -229,7 +229,7 @@ public interface IPublicAPI MatchResult FuzzySearch(string query, string stringToCompare); /// - /// Http download the spefic url and return as string + /// Http download the specific url and return as string /// /// URL to call Http Get /// Cancellation Token @@ -237,7 +237,7 @@ public interface IPublicAPI Task HttpGetStringAsync(string url, CancellationToken token = default); /// - /// Http download the spefic url and return as stream + /// Http download the specific url and return as stream /// /// URL to call Http Get /// Cancellation Token @@ -305,8 +305,8 @@ public interface IPublicAPI void LogError(string className, string message, [CallerMemberName] string methodName = ""); /// - /// Log an Exception. Will throw if in debug mode so developer will be aware, - /// otherwise logs the eror message. This is the primary logging method used for Flow + /// Log an Exception. Will throw if in debug mode so developer will be aware, + /// otherwise logs the eror message. This is the primary logging method used for Flow /// void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = ""); @@ -393,7 +393,7 @@ public interface IPublicAPI /// /// Reloads the query. - /// When current results are from context menu or history, it will go back to query results before requerying. + /// When current results are from context menu or history, it will go back to query results before re-querying. /// /// Choose the first result after reload if true; keep the last selected result if false. Default is true. public void ReQuery(bool reselect = true); @@ -547,8 +547,10 @@ public interface IPublicAPI /// /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. /// - /// - public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath); + /// + /// True if the plugin is updated successfully, false otherwise. + /// + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath); /// /// Install a plugin. By default will remove the zip file if installation is from url, @@ -558,7 +560,10 @@ public interface IPublicAPI /// /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. /// - public void InstallPlugin(UserPlugin plugin, string zipFilePath); + /// + /// True if the plugin is installed successfully, false otherwise. + /// + public bool InstallPlugin(UserPlugin plugin, string zipFilePath); /// /// Uninstall a plugin @@ -567,8 +572,10 @@ public interface IPublicAPI /// /// Plugin has their own settings. If this is set to true, the plugin settings will be removed. /// - /// - public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); + /// + /// True if the plugin is updated successfully, false otherwise. + /// + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); /// /// Log debug message of the time taken to execute a method @@ -603,7 +610,7 @@ public interface IPublicAPI bool IsApplicationDarkTheme(); /// - /// Invoked when the actual theme of the application has changed. Currently, the plugin will continue to be subscribed even if it is turned off. + /// Invoked when the actual theme of the application has changed. Currently, the plugin will continue to be subscribed even if it is turned off. /// event ActualApplicationThemeChangedEventHandler ActualApplicationThemeChanged; } diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index e99f5e643e3..7b82748fca3 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -211,6 +211,9 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => Http.Proxy = _settings.Proxy; + // Initialize plugin manifest before initializing plugins so that they can use the manifest instantly + await API.UpdatePluginManifestAsync(); + await PluginManager.InitializePluginsAsync(); // Change language after all plugins are initialized because we need to update plugin title based on their api diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index bd4cbd28247..fadda08bc05 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -133,6 +133,10 @@ This can only be edited if plugin supports Home feature and Home Page is enabled. Show Search Window at Foremost Overrides other programs' 'Always on Top' setting and displays Flow in the foremost position. + Restart after modifying plugin via Plugin Store + Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugin Store + Show unknown source warning + Show warning when installing plugins from unknown sources Search Plugin @@ -171,6 +175,12 @@ Plugins: {0} - Fail to remove plugin settings files, please remove them manually Fail to remove plugin cache Plugins: {0} - Fail to remove plugin cache files, please remove them manually + {0} modified already + Please restart Flow before making any further changes + Fail to install {0} + Fail to uninstall {0} + Unable to find plugin.json from the extracted zip file, or this path {0} does not exist + A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin Plugin Store @@ -186,6 +196,28 @@ New Version This plugin has been updated within the last 7 days New Update is Available + Error installing plugin + Error uninstalling plugin + Error updating plugin + Keep plugin settings + Do you want to keep the settings of the plugin for the next usage? + Plugin {0} successfully installed. Please restart Flow. + Plugin {0} successfully uninstalled. Please restart Flow. + Plugin {0} successfully updated. Please restart Flow. + Plugin install + {0} by {1} {2}{2}Would you like to install this plugin? + Plugin uninstall + {0} by {1} {2}{2}Would you like to uninstall this plugin? + Plugin update + {0} by {1} {2}{2}Would you like to update this plugin? + Downloading plugin + Automatically restart after installing/uninstalling/updating plugins in plugin store + Zip file does not have a valid plugin.json configuration + Installing from an unknown source + This plugin is 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 in general section of setting window) + Zip files + Please select zip file + Install plugin from local path Theme diff --git a/Flow.Launcher/ProgressBoxEx.xaml.cs b/Flow.Launcher/ProgressBoxEx.xaml.cs index 840c8bade87..11946334869 100644 --- a/Flow.Launcher/ProgressBoxEx.xaml.cs +++ b/Flow.Launcher/ProgressBoxEx.xaml.cs @@ -19,32 +19,32 @@ private ProgressBoxEx(Action cancelProgress) public static async Task ShowAsync(string caption, Func, Task> reportProgressAsync, Action cancelProgress = null) { - ProgressBoxEx prgBox = null; + ProgressBoxEx progressBox = null; try { if (!Application.Current.Dispatcher.CheckAccess()) { await Application.Current.Dispatcher.InvokeAsync(() => { - prgBox = new ProgressBoxEx(cancelProgress) + progressBox = new ProgressBoxEx(cancelProgress) { Title = caption }; - prgBox.TitleTextBlock.Text = caption; - prgBox.Show(); + progressBox.TitleTextBlock.Text = caption; + progressBox.Show(); }); } else { - prgBox = new ProgressBoxEx(cancelProgress) + progressBox = new ProgressBoxEx(cancelProgress) { Title = caption }; - prgBox.TitleTextBlock.Text = caption; - prgBox.Show(); + progressBox.TitleTextBlock.Text = caption; + progressBox.Show(); } - await reportProgressAsync(prgBox.ReportProgress).ConfigureAwait(false); + await reportProgressAsync(progressBox.ReportProgress).ConfigureAwait(false); } catch (Exception e) { @@ -58,12 +58,12 @@ await Application.Current.Dispatcher.InvokeAsync(() => { await Application.Current.Dispatcher.InvokeAsync(() => { - prgBox?.Close(); + progressBox?.Close(); }); } else { - prgBox?.Close(); + progressBox?.Close(); } } } diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index a2e5f1f8591..d865a087b77 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -567,13 +567,13 @@ public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, Canc public bool PluginModified(string id) => PluginManager.PluginModified(id); - public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) => + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) => PluginManager.UpdatePluginAsync(pluginMetadata, plugin, zipFilePath); - public void InstallPlugin(UserPlugin plugin, string zipFilePath) => + public bool InstallPlugin(UserPlugin plugin, string zipFilePath) => PluginManager.InstallPlugin(plugin, zipFilePath); - public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => PluginManager.UninstallPluginAsync(pluginMetadata, removePluginSettings); public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "") => diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs index 07df0682dbb..efe67d01664 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; using Flow.Launcher.ViewModel; @@ -96,6 +98,35 @@ private async Task RefreshExternalPluginsAsync() } } + [RelayCommand] + private async Task InstallPluginAsync() + { + var file = GetFileFromDialog( + App.API.GetTranslation("SelectZipFile"), + $"{App.API.GetTranslation("ZipFiles")} (*.zip)|*.zip"); + + if (!string.IsNullOrEmpty(file)) + await PluginInstaller.InstallPluginAndCheckRestartAsync(file); + } + + private static string GetFileFromDialog(string title, string filter = "") + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "\\Downloads", + Multiselect = false, + CheckFileExists = true, + CheckPathExists = true, + Title = title, + Filter = filter + }; + var result = dlg.ShowDialog(); + if (result == true) + return dlg.FileName; + + return string.Empty; + } + public bool SatisfiesFilter(PluginStoreItemViewModel plugin) { // Check plugin language diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index d114736d588..ac27c3b40a9 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -225,6 +225,30 @@ + + + + + + + + + + + PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7"); + private readonly UserPlugin _newPlugin; + private readonly PluginPair _oldPluginPair; + public PluginStoreItemViewModel(UserPlugin plugin) { - _plugin = plugin; + _newPlugin = plugin; + _oldPluginPair = PluginManager.GetPluginForId(plugin.ID); } - private UserPlugin _plugin; - - public string ID => _plugin.ID; - public string Name => _plugin.Name; - public string Description => _plugin.Description; - public string Author => _plugin.Author; - public string Version => _plugin.Version; - public string Language => _plugin.Language; - public string Website => _plugin.Website; - public string UrlDownload => _plugin.UrlDownload; - public string UrlSourceCode => _plugin.UrlSourceCode; - public string IcoPath => _plugin.IcoPath; + public string ID => _newPlugin.ID; + public string Name => _newPlugin.Name; + public string Description => _newPlugin.Description; + public string Author => _newPlugin.Author; + public string Version => _newPlugin.Version; + public string Language => _newPlugin.Language; + public string Website => _newPlugin.Website; + public string UrlDownload => _newPlugin.UrlDownload; + public string UrlSourceCode => _newPlugin.UrlSourceCode; + public string IcoPath => _newPlugin.IcoPath; - public bool LabelInstalled => PluginManager.GetPluginForId(_plugin.ID) != null; - public bool LabelUpdate => LabelInstalled && new Version(_plugin.Version) > new Version(PluginManager.GetPluginForId(_plugin.ID).Metadata.Version); + public bool LabelInstalled => _oldPluginPair != null; + public bool LabelUpdate => LabelInstalled && new Version(_newPlugin.Version) > new Version(_oldPluginPair.Metadata.Version); internal const string None = "None"; internal const string RecentlyUpdated = "RecentlyUpdated"; @@ -41,15 +42,15 @@ public string Category get { string category = None; - if (DateTime.Now - _plugin.LatestReleaseDate < TimeSpan.FromDays(7)) + if (DateTime.Now - _newPlugin.LatestReleaseDate < TimeSpan.FromDays(7)) { category = RecentlyUpdated; } - if (DateTime.Now - _plugin.DateAdded < TimeSpan.FromDays(7)) + if (DateTime.Now - _newPlugin.DateAdded < TimeSpan.FromDays(7)) { category = NewRelease; } - if (PluginManager.GetPluginForId(_plugin.ID) != null) + if (_oldPluginPair != null) { category = Installed; } @@ -59,11 +60,22 @@ public string Category } [RelayCommand] - private void ShowCommandQuery(string action) + private async Task ShowCommandQueryAsync(string action) { - var actionKeyword = PluginManagerData.Metadata.ActionKeywords.Any() ? PluginManagerData.Metadata.ActionKeywords[0] + " " : String.Empty; - App.API.ChangeQuery($"{actionKeyword}{action} {_plugin.Name}"); - App.API.ShowMainWindow(); + switch (action) + { + case "install": + await PluginInstaller.InstallPluginAndCheckRestartAsync(_newPlugin); + break; + case "uninstall": + await PluginInstaller.UninstallPluginAndCheckRestartAsync(_oldPluginPair.Metadata); + break; + case "update": + await PluginInstaller.UpdatePluginAndCheckRestartAsync(_newPlugin, _oldPluginPair.Metadata); + break; + default: + break; + } } } } diff --git a/Flow.Launcher/ViewModel/PluginViewModel.cs b/Flow.Launcher/ViewModel/PluginViewModel.cs index 01fa3d20326..ea222d02374 100644 --- a/Flow.Launcher/ViewModel/PluginViewModel.cs +++ b/Flow.Launcher/ViewModel/PluginViewModel.cs @@ -1,5 +1,4 @@ -using System.Linq; -using System.Threading.Tasks; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -32,21 +31,6 @@ public PluginPair PluginPair } } - private static string PluginManagerActionKeyword - { - get - { - var keyword = PluginManager - .GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7") - .Metadata.ActionKeywords.FirstOrDefault(); - return keyword switch - { - null or "*" => string.Empty, - _ => keyword - }; - } - } - private async Task LoadIconAsync() { Image = await App.API.LoadImageAsync(PluginPair.Metadata.IcoPath); @@ -186,10 +170,9 @@ private void OpenSourceCodeLink() } [RelayCommand] - private void OpenDeletePluginWindow() + private async Task OpenDeletePluginWindowAsync() { - App.API.ChangeQuery($"{PluginManagerActionKeyword} uninstall {PluginPair.Metadata.Name}".Trim(), true); - App.API.ShowMainWindow(); + await PluginInstaller.UninstallPluginAndCheckRestartAsync(PluginPair.Metadata); } [RelayCommand] diff --git a/Flow.Launcher/ViewModel/SelectBrowserViewModel.cs b/Flow.Launcher/ViewModel/SelectBrowserViewModel.cs index 1eee6dba5d3..67bbbd9301f 100644 --- a/Flow.Launcher/ViewModel/SelectBrowserViewModel.cs +++ b/Flow.Launcher/ViewModel/SelectBrowserViewModel.cs @@ -1,6 +1,5 @@ using System.Collections.ObjectModel; using System.Linq; -using System.Windows; using CommunityToolkit.Mvvm.Input; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml index 573ca90519e..fa2e65240ae 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml @@ -45,10 +45,15 @@ Plugin {0} successfully updated. Please restart Flow. {0} plugins successfully updated. Please restart Flow. Plugin {0} has already been modified. Please restart Flow before making any further changes. + {0} modified already + Please restart Flow before making any further changes + + Invalid zip installer file + Please check if there is a plugin.json in {0} Plugins Manager - Management of installing, uninstalling or updating Flow Launcher plugins + Install, uninstall or update Flow Launcher plugins via the search window Unknown Author @@ -63,5 +68,5 @@ Install from unknown source warning - Automatically restart Flow Launcher after installing/uninstalling/updating plugins + Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugins Manager \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs index 25182f6d3d2..efbe8d7ba7d 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -114,6 +114,14 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin) return; } + if (Context.API.PluginModified(plugin.ID)) + { + Context.API.ShowMsgError( + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_title"), plugin.Name), + Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_message")); + return; + } + string message; if (Settings.AutoRestartAfterChanging) { @@ -158,7 +166,8 @@ await DownloadFileAsync( if (cts.IsCancellationRequested) return; else - Install(plugin, filePath); + if (!Install(plugin, filePath)) + return; } catch (HttpRequestException e) { @@ -196,7 +205,7 @@ await DownloadFileAsync( } } - private async Task DownloadFileAsync(string prgBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true) + private async Task DownloadFileAsync(string progressBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true) { if (deleteFile && File.Exists(filePath)) File.Delete(filePath); @@ -204,12 +213,12 @@ private async Task DownloadFileAsync(string prgBoxTitle, string downloadUrl, str if (showProgress) { var exceptionHappened = false; - await Context.API.ShowProgressBoxAsync(prgBoxTitle, + await Context.API.ShowProgressBoxAsync(progressBoxTitle, async (reportProgress) => { if (reportProgress == null) { - // when reportProgress is null, it means there is expcetion with the progress box + // when reportProgress is null, it means there is exception with the progress box // so we record it with exceptionHappened and return so that progress box will close instantly exceptionHappened = true; return; @@ -242,6 +251,18 @@ internal async ValueTask> RequestUpdateAsync(string search, Cancell if (FilesFolders.IsZipFilePath(search, checkFileExists: true)) { pluginFromLocalPath = Utilities.GetPluginInfoFromZip(search); + + if (pluginFromLocalPath == null) return new List + { + new() + { + Title = Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_title"), + SubTitle = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_subtitle"), + search), + IcoPath = icoPath + } + }; + pluginFromLocalPath.LocalInstallPath = search; updateFromLocalPath = true; } @@ -261,6 +282,7 @@ where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version select new { + existingPlugin.Metadata.ID, pluginUpdateSource.Name, pluginUpdateSource.Author, CurrentVersion = existingPlugin.Metadata.Version, @@ -290,6 +312,14 @@ where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version IcoPath = x.IcoPath, Action = e => { + if (Context.API.PluginModified(x.ID)) + { + Context.API.ShowMsgError( + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_title"), x.Name), + Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_message")); + return false; + } + string message; if (Settings.AutoRestartAfterChanging) { @@ -340,8 +370,11 @@ await DownloadFileAsync( } else { - await Context.API.UpdatePluginAsync(x.PluginExistingMetadata, x.PluginNewUserPlugin, - downloadToFilePath); + if (!await Context.API.UpdatePluginAsync(x.PluginExistingMetadata, x.PluginNewUserPlugin, + downloadToFilePath)) + { + return; + } if (Settings.AutoRestartAfterChanging) { @@ -406,6 +439,14 @@ await Context.API.UpdatePluginAsync(x.PluginExistingMetadata, x.PluginNewUserPlu IcoPath = icoPath, AsyncAction = async e => { + if (resultsForUpdate.All(x => Context.API.PluginModified(x.ID))) + { + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error"), + string.Join(" ", resultsForUpdate.Select(x => x.Name)))); + return false; + } + string message; if (Settings.AutoRestartAfterChanging) { @@ -427,6 +468,7 @@ await Context.API.UpdatePluginAsync(x.PluginExistingMetadata, x.PluginNewUserPlu return false; } + var anyPluginSuccess = false; await Task.WhenAll(resultsForUpdate.Select(async plugin => { var downloadToFilePath = Path.Combine(Path.GetTempPath(), @@ -444,8 +486,11 @@ await DownloadFileAsync( if (cts.IsCancellationRequested) return; else - await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, - downloadToFilePath); + if (!await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, + downloadToFilePath)) + return; + + anyPluginSuccess = true; } catch (Exception ex) { @@ -458,6 +503,8 @@ await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.Plugin } })); + if (!anyPluginSuccess) return false; + if (Settings.AutoRestartAfterChanging) { Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"), @@ -559,6 +606,20 @@ internal List InstallFromLocalPath(string localPath) { var plugin = Utilities.GetPluginInfoFromZip(localPath); + if (plugin == null) + { + return new List + { + new() + { + Title = Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_title"), + SubTitle = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_subtitle"), + localPath), + IcoPath = icoPath + } + }; + } + plugin.LocalInstallPath = localPath; return new List @@ -600,14 +661,17 @@ private bool InstallSourceKnown(string url) return false; var author = pieces[3]; + var acceptedHost = "github.com"; var acceptedSource = "https://github.com"; var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author); - return url.StartsWith(acceptedSource) && - Context.API.GetAllPlugins().Any(x => - !string.IsNullOrEmpty(x.Metadata.Website) && - x.Metadata.Website.StartsWith(constructedUrlPart) - ); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost) + return false; + + return Context.API.GetAllPlugins().Any(x => + !string.IsNullOrEmpty(x.Metadata.Website) && + x.Metadata.Website.StartsWith(constructedUrlPart) + ); } internal async ValueTask> RequestInstallOrUpdateAsync(string search, CancellationToken token, @@ -649,7 +713,7 @@ internal async ValueTask> RequestInstallOrUpdateAsync(string search return Search(results, search); } - private void Install(UserPlugin plugin, string downloadedFilePath) + private bool Install(UserPlugin plugin, string downloadedFilePath) { if (!File.Exists(downloadedFilePath)) throw new FileNotFoundException($"Plugin {plugin.ID} zip file not found at {downloadedFilePath}", @@ -657,10 +721,13 @@ private void Install(UserPlugin plugin, string downloadedFilePath) try { - Context.API.InstallPlugin(plugin, downloadedFilePath); + if (!Context.API.InstallPlugin(plugin, downloadedFilePath)) + return false; if (!plugin.IsFromLocalInstallPath) File.Delete(downloadedFilePath); + + return true; } catch (FileNotFoundException e) { @@ -682,6 +749,8 @@ private void Install(UserPlugin plugin, string downloadedFilePath) plugin.Name)); Context.API.LogException(ClassName, e.Message, e); } + + return false; } internal List RequestUninstall(string search) @@ -696,6 +765,14 @@ internal List RequestUninstall(string search) IcoPath = x.Metadata.IcoPath, AsyncAction = async e => { + if (Context.API.PluginModified(x.Metadata.ID)) + { + Context.API.ShowMsgError( + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_title"), x.Metadata.Name), + Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_message")); + return false; + } + string message; if (Settings.AutoRestartAfterChanging) { @@ -717,7 +794,10 @@ internal List RequestUninstall(string search) MessageBoxButton.YesNo) == MessageBoxResult.Yes) { Context.API.HideMainWindow(); - await UninstallAsync(x.Metadata); + if (!await UninstallAsync(x.Metadata)) + { + return false; + } if (Settings.AutoRestartAfterChanging) { Context.API.RestartApp(); @@ -742,7 +822,7 @@ internal List RequestUninstall(string search) return Search(results, search); } - private async Task UninstallAsync(PluginMetadata plugin) + private async Task UninstallAsync(PluginMetadata plugin) { try { @@ -750,13 +830,14 @@ private async Task UninstallAsync(PluginMetadata plugin) Context.API.GetTranslation("plugin_pluginsmanager_keep_plugin_settings_subtitle"), Context.API.GetTranslation("plugin_pluginsmanager_keep_plugin_settings_title"), button: MessageBoxButton.YesNo) == MessageBoxResult.No; - await Context.API.UninstallPluginAsync(plugin, removePluginSettings); + return await Context.API.UninstallPluginAsync(plugin, removePluginSettings); } catch (ArgumentException e) { Context.API.LogException(ClassName, e.Message, e); Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_error_title"), - Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error")); + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error"), plugin.Name)); + return false; } } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Utilities.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Utilities.cs index 4bb78f6ff8d..d76ce40c41e 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Utilities.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Utilities.cs @@ -65,9 +65,7 @@ internal static UserPlugin GetPluginInfoFromZip(string filePath) 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); - + var pluginJsonEntry = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json"); if (pluginJsonEntry != null) { using Stream stream = pluginJsonEntry.Open(); diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json b/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json index 327011ac31d..949e9e9db8b 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json @@ -4,7 +4,7 @@ "pm" ], "Name": "Plugins Manager", - "Description": "Management of installing, uninstalling or updating Flow Launcher plugins", + "Description": "Install, uninstall or update Flow Launcher plugins via the search window", "Author": "Jeremy Wu", "Version": "1.0.0", "Language": "csharp",