Implement JSONRPC V2 Draft

This commit is contained in:
Hongtao Zhang 2022-08-31 21:34:47 -05:00
parent c74eafb9d5
commit 67d1b896b1
No known key found for this signature in database
GPG key ID: 75F655B91C7AC9BB
11 changed files with 276 additions and 128 deletions

View file

@ -6,7 +6,7 @@ using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin
{
internal class ExecutablePlugin : JsonRPCPlugin
internal class ExecutablePlugin : JsonRpcPlugin
{
private readonly ProcessStartInfo _startInfo;
public override string SupportedLanguage { get; set; } = AllowedLanguage.Executable;

View file

@ -21,67 +21,36 @@ using System.Text.Json;
namespace Flow.Launcher.Core.Plugin
{
public class JsonRPCErrorModel
{
public int Code { get; set; }
public record JsonRPCRequestMessage(PluginMetadata PluginMetadata, IAsyncEnumerable<JsonRPCRequestModel> Requests);
public record JsonRPCBase(int Id, JsonRPCErrorModel Error = default);
public record JsonRPCErrorModel(int Code, string Message, string Data);
public string Message { get; set; }
public record JsonRPCResponseModel(int Id, JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error);
public record JsonRPCQueryResponseModel(int Id,
[property: JsonPropertyName("result")] List<JsonRPCResult> Result,
Dictionary<string, object> SettingsChange = null,
string DebugMessage = "",
JsonRPCErrorModel Error = default) : JsonRPCResponseModel(Id, Error);
public string Data { get; set; }
}
public record JsonRPCRequestModel(int Id,
string Method,
object[] Parameters,
Dictionary<string, object> Settings = default,
JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error);
public class JsonRPCResponseModel
{
public string Result { get; set; }
public JsonRPCErrorModel Error { get; set; }
}
public class JsonRPCQueryResponseModel : JsonRPCResponseModel
{
[JsonPropertyName("result")]
public new List<JsonRPCResult> Result { get; set; }
public Dictionary<string, object> SettingsChange { get; set; }
public string DebugMessage { get; set; }
}
public class JsonRPCRequestModel
{
public string Method { get; set; }
public object[] Parameters { get; set; }
public Dictionary<string, object> Settings { get; set; }
private static readonly JsonSerializerOptions options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public override string ToString()
{
return JsonSerializer.Serialize(this, options);
}
}
/// <summary>
/// Json RPC Request that Flow Launcher sent to client
/// </summary>
public class JsonRPCServerRequestModel : JsonRPCRequestModel
{
}
/// <summary>
/// Json RPC Request(in query response) that client sent to Flow Launcher
/// </summary>
public class JsonRPCClientRequestModel : JsonRPCRequestModel
{
public bool DontHideAfterAction { get; set; }
}
public record JsonRPCClientRequestModel(
int Id,
string Method,
object[] Parameters,
Dictionary<string, object> Settings,
bool DontHideAfterAction = false,
JsonRPCErrorModel Error = default) : JsonRPCRequestModel(Id, Method, Parameters, Settings, Error);
/// <summary>
/// Represent the json-rpc result item that client send to Flow Launcher
/// Typically, we will send back this request model to client after user select the result item

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Flow.Launcher.Core.Plugin
{
// TODO: After Upgrading to .Net 7, adding Source Generating Context for IAsyncEnumerable JsonRPCMessage
[JsonSerializable(typeof(JsonRPCQueryResponseModel))]
public partial class JsonRPCQueryResponseModelContext : JsonSerializerContext
{
}
[JsonSerializable(typeof(JsonRPCRequestModel))]
public partial class JsonRPCRequestModelContext : JsonSerializerContext
{
}
[JsonSerializable(typeof(JsonRPCClientRequestModel))]
public partial class JsonRPCClientRequestModelContext : JsonSerializerContext
{
}
}

View file

@ -29,10 +29,10 @@ namespace Flow.Launcher.Core.Plugin
/// Represent the plugin that using JsonPRC
/// every JsonRPC plugin should has its own plugin instance
/// </summary>
internal abstract class JsonRPCPlugin : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable
internal abstract class JsonRpcPlugin : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable
{
protected PluginInitContext context;
public const string JsonRPC = "JsonRPC";
protected PluginInitContext Context;
public const string JsonRpc = "JsonRPC";
/// <summary>
/// The language this JsonRPCPlugin support
@ -43,19 +43,16 @@ namespace Flow.Launcher.Core.Plugin
private static readonly RecyclableMemoryStreamManager BufferManager = new();
private string SettingConfigurationPath => Path.Combine(context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml");
private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, context.CurrentPluginMetadata.Name, "Settings.json");
private int RequestId { get; set; }
private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml");
private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name, "Settings.json");
public List<Result> LoadContextMenus(Result selectedResult)
{
var request = new JsonRPCRequestModel
{
Method = "context_menu",
Parameters = new[]
{
selectedResult.ContextData
}
};
var request = new JsonRPCRequestModel(RequestId++,
"context_menu",
new[] { selectedResult.ContextData });
var output = Request(request);
return DeserializedResult(output);
}
@ -113,7 +110,7 @@ namespace Flow.Launcher.Core.Plugin
if (!string.IsNullOrEmpty(queryResponseModel.DebugMessage))
{
context.API.ShowMsg(queryResponseModel.DebugMessage);
Context.API.ShowMsg(queryResponseModel.DebugMessage);
}
foreach (var result in queryResponseModel.Result)
@ -281,7 +278,7 @@ namespace Flow.Launcher.Core.Plugin
{
case (0, 0):
const string errorMessage = "Empty JSON-RPC Response.";
Log.Warn($"|{nameof(JsonRPCPlugin)}.{nameof(ExecuteAsync)}|{errorMessage}");
Log.Warn($"|{nameof(JsonRpcPlugin)}.{nameof(ExecuteAsync)}|{errorMessage}");
break;
case (_, not 0):
throw new InvalidDataException(Encoding.UTF8.GetString(errorBuffer.ToArray())); // The process has exited with an error message
@ -295,20 +292,15 @@ namespace Flow.Launcher.Core.Plugin
public async Task<List<Result>> QueryAsync(Query query, CancellationToken token)
{
var request = new JsonRPCRequestModel
{
Method = "query",
Parameters = new object[]
{
query.Search
},
Settings = Settings
};
var request = new JsonRPCRequestModel(RequestId++,
"query",
new object[]{ query.Search },
Settings);
var output = await RequestAsync(request, token);
return await DeserializedResultAsync(output);
}
public async Task InitSettingAsync()
private async Task InitSettingAsync()
{
if (!File.Exists(SettingConfigurationPath))
return;
@ -337,7 +329,7 @@ namespace Flow.Launcher.Core.Plugin
public virtual async Task InitAsync(PluginInitContext context)
{
this.context = context;
this.Context = context;
await InitSettingAsync();
}
private static readonly Thickness settingControlMargin = new(10, 4, 10, 4);
@ -483,7 +475,7 @@ namespace Flow.Launcher.Core.Plugin
{
if (Settings != null)
{
Helper.ValidateDirectory(Path.Combine(DataLocation.PluginSettingsDirectory, context.CurrentPluginMetadata.Name));
Helper.ValidateDirectory(Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name));
File.WriteAllText(SettingPath, JsonSerializer.Serialize(Settings, settingSerializeOption));
}
}

View file

@ -0,0 +1,100 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows.Controls;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin
{
public abstract class JsonRpcPluginV2 : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable
{
public abstract string SupportedLanguage { get; set; }
public const string JsonRpc = "JsonRPC";
protected abstract Stream InputStream { get; set; }
protected abstract Stream OutputStream { get; set; }
protected abstract StreamReader ErrorStream { get; set; }
protected Channel<JsonRPCRequestModel> InputMessageChannel { get; set; }
private (Task SendTask, Task ReceiveTask) MessageTask { get; set; }
private CancellationTokenSource MessageCancellationTokenSource { get; set; }
protected int RequestId;
private ConcurrentDictionary<int, TaskCompletionSource<JsonRPCQueryResponseModel>> RequestTaskDictionary { get; } = new();
// TODO: Switch to Async Task
private async void ReceiveMessageAsync(CancellationToken token)
{
var response =
JsonSerializer.DeserializeAsyncEnumerable<JsonRPCQueryResponseModel>(OutputStream, cancellationToken: token);
ArgumentNullException.ThrowIfNull(response);
await foreach (var message in response.WithCancellation(token))
{
if (!RequestTaskDictionary.TryGetValue(message.Id, out var task))
{
// Either Task is already handled or it is a invalid resopnse.
continue;
}
RequestTaskDictionary.Remove(message.Id, out _);
task.TrySetResult(message);
}
}
// TODO: Switch to Async Task
private async void SendMessageAsync(PluginMetadata metadata, CancellationToken token)
{
var fullMessage = new JsonRPCRequestMessage(metadata, InputMessageChannel.Reader.ReadAllAsync(token));
await JsonSerializer.SerializeAsync(InputStream, fullMessage, cancellationToken: token);
}
public async Task<List<Result>> QueryAsync(Query query, CancellationToken token)
{
int currentRequestId = Interlocked.Add(ref RequestId, 1);
var message = new JsonRPCRequestModel(currentRequestId, "query", new object[]
{
query
});
await InputMessageChannel.Writer.WriteAsync(message, token);
await Task.Delay(50);
await InputStream.FlushAsync();
var task = new TaskCompletionSource<JsonRPCQueryResponseModel>();
RequestTaskDictionary[currentRequestId] = task;
var result = await task.Task;
//TODO: Parse Result
return new List<Result>();
}
public virtual Task InitAsync(PluginInitContext context)
{
InputMessageChannel = Channel.CreateUnbounded<JsonRPCRequestModel>();
MessageCancellationTokenSource = new CancellationTokenSource();
SendMessageAsync(context.CurrentPluginMetadata, MessageCancellationTokenSource.Token);
ReceiveMessageAsync(MessageCancellationTokenSource.Token);
// MessageTask =
// (SendMessageAsync(context.CurrentPluginMetadata, MessageCancellationTokenSource.Token),
// ReceiveMessageAsync(MessageCancellationTokenSource.Token));
return Task.CompletedTask;
}
public List<Result> LoadContextMenus(Result selectedResult)
{
throw new System.NotImplementedException();
}
public Control CreateSettingPanel()
{
// TODO: Implement CreateSettingPanel
return new Control();
}
public void Save()
{
// TODO: Save settings
}
}
}

View file

@ -83,7 +83,10 @@ 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;
}
@ -110,14 +113,14 @@ namespace Flow.Launcher.Core.Plugin
public static IEnumerable<PluginPair> PythonPlugins(List<PluginMetadata> source, PluginsSettings settings)
{
if (!source.Any(o => o.Language.ToUpper() == AllowedLanguage.Python))
if (!source.Any(o => o.Language.ToUpper() is AllowedLanguage.Python or AllowedLanguage.PythonV2))
return new List<PluginPair>();
if (!string.IsNullOrEmpty(settings.PythonDirectory) && FilesFolders.LocationExists(settings.PythonDirectory))
return SetPythonPathForPluginPairs(source, Path.Combine(settings.PythonDirectory, PythonExecutable));
var pythonPath = string.Empty;
if (MessageBox.Show("Flow detected you have installed Python plugins, which " +
"will need Python to run. Would you like to download Python? " +
Environment.NewLine + Environment.NewLine +
@ -185,17 +188,22 @@ namespace Flow.Launcher.Core.Plugin
return SetPythonPathForPluginPairs(source, pythonPath);
}
private static IEnumerable<PluginPair> SetPythonPathForPluginPairs(List<PluginMetadata> source, string pythonPath)
=> source
.Where(o => o.Language.ToUpper() == AllowedLanguage.Python)
private static IEnumerable<PluginPair> SetPythonPathForPluginPairs(IEnumerable<PluginMetadata> source, string pythonPath)
=> source
.Where(o => o.Language.ToUpper() is AllowedLanguage.Python or AllowedLanguage.PythonV2)
.Select(metadata => new PluginPair
{
Plugin = new PythonPlugin(pythonPath),
Plugin = metadata.Language.ToUpper() switch
{
AllowedLanguage.Python => new PythonPlugin(pythonPath),
AllowedLanguage.PythonV2 => new PythonPluginV2(pythonPath),
_ => throw new ArgumentOutOfRangeException()
},
Metadata = metadata
})
.ToList();
public static IEnumerable<PluginPair> ExecutablePlugins(IEnumerable<PluginMetadata> source)
public static IEnumerable<PluginPair> ExecutablePlugins(IEnumerable<PluginMetadata> source)
{
return source
.Where(o => o.Language.ToUpper() == AllowedLanguage.Executable)

View file

@ -8,7 +8,7 @@ using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin
{
internal class PythonPlugin : JsonRPCPlugin
internal class PythonPlugin : JsonRpcPlugin
{
private readonly ProcessStartInfo _startInfo;
public override string SupportedLanguage { get; set; } = AllowedLanguage.Python;
@ -25,7 +25,7 @@ namespace Flow.Launcher.Core.Plugin
};
// temp fix for issue #667
var path = Path.Combine(Constant.ProgramDirectory, JsonRPC);
var path = Path.Combine(Constant.ProgramDirectory, JsonRpc);
_startInfo.EnvironmentVariables["PYTHONPATH"] = path;
_startInfo.EnvironmentVariables["FLOW_VERSION"] = Constant.Version;
@ -47,7 +47,7 @@ namespace Flow.Launcher.Core.Plugin
protected override string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default)
{
_startInfo.ArgumentList[2] = rpcRequest.ToString();
_startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory;
_startInfo.WorkingDirectory = Context.CurrentPluginMetadata.PluginDirectory;
// TODO: Async Action
return Execute(_startInfo);
}

View file

@ -0,0 +1,63 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin
{
public class PythonPluginV2 : JsonRpcPluginV2
{
private readonly ProcessStartInfo _startInfo;
private Process _process;
public override string SupportedLanguage { get; set; } = AllowedLanguage.Python;
protected override Stream InputStream { get; set; }
protected override Stream OutputStream { get; set; }
protected override StreamReader ErrorStream { get; set; }
public PythonPluginV2(string filename)
{
_startInfo = new ProcessStartInfo
{
FileName = filename,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true
};
// temp fix for issue #667
var path = Path.Combine(Constant.ProgramDirectory, JsonRpc);
_startInfo.EnvironmentVariables["PYTHONPATH"] = path;
_startInfo.EnvironmentVariables["FLOW_VERSION"] = Constant.Version;
_startInfo.EnvironmentVariables["FLOW_PROGRAM_DIRECTORY"] = Constant.ProgramDirectory;
_startInfo.EnvironmentVariables["FLOW_APPLICATION_DIRECTORY"] = Constant.ApplicationDirectory;
//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");
}
public override async Task InitAsync(PluginInitContext context)
{
_startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath);
_startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory;
_process = Process.Start(_startInfo);
ArgumentNullException.ThrowIfNull(_process);
InputStream = _process.StandardInput.BaseStream;
OutputStream = _process.StandardOutput.BaseStream;
ErrorStream = _process.StandardError;
await base.InitAsync(context);
}
}
}

View file

@ -1,4 +1,6 @@
namespace Flow.Launcher.Plugin
using System;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Allowed plugin languages
@ -8,34 +10,27 @@
/// <summary>
/// Python
/// </summary>
public static string Python
{
get { return "PYTHON"; }
}
public const string Python = "PYTHON";
/// <summary>
/// Python V2
/// </summary>
public const string PythonV2 = "PYTHON_V2";
/// <summary>
/// C#
/// </summary>
public static string CSharp
{
get { return "CSHARP"; }
}
public const string CSharp = "CSHARP";
/// <summary>
/// F#
/// </summary>
public static string FSharp
{
get { return "FSHARP"; }
}
public const string FSharp = "FSHARP";
/// <summary>
/// Standard .exe
/// </summary>
public static string Executable
{
get { return "EXECUTABLE"; }
}
public const string Executable = "EXECUTABLE";
/// <summary>
/// Determines if this language is a .NET language
@ -56,8 +51,9 @@
public static bool IsAllowed(string language)
{
return IsDotNet(language)
|| language.ToUpper() == Python.ToUpper()
|| language.ToUpper() == Executable.ToUpper();
|| String.Equals(language, Python, StringComparison.CurrentCultureIgnoreCase)
|| String.Equals(language, PythonV2, StringComparison.CurrentCultureIgnoreCase)
|| String.Equals(language, Executable, StringComparison.CurrentCultureIgnoreCase);
}
}
}

View file

@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace Flow.Launcher.Plugin
{
@ -71,26 +72,31 @@ namespace Flow.Launcher.Plugin
public string ActionKeyword { get; init; }
[JsonIgnore]
/// <summary>
/// Return first search split by space if it has
/// </summary>
public string FirstSearch => SplitSearch(0);
[JsonIgnore]
private string _secondToEndSearch;
/// <summary>
/// strings from second search (including) to last search
/// </summary>
[JsonIgnore]
public string SecondToEndSearch => SearchTerms.Length > 1 ? (_secondToEndSearch ??= string.Join(' ', SearchTerms[1..])) : "";
/// <summary>
/// Return second search split by space if it has
/// </summary>
[JsonIgnore]
public string SecondSearch => SplitSearch(1);
/// <summary>
/// Return third search split by space if it has
/// </summary>
[JsonIgnore]
public string ThirdSearch => SplitSearch(2);
private string SplitSearch(int index)

View file

@ -14,7 +14,7 @@ namespace Flow.Launcher.Test.Plugins
{
[TestFixture]
// ReSharper disable once InconsistentNaming
internal class JsonRPCPluginTest : JsonRPCPlugin
internal class JsonRPCPluginTest : JsonRpcPlugin
{
public override string SupportedLanguage { get; set; } = AllowedLanguage.Executable;
@ -56,21 +56,14 @@ namespace Flow.Launcher.Test.Plugins
public static List<JsonRPCQueryResponseModel> ResponseModelsSource = new()
{
new()
new JsonRPCQueryResponseModel(0, new List<JsonRPCResult>()),
new JsonRPCQueryResponseModel(0, new List<JsonRPCResult>
{
Result = new()
},
new()
{
Result = new()
new JsonRPCResult
{
new JsonRPCResult
{
Title = "Test1",
SubTitle = "Test2"
}
Title = "Test1", SubTitle = "Test2"
}
}
})
};
[TestCaseSource(typeof(JsonRPCPluginTest), nameof(ResponseModelsSource))]
@ -97,4 +90,4 @@ namespace Flow.Launcher.Test.Plugins
}
}
}
}