From 39b2869cae7a1ba7ce1402b2be52825b91859607 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 16:33:39 +0800 Subject: [PATCH 01/20] Deprecate ActionKeywordRegistered, update API docs Mark ActionKeywordRegistered as obsolete and always return false, reflecting support for multiple plugins per action keyword. Update IPublicAPI docs to clarify ActionKeywordAssigned is for legacy compatibility. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 8 ++++---- Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index b808e2a7fbd..70a9c9c0fec 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -721,12 +721,12 @@ public static bool IsInitializationFailed(string id) #region Plugin Action Keyword + [Obsolete("This method is only used for old Flow compatibility.")] 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); + // Since now we support to assign one action keyword to multiple plugins, + // this check is unnecessary, so we will just return false here to ensure compatibility for old plugins. + return false; } /// diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 93844159f75..b15aa844e5e 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -293,6 +293,10 @@ public interface IPublicAPI /// /// The actionkeyword for checking /// True if the actionkeyword is already assigned, False otherwise + /// + /// Flow now supports to one action keyword to multiple plugins, + /// so this method is only used for old Flow compatibility. + /// bool ActionKeywordAssigned(string actionKeyword); /// From f05d31a1c134ce5291c5503b98064803b35d86b6 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 16:43:55 +0800 Subject: [PATCH 02/20] Add ActionKeywordAssigned method and new using directives Expanded using directives for .NET collections and diagnostics. Added ActionKeywordAssigned to PublicAPIInstance, using obsolete PluginManager.ActionKeywordRegistered with warning suppression. --- Flow.Launcher/PublicAPIInstance.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index 55737151af3..a1b9cd6145c 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Specialized; @@ -267,7 +267,9 @@ public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, A public void AddActionKeyword(string pluginId, string newActionKeyword) => PluginManager.AddActionKeyword(pluginId, newActionKeyword); +#pragma warning disable CS0618 // Type or member is obsolete public bool ActionKeywordAssigned(string actionKeyword) => PluginManager.ActionKeywordRegistered(actionKeyword); +#pragma warning restore CS0618 // Type or member is obsolete public void RemoveActionKeyword(string pluginId, string oldActionKeyword) => PluginManager.RemoveActionKeyword(pluginId, oldActionKeyword); From b8bc7fa82db6efb89c0865a9cb6809b4982fdd8f Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 16:54:03 +0800 Subject: [PATCH 03/20] Support multiple plugins per action keyword Refactor non-global plugin storage to allow multiple plugins to share the same action keyword by using ConcurrentDictionary>. Update all relevant methods to handle lists of plugins, ensure thread safety, and adjust the QueryBuilder logic accordingly. This change improves extensibility and flexibility in plugin management. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 65 +++++++++++++++++----- Flow.Launcher.Core/Plugin/QueryBuilder.cs | 6 +- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 70a9c9c0fec..e837843f43d 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -29,7 +29,7 @@ public static class PluginManager private static readonly ConcurrentDictionary _allInitializedPlugins = []; private static readonly ConcurrentDictionary _initFailedPlugins = []; private static readonly ConcurrentDictionary _globalPlugins = []; - private static readonly ConcurrentDictionary _nonGlobalPlugins = []; + private static readonly ConcurrentDictionary> _nonGlobalPlugins = []; private static PluginsSettings Settings; private static readonly ConcurrentBag ModifiedPlugins = []; @@ -332,7 +332,19 @@ private static void RegisterPluginActionKeywords(PluginPair pair) _globalPlugins.TryAdd(pair.Metadata.ID, pair); break; default: - _nonGlobalPlugins.TryAdd(actionKeyword, pair); + _nonGlobalPlugins.AddOrUpdate(actionKeyword, + _ => [pair], + (_, existing) => + { + lock (existing) + { + if (!existing.Contains(pair)) + { + existing.Add(pair); + } + } + return existing; + }); break; } } @@ -368,7 +380,7 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia if (query is null) return Array.Empty(); - if (!_nonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) + if (!_nonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugins)) { if (dialogJump) return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))]; @@ -376,13 +388,11 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia return [.. GetGlobalPlugins().Where(p => !PluginModified(p.Metadata.ID))]; } - if (dialogJump && plugin.Plugin is not IAsyncDialogJump) - return Array.Empty(); - - if (PluginModified(plugin.Metadata.ID)) - return Array.Empty(); + var validPlugins = plugins.Where(p => !PluginModified(p.Metadata.ID)); + if (dialogJump) + validPlugins = validPlugins.Where(p => p.Plugin is IAsyncDialogJump); - return [plugin]; + return [.. validPlugins]; } public static ICollection ValidPluginsForHomeQuery() @@ -576,7 +586,7 @@ private static List GetGlobalPlugins() return [.. _globalPlugins.Values]; } - public static Dictionary GetNonGlobalPlugins() + public static Dictionary> GetNonGlobalPlugins() { return _nonGlobalPlugins.ToDictionary(); } @@ -736,13 +746,27 @@ public static bool ActionKeywordRegistered(string actionKeyword) public static void AddActionKeyword(string id, string newActionKeyword) { var plugin = GetPluginForId(id); + if (plugin == null) return; + if (newActionKeyword == Query.GlobalPluginWildcardSign) { _globalPlugins.TryAdd(id, plugin); } else { - _nonGlobalPlugins.AddOrUpdate(newActionKeyword, plugin, (key, oldValue) => plugin); + _nonGlobalPlugins.AddOrUpdate(newActionKeyword, + _ => [plugin], + (_, existing) => + { + lock (existing) + { + if (!existing.Contains(plugin)) + { + existing.Add(plugin); + } + } + return existing; + }); } // Update action keywords and action keyword in plugin metadata @@ -764,6 +788,8 @@ public static void AddActionKeyword(string id, string newActionKeyword) public static void RemoveActionKeyword(string id, string oldActionkeyword) { var plugin = GetPluginForId(id); + if (plugin == null) return; + if (oldActionkeyword == Query.GlobalPluginWildcardSign && // Plugins may have multiple ActionKeywords that are global, eg. WebSearch plugin.Metadata.ActionKeywords @@ -774,7 +800,13 @@ public static void RemoveActionKeyword(string id, string oldActionkeyword) if (oldActionkeyword != Query.GlobalPluginWildcardSign) { - _nonGlobalPlugins.TryRemove(oldActionkeyword, out _); + if (_nonGlobalPlugins.TryGetValue(oldActionkeyword, out var plugins)) + { + lock (plugins) + { + plugins.RemoveAll(p => p.Metadata.ID == id); + } + } } // Update action keywords and action keyword in plugin metadata @@ -1032,10 +1064,13 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo { _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) + var entriesToUpdate = _nonGlobalPlugins.ToList(); + foreach (var entry in entriesToUpdate) { - _nonGlobalPlugins.TryRemove(key, out var _); + lock (entry.Value) + { + entry.Value.RemoveAll(p => p.Metadata.ID == plugin.ID); + } } } diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index aac620cce64..3b38a56b75d 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Plugin { public static class QueryBuilder { - public static Query Build(string originalQuery, string trimmedQuery, Dictionary nonGlobalPlugins) + public static Query Build(string originalQuery, string trimmedQuery, Dictionary> nonGlobalPlugins) { // home query if (string.IsNullOrEmpty(trimmedQuery)) @@ -34,7 +35,8 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< string possibleActionKeyword = terms[0]; string[] searchTerms; - if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled) + if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPairs) + && pluginPairs.Any(plugin => !plugin.Metadata.Disabled)) { // use non global plugin for query actionKeyword = possibleActionKeyword; From 8760fe29eeded1c43d6e016ae531ea1443903954 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:05:12 +0800 Subject: [PATCH 04/20] Return deep copy in GetNonGlobalPlugins to protect state GetNonGlobalPlugins now returns a new dictionary with copied lists, preventing external modification of the internal _nonGlobalPlugins collection. This change improves encapsulation and safeguards plugin manager state integrity. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index e837843f43d..ddc3a2d526e 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -588,7 +588,12 @@ private static List GetGlobalPlugins() public static Dictionary> GetNonGlobalPlugins() { - return _nonGlobalPlugins.ToDictionary(); + var nonGlobalPlugins = new Dictionary>(); + foreach (var kvp in _nonGlobalPlugins) + { + nonGlobalPlugins.Add(kvp.Key, [.. kvp.Value]); + } + return nonGlobalPlugins; } public static List GetTranslationPlugins() From 452e60f3b5dbdc0597d78df6ae8adf95d448188e Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:07:09 +0800 Subject: [PATCH 05/20] Use GetNonGlobalPlugins() instead of field access Replaced direct _nonGlobalPlugins field access with the GetNonGlobalPlugins() method to improve encapsulation and ensure up-to-date plugin data is used when retrieving non-global plugins by action keyword. No other logic was changed. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index ddc3a2d526e..13308c23363 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -380,7 +380,7 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia if (query is null) return Array.Empty(); - if (!_nonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugins)) + if (!GetNonGlobalPlugins().TryGetValue(query.ActionKeyword, out var plugins)) { if (dialogJump) return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))]; From 3b771d155da131e856112410448c96c869c48c99 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:07:41 +0800 Subject: [PATCH 06/20] Add locking for thread safety in GetNonGlobalPlugins Wrap kvp.Value access in a lock when copying to nonGlobalPlugins to prevent race conditions and ensure thread safety during concurrent access. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 13308c23363..0223affeba7 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -591,7 +591,10 @@ public static Dictionary> GetNonGlobalPlugins() var nonGlobalPlugins = new Dictionary>(); foreach (var kvp in _nonGlobalPlugins) { - nonGlobalPlugins.Add(kvp.Key, [.. kvp.Value]); + lock (kvp.Value) + { + nonGlobalPlugins.Add(kvp.Key, [.. kvp.Value]); + } } return nonGlobalPlugins; } From 1294d722f2b33880d4488e99caaf017cf2bc8738 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:12:46 +0800 Subject: [PATCH 07/20] Improve action keyword management in plugin metadata Prevent duplicate action keywords by checking for existence before adding. Remove all instances of an old action keyword instead of just the first. Ensures action keyword lists remain unique and consistent. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 0223affeba7..c7443ff4c55 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -778,7 +778,10 @@ public static void AddActionKeyword(string id, string newActionKeyword) } // Update action keywords and action keyword in plugin metadata - plugin.Metadata.ActionKeywords.Add(newActionKeyword); + if (!plugin.Metadata.ActionKeywords.Contains(newActionKeyword)) + { + plugin.Metadata.ActionKeywords.Add(newActionKeyword); + } if (plugin.Metadata.ActionKeywords.Count > 0) { plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; @@ -818,7 +821,7 @@ public static void RemoveActionKeyword(string id, string oldActionkeyword) } // Update action keywords and action keyword in plugin metadata - plugin.Metadata.ActionKeywords.Remove(oldActionkeyword); + plugin.Metadata.ActionKeywords.RemoveAll(k => k == oldActionkeyword); if (plugin.Metadata.ActionKeywords.Count > 0) { plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; From 43a74ba8e634fa0144d49d06bb6f51302bcadb75 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Thu, 26 Feb 2026 17:13:49 +0800 Subject: [PATCH 08/20] Fix code comments Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index b15aa844e5e..79be520a735 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -294,8 +294,8 @@ public interface IPublicAPI /// The actionkeyword for checking /// True if the actionkeyword is already assigned, False otherwise /// - /// Flow now supports to one action keyword to multiple plugins, - /// so this method is only used for old Flow compatibility. + /// Flow now supports assigning one action keyword to multiple plugins. + /// This method is kept only for legacy Flow compatibility. /// bool ActionKeywordAssigned(string actionKeyword); From 2529efeed11f59ec4c27ea211e02ca03a4bf1190 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:15:29 +0800 Subject: [PATCH 09/20] Filter out disabled plugins from valid plugin list Previously, the code only excluded modified plugins from the valid plugin list. This update adds an additional check to also exclude plugins marked as disabled in their metadata, ensuring that disabled plugins are not considered valid or processed further. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index c7443ff4c55..9bd57317987 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -388,7 +388,7 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia return [.. GetGlobalPlugins().Where(p => !PluginModified(p.Metadata.ID))]; } - var validPlugins = plugins.Where(p => !PluginModified(p.Metadata.ID)); + var validPlugins = plugins.Where(p => !p.Metadata.Disabled && !PluginModified(p.Metadata.ID)); if (dialogJump) validPlugins = validPlugins.Where(p => p.Plugin is IAsyncDialogJump); From 4537013cde8acd362431a2106ba1e92fe98536d1 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:19:36 +0800 Subject: [PATCH 10/20] Clean up empty plugin lists from _nonGlobalPlugins After removing plugins, also remove dictionary entries if their associated lists become empty. This prevents unused empty lists from accumulating and keeps the plugin manager's state clean. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 9bd57317987..a4fcb866eba 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -816,6 +816,11 @@ public static void RemoveActionKeyword(string id, string oldActionkeyword) lock (plugins) { plugins.RemoveAll(p => p.Metadata.ID == id); + + if (plugins.Count == 0) + { + _nonGlobalPlugins.TryRemove(new KeyValuePair>(oldActionkeyword, plugins)); + } } } } @@ -1081,6 +1086,11 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo lock (entry.Value) { entry.Value.RemoveAll(p => p.Metadata.ID == plugin.ID); + + if (entry.Value.Count == 0) + { + _nonGlobalPlugins.TryRemove(entry.Key, out var __); + } } } } From 17d675e5ce9dce524647c6fb49324b76080c30b4 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:22:00 +0800 Subject: [PATCH 11/20] Update QueryBuilder tests for new nonGlobalPlugins type Refactored QueryBuilderTest.cs to use Dictionary> for nonGlobalPlugins, updating test cases to use lists of PluginPair. Also replaced empty dictionary instantiation with shorthand [] where appropriate. --- Flow.Launcher.Test/QueryBuilderTest.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index 0ede781f8ab..0064f23e5c1 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -11,9 +11,9 @@ public class QueryBuilderTest [Test] public void ExclusivePluginQueryTest() { - var nonGlobalPlugins = new Dictionary + var nonGlobalPlugins = new Dictionary> { - {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List {">"}}}} + { ">", new List(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } } } }; Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); @@ -34,9 +34,9 @@ public void ExclusivePluginQueryTest() [Test] public void ExclusivePluginQueryIgnoreDisabledTest() { - var nonGlobalPlugins = new Dictionary + var nonGlobalPlugins = new Dictionary> { - {">", new PluginPair {Metadata = new PluginMetadata {ActionKeywords = new List {">"}, Disabled = true}}} + { ">", new List(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } } } }; Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); @@ -51,7 +51,7 @@ public void ExclusivePluginQueryIgnoreDisabledTest() [Test] public void GenericPluginQueryTest() { - Query q = QueryBuilder.Build("file.txt file2 file3", "file.txt file2 file3", new Dictionary()); + Query q = QueryBuilder.Build("file.txt file2 file3", "file.txt file2 file3", []); ClassicAssert.AreEqual("file.txt file2 file3", q.Search); ClassicAssert.AreEqual("", q.ActionKeyword); From 211eb8a747ea5b1ee13ca6764202ecc5cac78fa8 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:24:41 +0800 Subject: [PATCH 12/20] Refactor plugin enabled check with thread safety Extract plugin enabled check into a new CheckPlugin method, adding a lock for thread safety. Update the if statement to use this method instead of inline logic. --- Flow.Launcher.Core/Plugin/QueryBuilder.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 3b38a56b75d..69d75616e45 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -35,8 +35,7 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< string possibleActionKeyword = terms[0]; string[] searchTerms; - if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPairs) - && pluginPairs.Any(plugin => !plugin.Metadata.Disabled)) + if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPairs) && CheckPlugin(pluginPairs)) { // use non global plugin for query actionKeyword = possibleActionKeyword; @@ -61,5 +60,13 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< IsHomeQuery = false }; } + + private static bool CheckPlugin(List pluginPairs) + { + lock (pluginPairs) + { + return pluginPairs.Any(plugin => !plugin.Metadata.Disabled); + } + } } } From a90c787b4ad2eb3bb6a936e7c2a209beea536403 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Thu, 26 Feb 2026 17:37:05 +0800 Subject: [PATCH 13/20] Improve obsolete description Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 79be520a735..e67c844aa66 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -297,6 +297,7 @@ public interface IPublicAPI /// Flow now supports assigning one action keyword to multiple plugins. /// This method is kept only for legacy Flow compatibility. /// + [Obsolete("Flow now supports assigning one action keyword to multiple plugins. This method always returns false for compatibility.")] bool ActionKeywordAssigned(string actionKeyword); /// From e103d649ed3e820eb7807a632a3a8c935d4049ba Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Thu, 26 Feb 2026 17:37:15 +0800 Subject: [PATCH 14/20] Update test to mark exclusive plugin as disabled ExclusivePluginQueryIgnoreDisabledTest now sets the ">" plugin's Disabled property to true in PluginMetadata. This verifies that QueryBuilder correctly ignores disabled exclusive plugins. --- Flow.Launcher.Test/QueryBuilderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index 0064f23e5c1..d8e8e4973e7 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -36,7 +36,7 @@ public void ExclusivePluginQueryIgnoreDisabledTest() { var nonGlobalPlugins = new Dictionary> { - { ">", new List(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } } } + { ">", new List(){ new() { Metadata = new PluginMetadata { ActionKeywords = [">"], Disabled = true } } } } }; Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); From baa3a690a309e6694106776fa6cc217372e0dbc3 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Thu, 26 Feb 2026 17:38:21 +0800 Subject: [PATCH 15/20] Fix potential race condition in the uninstall logic Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Flow.Launcher.Core/Plugin/PluginManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index a4fcb866eba..199d4e7b154 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -1089,7 +1089,7 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo if (entry.Value.Count == 0) { - _nonGlobalPlugins.TryRemove(entry.Key, out var __); + _nonGlobalPlugins.TryRemove(new KeyValuePair>(entry.Key, entry.Value)); } } } From 88ac19af7646b16aa4d895acd7c2b47046ab64d4 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Sun, 1 Mar 2026 12:39:49 +0800 Subject: [PATCH 16/20] Refactor non-global plugin retrieval for performance optimization Refactored plugin lookup in ValidPluginsForQuery to use a new TryGetNonGlobalPlugins method, which safely copies plugin lists using a lock. This improves thread safety and performance when accessing non-global plugins. --- Flow.Launcher.Core/Plugin/PluginManager.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 199d4e7b154..e6b2bc2a2ca 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -380,7 +380,7 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia if (query is null) return Array.Empty(); - if (!GetNonGlobalPlugins().TryGetValue(query.ActionKeyword, out var plugins)) + if (TryGetNonGlobalPlugins(query.ActionKeyword, out var plugins)) { if (dialogJump) return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))]; @@ -395,6 +395,20 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia return [.. validPlugins]; } + private static bool TryGetNonGlobalPlugins(string actionKeyword, out List plugins) + { + if (_nonGlobalPlugins.TryGetValue(actionKeyword, out var list)) + { + lock (list) + { + plugins = [.. list]; + } + return true; + } + plugins = []; + return false; + } + public static ICollection ValidPluginsForHomeQuery() { return [.. _homePlugins.Where(p => !PluginModified(p.Metadata.ID))]; From f10f71661978cb7e0a4e503d57528748fa39bf11 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Sun, 1 Mar 2026 12:43:29 +0800 Subject: [PATCH 17/20] Fix logic inversion bug Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- Flow.Launcher.Core/Plugin/PluginManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index e6b2bc2a2ca..8a6ce0ee01b 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -380,7 +380,7 @@ public static ICollection ValidPluginsForQuery(Query query, bool dia if (query is null) return Array.Empty(); - if (TryGetNonGlobalPlugins(query.ActionKeyword, out var plugins)) + if (!TryGetNonGlobalPlugins(query.ActionKeyword, out var plugins)) { if (dialogJump) return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))]; From cec3489afada141f0a25595cf654bad5e75ebca7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:52:34 +0000 Subject: [PATCH 18/20] Fix Plugin Indicator to show all plugins per shared keyword and add shared keyword test --- Flow.Launcher.Test/QueryBuilderTest.cs | 21 +++++++++++++++++++ .../Main.cs | 17 ++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index d8e8e4973e7..3a708ea5690 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -48,6 +48,27 @@ public void ExclusivePluginQueryIgnoreDisabledTest() ClassicAssert.AreEqual("ping google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } + [Test] + public void SharedKeywordOneDisabledPluginQueryTest() + { + var nonGlobalPlugins = new Dictionary> + { + { ">", new List() + { + new() { Metadata = new PluginMetadata { ActionKeywords = [">"], Disabled = true } }, + new() { Metadata = new PluginMetadata { ActionKeywords = [">"] } } + } + } + }; + + Query q = QueryBuilder.Build("> ping google.com -n 20 -6", "> ping google.com -n 20 -6", nonGlobalPlugins); + + ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.TrimmedQuery); + ClassicAssert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); + ClassicAssert.AreEqual(">", q.ActionKeyword, "ActionKeyword should still match because an enabled plugin shares the keyword."); + ClassicAssert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); + } + [Test] public void GenericPluginQueryTest() { diff --git a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs index 503d82cc30a..271235599e2 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs @@ -24,7 +24,8 @@ private static List QueryResults(Query query = null) var results = from keyword in nonGlobalPlugins.Keys - let plugin = nonGlobalPlugins[keyword].Metadata + from pluginPair in nonGlobalPlugins[keyword] + let plugin = pluginPair.Metadata let keywordSearchResult = Context.API.FuzzySearch(querySearch, keyword) let searchResult = keywordSearchResult.IsSearchPrecisionScoreMet() ? keywordSearchResult : Context.API.FuzzySearch(querySearch, plugin.Name) let score = searchResult.Score @@ -47,9 +48,9 @@ from keyword in nonGlobalPlugins.Keys return [.. results]; } - private static Dictionary GetNonGlobalPlugins() + private static Dictionary> GetNonGlobalPlugins() { - var nonGlobalPlugins = new Dictionary(); + var nonGlobalPlugins = new Dictionary>(); foreach (var plugin in Context.API.GetAllPlugins()) { foreach (var actionKeyword in plugin.Metadata.ActionKeywords) @@ -57,10 +58,12 @@ private static Dictionary GetNonGlobalPlugins() // Skip global keywords if (actionKeyword == Plugin.Query.GlobalPluginWildcardSign) continue; - // Skip dulpicated keywords - if (nonGlobalPlugins.ContainsKey(actionKeyword)) continue; - - nonGlobalPlugins.Add(actionKeyword, plugin); + if (!nonGlobalPlugins.TryGetValue(actionKeyword, out var plugins)) + { + plugins = []; + nonGlobalPlugins[actionKeyword] = plugins; + } + plugins.Add(plugin); } } return nonGlobalPlugins; From a42ec6b540ce77fdb562cb3bc0d40f0c7a1236b7 Mon Sep 17 00:00:00 2001 From: David Brett Date: Wed, 10 Jun 2026 13:53:47 +0200 Subject: [PATCH 19/20] Rename CheckPlugin to HasAnyEnabledPlugin in QueryBuilder This makes it clearer what its actually checking --- Flow.Launcher.Core/Plugin/QueryBuilder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 69d75616e45..c9b8b134134 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -35,7 +35,7 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< string possibleActionKeyword = terms[0]; string[] searchTerms; - if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPairs) && CheckPlugin(pluginPairs)) + if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPairs) && HasAnyEnabledPlugin(pluginPairs)) { // use non global plugin for query actionKeyword = possibleActionKeyword; @@ -61,7 +61,7 @@ public static Query Build(string originalQuery, string trimmedQuery, Dictionary< }; } - private static bool CheckPlugin(List pluginPairs) + private static bool HasAnyEnabledPlugin(List pluginPairs) { lock (pluginPairs) { From 0865fa6a34a9b5cc5762f4b42f158a8f5520a675 Mon Sep 17 00:00:00 2001 From: David Brett Date: Wed, 10 Jun 2026 13:56:04 +0200 Subject: [PATCH 20/20] Improve variable names and comments in GetNonGlobalPlugins of Plugin Indicator --- .../Flow.Launcher.Plugin.PluginIndicator/Main.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs index 271235599e2..631d3daee49 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginIndicator/Main.cs @@ -58,12 +58,17 @@ private static Dictionary> GetNonGlobalPlugins() // Skip global keywords if (actionKeyword == Plugin.Query.GlobalPluginWildcardSign) continue; - if (!nonGlobalPlugins.TryGetValue(actionKeyword, out var plugins)) + // See if we already assigned plugins to this keyword + if (!nonGlobalPlugins.TryGetValue(actionKeyword, out var pluginsForKeyword)) { - plugins = []; - nonGlobalPlugins[actionKeyword] = plugins; + pluginsForKeyword = []; + nonGlobalPlugins[actionKeyword] = pluginsForKeyword; } - plugins.Add(plugin); + + // We allow the same keyword to have multiple different plugins and + // there is no need to check for the same plugin having the same keyword multiple times, + // as plugin manager and UI should prevent this - we can still display this state regardless + pluginsForKeyword.Add(plugin); } } return nonGlobalPlugins;