using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; using IRemovable = Flow.Launcher.Core.Storage.IRemovable; using ISavable = Flow.Launcher.Plugin.ISavable; namespace Flow.Launcher.Core.Plugin { /// /// The entry for managing Flow Launcher plugins /// public static class PluginManager { private static readonly string ClassName = nameof(PluginManager); private static IEnumerable _contextMenuPlugins; private static IEnumerable _homePlugins; public static List AllPlugins { get; private set; } public static readonly HashSet GlobalPlugins = new(); public static readonly Dictionary NonGlobalPlugins = new(); // 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(); private static PluginsSettings Settings; private static List _metadatas; private static readonly List _modifiedPlugins = new(); /// /// Directories that will hold Flow Launcher plugin directory /// private static readonly string[] Directories = { Constant.PreinstalledDirectory, DataLocation.PluginsDirectory }; private static void DeletePythonBinding() { const string binding = "flowlauncher.py"; foreach (var subDirectory in Directory.GetDirectories(DataLocation.PluginsDirectory)) { File.Delete(Path.Combine(subDirectory, binding)); } } /// /// Save json and ISavable /// public static void Save() { foreach (var pluginPair in AllPlugins) { var savable = pluginPair.Plugin as ISavable; try { savable?.Save(); } catch (Exception e) { API.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e); } } API.SavePluginSettings(); API.SavePluginCaches(); } public static async ValueTask DisposePluginsAsync() { foreach (var pluginPair in AllPlugins) { await DisposePluginAsync(pluginPair); } } private static async Task DisposePluginAsync(PluginPair pluginPair) { try { switch (pluginPair.Plugin) { case IDisposable disposable: disposable.Dispose(); break; case IAsyncDisposable asyncDisposable: await asyncDisposable.DisposeAsync(); break; } } catch (Exception e) { API.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e); } } public static async Task ReloadDataAsync() { await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch { IReloadable p => Task.Run(p.ReloadData), IAsyncReloadable p => p.ReloadDataAsync(), _ => Task.CompletedTask, }).ToArray()); } public static async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true) { await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch { IAsyncExternalPreview p => p.OpenPreviewAsync(path, sendFailToast), _ => Task.CompletedTask, }).ToArray()); } public static async Task CloseExternalPreviewAsync() { await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch { IAsyncExternalPreview p => p.ClosePreviewAsync(), _ => Task.CompletedTask, }).ToArray()); } public static async Task SwitchExternalPreviewAsync(string path, bool sendFailToast = true) { await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch { IAsyncExternalPreview p => p.SwitchPreviewAsync(path, sendFailToast), _ => Task.CompletedTask, }).ToArray()); } public static bool UseExternalPreview() { return GetPluginsForInterface().Any(x => !x.Metadata.Disabled); } public static bool AllowAlwaysPreview() { var plugin = GetPluginsForInterface().FirstOrDefault(x => !x.Metadata.Disabled); if (plugin is null) return false; return ((IAsyncExternalPreview)plugin.Plugin).AllowAlwaysPreview(); } static PluginManager() { // validate user directory Directory.CreateDirectory(DataLocation.PluginsDirectory); // force old plugins use new python binding DeletePythonBinding(); } /// /// because InitializePlugins needs API, so LoadPlugins needs to be called first /// todo happlebao The API should be removed /// /// public static void LoadPlugins(PluginsSettings settings) { _metadatas = PluginConfig.Parse(Directories); Settings = settings; Settings.UpdatePluginSettings(_metadatas); AllPlugins = PluginsLoader.Plugins(_metadatas, Settings); // Since dotnet plugins need to get assembly name first, we should update plugin directory after loading plugins UpdatePluginDirectory(_metadatas); } private static void UpdatePluginDirectory(List metadatas) { foreach (var metadata in metadatas) { if (AllowedLanguage.IsDotNet(metadata.Language)) { if (string.IsNullOrEmpty(metadata.AssemblyName)) { API.LogWarn(ClassName, $"AssemblyName is empty for plugin with metadata: {metadata.Name}"); continue; // Skip if AssemblyName is not set, which can happen for erroneous plugins } metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.AssemblyName); metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.AssemblyName); } else { if (string.IsNullOrEmpty(metadata.Name)) { API.LogWarn(ClassName, $"Name is empty for plugin with metadata: {metadata.Name}"); continue; // Skip if Name is not set, which can happen for erroneous plugins } metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.Name); metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.Name); } } } /// /// Call initialize for all plugins /// /// return the list of failed to init plugins or null for none public static async Task InitializePluginsAsync() { var failedPlugins = new ConcurrentQueue(); var InitTasks = AllPlugins.Select(pair => Task.Run(async delegate { try { var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API))); pair.Metadata.InitTime += milliseconds; API.LogInfo(ClassName, $"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); } catch (Exception e) { API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); if (pair.Metadata.Disabled && pair.Metadata.HomeDisabled) { // If this plugin is already disabled, do not show error message again // Or else it will be shown every time API.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error"); } else { pair.Metadata.Disabled = true; pair.Metadata.HomeDisabled = true; failedPlugins.Enqueue(pair); API.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); } } })); await Task.WhenAll(InitTasks); _contextMenuPlugins = GetPluginsForInterface(); _homePlugins = GetPluginsForInterface(); foreach (var plugin in AllPlugins) { // set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin // has multiple global and action keywords because we will only add them here once. foreach (var actionKeyword in plugin.Metadata.ActionKeywords.Distinct()) { switch (actionKeyword) { case Query.GlobalPluginWildcardSign: GlobalPlugins.Add(plugin); break; default: NonGlobalPlugins[actionKeyword] = plugin; break; } } } if (failedPlugins.Any()) { var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); API.ShowMsg( API.GetTranslation("failedToInitializePluginsTitle"), string.Format( API.GetTranslation("failedToInitializePluginsMessage"), failed ), "", false ); } } public static ICollection ValidPluginsForQuery(Query query) { if (query is null) return Array.Empty(); if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) return GlobalPlugins; return new List { plugin }; } public static ICollection ValidPluginsForHomeQuery() { return _homePlugins.ToList(); } public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); var metadata = pair.Metadata; try { var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", async () => results = await pair.Plugin.QueryAsync(query, token).ConfigureAwait(false)); token.ThrowIfCancellationRequested(); if (results == null) return null; UpdatePluginMetadata(results, metadata, query); metadata.QueryCount += 1; metadata.AvgQueryTime = metadata.QueryCount == 1 ? milliseconds : (metadata.AvgQueryTime + milliseconds) / 2; token.ThrowIfCancellationRequested(); } catch (OperationCanceledException) { // null will be fine since the results will only be added into queue if the token hasn't been cancelled return null; } catch (Exception e) { Result r = new() { Title = $"{metadata.Name}: Failed to respond!", SubTitle = "Select this result for more info", IcoPath = Constant.ErrorIcon, PluginDirectory = metadata.PluginDirectory, ActionKeywordAssigned = query.ActionKeyword, PluginID = metadata.ID, OriginQuery = query, Action = _ => { throw new FlowPluginException(metadata, e);}, Score = -100 }; results.Add(r); } return results; } public static async Task> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); var metadata = pair.Metadata; try { var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false)); token.ThrowIfCancellationRequested(); if (results == null) return null; UpdatePluginMetadata(results, metadata, query); token.ThrowIfCancellationRequested(); } catch (OperationCanceledException) { // null will be fine since the results will only be added into queue if the token hasn't been cancelled return null; } catch (Exception e) { API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e); return null; } return results; } public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) { r.PluginDirectory = metadata.PluginDirectory; r.PluginID = metadata.ID; r.OriginQuery = query; // ActionKeywordAssigned is used for constructing MainViewModel's query text auto-complete suggestions // Plugins may have multi-actionkeywords eg. WebSearches. In this scenario it needs to be overriden on the plugin level if (metadata.ActionKeywords.Count == 1) r.ActionKeywordAssigned = query.ActionKeyword; } } /// /// get specified plugin, return null if not found /// /// /// public static PluginPair GetPluginForId(string id) { return AllPlugins.FirstOrDefault(o => o.Metadata.ID == id); } public static IEnumerable GetPluginsForInterface() where T : IFeatures { // Handle scenario where this is called before all plugins are instantiated, e.g. language change on startup return AllPlugins?.Where(p => p.Plugin is T) ?? Array.Empty(); } public static List GetContextMenusForPlugin(Result result) { var results = new List(); var pluginPair = _contextMenuPlugins.FirstOrDefault(o => o.Metadata.ID == result.PluginID); if (pluginPair != null) { var plugin = (IContextMenu)pluginPair.Plugin; try { results = plugin.LoadContextMenus(result) ?? results; foreach (var r in results) { r.PluginDirectory = pluginPair.Metadata.PluginDirectory; r.PluginID = pluginPair.Metadata.ID; r.OriginQuery = result.OriginQuery; } } catch (Exception e) { API.LogException(ClassName, $"Can't load context menus for plugin <{pluginPair.Metadata.Name}>", e); } } return results; } public static bool IsHomePlugin(string id) { return _homePlugins.Any(p => p.Metadata.ID == id); } public static bool ActionKeywordRegistered(string actionKeyword) { // this method is only checking for action keywords (defined as not '*') registration // hence the actionKeyword != Query.GlobalPluginWildcardSign logic return actionKeyword != Query.GlobalPluginWildcardSign && NonGlobalPlugins.ContainsKey(actionKeyword); } /// /// used to add action keyword for multiple action keyword plugin /// e.g. web search /// public static void AddActionKeyword(string id, string newActionKeyword) { var plugin = GetPluginForId(id); if (newActionKeyword == Query.GlobalPluginWildcardSign) { GlobalPlugins.Add(plugin); } else { NonGlobalPlugins[newActionKeyword] = plugin; } // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Add(newActionKeyword); if (plugin.Metadata.ActionKeywords.Count > 0) { plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; } else { plugin.Metadata.ActionKeyword = string.Empty; } } /// /// used to remove action keyword for multiple action keyword plugin /// e.g. web search /// public static void RemoveActionKeyword(string id, string oldActionkeyword) { var plugin = GetPluginForId(id); if (oldActionkeyword == Query.GlobalPluginWildcardSign && // Plugins may have multiple ActionKeywords that are global, eg. WebSearch plugin.Metadata.ActionKeywords .Count(x => x == Query.GlobalPluginWildcardSign) == 1) { GlobalPlugins.Remove(plugin); } if (oldActionkeyword != Query.GlobalPluginWildcardSign) NonGlobalPlugins.Remove(oldActionkeyword); // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Remove(oldActionkeyword); if (plugin.Metadata.ActionKeywords.Count > 0) { plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; } else { plugin.Metadata.ActionKeyword = string.Empty; } } private static string GetContainingFolderPathAfterUnzip(string unzippedParentFolderPath) { var unzippedFolderCount = Directory.GetDirectories(unzippedParentFolderPath).Length; var unzippedFilesCount = Directory.GetFiles(unzippedParentFolderPath).Length; // adjust path depending on how the plugin is zipped up // the recommended should be to zip up the folder not the contents if (unzippedFolderCount == 1 && unzippedFilesCount == 0) // folder is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/pluginFolderName/ return Directory.GetDirectories(unzippedParentFolderPath)[0]; if (unzippedFilesCount > 1) // content is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/ return unzippedParentFolderPath; return string.Empty; } private static bool SameOrLesserPluginVersionExists(string metadataPath) { var newMetadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID && newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); } #region Public functions public static bool PluginModified(string id) { return _modifiedPlugins.Contains(id); } public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) { var success = InstallPlugin(newVersion, zipFilePath, checkModified:false); if (!success) return; await UninstallPluginAsync(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false); _modifiedPlugins.Add(existingVersion.ID); } public static void InstallPlugin(UserPlugin plugin, string zipFilePath) { InstallPlugin(plugin, zipFilePath, checkModified: true); } public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false) { await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true); } #endregion #region Internal functions internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { API.ShowMsg(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name), API.GetTranslation("pluginModifiedAlreadyMessage")); return false; } // Unzip plugin files to temp folder var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath); if(!plugin.IsFromLocalInstallPath) File.Delete(zipFilePath); var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath); var metadataJsonFilePath = string.Empty; if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName))) metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName); if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) { API.ShowMsg(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name), string.Format(API.GetTranslation("fileNotFoundMessage"), pluginFolderPath)); return false; } if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) { API.ShowMsg(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}"; var defaultPluginIDs = new List { "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator "572be03c74c642baae319fc283e561a8", // Explorer "6A122269676E40EB86EB543B945932B9", // PluginIndicator "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller "791FC278BA414111B8D1886DFE447410", // Program "D409510CD0D2481F853690A07E6DC426", // Shell "CEA08895D2544B019B2E9C5009600DF4", // Sys "0308FD86DE0A4DEE8D62B9B535370992", // URL "565B73353DBF4806919830B9202EE3BF", // WebSearch "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings }; // Treat default plugin differently, it needs to be removable along with each flow release var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID) ? DataLocation.PluginsDirectory : Constant.PreinstalledDirectory; var newPluginPath = Path.Combine(installDirectory, folderName); FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => API.ShowMsgBox(s)); try { if (Directory.Exists(tempFolderPluginPath)) Directory.Delete(tempFolderPluginPath, true); } catch (Exception e) { API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e); } if (checkModified) { _modifiedPlugins.Add(plugin.ID); } return true; } internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { API.ShowMsg(string.Format(API.GetTranslation("failedToUninstallPluginTitle"), plugin.Name), API.GetTranslation("pluginModifiedAlreadyMessage")); return; } if (removePluginSettings || removePluginFromSettings) { // If we want to remove plugin from AllPlugins, // we need to dispose them so that they can release file handles // which can help FL to delete the plugin settings & cache folders successfully var pluginPairs = AllPlugins.FindAll(p => p.Metadata.ID == plugin.ID); foreach (var pluginPair in pluginPairs) { await DisposePluginAsync(pluginPair); } } if (removePluginSettings) { // For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances if (AllowedLanguage.IsDotNet(plugin.Language) && API is IRemovable removable) { removable.RemovePluginSettings(plugin.AssemblyName); removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath); } try { var pluginSettingsDirectory = plugin.PluginSettingsDirectoryPath; if (Directory.Exists(pluginSettingsDirectory)) Directory.Delete(pluginSettingsDirectory, true); } catch (Exception e) { API.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e); API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); } } if (removePluginFromSettings) { try { var pluginCacheDirectory = plugin.PluginCacheDirectoryPath; if (Directory.Exists(pluginCacheDirectory)) Directory.Delete(pluginCacheDirectory, true); } catch (Exception e) { API.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e); API.ShowMsg(API.GetTranslation("failedToRemovePluginCacheTitle"), string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name)); } Settings.RemovePluginSettings(plugin.ID); AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); } // Marked for deletion. Will be deleted on next start up using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, "NeedDelete.txt")); if (checkModified) { _modifiedPlugins.Add(plugin.ID); } } #endregion } }