diff --git a/.github/ISSUE_TEMPLATE/discussion.md b/.github/ISSUE_TEMPLATE/discussion.md.not_used similarity index 100% rename from .github/ISSUE_TEMPLATE/discussion.md rename to .github/ISSUE_TEMPLATE/discussion.md.not_used diff --git a/Flow.Launcher.Core/Flow.Launcher.Core.csproj b/Flow.Launcher.Core/Flow.Launcher.Core.csproj index 2b2bef4cf..9e932a508 100644 --- a/Flow.Launcher.Core/Flow.Launcher.Core.csproj +++ b/Flow.Launcher.Core/Flow.Launcher.Core.csproj @@ -53,15 +53,12 @@ - + + - - - - diff --git a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs index b1c8ff6ea..5232e46da 100644 --- a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs +++ b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs @@ -1,5 +1,4 @@ - -/* We basically follow the Json-RPC 2.0 spec (http://www.jsonrpc.org/specification) to invoke methods between Flow Launcher and other plugins, +/* We basically follow the Json-RPC 2.0 spec (http://www.jsonrpc.org/specification) to invoke methods between Flow Launcher and other plugins, * like python or other self-execute program. But, we added addtional infos (proxy and so on) into rpc request. Also, we didn't use the * "id" and "jsonrpc" in the request, since it's not so useful in our request model. * @@ -13,10 +12,12 @@ * */ +using Flow.Launcher.Core.Resource; using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; using Flow.Launcher.Plugin; +using System.Text.Json; namespace Flow.Launcher.Core.Plugin { @@ -29,12 +30,8 @@ namespace Flow.Launcher.Core.Plugin public string Data { get; set; } } - public class JsonRPCModelBase - { - public int Id { get; set; } - } - public class JsonRPCResponseModel : JsonRPCModelBase + public class JsonRPCResponseModel { public string Result { get; set; } @@ -48,45 +45,20 @@ namespace Flow.Launcher.Core.Plugin public string DebugMessage { get; set; } } - - public class JsonRPCRequestModel : JsonRPCModelBase + + public class JsonRPCRequestModel { public string Method { get; set; } public object[] Parameters { get; set; } + private static readonly JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; public override string ToString() { - string rpc = string.Empty; - if (Parameters != null && Parameters.Length > 0) - { - string parameters = $"[{string.Join(',', Parameters.Select(GetParameterByType))}]"; - rpc = $@"{{\""method\"":\""{Method}\"",\""parameters\"":{parameters}"; - } - else - { - rpc = $@"{{\""method\"":\""{Method}\"",\""parameters\"":[]"; - } - - return rpc; - - } - - private string GetParameterByType(object parameter) - => parameter switch - { - null => "null", - string _ => $@"\""{ReplaceEscapes(parameter.ToString())}\""", - bool _ => $@"{parameter.ToString().ToLower()}", - _ => parameter.ToString() - }; - - - private string ReplaceEscapes(string str) - { - return str.Replace(@"\", @"\\") //Escapes in ProcessStartInfo - .Replace(@"\", @"\\") //Escapes itself when passed to client - .Replace(@"""", @"\\"""""); + return JsonSerializer.Serialize(this, options); } } @@ -95,11 +67,7 @@ namespace Flow.Launcher.Core.Plugin /// public class JsonRPCServerRequestModel : JsonRPCRequestModel { - public override string ToString() - { - string rpc = base.ToString(); - return rpc + "}"; - } + } /// @@ -108,12 +76,6 @@ namespace Flow.Launcher.Core.Plugin public class JsonRPCClientRequestModel : JsonRPCRequestModel { public bool DontHideAfterAction { get; set; } - - public override string ToString() - { - string rpc = base.ToString(); - return rpc + "}"; - } } /// @@ -125,4 +87,4 @@ namespace Flow.Launcher.Core.Plugin { public JsonRPCClientRequestModel JsonRPCAction { get; set; } } -} +} \ No newline at end of file diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs index 7a088bd09..0df853a5d 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs @@ -1,7 +1,9 @@ -using System; +using Flow.Launcher.Core.Resource; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Reflection; using System.Text.Json; using System.Threading; @@ -9,7 +11,9 @@ using System.Threading.Tasks; using System.Windows.Forms; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Plugin; +using ICSharpCode.SharpZipLib.Zip; using JetBrains.Annotations; +using Microsoft.IO; namespace Flow.Launcher.Core.Plugin { @@ -31,28 +35,30 @@ namespace Flow.Launcher.Core.Plugin protected abstract string ExecuteCallback(JsonRPCRequestModel rpcRequest); protected abstract string ExecuteContextMenu(Result selectedResult); + private static readonly RecyclableMemoryStreamManager BufferManager = new(); + public List LoadContextMenus(Result selectedResult) { - string output = ExecuteContextMenu(selectedResult); - try - { - return DeserializedResult(output); - } - catch (Exception e) - { - Log.Exception($"|JsonRPCPlugin.LoadContextMenus|Exception on result <{selectedResult}>", e); - return null; - } + var output = ExecuteContextMenu(selectedResult); + return DeserializedResult(output); } - + private static readonly JsonSerializerOptions options = new() + { + PropertyNameCaseInsensitive = true, + IgnoreNullValues = true, + Converters = + { + new JsonObjectConverter() + } + }; private async Task> DeserializedResultAsync(Stream output) { if (output == Stream.Null) return null; - JsonRPCQueryResponseModel queryResponseModel = await - JsonSerializer.DeserializeAsync(output); + var queryResponseModel = + await JsonSerializer.DeserializeAsync(output, options); return ParseResults(queryResponseModel); } @@ -61,60 +67,71 @@ namespace Flow.Launcher.Core.Plugin { if (string.IsNullOrEmpty(output)) return null; - JsonRPCQueryResponseModel queryResponseModel = - JsonSerializer.Deserialize(output); + var queryResponseModel = + JsonSerializer.Deserialize(output, options); return ParseResults(queryResponseModel); } + private List ParseResults(JsonRPCQueryResponseModel queryResponseModel) { - var results = new List(); if (queryResponseModel.Result == null) return null; - if(!string.IsNullOrEmpty(queryResponseModel.DebugMessage)) + if (!string.IsNullOrEmpty(queryResponseModel.DebugMessage)) { context.API.ShowMsg(queryResponseModel.DebugMessage); } - foreach (JsonRPCResult result in queryResponseModel.Result) + foreach (var result in queryResponseModel.Result) { result.Action = c => { if (result.JsonRPCAction == null) return false; - if (!string.IsNullOrEmpty(result.JsonRPCAction.Method)) + if (string.IsNullOrEmpty(result.JsonRPCAction.Method)) { - if (result.JsonRPCAction.Method.StartsWith("Flow.Launcher.")) + return !result.JsonRPCAction.DontHideAfterAction; + } + + if (result.JsonRPCAction.Method.StartsWith("Flow.Launcher.")) + { + ExecuteFlowLauncherAPI(result.JsonRPCAction.Method["Flow.Launcher.".Length..], + result.JsonRPCAction.Parameters); + } + else + { + var actionResponse = ExecuteCallback(result.JsonRPCAction); + + if (string.IsNullOrEmpty(actionResponse)) { - ExecuteFlowLauncherAPI(result.JsonRPCAction.Method.Substring(4), - result.JsonRPCAction.Parameters); + return !result.JsonRPCAction.DontHideAfterAction; } - else + + var jsonRpcRequestModel = + JsonSerializer.Deserialize(actionResponse, options); + + if (jsonRpcRequestModel?.Method?.StartsWith("Flow.Launcher.") ?? false) { - string actionReponse = ExecuteCallback(result.JsonRPCAction); - JsonRPCRequestModel jsonRpcRequestModel = - JsonSerializer.Deserialize(actionReponse); - if (jsonRpcRequestModel != null - && !string.IsNullOrEmpty(jsonRpcRequestModel.Method) - && jsonRpcRequestModel.Method.StartsWith("Flow.Launcher.")) - { - ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method.Substring(4), - jsonRpcRequestModel.Parameters); - } + ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method["Flow.Launcher.".Length..], + jsonRpcRequestModel.Parameters); } } return !result.JsonRPCAction.DontHideAfterAction; }; - results.Add(result); } + var results = new List(); + + results.AddRange(queryResponseModel.Result); + return results; } private void ExecuteFlowLauncherAPI(string method, object[] parameters) { - MethodInfo methodInfo = PluginManager.API.GetType().GetMethod(method); + var parametersTypeArray = parameters.Select(param => param.GetType()).ToArray(); + MethodInfo methodInfo = PluginManager.API.GetType().GetMethod(method, parametersTypeArray); if (methodInfo != null) { try @@ -177,12 +194,14 @@ namespace Flow.Launcher.Core.Plugin if (result.StartsWith("DEBUG:")) { - MessageBox.Show(new Form { TopMost = true }, result.Substring(6)); + MessageBox.Show(new Form + { + TopMost = true + }, result.Substring(6)); return string.Empty; } return result; - } catch (Exception e) { @@ -195,19 +214,53 @@ namespace Flow.Launcher.Core.Plugin protected async Task ExecuteAsync(ProcessStartInfo startInfo, CancellationToken token = default) { + Process process = null; + bool disposed = false; try { - using var process = Process.Start(startInfo); + process = Process.Start(startInfo); if (process == null) { Log.Error("|JsonRPCPlugin.ExecuteAsync|Can't start new process"); return Stream.Null; } - var result = process.StandardOutput.BaseStream; + await using var source = process.StandardOutput.BaseStream; + + var buffer = BufferManager.GetStream(); + + token.Register(() => + { + // ReSharper disable once AccessToModifiedClosure + // Manually Check whether disposed + if (!disposed && !process.HasExited) + process.Kill(); + }); + + try + { + // token expire won't instantly trigger the exception, + // manually kill process at before + await source.CopyToAsync(buffer, token); + } + catch (OperationCanceledException) + { + await buffer.DisposeAsync(); + return Stream.Null; + } + + buffer.Seek(0, SeekOrigin.Begin); token.ThrowIfCancellationRequested(); + if (buffer.Length == 0) + { + var errorMessage = process.StandardError.EndOfStream ? + "Empty JSONRPC Response" : + await process.StandardError.ReadToEndAsync(); + throw new InvalidDataException($"{context.CurrentPluginMetadata.Name}|{errorMessage}"); + } + if (!process.StandardError.EndOfStream) { using var standardError = process.StandardError; @@ -215,40 +268,26 @@ namespace Flow.Launcher.Core.Plugin if (!string.IsNullOrEmpty(error)) { - Log.Error($"|JsonRPCPlugin.ExecuteAsync|{error}"); - return Stream.Null; + Log.Error($"|{context.CurrentPluginMetadata.Name}.{nameof(ExecuteAsync)}|{error}"); } - - Log.Error("|JsonRPCPlugin.ExecuteAsync|Empty standard output and standard error."); - return Stream.Null; } - return result; + return buffer; } - catch (Exception e) + finally { - Log.Exception( - $"|JsonRPCPlugin.ExecuteAsync|Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>", - e); - return Stream.Null; + process?.Dispose(); + disposed = true; } } public async Task> QueryAsync(Query query, CancellationToken token) { var output = await ExecuteQueryAsync(query, token); - try - { - return await DeserializedResultAsync(output); - } - catch (Exception e) - { - Log.Exception($"|JsonRPCPlugin.Query|Exception when query <{query}>", e); - return null; - } + return await DeserializedResultAsync(output); } - public Task InitAsync(PluginInitContext context) + public virtual Task InitAsync(PluginInitContext context) { this.context = context; return Task.CompletedTask; diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index e2c009ee3..134c3c002 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -7,9 +7,9 @@ using System.Threading; using System.Threading.Tasks; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; +using ISavable = Flow.Launcher.Plugin.ISavable; namespace Flow.Launcher.Core.Plugin { @@ -21,8 +21,8 @@ namespace Flow.Launcher.Core.Plugin private static IEnumerable _contextMenuPlugins; public static List AllPlugins { get; private set; } - public static readonly List GlobalPlugins = new List(); - public static readonly Dictionary NonGlobalPlugins = new Dictionary(); + public static readonly HashSet GlobalPlugins = new(); + public static readonly Dictionary NonGlobalPlugins = new(); public static IPublicAPI API { private set; get; } @@ -33,7 +33,10 @@ namespace Flow.Launcher.Core.Plugin /// /// Directories that will hold Flow Launcher plugin directory /// - private static readonly string[] Directories = { Constant.PreinstalledDirectory, DataLocation.PluginsDirectory }; + private static readonly string[] Directories = + { + Constant.PreinstalledDirectory, DataLocation.PluginsDirectory + }; private static void DeletePythonBinding() { @@ -51,9 +54,26 @@ namespace Flow.Launcher.Core.Plugin var savable = plugin.Plugin as ISavable; savable?.Save(); } + API.SavePluginSettings(); } + public static async ValueTask DisposePluginsAsync() + { + foreach (var pluginPair in AllPlugins) + { + switch (pluginPair.Plugin) + { + case IDisposable disposable: + disposable.Dispose(); + break; + case IAsyncDisposable asyncDisposable: + await asyncDisposable.DisposeAsync(); + break; + } + } + } + public static async Task ReloadData() { await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch @@ -99,7 +119,7 @@ namespace Flow.Launcher.Core.Plugin try { var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.InitializePlugins|Init method time cost for <{pair.Metadata.Name}>", - () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API))); + () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API))); pair.Metadata.InitTime += milliseconds; Log.Info( @@ -118,7 +138,9 @@ namespace Flow.Launcher.Core.Plugin _contextMenuPlugins = GetPluginsForInterface(); foreach (var plugin in AllPlugins) { - foreach (var actionKeyword in plugin.Metadata.ActionKeywords) + // 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) { @@ -141,12 +163,15 @@ namespace Flow.Launcher.Core.Plugin } } - public static List ValidPluginsForQuery(Query query) + public static ICollection ValidPluginsForQuery(Query query) { if (NonGlobalPlugins.ContainsKey(query.ActionKeyword)) { var plugin = NonGlobalPlugins[query.ActionKeyword]; - return new List { plugin }; + return new List + { + plugin + }; } else { @@ -154,7 +179,7 @@ namespace Flow.Launcher.Core.Plugin } } - public static async Task> QueryForPlugin(PluginPair pair, Query query, CancellationToken token) + public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); try @@ -168,7 +193,7 @@ namespace Flow.Launcher.Core.Plugin token.ThrowIfCancellationRequested(); if (results == null) - return results; + return null; UpdatePluginMetadata(results, metadata, query); metadata.QueryCount += 1; @@ -181,10 +206,6 @@ namespace Flow.Launcher.Core.Plugin // 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) - { - Log.Exception($"|PluginManager.QueryForPlugin|Exception for plugin <{pair.Metadata.Name}> when query <{query}>", e); - } return results; } @@ -229,7 +250,7 @@ namespace Flow.Launcher.Core.Plugin try { - results = plugin.LoadContextMenus(result); + results = plugin.LoadContextMenus(result) ?? results; foreach (var r in results) { r.PluginDirectory = pluginPair.Metadata.PluginDirectory; @@ -250,6 +271,8 @@ namespace Flow.Launcher.Core.Plugin 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); } @@ -274,7 +297,7 @@ namespace Flow.Launcher.Core.Plugin } /// - /// used to add action keyword for multiple action keyword plugin + /// used to remove action keyword for multiple action keyword plugin /// e.g. web search /// public static void RemoveActionKeyword(string id, string oldActionkeyword) @@ -283,9 +306,7 @@ namespace Flow.Launcher.Core.Plugin if (oldActionkeyword == Query.GlobalPluginWildcardSign && // Plugins may have multiple ActionKeywords that are global, eg. WebSearch plugin.Metadata.ActionKeywords - .Where(x => x == Query.GlobalPluginWildcardSign) - .ToList() - .Count == 1) + .Count(x => x == Query.GlobalPluginWildcardSign) == 1) { GlobalPlugins.Remove(plugin); } @@ -306,4 +327,4 @@ namespace Flow.Launcher.Core.Plugin } } } -} +} \ No newline at end of file diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 92aca8109..1b78c68ae 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -11,13 +11,13 @@ using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; +using System.Diagnostics; +using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch; namespace Flow.Launcher.Core.Plugin { public static class PluginsLoader { - public const string PATH = "PATH"; - public const string Python = "python"; public const string PythonExecutable = "pythonw.exe"; public static List Plugins(List metadatas, PluginsSettings settings) @@ -85,11 +85,7 @@ namespace Flow.Launcher.Core.Plugin return; } - plugins.Add(new PluginPair - { - Plugin = plugin, - Metadata = metadata - }); + plugins.Add(new PluginPair {Plugin = plugin, Metadata = metadata}); }); metadata.InitTime += milliseconds; } @@ -119,102 +115,68 @@ namespace Flow.Launcher.Core.Plugin if (!source.Any(o => o.Language.ToUpper() == AllowedLanguage.Python)) return new List(); - // Try setting Constant.PythonPath first, either from - // PATH or from the given pythonDirectory - if (string.IsNullOrEmpty(settings.PythonDirectory)) - { - var paths = Environment.GetEnvironmentVariable(PATH); - if (paths != null) - { - var pythonInPath = paths - .Split(';') - .Where(p => p.ToLower().Contains(Python)) - .Any(); + if (!string.IsNullOrEmpty(settings.PythonDirectory) && FilesFolders.LocationExists(settings.PythonDirectory)) + return SetPythonPathForPluginPairs(source, Path.Combine(settings.PythonDirectory, PythonExecutable)); - if (pythonInPath) + var pythonPath = string.Empty; + + if (MessageBox.Show("Flow detected you have installed Python plugins, " + + "would you like to install Python to run them? " + + Environment.NewLine + Environment.NewLine + + "Click no if it's already installed, " + + "and you will be prompted to select the folder that contains the Python executable", + string.Empty, MessageBoxButtons.YesNo) == DialogResult.No + && string.IsNullOrEmpty(settings.PythonDirectory)) + { + var dlg = new FolderBrowserDialog + { + SelectedPath = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + }; + + var result = dlg.ShowDialog(); + if (result == DialogResult.OK) + { + string pythonDirectory = dlg.SelectedPath; + if (!string.IsNullOrEmpty(pythonDirectory)) { - Constant.PythonPath = - Path.Combine(paths.Split(';').Where(p => p.ToLower().Contains(Python)).FirstOrDefault(), PythonExecutable); - settings.PythonDirectory = FilesFolders.GetPreviousExistingDirectory(FilesFolders.LocationExists, Constant.PythonPath); - } - else - { - Log.Error("PluginsLoader", "Failed to set Python path despite the environment variable PATH is found", "PythonPlugins"); + pythonPath = Path.Combine(pythonDirectory, PythonExecutable); + if (File.Exists(pythonPath)) + { + settings.PythonDirectory = pythonDirectory; + Constant.PythonPath = pythonPath; + } + else + { + MessageBox.Show("Can't find python in given directory"); + } } } } else { - var path = Path.Combine(settings.PythonDirectory, PythonExecutable); - if (File.Exists(path)) + var installedPythonDirectory = Path.Combine(DataLocation.DataDirectory(), "PythonEmbeddable"); + + // Python 3.8.9 is used for Windows 7 compatibility + DroplexPackage.Drop(App.python_3_8_9_embeddable, installedPythonDirectory).Wait(); + + pythonPath = Path.Combine(installedPythonDirectory, PythonExecutable); + if (FilesFolders.FileExists(pythonPath)) { - Constant.PythonPath = path; + settings.PythonDirectory = installedPythonDirectory; + Constant.PythonPath = pythonPath; } else { - Log.Error("PluginsLoader", $"Tried to automatically set from Settings.PythonDirectory " + - $"but can't find python executable in {path}", "PythonPlugins"); + Log.Error("PluginsLoader", + $"Failed to set Python path after Droplex install, {pythonPath} does not exist", + "PythonPlugins"); } } - if (string.IsNullOrEmpty(settings.PythonDirectory)) + if (string.IsNullOrEmpty(settings.PythonDirectory) || string.IsNullOrEmpty(pythonPath)) { - if (MessageBox.Show("Flow detected you have installed Python plugins, " + - "would you like to install Python to run them? " + - Environment.NewLine + Environment.NewLine + - "Click no if it's already installed, " + - "and you will be prompted to select the folder that contains the Python executable", - string.Empty, MessageBoxButtons.YesNo) == DialogResult.No - && string.IsNullOrEmpty(settings.PythonDirectory)) - { - var dlg = new FolderBrowserDialog - { - SelectedPath = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) - }; - - var result = dlg.ShowDialog(); - if (result == DialogResult.OK) - { - string pythonDirectory = dlg.SelectedPath; - if (!string.IsNullOrEmpty(pythonDirectory)) - { - var pythonPath = Path.Combine(pythonDirectory, PythonExecutable); - if (File.Exists(pythonPath)) - { - settings.PythonDirectory = pythonDirectory; - Constant.PythonPath = pythonPath; - } - else - { - MessageBox.Show("Can't find python in given directory"); - } - } - } - } - else - { - DroplexPackage.Drop(App.python3_9_1).Wait(); - - var installedPythonDirectory = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39"); - var pythonPath = Path.Combine(installedPythonDirectory, PythonExecutable); - if (FilesFolders.FileExists(pythonPath)) - { - settings.PythonDirectory = installedPythonDirectory; - Constant.PythonPath = pythonPath; - } - else - { - Log.Error("PluginsLoader", - $"Failed to set Python path after Droplex install, {pythonPath} does not exist", - "PythonPlugins"); - } - } - } - - if (string.IsNullOrEmpty(settings.PythonDirectory)) - { - MessageBox.Show("Unable to set Python executable path, please try from Flow's settings (scroll down to the bottom)."); + MessageBox.Show( + "Unable to set Python executable path, please try from Flow's settings (scroll down to the bottom)."); Log.Error("PluginsLoader", $"Not able to successfully set Python path, the PythonDirectory variable is still an empty string.", "PythonPlugins"); @@ -222,24 +184,26 @@ namespace Flow.Launcher.Core.Plugin return new List(); } - return source + return SetPythonPathForPluginPairs(source, pythonPath); + } + + private static IEnumerable SetPythonPathForPluginPairs(List source, string pythonPath) + => source .Where(o => o.Language.ToUpper() == AllowedLanguage.Python) .Select(metadata => new PluginPair { - Plugin = new PythonPlugin(Constant.PythonPath), + Plugin = new PythonPlugin(pythonPath), Metadata = metadata }) .ToList(); - } - public static IEnumerable ExecutablePlugins(IEnumerable source) + public static IEnumerable ExecutablePlugins(IEnumerable source) { return source .Where(o => o.Language.ToUpper() == AllowedLanguage.Executable) .Select(metadata => new PluginPair { - Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), - Metadata = metadata + Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), Metadata = metadata }); } } diff --git a/Flow.Launcher.Core/Plugin/PythonPlugin.cs b/Flow.Launcher.Core/Plugin/PythonPlugin.cs index 356a68a81..5d2d8d51d 100644 --- a/Flow.Launcher.Core/Plugin/PythonPlugin.cs +++ b/Flow.Launcher.Core/Plugin/PythonPlugin.cs @@ -28,17 +28,19 @@ namespace Flow.Launcher.Core.Plugin var path = Path.Combine(Constant.ProgramDirectory, JsonRPC); _startInfo.EnvironmentVariables["PYTHONPATH"] = path; + //Add -B flag to tell python don't write .py[co] files. Because .pyc contains location infos which will prevent python portable + _startInfo.ArgumentList.Add("-B"); } protected override Task ExecuteQueryAsync(Query query, CancellationToken token) { JsonRPCServerRequestModel request = new JsonRPCServerRequestModel { - Method = "query", - Parameters = new object[] { query.Search }, + Method = "query", Parameters = new object[] {query.Search}, }; - //Add -B flag to tell python don't write .py[co] files. Because .pyc contains location infos which will prevent python portable - _startInfo.Arguments = $"-B \"{context.CurrentPluginMetadata.ExecuteFilePath}\" \"{request}\""; + + _startInfo.ArgumentList[2] = request.ToString(); + // todo happlebao why context can't be used in constructor _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; @@ -47,22 +49,31 @@ namespace Flow.Launcher.Core.Plugin protected override string ExecuteCallback(JsonRPCRequestModel rpcRequest) { - _startInfo.Arguments = $"-B \"{context.CurrentPluginMetadata.ExecuteFilePath}\" \"{rpcRequest}\""; + _startInfo.ArgumentList[2] = rpcRequest.ToString(); _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; // TODO: Async Action return Execute(_startInfo); } - protected override string ExecuteContextMenu(Result selectedResult) { - JsonRPCServerRequestModel request = new JsonRPCServerRequestModel { - Method = "context_menu", - Parameters = new object[] { selectedResult.ContextData }, + protected override string ExecuteContextMenu(Result selectedResult) + { + JsonRPCServerRequestModel request = new JsonRPCServerRequestModel + { + Method = "context_menu", Parameters = new object[] {selectedResult.ContextData}, }; - _startInfo.Arguments = $"-B \"{context.CurrentPluginMetadata.ExecuteFilePath}\" \"{request}\""; + _startInfo.ArgumentList[2] = request.ToString(); _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; // TODO: Async Action return Execute(_startInfo); } + + public override Task InitAsync(PluginInitContext context) + { + this.context = context; + _startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + _startInfo.ArgumentList.Add(""); + return Task.CompletedTask; + } } } \ No newline at end of file diff --git a/Flow.Launcher.Core/Properties/AssemblyInfo.cs b/Flow.Launcher.Core/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..40017c46c --- /dev/null +++ b/Flow.Launcher.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Flow.Launcher.Test")] \ No newline at end of file diff --git a/Flow.Launcher.Core/Resource/JsonObjectConverter.cs b/Flow.Launcher.Core/Resource/JsonObjectConverter.cs new file mode 100644 index 000000000..5a891ae30 --- /dev/null +++ b/Flow.Launcher.Core/Resource/JsonObjectConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Flow.Launcher.Core.Resource +{ + public class JsonObjectConverter : JsonConverter + { + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Number when reader.TryGetInt32(out var i): + return i; + case JsonTokenType.Number when reader.TryGetInt64(out var l): + return l; + case JsonTokenType.Number: + return reader.GetDouble(); + case JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime): + return datetime; + case JsonTokenType.String: + return reader.GetString(); + default: + // Use JsonElement as fallback. + // Newtonsoft uses JArray or JObject. + using (var document = JsonDocument.ParseValue(ref reader)) + { + return document.RootElement.Clone(); + } + } + } + + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + throw new InvalidOperationException("Should not get here."); + } + } +} \ No newline at end of file diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index a5f12bbb9..e70de673e 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -17,6 +17,8 @@ namespace Flow.Launcher.Core.Resource { public class Theme { + private const int ShadowExtraMargin = 12; + private readonly List _themeDirectories = new List(); private ResourceDictionary _oldResource; private string _oldTheme; @@ -26,6 +28,10 @@ namespace Flow.Launcher.Core.Resource private string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder); private string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder); + public bool BlurEnabled { get; set; } + + private double mainWindowWidth; + public Theme() { _themeDirectories.Add(DirectoryPath); @@ -86,8 +92,12 @@ namespace Flow.Launcher.Core.Resource _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); } - if (Settings.UseDropShadowEffect) + BlurEnabled = IsBlurTheme(); + + if (Settings.UseDropShadowEffect && !BlurEnabled) AddDropShadowEffectToCurrentTheme(); + + SetBlurForWindow(); } catch (DirectoryNotFoundException e) { @@ -179,6 +189,21 @@ namespace Flow.Launcher.Core.Resource Array.ForEach(new[] { resultItemStyle, resultSubItemStyle, resultItemSelectedStyle, resultSubItemSelectedStyle }, o => Array.ForEach(setters, p => o.Setters.Add(p))); } + var windowStyle = dict["WindowStyle"] as Style; + + var width = windowStyle?.Setters.OfType().Where(x => x.Property.Name == "Width") + .Select(x => x.Value).FirstOrDefault(); + + if (width == null) + { + windowStyle = dict["BaseWindowStyle"] as Style; + + width = windowStyle?.Setters.OfType().Where(x => x.Property.Name == "Width") + .Select(x => x.Value).FirstOrDefault(); + } + + mainWindowWidth = (double)width; + return dict; } @@ -224,17 +249,54 @@ namespace Flow.Launcher.Core.Resource BlurRadius = 15 }; + var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; + if (marginSetter == null) + { + marginSetter = new Setter() + { + Property = Border.MarginProperty, + Value = new Thickness(ShadowExtraMargin), + }; + windowBorderStyle.Setters.Add(marginSetter); + } + else + { + var baseMargin = (Thickness) marginSetter.Value; + var newMargin = new Thickness( + baseMargin.Left + ShadowExtraMargin, + baseMargin.Top + ShadowExtraMargin, + baseMargin.Right + ShadowExtraMargin, + baseMargin.Bottom + ShadowExtraMargin); + marginSetter.Value = newMargin; + } + windowBorderStyle.Setters.Add(effectSetter); UpdateResourceDictionary(dict); } - public void RemoveDropShadowEffectToCurrentTheme() + public void RemoveDropShadowEffectFromCurrentTheme() { var dict = CurrentThemeResourceDictionary(); var windowBorderStyle = dict["WindowBorderStyle"] as Style; - dict.Remove(Border.EffectProperty); + var effectSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.EffectProperty) as Setter; + var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; + + if(effectSetter != null) + { + windowBorderStyle.Setters.Remove(effectSetter); + } + if (marginSetter != null) + { + var currentMargin = (Thickness)marginSetter.Value; + var newMargin = new Thickness( + currentMargin.Left - ShadowExtraMargin, + currentMargin.Top - ShadowExtraMargin, + currentMargin.Right - ShadowExtraMargin, + currentMargin.Bottom - ShadowExtraMargin); + marginSetter.Value = newMargin; + } UpdateResourceDictionary(dict); } @@ -281,35 +343,39 @@ namespace Flow.Launcher.Core.Resource /// public void SetBlurForWindow() { + if (BlurEnabled) + { + SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_ENABLE_BLURBEHIND); + } + else + { + SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_DISABLED); + } + } - // Exception of FindResource can't be cathed if global exception handle is set + private bool IsBlurTheme() + { if (Environment.OSVersion.Version >= new Version(6, 2)) { var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); - bool blur; - if (resource is bool) - { - blur = (bool)resource; - } - else - { - blur = false; - } - if (blur) - { - SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_ENABLE_BLURBEHIND); - } - else - { - SetWindowAccent(Application.Current.MainWindow, AccentState.ACCENT_DISABLED); - } + if (resource is bool) + return (bool)resource; + + return false; } + + return false; } private void SetWindowAccent(Window w, AccentState state) { var windowHelper = new WindowInteropHelper(w); + + // this determines the width of the main query window + w.Width = mainWindowWidth; + windowHelper.EnsureHandle(); + var accent = new AccentPolicy { AccentState = state }; var accentStructSize = Marshal.SizeOf(accent); diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index 2f919b5c9..aea43506e 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -49,8 +49,11 @@ - - + + + + + diff --git a/Flow.Launcher.Infrastructure/Image/ImageCache.cs b/Flow.Launcher.Infrastructure/Image/ImageCache.cs index bb7ec6817..13277c7d9 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageCache.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageCache.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows.Media; @@ -26,7 +27,8 @@ namespace Flow.Launcher.Infrastructure.Image private const int MaxCached = 50; public ConcurrentDictionary Data { get; private set; } = new ConcurrentDictionary(); private const int permissibleFactor = 2; - + private SemaphoreSlim semaphore = new(1, 1); + public void Initialization(Dictionary usage) { foreach (var key in usage.Keys) @@ -60,20 +62,29 @@ namespace Flow.Launcher.Infrastructure.Image } ); - // To prevent the dictionary from drastically increasing in size by caching images, the dictionary size is not allowed to grow more than the permissibleFactor * maxCached size - // This is done so that we don't constantly perform this resizing operation and also maintain the image cache size at the same time - if (Data.Count > permissibleFactor * MaxCached) + SliceExtra(); + + async void SliceExtra() { - // To delete the images from the data dictionary based on the resizing of the Usage Dictionary. - foreach (var key in Data.OrderBy(x => x.Value.usage).Take(Data.Count - MaxCached).Select(x => x.Key)) - Data.TryRemove(key, out _); + // To prevent the dictionary from drastically increasing in size by caching images, the dictionary size is not allowed to grow more than the permissibleFactor * maxCached size + // This is done so that we don't constantly perform this resizing operation and also maintain the image cache size at the same time + if (Data.Count > permissibleFactor * MaxCached) + { + await semaphore.WaitAsync().ConfigureAwait(false); + // To delete the images from the data dictionary based on the resizing of the Usage Dictionary + // Double Check to avoid concurrent remove + if (Data.Count > permissibleFactor * MaxCached) + foreach (var key in Data.OrderBy(x => x.Value.usage).Take(Data.Count - MaxCached).Select(x => x.Key).ToArray()) + Data.TryRemove(key, out _); + semaphore.Release(); + } } } } public bool ContainsKey(string key) { - return Data.ContainsKey(key) && Data[key].imageSource != null; + return key is not null && Data.ContainsKey(key) && Data[key].imageSource != null; } public int CacheSize() diff --git a/Flow.Launcher.Infrastructure/Logger/Log.cs b/Flow.Launcher.Infrastructure/Logger/Log.cs index 94132b27f..26e305ace 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -5,6 +5,11 @@ using NLog; using NLog.Config; using NLog.Targets; using Flow.Launcher.Infrastructure.UserSettings; +using JetBrains.Annotations; +using NLog.Fluent; +using NLog.Targets.Wrappers; +using System.Runtime.ExceptionServices; +using System.Text; namespace Flow.Launcher.Infrastructure.Logger { @@ -23,15 +28,37 @@ namespace Flow.Launcher.Infrastructure.Logger } var configuration = new LoggingConfiguration(); - var target = new FileTarget(); - configuration.AddTarget("file", target); - target.FileName = CurrentLogDirectory.Replace(@"\", "/") + "/${shortdate}.txt"; + + const string layout = + @"${date:format=HH\:mm\:ss.ffffK} - " + + @"${level:uppercase=true:padding=-5} - ${logger} - ${message:l}" + + @"${onexception:${newline}" + + @"EXCEPTION OCCURS\: ${exception:format=tostring}${newline}}"; + + var fileTarget = new FileTarget + { + FileName = CurrentLogDirectory.Replace(@"\", "/") + "/${shortdate}.txt", + Layout = layout + }; + + var fileTargetASyncWrapper = new AsyncTargetWrapper(fileTarget); + + var debugTarget = new OutputDebugStringTarget + { + Layout = layout + }; + + configuration.AddTarget("file", fileTargetASyncWrapper); + configuration.AddTarget("debug", debugTarget); + #if DEBUG - var rule = new LoggingRule("*", LogLevel.Debug, target); + var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper); + var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget); + configuration.LoggingRules.Add(debugRule); #else - var rule = new LoggingRule("*", LogLevel.Info, target); + var fileRule = new LoggingRule("*", LogLevel.Info, fileTargetASyncWrapper); #endif - configuration.LoggingRules.Add(rule); + configuration.LoggingRules.Add(fileRule); LogManager.Configuration = configuration; } @@ -39,7 +66,6 @@ namespace Flow.Launcher.Infrastructure.Logger { var logger = LogManager.GetLogger("FaultyLogger"); message = $"Wrong logger message format <{message}>"; - System.Diagnostics.Debug.WriteLine($"FATAL|{message}"); logger.Fatal(message); } @@ -51,12 +77,11 @@ namespace Flow.Launcher.Infrastructure.Logger } - - [MethodImpl(MethodImplOptions.Synchronized)] public static void Exception(string className, string message, System.Exception exception, [CallerMemberName] string methodName = "") { + exception = exception.Demystify(); #if DEBUG - throw exception; + ExceptionDispatchInfo.Capture(exception).Throw(); #else var classNameWithMethod = CheckClassAndMessageAndReturnFullClassWithMethod(className, message, methodName); @@ -90,23 +115,9 @@ namespace Flow.Launcher.Infrastructure.Logger { var logger = LogManager.GetLogger(classAndMethod); - System.Diagnostics.Debug.WriteLine($"ERROR|{message}"); + var messageBuilder = new StringBuilder(); - logger.Error("-------------------------- Begin exception --------------------------"); - logger.Error(message); - - do - { - logger.Error($"Exception full name:\n <{e.GetType().FullName}>"); - logger.Error($"Exception message:\n <{e.Message}>"); - logger.Error($"Exception stack trace:\n <{e.StackTrace}>"); - logger.Error($"Exception source:\n <{e.Source}>"); - logger.Error($"Exception target site:\n <{e.TargetSite}>"); - logger.Error($"Exception HResult:\n <{e.HResult}>"); - e = e.InnerException; - } while (e != null); - - logger.Error("-------------------------- End exception --------------------------"); + logger.Error(e, message); } private static void LogInternal(string message, LogLevel level) @@ -117,8 +128,6 @@ namespace Flow.Launcher.Infrastructure.Logger var prefix = parts[1]; var unprefixed = parts[2]; var logger = LogManager.GetLogger(prefix); - - System.Diagnostics.Debug.WriteLine($"{level.Name}|{message}"); logger.Log(level, unprefixed); } else @@ -128,11 +137,12 @@ namespace Flow.Launcher.Infrastructure.Logger } /// example: "|prefix|unprefixed" - [MethodImpl(MethodImplOptions.Synchronized)] + /// Exception public static void Exception(string message, System.Exception e) { + e = e.Demystify(); #if DEBUG - throw e; + ExceptionDispatchInfo.Capture(e).Throw(); #else if (FormatValid(message)) { @@ -165,7 +175,6 @@ namespace Flow.Launcher.Infrastructure.Logger var logger = LogManager.GetLogger(classNameWithMethod); - System.Diagnostics.Debug.WriteLine($"{level.Name}|{message}"); logger.Log(level, message); } diff --git a/Flow.Launcher.Infrastructure/Storage/ISavable.cs b/Flow.Launcher.Infrastructure/Storage/ISavable.cs new file mode 100644 index 000000000..ba2b58c6a --- /dev/null +++ b/Flow.Launcher.Infrastructure/Storage/ISavable.cs @@ -0,0 +1,8 @@ +using System; + +namespace Flow.Launcher.Infrastructure.Storage +{ + [Obsolete("Deprecated as of Flow Launcher v1.8.0, on 2021.06.21. " + + "This is used only for Everything plugin v1.4.9 or below backwards compatibility")] + public interface ISavable : Plugin.ISavable { } +} \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs index 43d19bea8..0083ccb87 100644 --- a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs @@ -87,4 +87,8 @@ namespace Flow.Launcher.Infrastructure.Storage File.WriteAllText(FilePath, serialized); } } + + [Obsolete("Deprecated as of Flow Launcher v1.8.0, on 2021.06.21. " + + "This is used only for Everything plugin v1.4.9 or below backwards compatibility")] + public class JsonStrorage : JsonStorage where T : new() { } } diff --git a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs index c26aef95d..923a1a6b5 100644 --- a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs @@ -22,3 +22,4 @@ namespace Flow.Launcher.Infrastructure.Storage } } } + diff --git a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs index 29bc11480..fd2464f2e 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs @@ -16,11 +16,13 @@ namespace Flow.Launcher.Infrastructure.UserSettings { var settings = Plugins[metadata.ID]; - // TODO: Remove. This is one off for 1.2.0 release. - // Introduced a new action keyword in Explorer, so need to update plugin setting in the UserData folder. - // This kind of plugin meta update should be handled by a dedicated method trigger by version bump. - if (metadata.ID == "572be03c74c642baae319fc283e561a8" && metadata.ActionKeywords.Count != settings.ActionKeywords.Count) - settings.ActionKeywords = metadata.ActionKeywords; + // TODO: Remove. This is backwards compatibility for 1.8.0 release. + // Introduced two new action keywords in Explorer, so need to update plugin setting in the UserData folder. + if (metadata.ID == "572be03c74c642baae319fc283e561a8" && metadata.ActionKeywords.Count > settings.ActionKeywords.Count) + { + settings.ActionKeywords.Add(Query.GlobalPluginWildcardSign); // for index search + settings.ActionKeywords.Add(Query.GlobalPluginWildcardSign); // for path search + } if (string.IsNullOrEmpty(settings.Version)) settings.Version = metadata.Version; @@ -30,6 +32,11 @@ namespace Flow.Launcher.Infrastructure.UserSettings metadata.ActionKeywords = settings.ActionKeywords; metadata.ActionKeyword = settings.ActionKeywords[0]; } + else + { + metadata.ActionKeywords = new List(); + metadata.ActionKeyword = string.Empty; + } metadata.Disabled = settings.Disabled; metadata.Priority = settings.Priority; } diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 76a370978..ebef2c631 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -39,7 +39,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings /// public bool ShouldUsePinyin { get; set; } = false; - internal SearchPrecisionScore QuerySearchPrecision { get; private set; } = SearchPrecisionScore.Regular; + [JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))] + public SearchPrecisionScore QuerySearchPrecision { get; private set; } = SearchPrecisionScore.Regular; [JsonIgnore] public string QuerySearchPrecisionString @@ -97,6 +98,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool RememberLastLaunchLocation { get; set; } public bool IgnoreHotkeysOnFullscreen { get; set; } + public bool AutoHideScrollBar { get; set; } + public HttpProxy Proxy { get; set; } = new HttpProxy(); [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/Flow.Launcher.Plugin/Feature.cs b/Flow.Launcher.Plugin/Feature.cs deleted file mode 100644 index 81839d816..000000000 --- a/Flow.Launcher.Plugin/Feature.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Specialized; - -namespace Flow.Launcher.Plugin -{ - public interface IFeatures { } - - public interface IContextMenu : IFeatures - { - List LoadContextMenus(Result selectedResult); - } - - /// - /// Represent plugins that support internationalization - /// - public interface IPluginI18n : IFeatures - { - string GetTranslatedPluginTitle(); - - string GetTranslatedPluginDescription(); - } - - public interface IResultUpdated : IFeatures - { - event ResultUpdatedEventHandler ResultsUpdated; - } - - public delegate void ResultUpdatedEventHandler(IResultUpdated sender, ResultUpdatedEventArgs e); - - public class ResultUpdatedEventArgs : EventArgs - { - public List Results; - public Query Query; - } -} diff --git a/Flow.Launcher.Plugin/Features.cs b/Flow.Launcher.Plugin/Features.cs new file mode 100644 index 000000000..5b9c6a7b9 --- /dev/null +++ b/Flow.Launcher.Plugin/Features.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Threading; + +namespace Flow.Launcher.Plugin +{ + /// + /// Base Interface for Flow's special plugin feature interface + /// + public interface IFeatures + { + } +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj index e3d26f5d3..664373434 100644 --- a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj +++ b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj @@ -14,10 +14,10 @@ - 1.4.0 - 1.4.0 - 1.4.0 - 1.4.0 + 2.0.0 + 2.0.0 + 2.0.0 + 2.0.0 Flow.Launcher.Plugin Flow-Launcher MIT diff --git a/Flow.Launcher.Plugin/IAsyncPlugin.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncPlugin.cs similarity index 100% rename from Flow.Launcher.Plugin/IAsyncPlugin.cs rename to Flow.Launcher.Plugin/Interfaces/IAsyncPlugin.cs diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncReloadable.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncReloadable.cs index fc4ac4715..bd4500a7e 100644 --- a/Flow.Launcher.Plugin/Interfaces/IAsyncReloadable.cs +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncReloadable.cs @@ -13,8 +13,8 @@ namespace Flow.Launcher.Plugin /// The command that allows user to manual reload is exposed via Plugin.Sys, and /// it will call the plugins that have implemented this interface. /// - public interface IAsyncReloadable + public interface IAsyncReloadable : IFeatures { Task ReloadDataAsync(); } -} +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs b/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs new file mode 100644 index 000000000..5befbf5c9 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IContextMenu.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Flow.Launcher.Plugin +{ + public interface IContextMenu : IFeatures + { + List LoadContextMenus(Result selectedResult); + } +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/IPlugin.cs b/Flow.Launcher.Plugin/Interfaces/IPlugin.cs similarity index 100% rename from Flow.Launcher.Plugin/IPlugin.cs rename to Flow.Launcher.Plugin/Interfaces/IPlugin.cs diff --git a/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs b/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs new file mode 100644 index 000000000..e332d450e --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IPluginI18n.cs @@ -0,0 +1,12 @@ +namespace Flow.Launcher.Plugin +{ + /// + /// Represent plugins that support internationalization + /// + public interface IPluginI18n : IFeatures + { + string GetTranslatedPluginTitle(); + + string GetTranslatedPluginDescription(); + } +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs similarity index 100% rename from Flow.Launcher.Plugin/IPublicAPI.cs rename to Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs diff --git a/Flow.Launcher.Plugin/Interfaces/IReloadable.cs b/Flow.Launcher.Plugin/Interfaces/IReloadable.cs index 31611519c..bd1ad406e 100644 --- a/Flow.Launcher.Plugin/Interfaces/IReloadable.cs +++ b/Flow.Launcher.Plugin/Interfaces/IReloadable.cs @@ -15,8 +15,8 @@ /// If requiring reloading data asynchronously, please use the IAsyncReloadable interface /// /// - public interface IReloadable + public interface IReloadable : IFeatures { void ReloadData(); } -} +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs b/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs new file mode 100644 index 000000000..fd21460ac --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Flow.Launcher.Plugin +{ + public interface IResultUpdated : IFeatures + { + event ResultUpdatedEventHandler ResultsUpdated; + } + + public delegate void ResultUpdatedEventHandler(IResultUpdated sender, ResultUpdatedEventArgs e); + + public class ResultUpdatedEventArgs : EventArgs + { + public List Results; + public Query Query; + public CancellationToken Token { get; init; } + } +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/Interfaces/ISavable.cs b/Flow.Launcher.Plugin/Interfaces/ISavable.cs index 6f408dc2e..2d13eaa6e 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISavable.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISavable.cs @@ -5,8 +5,8 @@ /// Otherwise if LoadSettingJsonStorage or SaveSettingJsonStorage has been callded, /// plugin settings will be automatically saved (see Flow.Launcher/PublicAPIInstance.SavePluginSettings) by Flow /// - public interface ISavable + public interface ISavable : IFeatures { void Save(); } -} +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/ISettingProvider.cs b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs similarity index 100% rename from Flow.Launcher.Plugin/ISettingProvider.cs rename to Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs diff --git a/Flow.Launcher.Plugin/README.md b/Flow.Launcher.Plugin/README.md index 3b4d1598a..5c5b7c3ed 100644 --- a/Flow.Launcher.Plugin/README.md +++ b/Flow.Launcher.Plugin/README.md @@ -3,4 +3,4 @@ * Defines base objects and interfaces for plugins * Plugin authors making C# plugins should reference this DLL via nuget -* Contains base commands used by all plugins +* Contains commands and models that can be used by plugins diff --git a/Flow.Launcher.Test/Flow.Launcher.Test.csproj b/Flow.Launcher.Test/Flow.Launcher.Test.csproj index 1d0dce5e7..84c9137b7 100644 --- a/Flow.Launcher.Test/Flow.Launcher.Test.csproj +++ b/Flow.Launcher.Test/Flow.Launcher.Test.csproj @@ -49,12 +49,12 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Flow.Launcher.Test/Plugins/ExplorerTest.cs b/Flow.Launcher.Test/Plugins/ExplorerTest.cs index 3d0a9a64f..9d7fccad9 100644 --- a/Flow.Launcher.Test/Plugins/ExplorerTest.cs +++ b/Flow.Launcher.Test/Plugins/ExplorerTest.cs @@ -133,6 +133,13 @@ namespace Flow.Launcher.Test.Plugins { // Given var queryConstructor = new QueryConstructor(new Settings()); + var baseQuery = queryConstructor.CreateBaseQuery(); + + // system running this test could have different locale than the hard-coded 1033 LCID en-US. + var queryKeywordLocale = baseQuery.QueryKeywordLocale; + expectedString = expectedString.Replace("1033", queryKeywordLocale.ToString()); + + //When var resultString = queryConstructor.QueryForAllFilesAndFolders(userSearchString); diff --git a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs new file mode 100644 index 000000000..2b9512692 --- /dev/null +++ b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs @@ -0,0 +1,105 @@ +using NUnit; +using NUnit.Framework; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Plugin; +using System.Threading.Tasks; +using System.IO; +using System.Threading; +using System.Text; +using System.Text.Json; +using System.Linq; +using System.Collections.Generic; + +namespace Flow.Launcher.Test.Plugins +{ + [TestFixture] + // ReSharper disable once InconsistentNaming + internal class JsonRPCPluginTest : JsonRPCPlugin + { + public override string SupportedLanguage { get; set; } = AllowedLanguage.Executable; + + protected override string ExecuteCallback(JsonRPCRequestModel rpcRequest) + { + throw new System.NotImplementedException(); + } + + protected override string ExecuteContextMenu(Result selectedResult) + { + throw new System.NotImplementedException(); + } + + protected override Task ExecuteQueryAsync(Query query, CancellationToken token) + { + var byteInfo = Encoding.UTF8.GetBytes(query.RawQuery); + + var resultStream = new MemoryStream(byteInfo); + return Task.FromResult((Stream)resultStream); + } + + [TestCase("{\"result\":[],\"DebugMessage\":null}", Description = "Empty Result")] + [TestCase("{\"result\":[{\"JsonRPCAction\":null,\"Title\":\"something\",\"SubTitle\":\"\",\"ActionKeywordAssigned\":null,\"IcoPath\":null}],\"DebugMessage\":null}", Description = "One Result with Pascal Case")] + [TestCase("{\"result\":[{\"jsonRPCAction\":null,\"title\":\"something\",\"subTitle\":\"\",\"actionKeywordAssigned\":null,\"icoPath\":null}],\"debugMessage\":null}", Description = "One Result with camel Case")] + [TestCase("{\"result\":[{\"JsonRPCAction\":null,\"Title\":\"iii\",\"SubTitle\":\"\",\"ActionKeywordAssigned\":null,\"IcoPath\":null},{\"JsonRPCAction\":null,\"Title\":\"iii\",\"SubTitle\":\"\",\"ActionKeywordAssigned\":null,\"IcoPath\":null}],\"DebugMessage\":null}", Description = "Two Result with Pascal Case")] + [TestCase("{\"result\":[{\"jsonrpcAction\":null,\"TItLE\":\"iii\",\"Subtitle\":\"\",\"Actionkeywordassigned\":null,\"icoPath\":null},{\"jsonRPCAction\":null,\"tiTle\":\"iii\",\"subTitle\":\"\",\"ActionKeywordAssigned\":null,\"IcoPath\":null}],\"DebugMessage\":null}", Description = "Two Result with Weird Case")] + public async Task GivenVariousJsonText_WhenVariousNamingCase_ThenExpectNotNullResults_Async(string resultText) + { + var results = await QueryAsync(new Query + { + RawQuery = resultText + }, default); + + Assert.IsNotNull(results); + + foreach (var result in results) + { + Assert.IsNotNull(result); + Assert.IsNotNull(result.Action); + Assert.IsNotNull(result.Title); + } + + } + + public static List ResponseModelsSource = new() + { + new() + { + Result = new() + }, + new() + { + Result = new() + { + new JsonRPCResult + { + Title = "Test1", + SubTitle = "Test2" + } + } + } + }; + + [TestCaseSource(typeof(JsonRPCPluginTest), nameof(ResponseModelsSource))] + public async Task GivenModel_WhenSerializeWithDifferentNamingPolicy_ThenExpectSameResult_Async(JsonRPCQueryResponseModel reference) + { + var camelText = JsonSerializer.Serialize(reference, new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + var pascalText = JsonSerializer.Serialize(reference); + + var results1 = await QueryAsync(new Query { RawQuery = camelText }, default); + var results2 = await QueryAsync(new Query { RawQuery = pascalText }, default); + + Assert.IsNotNull(results1); + Assert.IsNotNull(results2); + + foreach (var ((result1, result2), referenceResult) in results1.Zip(results2).Zip(reference.Result)) + { + Assert.AreEqual(result1, result2); + Assert.AreEqual(result1, referenceResult); + + Assert.IsNotNull(result1); + Assert.IsNotNull(result1.Action); + } + } + + } +} \ No newline at end of file diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 7b808dd54..c2a32100d 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -68,11 +68,14 @@ namespace Flow.Launcher PluginManager.LoadPlugins(_settings.PluginSettings); _mainVM = new MainViewModel(_settings); + + HotKeyMapper.Initialize(_mainVM); + API = new PublicAPIInstance(_settingsVM, _mainVM, _alphabet); Http.API = API; Http.Proxy = _settings.Proxy; - + await PluginManager.InitializePlugins(API); var window = new MainWindow(_settings, _mainVM); @@ -96,6 +99,8 @@ namespace Flow.Launcher AutoStartup(); AutoUpdates(); + API.SaveAppAllSettings(); + _mainVM.MainWindowVisibility = _settings.HideOnStartup ? Visibility.Hidden : Visibility.Visible; Log.Info("|App.OnStartup|End Flow Launcher startup ---------------------------------------------------- "); }); diff --git a/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs b/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs index d036c008c..b18bbcfad 100644 --- a/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs +++ b/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs @@ -1,8 +1,7 @@ using Flow.Launcher.Core.Resource; +using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.UserSettings; -using NHotkey; -using NHotkey.Wpf; using System; using System.Collections.ObjectModel; using System.Linq; @@ -52,11 +51,7 @@ namespace Flow.Launcher }; _settings.CustomPluginHotkeys.Add(pluginHotkey); - SetHotkey(ctlHotkey.CurrentHotkey, delegate - { - App.API.ChangeQuery(pluginHotkey.ActionKeyword); - Application.Current.MainWindow.Visibility = Visibility.Visible; - }); + HotKeyMapper.SetCustomQueryHotkey(pluginHotkey); } else { @@ -69,12 +64,8 @@ namespace Flow.Launcher updateCustomHotkey.ActionKeyword = tbAction.Text; updateCustomHotkey.Hotkey = ctlHotkey.CurrentHotkey.ToString(); //remove origin hotkey - RemoveHotkey(oldHotkey); - SetHotkey(new HotkeyModel(updateCustomHotkey.Hotkey), delegate - { - App.API.ChangeQuery(updateCustomHotkey.ActionKeyword); - Application.Current.MainWindow.Visibility = Visibility.Visible; - }); + HotKeyMapper.RemoveHotkey(oldHotkey); + HotKeyMapper.SetCustomQueryHotkey(updateCustomHotkey); } Close(); @@ -100,28 +91,8 @@ namespace Flow.Launcher { App.API.ChangeQuery(tbAction.Text); Application.Current.MainWindow.Visibility = Visibility.Visible; - } + Application.Current.MainWindow.Focus(); - private void RemoveHotkey(string hotkeyStr) - { - if (!string.IsNullOrEmpty(hotkeyStr)) - { - HotkeyManager.Current.Remove(hotkeyStr); - } - } - - private void SetHotkey(HotkeyModel hotkey, EventHandler action) - { - string hotkeyStr = hotkey.ToString(); - try - { - HotkeyManager.Current.AddOrReplace(hotkeyStr, hotkey.CharKey, hotkey.ModifierKeys, action); - } - catch (Exception) - { - string errorMsg = string.Format(InternationalizationManager.Instance.GetTranslation("registerHotkeyFailed"), hotkeyStr); - MessageBox.Show(errorMsg); - } } private void cmdEsc_OnPress(object sender, ExecutedRoutedEventArgs e) diff --git a/Flow.Launcher/Helper/ErrorReporting.cs b/Flow.Launcher/Helper/ErrorReporting.cs index 94e2ed2bc..f3f590167 100644 --- a/Flow.Launcher/Helper/ErrorReporting.cs +++ b/Flow.Launcher/Helper/ErrorReporting.cs @@ -3,6 +3,8 @@ using System.Windows.Threading; using NLog; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Exception; +using NLog.Fluent; +using Log = Flow.Launcher.Infrastructure.Logger.Log; namespace Flow.Launcher.Helper { @@ -45,4 +47,4 @@ namespace Flow.Launcher.Helper return info; } } -} +} \ No newline at end of file diff --git a/Flow.Launcher/Helper/HotKeyMapper.cs b/Flow.Launcher/Helper/HotKeyMapper.cs new file mode 100644 index 000000000..d5fcabace --- /dev/null +++ b/Flow.Launcher/Helper/HotKeyMapper.cs @@ -0,0 +1,136 @@ +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.UserSettings; +using System; +using NHotkey; +using NHotkey.Wpf; +using Flow.Launcher.Core.Resource; +using System.Windows; +using Flow.Launcher.ViewModel; + +namespace Flow.Launcher.Helper +{ + internal static class HotKeyMapper + { + private static Settings settings; + private static MainViewModel mainViewModel; + + internal static void Initialize(MainViewModel mainVM) + { + mainViewModel = mainVM; + settings = mainViewModel._settings; + + SetHotkey(settings.Hotkey, OnHotkey); + LoadCustomPluginHotkey(); + } + + private static void SetHotkey(string hotkeyStr, EventHandler action) + { + var hotkey = new HotkeyModel(hotkeyStr); + SetHotkey(hotkey, action); + } + + internal static void SetHotkey(HotkeyModel hotkey, EventHandler action) + { + string hotkeyStr = hotkey.ToString(); + try + { + HotkeyManager.Current.AddOrReplace(hotkeyStr, hotkey.CharKey, hotkey.ModifierKeys, action); + } + catch (Exception) + { + string errorMsg = + string.Format(InternationalizationManager.Instance.GetTranslation("registerHotkeyFailed"), + hotkeyStr); + MessageBox.Show(errorMsg); + } + } + + internal static void RemoveHotkey(string hotkeyStr) + { + if (!string.IsNullOrEmpty(hotkeyStr)) + { + HotkeyManager.Current.Remove(hotkeyStr); + } + } + + internal static void OnHotkey(object sender, HotkeyEventArgs e) + { + if (!ShouldIgnoreHotkeys()) + { + UpdateLastQUeryMode(); + + mainViewModel.ToggleFlowLauncher(); + e.Handled = true; + } + } + + /// + /// Checks if Flow Launcher should ignore any hotkeys + /// + private static bool ShouldIgnoreHotkeys() + { + return settings.IgnoreHotkeysOnFullscreen && WindowsInteropHelper.IsWindowFullscreen(); + } + + private static void UpdateLastQUeryMode() + { + switch(settings.LastQueryMode) + { + case LastQueryMode.Empty: + mainViewModel.ChangeQueryText(string.Empty); + break; + case LastQueryMode.Preserved: + mainViewModel.LastQuerySelected = true; + break; + case LastQueryMode.Selected: + mainViewModel.LastQuerySelected = false; + break; + default: + throw new ArgumentException($"wrong LastQueryMode: <{settings.LastQueryMode}>"); + + } + } + + internal static void LoadCustomPluginHotkey() + { + if (settings.CustomPluginHotkeys == null) + return; + + foreach (CustomPluginHotkey hotkey in settings.CustomPluginHotkeys) + { + SetCustomQueryHotkey(hotkey); + } + } + + internal static void SetCustomQueryHotkey(CustomPluginHotkey hotkey) + { + SetHotkey(hotkey.Hotkey, (s, e) => + { + if (ShouldIgnoreHotkeys()) + return; + + mainViewModel.MainWindowVisibility = Visibility.Visible; + mainViewModel.ChangeQueryText(hotkey.ActionKeyword); + }); + } + + internal static bool CheckAvailability(HotkeyModel currentHotkey) + { + try + { + HotkeyManager.Current.AddOrReplace("HotkeyAvailabilityTest", currentHotkey.CharKey, currentHotkey.ModifierKeys, (sender, e) => { }); + + return true; + } + catch + { + } + finally + { + HotkeyManager.Current.Remove("HotkeyAvailabilityTest"); + } + + return false; + } + } +} diff --git a/Flow.Launcher/Helper/SingletonWindowOpener.cs b/Flow.Launcher/Helper/SingletonWindowOpener.cs index 3671b9fd3..fdfaaa4fc 100644 --- a/Flow.Launcher/Helper/SingletonWindowOpener.cs +++ b/Flow.Launcher/Helper/SingletonWindowOpener.cs @@ -11,7 +11,16 @@ namespace Flow.Launcher.Helper var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.GetType() == typeof(T)) ?? (T)Activator.CreateInstance(typeof(T), args); Application.Current.MainWindow.Hide(); + + // Fix UI bug + // Add `window.WindowState = WindowState.Normal` + // If only use `window.Show()`, Settings-window doesn't show when minimized in taskbar + // Not sure why this works tho + // Probably because, when `.Show()` fails, `window.WindowState == Minimized` (not `Normal`) + // https://stackoverflow.com/a/59719760/4230390 + window.WindowState = WindowState.Normal; window.Show(); + window.Focus(); return (T)window; diff --git a/Flow.Launcher/HotkeyControl.xaml b/Flow.Launcher/HotkeyControl.xaml index 0164d7dd7..e732cbe97 100644 --- a/Flow.Launcher/HotkeyControl.xaml +++ b/Flow.Launcher/HotkeyControl.xaml @@ -10,7 +10,7 @@ - + diff --git a/Flow.Launcher/HotkeyControl.xaml.cs b/Flow.Launcher/HotkeyControl.xaml.cs index 5c7141d1c..2b6e275df 100644 --- a/Flow.Launcher/HotkeyControl.xaml.cs +++ b/Flow.Launcher/HotkeyControl.xaml.cs @@ -4,8 +4,8 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; -using NHotkey.Wpf; using Flow.Launcher.Core.Resource; +using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Plugin; @@ -18,11 +18,7 @@ namespace Flow.Launcher public event EventHandler HotkeyChanged; - protected virtual void OnHotkeyChanged() - { - EventHandler handler = HotkeyChanged; - if (handler != null) handler(this, EventArgs.Empty); - } + protected virtual void OnHotkeyChanged() => HotkeyChanged?.Invoke(this, EventArgs.Empty); public HotkeyControl() { @@ -90,24 +86,7 @@ namespace Flow.Launcher SetHotkey(new HotkeyModel(keyStr), triggerValidate); } - private bool CheckHotkeyAvailability() - { - try - { - HotkeyManager.Current.AddOrReplace("HotkeyAvailabilityTest", CurrentHotkey.CharKey, CurrentHotkey.ModifierKeys, (sender, e) => { }); - - return true; - } - catch - { - } - finally - { - HotkeyManager.Current.Remove("HotkeyAvailabilityTest"); - } - - return false; - } + private bool CheckHotkeyAvailability() => HotKeyMapper.CheckAvailability(CurrentHotkey); public new bool IsFocused { diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index 6232492ba..90a920e3e 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -31,12 +31,15 @@ Ignore hotkeys in fullscreen mode Python Directory Auto Update + Auto Hide Scroll Bar + Automatically hides the Settings window scroll bar and show when hover the mouse over it Select Hide Flow Launcher on startup Hide tray icon Query Search Precision Should Use Pinyin Allows using Pinyin to search. Pinyin is the standard system of romanized spelling for transliterating Chinese + Shadow effect is not allowed while current theme has blur effect enabled Plugin @@ -48,6 +51,7 @@ New action keyword: Current Priority: New Priority: + Priority: Plugin Directory Author Init time: @@ -70,6 +74,7 @@ Open Result Modifiers Show Hotkey Custom Query Hotkey + Query Delete Edit Add @@ -134,7 +139,7 @@ Update - Hotkey unavailable + Hotkey Unavailable Version diff --git a/Flow.Launcher/Languages/sk.xaml b/Flow.Launcher/Languages/sk.xaml index ec1b015da..346c70837 100644 --- a/Flow.Launcher/Languages/sk.xaml +++ b/Flow.Launcher/Languages/sk.xaml @@ -31,12 +31,15 @@ Ignorovať klávesové skratky v režime na celú obrazovku Priečinok s Pythonom Automatická aktualizácia + Automaticky skryť posuvník + Automaticky skrývať posuvník v okne nastavení a zobraziť ho, keď naň prejdete myšou Vybrať Schovať Flow Launcher po spustení Schovať ikonu z oblasti oznámení Presnosť vyhľadávania Použiť Pinyin Umožňuje vyhľadávanie pomocou Pinyin. Pinyin je štandardný systém romanizovaného pravopisu pre transliteráciu čínštiny + Efekt tieňa nie je povolený, kým má aktuálny motív povolený efekt rozostrenia Plugin @@ -48,6 +51,7 @@ Nová akcia skratky: Aktuálna priorita: Nová priorita: + Priorita: Priečinok s pluginmi Autor Príprava: @@ -70,6 +74,7 @@ Modifikáčné klávesy na otvorenie výsledkov Zobraziť klávesovú skratku Vlastná klávesová skratka na vyhľadávanie + Dopyt Odstrániť Upraviť Pridať diff --git a/Flow.Launcher/MainWindow.xaml b/Flow.Launcher/MainWindow.xaml index aa0240dd4..fcc3af5c5 100644 --- a/Flow.Launcher/MainWindow.xaml +++ b/Flow.Launcher/MainWindow.xaml @@ -1,4 +1,4 @@ - - + @@ -79,7 +79,6 @@ Style="{DynamicResource QueryBoxStyle}" Text="{Binding QueryText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" PreviewDragOver="OnPreviewDragOver" - TextChanged="OnTextChanged" AllowDrop="True" Visibility="Visible" Background="Transparent" @@ -94,7 +93,7 @@ -