diff --git a/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs b/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs new file mode 100644 index 00000000000..1da04bf01a6 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs @@ -0,0 +1,12 @@ +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin; + +public interface IResultUpdateRegister +{ + /// + /// Register a plugin to receive results updated event. + /// + /// + void RegisterResultsUpdatedEvent(PluginPair pair); +} diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index a4ab8de08ae..b0624f373b6 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -8,6 +8,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.ExternalPlugins; +using Flow.Launcher.Core.Resource; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.DialogJump; using Flow.Launcher.Infrastructure.UserSettings; @@ -25,48 +26,40 @@ public static class PluginManager { private static readonly string ClassName = nameof(PluginManager); - 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 readonly ConcurrentBag ModifiedPlugins = new(); + private static List _allLoadedPlugins; + private static readonly ConcurrentDictionary _allInitializedPlugins = []; + private static readonly ConcurrentDictionary _initFailedPlugins = []; + private static readonly ConcurrentDictionary _globalPlugins = []; + private static readonly ConcurrentDictionary _nonGlobalPlugins = []; - private static IEnumerable _contextMenuPlugins; - private static IEnumerable _homePlugins; - private static IEnumerable _resultUpdatePlugin; - private static IEnumerable _translationPlugins; + private static PluginsSettings Settings; + private static readonly ConcurrentBag ModifiedPlugins = []; - private static readonly List _dialogJumpExplorerPlugins = new(); - private static readonly List _dialogJumpDialogPlugins = new(); + private static readonly ConcurrentBag _contextMenuPlugins = []; + private static readonly ConcurrentBag _homePlugins = []; + private static readonly ConcurrentBag _translationPlugins = []; + private static readonly ConcurrentBag _externalPreviewPlugins = []; /// /// Directories that will hold Flow Launcher plugin directory /// public 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)); - } - } + #region Save & Dispose & Reload Plugin /// /// Save json and ISavable /// public static void Save() { - foreach (var pluginPair in AllPlugins) + foreach (var pluginPair in GetAllInitializedPlugins(false)) { var savable = pluginPair.Plugin as ISavable; try @@ -85,7 +78,8 @@ public static void Save() public static async ValueTask DisposePluginsAsync() { - foreach (var pluginPair in AllPlugins) + // Still call dispose for all plugins even if initialization failed, so that we can clean up resources + foreach (var pluginPair in GetAllInitializedPlugins(true)) { await DisposePluginAsync(pluginPair); } @@ -113,49 +107,53 @@ private static async Task DisposePluginAsync(PluginPair pluginPair) public static async Task ReloadDataAsync() { - await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch + await Task.WhenAll([.. GetAllInitializedPlugins(false).Select(plugin => plugin.Plugin switch { IReloadable p => Task.Run(p.ReloadData), IAsyncReloadable p => p.ReloadDataAsync(), _ => Task.CompletedTask, - }).ToArray()); + })]); } + #endregion + + #region External Preview + public static async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true) { - await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch + await Task.WhenAll([.. GetAllInitializedPlugins(false).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 + await Task.WhenAll([.. GetAllInitializedPlugins(false).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 + await Task.WhenAll([.. GetAllInitializedPlugins(false).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); + return GetExternalPreviewPlugins().Any(x => !x.Metadata.Disabled); } public static bool AllowAlwaysPreview() { - var plugin = GetPluginsForInterface().FirstOrDefault(x => !x.Metadata.Disabled); + var plugin = GetExternalPreviewPlugins().FirstOrDefault(x => !x.Metadata.Disabled); if (plugin is null) return false; @@ -163,6 +161,15 @@ public static bool AllowAlwaysPreview() return ((IAsyncExternalPreview)plugin.Plugin).AllowAlwaysPreview(); } + private static IList GetExternalPreviewPlugins() + { + return [.. _externalPreviewPlugins.Where(p => !PluginModified(p.Metadata.ID))]; + } + + #endregion + + #region Constructor + static PluginManager() { // validate user directory @@ -171,9 +178,28 @@ static PluginManager() DeletePythonBinding(); } + private static void DeletePythonBinding() + { + const string binding = "flowlauncher.py"; + foreach (var subDirectory in Directory.GetDirectories(DataLocation.PluginsDirectory)) + { + try + { + File.Delete(Path.Combine(subDirectory, binding)); + } + catch (Exception e) + { + API.LogDebug(ClassName, $"Failed to delete {binding} in {subDirectory}: {e.Message}"); + } + } + } + + #endregion + + #region Load & Initialize Plugins + /// - /// because InitializePlugins needs API, so LoadPlugins needs to be called first - /// todo happlebao The API should be removed + /// Load plugins from the directories specified in Directories. /// /// public static void LoadPlugins(PluginsSettings settings) @@ -181,33 +207,12 @@ public static void LoadPlugins(PluginsSettings settings) var 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); - // Initialize plugin enumerable after all plugins are initialized - _contextMenuPlugins = GetPluginsForInterface(); - _homePlugins = GetPluginsForInterface(); - _resultUpdatePlugin = GetPluginsForInterface(); - _translationPlugins = GetPluginsForInterface(); + // Load plugins + _allLoadedPlugins = PluginsLoader.Plugins(metadatas, Settings); - // Initialize Dialog Jump plugin pairs - foreach (var pair in GetPluginsForInterface()) - { - _dialogJumpExplorerPlugins.Add(new DialogJumpExplorerPair - { - Plugin = (IDialogJumpExplorer)pair.Plugin, - Metadata = pair.Metadata - }); - } - foreach (var pair in GetPluginsForInterface()) - { - _dialogJumpDialogPlugins.Add(new DialogJumpDialogPair - { - Plugin = (IDialogJumpDialog)pair.Plugin, - Metadata = pair.Metadata - }); - } + // 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) @@ -238,14 +243,13 @@ private static void UpdatePluginDirectory(List metadatas) } /// - /// Call initialize for all plugins + /// Initialize all plugins asynchronously. /// + /// The register to register results updated event for each plugin. /// return the list of failed to init plugins or null for none - public static async Task InitializePluginsAsync() + public static async Task InitializePluginsAsync(IResultUpdateRegister register) { - var failedPlugins = new ConcurrentQueue(); - - var InitTasks = AllPlugins.Select(pair => Task.Run(async delegate + var initTasks = _allLoadedPlugins.Select(pair => Task.Run(async () => { try { @@ -269,35 +273,37 @@ public static async Task InitializePluginsAsync() { pair.Metadata.Disabled = true; pair.Metadata.HomeDisabled = true; - failedPlugins.Enqueue(pair); API.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); } + + // Even if the plugin cannot be initialized, we still need to add it in all plugin list so that + // we can remove the plugin from Plugin or Store page or Plugin Manager plugin. + _allInitializedPlugins.TryAdd(pair.Metadata.ID, pair); + _initFailedPlugins.TryAdd(pair.Metadata.ID, pair); + return; } - })); - await Task.WhenAll(InitTasks); + // Register ResultsUpdated event so that plugin query can use results updated interface + register.RegisterResultsUpdatedEvent(pair); - 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; - } - } - } + // Update plugin metadata translation after the plugin is initialized with IPublicAPI instance + Internationalization.UpdatePluginMetadataTranslation(pair); + + // Register plugin action keywords so that plugins can be queried in results + RegisterPluginActionKeywords(pair); + + // Add plugin to Dialog Jump plugin list after the plugin is initialized + DialogJump.InitializeDialogJumpPlugin(pair); - if (failedPlugins.Any()) + // Add plugin to lists after the plugin is initialized + AddPluginToLists(pair); + })); + + await Task.WhenAll(initTasks); + + if (!_initFailedPlugins.IsEmpty) { - var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); + var failed = string.Join(",", _initFailedPlugins.Values.Select(x => x.Metadata.Name)); API.ShowMsg( API.GetTranslation("failedToInitializePluginsTitle"), string.Format( @@ -310,34 +316,74 @@ public static async Task InitializePluginsAsync() } } + private static void RegisterPluginActionKeywords(PluginPair pair) + { + // 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 pair.Metadata.ActionKeywords.Distinct()) + { + switch (actionKeyword) + { + case Query.GlobalPluginWildcardSign: + _globalPlugins.TryAdd(pair.Metadata.ID, pair); + break; + default: + _nonGlobalPlugins.TryAdd(actionKeyword, pair); + break; + } + } + } + + private static void AddPluginToLists(PluginPair pair) + { + if (pair.Plugin is IContextMenu) + { + _contextMenuPlugins.Add(pair); + } + if (pair.Plugin is IAsyncHomeQuery) + { + _homePlugins.Add(pair); + } + if (pair.Plugin is IPluginI18n) + { + _translationPlugins.Add(pair); + } + if (pair.Plugin is IAsyncExternalPreview) + { + _externalPreviewPlugins.Add(pair); + } + _allInitializedPlugins.TryAdd(pair.Metadata.ID, pair); + } + + #endregion + + #region Validate & Query Plugins + public static ICollection ValidPluginsForQuery(Query query, bool dialogJump) { if (query is null) return Array.Empty(); - if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) + if (!_nonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) { if (dialogJump) - return GlobalPlugins.Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID)).ToList(); + return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))]; else - return GlobalPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + return [.. GetGlobalPlugins().Where(p => !PluginModified(p.Metadata.ID))]; } if (dialogJump && plugin.Plugin is not IAsyncDialogJump) return Array.Empty(); - if (API.PluginModified(plugin.Metadata.ID)) + if (PluginModified(plugin.Metadata.ID)) return Array.Empty(); - return new List - { - plugin - }; + return [plugin]; } public static ICollection ValidPluginsForHomeQuery() { - return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + return [.. _homePlugins.Where(p => !PluginModified(p.Metadata.ID))]; } public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) @@ -444,6 +490,47 @@ public static async Task> QueryDialogJumpForPluginAsync(P return results; } + #endregion + + #region Get Plugin List + + public static List GetAllLoadedPlugins() + { + return [.. _allLoadedPlugins]; + } + + private static List GetAllInitializedPlugins(bool includeFailed) + { + if (includeFailed) + { + return [.. _allInitializedPlugins.Values]; + } + else + { + return [.. _allInitializedPlugins.Values + .Where(p => !_initFailedPlugins.ContainsKey(p.Metadata.ID))]; + } + } + + public static List GetGlobalPlugins() + { + return [.. _globalPlugins.Values]; + } + + public static Dictionary GetNonGlobalPlugins() + { + return _nonGlobalPlugins.ToDictionary(); + } + + public static List GetTranslationPlugins() + { + return [.. _translationPlugins.Where(p => !PluginModified(p.Metadata.ID))]; + } + + #endregion + + #region Update Metadata & Get Plugin + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) @@ -462,28 +549,19 @@ public static void UpdatePluginMetadata(IReadOnlyList results, PluginMet /// /// get specified plugin, return null if not found /// + /// + /// Plugin may not be initialized, so do not use its plugin model to execute any commands + /// /// /// public static PluginPair GetPluginForId(string id) { - return AllPlugins.FirstOrDefault(o => o.Metadata.ID == id); + return GetAllLoadedPlugins().FirstOrDefault(o => o.Metadata.ID == id); } - private 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 IList GetResultUpdatePlugin() - { - return _resultUpdatePlugin.Where(p => !PluginModified(p.Metadata.ID)).ToList(); - } + #endregion - public static IList GetTranslationPlugins() - { - return _translationPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); - } + #region Get Context Menus public static List GetContextMenusForPlugin(Result result) { @@ -514,27 +592,47 @@ public static List GetContextMenusForPlugin(Result result) return results; } + #endregion + + #region Check Home Plugin + public static bool IsHomePlugin(string id) { return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id); } - public static IList GetDialogJumpExplorers() - { - return _dialogJumpExplorerPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); - } + #endregion - public static IList GetDialogJumpDialogs() + #region Check Initializing or Init Failed + + public static bool IsInitializingOrInitFailed(string id) { - return _dialogJumpDialogPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + // Id does not exist in loaded plugins + if (!_allLoadedPlugins.Any(x => x.Metadata.ID == id)) return false; + + // Plugin initialized already + if (_allInitializedPlugins.ContainsKey(id)) + { + // Check if the plugin initialization failed + return _initFailedPlugins.ContainsKey(id); + } + // Plugin is still initializing + else + { + return true; + } } + #endregion + + #region Plugin Action Keyword + 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); + && _nonGlobalPlugins.ContainsKey(actionKeyword); } /// @@ -546,11 +644,11 @@ public static void AddActionKeyword(string id, string newActionKeyword) var plugin = GetPluginForId(id); if (newActionKeyword == Query.GlobalPluginWildcardSign) { - GlobalPlugins.Add(plugin); + _globalPlugins.TryAdd(id, plugin); } else { - NonGlobalPlugins[newActionKeyword] = plugin; + _nonGlobalPlugins.AddOrUpdate(newActionKeyword, plugin, (key, oldValue) => plugin); } // Update action keywords and action keyword in plugin metadata @@ -577,11 +675,13 @@ public static void RemoveActionKeyword(string id, string oldActionkeyword) plugin.Metadata.ActionKeywords .Count(x => x == Query.GlobalPluginWildcardSign) == 1) { - GlobalPlugins.Remove(plugin); + _globalPlugins.TryRemove(id, out _); } if (oldActionkeyword != Query.GlobalPluginWildcardSign) - NonGlobalPlugins.Remove(oldActionkeyword); + { + _nonGlobalPlugins.TryRemove(oldActionkeyword, out var item); + } // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Remove(oldActionkeyword); @@ -595,6 +695,12 @@ public static void RemoveActionKeyword(string id, string oldActionkeyword) } } + #endregion + + #region Plugin Install & Uninstall & Update + + #region Private Functions + private static string GetContainingFolderPathAfterUnzip(string unzippedParentFolderPath) { var unzippedFolderCount = Directory.GetDirectories(unzippedParentFolderPath).Length; @@ -620,12 +726,15 @@ private static bool SameOrLesserPluginVersionExists(string metadataPath) if (!Version.TryParse(newMetadata.Version, out var newVersion)) return true; // If version is not valid, we assume it is lesser than any existing version - return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID - && Version.TryParse(x.Metadata.Version, out var version) - && newVersion <= version); + // Get all plugins even if initialization failed so that we can check if the plugin with the same ID exists + return GetAllInitializedPlugins(true).Any(x => x.Metadata.ID == newMetadata.ID + && Version.TryParse(x.Metadata.Version, out var version) + && newVersion <= version); } - #region Public functions + #endregion + + #region Public Functions public static bool PluginModified(string id) { @@ -663,7 +772,7 @@ public static async Task UninstallPluginAsync(PluginMetadata plugin, bool #endregion - #region Internal functions + #region Internal Functions internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) { @@ -760,7 +869,7 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo // 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); + var pluginPairs = GetAllInitializedPlugins(true).Where(p => p.Metadata.ID == plugin.ID).ToList(); foreach (var pluginPair in pluginPairs) { await DisposePluginAsync(pluginPair); @@ -805,12 +914,22 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name)); } Settings.RemovePluginSettings(plugin.ID); - AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); - GlobalPlugins.RemoveWhere(p => p.Metadata.ID == plugin.ID); - var keysToRemove = NonGlobalPlugins.Where(p => p.Value.Metadata.ID == plugin.ID).Select(p => p.Key).ToList(); + { + _allLoadedPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); + } + { + _allInitializedPlugins.TryRemove(plugin.ID, out var _); + } + { + _initFailedPlugins.TryRemove(plugin.ID, out var _); + } + { + _globalPlugins.TryRemove(plugin.ID, out var _); + } + var keysToRemove = _nonGlobalPlugins.Where(p => p.Value.Metadata.ID == plugin.ID).Select(p => p.Key).ToList(); foreach (var key in keysToRemove) { - NonGlobalPlugins.Remove(key); + _nonGlobalPlugins.Remove(key, out var ite3); } } @@ -826,5 +945,7 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo } #endregion + + #endregion } } diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 9d511297e3e..381772ea8bc 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -55,7 +55,7 @@ public static List Plugins(List metadatas, PluginsSe return plugins; } - private static IEnumerable DotNetPlugins(List source) + private static List DotNetPlugins(List source) { var erroredPlugins = new List(); @@ -65,55 +65,57 @@ private static IEnumerable DotNetPlugins(List source foreach (var metadata in metadatas) { var milliseconds = API.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => - { - Assembly assembly = null; - IAsyncPlugin plugin = null; + { + Assembly assembly = null; + IAsyncPlugin plugin = null; - try - { - var assemblyLoader = new PluginAssemblyLoader(metadata.ExecuteFilePath); - assembly = assemblyLoader.LoadAssemblyAndDependencies(); + try + { + var assemblyLoader = new PluginAssemblyLoader(metadata.ExecuteFilePath); + assembly = assemblyLoader.LoadAssemblyAndDependencies(); - var type = assemblyLoader.FromAssemblyGetTypeOfInterface(assembly, - typeof(IAsyncPlugin)); + var type = assemblyLoader.FromAssemblyGetTypeOfInterface(assembly, + typeof(IAsyncPlugin)); - plugin = Activator.CreateInstance(type) as IAsyncPlugin; + plugin = Activator.CreateInstance(type) as IAsyncPlugin; - metadata.AssemblyName = assembly.GetName().Name; - } + metadata.AssemblyName = assembly.GetName().Name; + } #if DEBUG - catch (Exception) - { - throw; - } + catch (Exception) + { + throw; + } #else - catch (Exception e) when (assembly == null) - { - Log.Exception(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); - } - catch (InvalidOperationException e) - { - Log.Exception(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); - } - catch (ReflectionTypeLoadException e) - { - Log.Exception(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); - } - catch (Exception e) - { - Log.Exception(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); - } + catch (Exception e) when (assembly == null) + { + Log.Exception(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); + } + catch (InvalidOperationException e) + { + Log.Exception(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); + } + catch (ReflectionTypeLoadException e) + { + Log.Exception(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); + } + catch (Exception e) + { + Log.Exception(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); + } #endif - if (plugin == null) - { - erroredPlugins.Add(metadata.Name); - return; - } + if (plugin == null) + { + erroredPlugins.Add(metadata.Name); + return; + } + + plugins.Add(new PluginPair { Plugin = plugin, Metadata = metadata }); + }); - plugins.Add(new PluginPair { Plugin = plugin, Metadata = metadata }); - }); metadata.InitTime += milliseconds; + API.LogInfo(ClassName, $"Constructor cost for <{metadata.Name}> is <{metadata.InitTime}ms>"); } if (erroredPlugins.Count > 0) diff --git a/Flow.Launcher.Core/Resource/Internationalization.cs b/Flow.Launcher.Core/Resource/Internationalization.cs index d2ab2d028df..37e4966de3b 100644 --- a/Flow.Launcher.Core/Resource/Internationalization.cs +++ b/Flow.Launcher.Core/Resource/Internationalization.cs @@ -367,6 +367,22 @@ public static void UpdatePluginMetadataTranslations() } } + public static void UpdatePluginMetadataTranslation(PluginPair p) + { + // Update plugin metadata name & description + if (p.Plugin is not IPluginI18n pluginI18N) return; + try + { + p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); + p.Metadata.Description = pluginI18N.GetTranslatedPluginDescription(); + pluginI18N.OnCultureInfoChanged(CultureInfo.CurrentCulture); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed for <{p.Metadata.Name}>", e); + } + } + #endregion } } diff --git a/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs index 65652878fc8..ea647491e90 100644 --- a/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs @@ -13,6 +13,7 @@ using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.UI.Accessibility; +using System.Collections.Concurrent; namespace Flow.Launcher.Infrastructure.DialogJump { @@ -64,12 +65,12 @@ public static class DialogJump private static HWND _mainWindowHandle = HWND.Null; - private static readonly Dictionary _dialogJumpExplorers = new(); + private static readonly ConcurrentDictionary _dialogJumpExplorers = new(); private static DialogJumpExplorerPair _lastExplorer = null; private static readonly object _lastExplorerLock = new(); - private static readonly Dictionary _dialogJumpDialogs = new(); + private static readonly ConcurrentDictionary _dialogJumpDialogs = new(); private static IDialogJumpDialogWindow _dialogWindow = null; private static readonly object _dialogWindowLock = new(); @@ -105,22 +106,13 @@ public static class DialogJump #region Initialize & Setup - public static void InitializeDialogJump(IList dialogJumpExplorers, - IList dialogJumpDialogs) + public static void InitializeDialogJump() { if (_initialized) return; - // Initialize Dialog Jump explorers & dialogs - _dialogJumpExplorers.Add(WindowsDialogJumpExplorer, null); - foreach (var explorer in dialogJumpExplorers) - { - _dialogJumpExplorers.Add(explorer, null); - } - _dialogJumpDialogs.Add(WindowsDialogJumpDialog, null); - foreach (var dialog in dialogJumpDialogs) - { - _dialogJumpDialogs.Add(dialog, null); - } + // Initialize preinstalled Dialog Jump explorers & dialogs + _dialogJumpExplorers.TryAdd(WindowsDialogJumpExplorer, null); + _dialogJumpDialogs.TryAdd(WindowsDialogJumpDialog, null); // Initialize main window handle _mainWindowHandle = Win32Helper.GetMainWindowHandle(); @@ -135,6 +127,29 @@ public static void InitializeDialogJump(IList dialogJump _initialized = true; } + public static void InitializeDialogJumpPlugin(PluginPair pair) + { + // Add Dialog Jump explorers & dialogs + if (pair.Plugin is IDialogJumpExplorer explorer) + { + var dialogJumpExplorer = new DialogJumpExplorerPair + { + Plugin = explorer, + Metadata = pair.Metadata + }; + _dialogJumpExplorers.TryAdd(dialogJumpExplorer, null); + } + if (pair.Plugin is IDialogJumpDialog dialog) + { + var dialogJumpDialog = new DialogJumpDialogPair + { + Plugin = dialog, + Metadata = pair.Metadata + }; + _dialogJumpDialogs.TryAdd(dialogJumpDialog, null); + } + } + public static void SetupDialogJump(bool enabled) { if (enabled == _enabled) return; diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index dcccaebebed..b2ad8a4ae84 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -173,6 +173,9 @@ public interface IPublicAPI /// /// Get all loaded plugins /// + /// + /// Part of plugins may not be initialized yet + /// /// List GetAllPlugins(); diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 6e053db29c8..be017fc6283 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -181,12 +181,14 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => // So set to OnExplicitShutdown to prevent the application from shutting down before main window is created Current.ShutdownMode = ShutdownMode.OnExplicitShutdown; + // Setup log level before any logging is done Log.SetLogLevel(_settings.LogLevel); // Update dynamic resources base on settings Current.Resources["SettingWindowFont"] = new FontFamily(_settings.SettingWindowFont); Current.Resources["ContentControlThemeFontFamily"] = new FontFamily(_settings.SettingWindowFont); + // Initialize notification system before any notification api is called Notification.Install(); // Enable Win32 dark mode if the system is in dark mode before creating all windows @@ -195,6 +197,7 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => // Initialize language before portable clean up since it needs translations await Ioc.Default.GetRequiredService().InitializeLanguageAsync(); + // Clean up after portability update Ioc.Default.GetRequiredService().PreStartCleanUpAfterPortabilityUpdate(); API.LogInfo(ClassName, "Begin Flow Launcher startup ----------------------------------------------------"); @@ -204,32 +207,25 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => RegisterDispatcherUnhandledException(); RegisterTaskSchedulerUnhandledException(); - var imageLoadertask = ImageLoader.InitializeAsync(); - - AbstractPluginEnvironment.PreStartPluginExecutablePathUpdate(_settings); - - PluginManager.LoadPlugins(_settings.PluginSettings); - - // Register ResultsUpdated event after all plugins are loaded - Ioc.Default.GetRequiredService().RegisterResultsUpdatedEvent(); + var imageLoaderTask = ImageLoader.InitializeAsync(); Http.Proxy = _settings.Proxy; // Initialize plugin manifest before initializing plugins so that they can use the manifest instantly await API.UpdatePluginManifestAsync(); - await PluginManager.InitializePluginsAsync(); - - // Update plugin titles after plugins are initialized with their api instances - Internationalization.UpdatePluginMetadataTranslations(); - - await imageLoadertask; + await imageLoaderTask; _mainWindow = new MainWindow(); Current.MainWindow = _mainWindow; Current.MainWindow.Title = Constant.FlowLauncher; + // Initialize quick jump before hotkey mapper since hotkey mapper will register quick jump hotkey + // Initialize quick jump after main window is created so that it can access main window handle + DialogJump.InitializeDialogJump(); + DialogJump.SetupDialogJump(_settings.EnableDialogJump); + // Initialize hotkey mapper instantly after main window is created because // it will steal focus from main window which causes window hide HotKeyMapper.Initialize(); @@ -237,19 +233,40 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => // Initialize theme for main window Ioc.Default.GetRequiredService().ChangeTheme(); - DialogJump.InitializeDialogJump(PluginManager.GetDialogJumpExplorers(), PluginManager.GetDialogJumpDialogs()); - DialogJump.SetupDialogJump(_settings.EnableDialogJump); - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); RegisterExitEvents(); AutoStartup(); AutoUpdates(); - AutoPluginUpdates(); API.SaveAppAllSettings(); - API.LogInfo(ClassName, "End Flow Launcher startup ----------------------------------------------------"); + API.LogInfo(ClassName, "End Flow Launcher startup ------------------------------------------------------"); + + _ = API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => + { + API.LogInfo(ClassName, "Begin plugin initialization ----------------------------------------------------"); + + AbstractPluginEnvironment.PreStartPluginExecutablePathUpdate(_settings); + + PluginManager.LoadPlugins(_settings.PluginSettings); + + await PluginManager.InitializePluginsAsync(_mainVM); + + // Refresh home page after plugins are initialized because users may open main window during plugin initialization + // And home page is created without full plugin list + if (_settings.ShowHomePage && _mainVM.QueryResultsSelected() && string.IsNullOrEmpty(_mainVM.QueryText)) + { + _mainVM.QueryResults(); + } + + AutoPluginUpdates(); + + // Save all settings since we possibly update the plugin environment paths + API.SaveAppAllSettings(); + + API.LogInfo(ClassName, "End plugin initialization ------------------------------------------------------"); + }); }); } diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index 8eb41e032fa..a146e0b4b71 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -474,7 +474,7 @@ private void OnKeyDown(object sender, KeyEventArgs e) && QueryTextBox.CaretIndex == QueryTextBox.Text.Length) { var queryWithoutActionKeyword = - QueryBuilder.Build(QueryTextBox.Text.Trim(), PluginManager.NonGlobalPlugins)?.Search; + QueryBuilder.Build(QueryTextBox.Text.Trim(), PluginManager.GetNonGlobalPlugins())?.Search; if (FilesFolders.IsLocationPathString(queryWithoutActionKeyword)) { diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index e0ed105cff9..7d5359378d7 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -5,7 +5,6 @@ using System.ComponentModel; using System.Diagnostics; using System.IO; -using System.Linq; using System.Net; using System.Runtime.CompilerServices; using System.Threading; @@ -251,7 +250,7 @@ private static async Task RetryActionOnSTAThreadAsync(Action action, public string GetTranslation(string key) => Internationalization.GetTranslation(key); - public List GetAllPlugins() => PluginManager.AllPlugins.ToList(); + public List GetAllPlugins() => PluginManager.GetAllLoadedPlugins(); public MatchResult FuzzySearch(string query, string stringToCompare) => StringMatcher.FuzzySearch(query, stringToCompare); diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs index 3e1294bc2d2..5ab3bd08757 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs @@ -114,8 +114,11 @@ public string FilterText } } - private IList? _pluginViewModels; - public IList PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins + private List? _pluginViewModels; + // Get all plugins: Initializing & Initialized & Init failed plugins + // Include init failed ones so that we can uninstall them + // Include initializing ones so that we can change related settings like action keywords, etc. + public List PluginViewModels => _pluginViewModels ??= App.API.GetAllPlugins() .OrderBy(plugin => plugin.Metadata.Disabled) .ThenBy(plugin => plugin.Metadata.Name) .Select(plugin => new PluginViewModel diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 045ff46cc9e..bc61efdcaf0 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -27,7 +27,7 @@ namespace Flow.Launcher.ViewModel { - public partial class MainViewModel : BaseModel, ISavable, IDisposable + public partial class MainViewModel : BaseModel, ISavable, IDisposable, IResultUpdateRegister { #region Private Fields @@ -274,52 +274,50 @@ void continueAction(Task t) } } - public void RegisterResultsUpdatedEvent() + public void RegisterResultsUpdatedEvent(PluginPair pair) { - foreach (var pair in PluginManager.GetResultUpdatePlugin()) + if (pair.Plugin is not IResultUpdated plugin) return; + + plugin.ResultsUpdated += (s, e) => { - var plugin = (IResultUpdated)pair.Plugin; - plugin.ResultsUpdated += (s, e) => + if (e.Query.RawQuery != QueryText || e.Token.IsCancellationRequested) { - if (e.Query.RawQuery != QueryText || e.Token.IsCancellationRequested) - { - return; - } + return; + } - var token = e.Token == default ? _updateToken : e.Token; + var token = e.Token == default ? _updateToken : e.Token; - IReadOnlyList resultsCopy; - if (e.Results == null) - { - resultsCopy = _emptyResult; - } - else - { - // make a clone to avoid possible issue that plugin will also change the list and items when updating view model - resultsCopy = DeepCloneResults(e.Results, false, token); - } + IReadOnlyList resultsCopy; + if (e.Results == null) + { + resultsCopy = _emptyResult; + } + else + { + // make a clone to avoid possible issue that plugin will also change the list and items when updating view model + resultsCopy = DeepCloneResults(e.Results, false, token); + } - foreach (var result in resultsCopy) + foreach (var result in resultsCopy) + { + if (string.IsNullOrEmpty(result.BadgeIcoPath)) { - if (string.IsNullOrEmpty(result.BadgeIcoPath)) - { - result.BadgeIcoPath = pair.Metadata.IcoPath; - } + result.BadgeIcoPath = pair.Metadata.IcoPath; } + } - PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query); + PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query); - if (token.IsCancellationRequested) return; + if (token.IsCancellationRequested) return; - App.API.LogDebug(ClassName, $"Update results for plugin <{pair.Metadata.Name}>"); + App.API.LogDebug(ClassName, $"Update results for plugin <{pair.Metadata.Name}>"); - if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, - token))) - { - App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); - } - }; - } + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, + token))) + { + App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); + } + }; } private async Task RegisterClockAndDateUpdateAsync() @@ -439,7 +437,7 @@ private void LoadContextMenu() [RelayCommand] private void Backspace(object index) { - var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.NonGlobalPlugins); + var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.GetNonGlobalPlugins()); // GetPreviousExistingDirectory does not require trailing '\', otherwise will return empty string var path = FilesFolders.GetPreviousExistingDirectory((_) => true, query.Search.TrimEnd('\\')); @@ -1328,7 +1326,10 @@ private static List GetHistoryItems(IEnumerable historyItem private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true) { - _updateSource?.Cancel(); + if (_updateSource != null) + { + await _updateSource.CancelAsync(); + } App.API.LogDebug(ClassName, $"Start query with text: <{QueryText}>"); @@ -1574,7 +1575,7 @@ private async Task ConstructQueryAsync(string queryText, IEnumerable ConstructQueryAsync(string queryText, IEnumerable builtInShortcuts, @@ -1907,7 +1908,10 @@ public async Task SetupDialogJumpAsync(nint handle) if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog) { // Cancel the previous Dialog Jump task - _dialogJumpSource?.Cancel(); + if (_dialogJumpSource != null) + { + await _dialogJumpSource.CancelAsync(); + } // Create a new cancellation token source _dialogJumpSource = new CancellationTokenSource(); diff --git a/Flow.Launcher/ViewModel/PluginViewModel.cs b/Flow.Launcher/ViewModel/PluginViewModel.cs index ea222d02374..acd3902abaf 100644 --- a/Flow.Launcher/ViewModel/PluginViewModel.cs +++ b/Flow.Launcher/ViewModel/PluginViewModel.cs @@ -120,8 +120,12 @@ public double PluginSearchDelayTime private Control _bottomPart2; public Control BottomPart2 => IsExpanded ? _bottomPart2 ??= new InstalledPluginDisplayBottomData() : null; - public bool HasSettingControl => PluginPair.Plugin is ISettingProvider && - (PluginPair.Plugin is not JsonRPCPluginBase jsonRPCPluginBase || jsonRPCPluginBase.NeedCreateSettingPanel()); + public bool HasSettingControl => + !PluginManager.IsInitializingOrInitFailed(PluginPair.Metadata.ID) && // Do not show setting panel for initializing or init failed plugins + PluginPair.Plugin is ISettingProvider && + (PluginPair.Plugin is not JsonRPCPluginBase jsonRPCPluginBase || // Is not JsonRPC plugin + jsonRPCPluginBase.NeedCreateSettingPanel()); // Is JsonRPC plugin and need to create setting panel + public Control SettingControl => IsExpanded ? _settingControl