diff --git a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs index cc06c8a0b..477ee620d 100644 --- a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs +++ b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs @@ -28,14 +28,14 @@ namespace Flow.Launcher.Core.Plugin public record JsonRPCResponseModel(int Id, JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error); public record JsonRPCQueryResponseModel(int Id, [property: JsonPropertyName("result")] List Result, - Dictionary SettingsChange = null, + IReadOnlyDictionary SettingsChange = null, string DebugMessage = "", JsonRPCErrorModel Error = default) : JsonRPCResponseModel(Id, Error); public record JsonRPCRequestModel(int Id, string Method, object[] Parameters, - Dictionary Settings = default, + IReadOnlyDictionary Settings = default, JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error); @@ -46,7 +46,7 @@ namespace Flow.Launcher.Core.Plugin int Id, string Method, object[] Parameters, - Dictionary Settings, + IReadOnlyDictionary Settings, bool DontHideAfterAction = false, JsonRPCErrorModel Error = default) : JsonRPCRequestModel(Id, Method, Parameters, Settings, Error); diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs index d3fc90224..3a7fcb216 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs @@ -33,9 +33,8 @@ namespace Flow.Launcher.Core.Plugin /// Represent the plugin that using JsonPRC /// every JsonRPC plugin should has its own plugin instance /// - internal abstract class JsonRPCPlugin : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable + internal abstract class JsonRPCPlugin : JsonRPCPluginBase { - protected PluginInitContext Context; public const string JsonRPC = "JsonRPC"; protected abstract Task RequestAsync(JsonRPCRequestModel rpcRequest, CancellationToken token = default); @@ -48,7 +47,7 @@ namespace Flow.Launcher.Core.Plugin private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name, "Settings.json"); - public List LoadContextMenus(Result selectedResult) + public override List LoadContextMenus(Result selectedResult) { var request = new JsonRPCRequestModel(RequestId++, "context_menu", @@ -77,7 +76,6 @@ namespace Flow.Launcher.Core.Plugin { WriteIndented = true }; - private Dictionary Settings { get; set; } private readonly Dictionary _settingControls = new(); @@ -103,85 +101,42 @@ namespace Flow.Launcher.Core.Plugin return ParseResults(queryResponseModel); } - - private List ParseResults(JsonRPCQueryResponseModel queryResponseModel) + protected override async Task ExecuteResultAsync(JsonRPCResult result) { - if (queryResponseModel.Result == null) return null; + if (result.JsonRPCAction == null) return false; - if (!string.IsNullOrEmpty(queryResponseModel.DebugMessage)) + if (string.IsNullOrEmpty(result.JsonRPCAction.Method)) { - Context.API.ShowMsg(queryResponseModel.DebugMessage); + return !result.JsonRPCAction.DontHideAfterAction; } - foreach (var result in queryResponseModel.Result) + if (result.JsonRPCAction.Method.StartsWith("Flow.Launcher.")) { - result.AsyncAction = async c => + ExecuteFlowLauncherAPI(result.JsonRPCAction.Method["Flow.Launcher.".Length..], + result.JsonRPCAction.Parameters); + } + else + { + await using var actionResponse = await RequestAsync(result.JsonRPCAction); + + if (actionResponse.Length == 0) { - UpdateSettings(result.SettingsChange); - - if (result.JsonRPCAction == null) return false; - - if (string.IsNullOrEmpty(result.JsonRPCAction.Method)) - { - return !result.JsonRPCAction.DontHideAfterAction; - } - - if (result.JsonRPCAction.Method.StartsWith("Flow.Launcher.")) - { - ExecuteFlowLauncherAPI(result.JsonRPCAction.Method["Flow.Launcher.".Length..], - result.JsonRPCAction.Parameters); - } - else - { - await using var actionResponse = await RequestAsync(result.JsonRPCAction); - - if (actionResponse.Length == 0) - { - return !result.JsonRPCAction.DontHideAfterAction; - } - - var jsonRpcRequestModel = await - JsonSerializer.DeserializeAsync(actionResponse, options); - - if (jsonRpcRequestModel?.Method?.StartsWith("Flow.Launcher.") ?? false) - { - ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method["Flow.Launcher.".Length..], - jsonRpcRequestModel.Parameters); - } - } - return !result.JsonRPCAction.DontHideAfterAction; - }; + } + + var jsonRpcRequestModel = await + JsonSerializer.DeserializeAsync(actionResponse, options); + + if (jsonRpcRequestModel?.Method?.StartsWith("Flow.Launcher.") ?? false) + { + ExecuteFlowLauncherAPI(jsonRpcRequestModel.Method["Flow.Launcher.".Length..], + jsonRpcRequestModel.Parameters); + } } - var results = new List(); - - results.AddRange(queryResponseModel.Result); - - UpdateSettings(queryResponseModel.SettingsChange); - - return results; + return !result.JsonRPCAction.DontHideAfterAction; } - private void ExecuteFlowLauncherAPI(string method, object[] parameters) - { - var parametersTypeArray = parameters.Select(param => param.GetType()).ToArray(); - var methodInfo = typeof(IPublicAPI).GetMethod(method, parametersTypeArray); - if (methodInfo == null) - { - return; - } - try - { - methodInfo.Invoke(PluginManager.API, parameters); - } - catch (Exception) - { -#if (DEBUG) - throw; -#endif - } - } /// /// Execute external program and return the output @@ -297,373 +252,10 @@ namespace Flow.Launcher.Core.Plugin } - public async Task> QueryAsync(Query query, CancellationToken token) + protected override async Task> QueryRequestAsync(JsonRPCRequestModel request, CancellationToken token) { - var request = new JsonRPCRequestModel(RequestId++, - "query", - new object[]{ query.Search }, - Settings); var output = await RequestAsync(request, token); return await DeserializedResultAsync(output); } - - private async Task InitSettingAsync() - { - if (!File.Exists(SettingConfigurationPath)) - return; - - if (File.Exists(SettingPath)) - { - await using var fileStream = File.OpenRead(SettingPath); - Settings = await JsonSerializer.DeserializeAsync>(fileStream, options); - } - - var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); - _settingsTemplate = deserializer.Deserialize(await File.ReadAllTextAsync(SettingConfigurationPath)); - - Settings ??= new Dictionary(); - - foreach (var (type, attribute) in _settingsTemplate.Body) - { - if (type == "textBlock") - continue; - if (!Settings.ContainsKey(attribute.Name)) - { - Settings[attribute.Name] = attribute.DefaultValue; - } - } - } - - public virtual async Task InitAsync(PluginInitContext context) - { - this.Context = context; - await InitSettingAsync(); - } - private static readonly Thickness settingControlMargin = new(0, 9, 18, 9); - private static readonly Thickness settingCheckboxMargin = new(0, 9, 9, 9); - private static readonly Thickness settingPanelMargin = new(0, 0, 0, 0); - private static readonly Thickness settingTextBlockMargin = new(70, 9, 18, 9); - private static readonly Thickness settingLabelPanelMargin = new(70, 9, 18, 9); - private static readonly Thickness settingLabelMargin = new(0, 0, 0, 0); - private static readonly Thickness settingDescMargin = new(0, 2, 0, 0); - private static readonly Thickness settingSepMargin = new(0, 0, 0, 2); - private JsonRpcConfigurationModel _settingsTemplate; - - public Control CreateSettingPanel() - { - if (Settings == null) - return new(); - var settingWindow = new UserControl(); - var mainPanel = new Grid - { - Margin = settingPanelMargin, VerticalAlignment = VerticalAlignment.Center - }; - ColumnDefinition gridCol1 = new ColumnDefinition(); - ColumnDefinition gridCol2 = new ColumnDefinition(); - - gridCol1.Width = new GridLength(70, GridUnitType.Star); - gridCol2.Width = new GridLength(30, GridUnitType.Star); - mainPanel.ColumnDefinitions.Add(gridCol1); - mainPanel.ColumnDefinitions.Add(gridCol2); - settingWindow.Content = mainPanel; - int rowCount = 0; - foreach (var (type, attribute) in _settingsTemplate.Body) - { - Separator sep = new Separator(); - sep.VerticalAlignment = VerticalAlignment.Top; - sep.Margin = settingSepMargin; - sep.SetResourceReference(Separator.BackgroundProperty, "Color03B"); /* for theme change */ - var panel = new StackPanel - { - Orientation = Orientation.Vertical, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingLabelPanelMargin - }; - RowDefinition gridRow = new RowDefinition(); - mainPanel.RowDefinitions.Add(gridRow); - var name = new TextBlock() - { - Text = attribute.Label, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingLabelMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - var desc = new TextBlock() - { - Text = attribute.Description, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingDescMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - desc.SetResourceReference(TextBlock.ForegroundProperty, "Color04B"); - - if (attribute.Description == null) /* if no description, hide */ - desc.Visibility = Visibility.Collapsed; - - - if (type != "textBlock") /* if textBlock, hide desc */ - { - panel.Children.Add(name); - panel.Children.Add(desc); - } - - - Grid.SetColumn(panel, 0); - Grid.SetRow(panel, rowCount); - - FrameworkElement contentControl; - - switch (type) - { - case "textBlock": - { - contentControl = new TextBlock - { - Text = attribute.Description.Replace("\\r\\n", "\r\n"), - Margin = settingTextBlockMargin, - Padding = new Thickness(0, 0, 0, 0), - HorizontalAlignment = System.Windows.HorizontalAlignment.Left, - TextAlignment = TextAlignment.Left, - TextWrapping = TextWrapping.Wrap - }; - Grid.SetColumn(contentControl, 0); - Grid.SetColumnSpan(contentControl, 2); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - } - case "input": - { - var textBox = new TextBox() - { - Text = Settings[attribute.Name] as string ?? string.Empty, - Margin = settingControlMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; - contentControl = textBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - } - case "inputWithFileBtn": - { - var textBox = new TextBox() - { - Margin = new Thickness(10, 0, 0, 0), - Text = Settings[attribute.Name] as string ?? string.Empty, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; - var Btn = new System.Windows.Controls.Button() - { - Margin = new Thickness(10, 0, 0, 0), Content = "Browse" - }; - var dockPanel = new DockPanel() - { - Margin = settingControlMargin - }; - DockPanel.SetDock(Btn, Dock.Right); - dockPanel.Children.Add(Btn); - dockPanel.Children.Add(textBox); - contentControl = dockPanel; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - } - case "textarea": - { - var textBox = new TextBox() - { - Height = 120, - Margin = settingControlMargin, - VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.WrapWithOverflow, - AcceptsReturn = true, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - Text = Settings[attribute.Name] as string ?? string.Empty, - ToolTip = attribute.Description - }; - textBox.TextChanged += (sender, _) => - { - Settings[attribute.Name] = ((TextBox)sender).Text; - }; - contentControl = textBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - } - case "passwordBox": - { - var passwordBox = new PasswordBox() - { - Margin = settingControlMargin, - Password = Settings[attribute.Name] as string ?? string.Empty, - PasswordChar = attribute.passwordChar == default ? '*' : attribute.passwordChar, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; - passwordBox.PasswordChanged += (sender, _) => - { - Settings[attribute.Name] = ((PasswordBox)sender).Password; - }; - contentControl = passwordBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - } - case "dropdown": - { - var comboBox = new System.Windows.Controls.ComboBox() - { - ItemsSource = attribute.Options, - SelectedItem = Settings[attribute.Name], - Margin = settingControlMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - ToolTip = attribute.Description - }; - comboBox.SelectionChanged += (sender, _) => - { - Settings[attribute.Name] = (string)((System.Windows.Controls.ComboBox)sender).SelectedItem; - }; - contentControl = comboBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - } - case "checkbox": - var checkBox = new CheckBox - { - IsChecked = Settings[attribute.Name] is bool isChecked ? isChecked : bool.Parse(attribute.DefaultValue), - Margin = settingCheckboxMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - ToolTip = attribute.Description - }; - checkBox.Click += (sender, _) => - { - Settings[attribute.Name] = ((CheckBox)sender).IsChecked; - }; - contentControl = checkBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - case "hyperlink": - var hyperlink = new Hyperlink - { - ToolTip = attribute.Description, NavigateUri = attribute.url - }; - var linkbtn = new System.Windows.Controls.Button - { - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, Margin = settingControlMargin - }; - linkbtn.Content = attribute.urlLabel; - - contentControl = linkbtn; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - break; - default: - continue; - } - if (type != "textBlock") - _settingControls[attribute.Name] = contentControl; - mainPanel.Children.Add(panel); - mainPanel.Children.Add(contentControl); - rowCount++; - - } - return settingWindow; - } - - public void Save() - { - if (Settings != null) - { - Helper.ValidateDirectory(Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name)); - File.WriteAllText(SettingPath, JsonSerializer.Serialize(Settings, settingSerializeOption)); - } - } - - public void UpdateSettings(Dictionary settings) - { - if (settings == null || settings.Count == 0) - return; - - foreach (var (key, value) in settings) - { - if (Settings.ContainsKey(key)) - { - Settings[key] = value; - } - if (_settingControls.ContainsKey(key)) - { - - switch (_settingControls[key]) - { - case TextBox textBox: - textBox.Dispatcher.Invoke(() => textBox.Text = value as string); - break; - case PasswordBox passwordBox: - passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string); - break; - case System.Windows.Controls.ComboBox comboBox: - comboBox.Dispatcher.Invoke(() => comboBox.SelectedItem = value); - break; - case CheckBox checkBox: - checkBox.Dispatcher.Invoke(() => checkBox.IsChecked = value is bool isChecked ? isChecked : bool.Parse(value as string)); - break; - } - } - } - } } - } diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs new file mode 100644 index 000000000..2ff076926 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs @@ -0,0 +1,176 @@ +using Flow.Launcher.Core.Resource; +using Flow.Launcher.Infrastructure; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Microsoft.IO; +using System.Windows; +using System.Windows.Controls; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using CheckBox = System.Windows.Controls.CheckBox; +using Control = System.Windows.Controls.Control; +using Orientation = System.Windows.Controls.Orientation; +using TextBox = System.Windows.Controls.TextBox; +using UserControl = System.Windows.Controls.UserControl; +using System.Windows.Documents; +using static System.Windows.Forms.LinkLabel; +using Droplex; +using System.Windows.Forms; +using Microsoft.VisualStudio.Threading; + +namespace Flow.Launcher.Core.Plugin +{ + /// + /// Represent the plugin that using JsonPRC + /// every JsonRPC plugin should has its own plugin instance + /// + internal abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable + { + protected PluginInitContext Context; + public const string JsonRPC = "JsonRPC"; + + 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 abstract List LoadContextMenus(Result selectedResult); + + private static readonly JsonSerializerOptions options = new() + { + PropertyNameCaseInsensitive = true, +#pragma warning disable SYSLIB0020 + // IgnoreNullValues is obsolete, but the replacement JsonIgnoreCondition.WhenWritingNull still + // deserializes null, instead of ignoring it and leaving the default (empty list). We can change the behaviour + // to accept null and fallback to a default etc, or just keep IgnoreNullValues for now + // see: https://github.com/dotnet/runtime/issues/39152 + IgnoreNullValues = true, +#pragma warning restore SYSLIB0020 // Type or member is obsolete + Converters = + { + new JsonObjectConverter() + } + }; + + private static readonly JsonSerializerOptions settingSerializeOption = new() + { + WriteIndented = true + }; + + private readonly Dictionary _settingControls = new(); + + protected abstract Task ExecuteResultAsync(JsonRPCResult result); + protected abstract Task> QueryRequestAsync(JsonRPCRequestModel request, CancellationToken token); + + protected PortableSettings Settings { get; set; } + + protected List ParseResults(JsonRPCQueryResponseModel queryResponseModel) + { + if (queryResponseModel.Result == null) return null; + + if (!string.IsNullOrEmpty(queryResponseModel.DebugMessage)) + { + Context.API.ShowMsg(queryResponseModel.DebugMessage); + } + + foreach (var result in queryResponseModel.Result) + { + result.AsyncAction = async c => + { + Settings.UpdateSettings(result.SettingsChange); + + return await ExecuteResultAsync(result); + }; + } + + var results = new List(); + + results.AddRange(queryResponseModel.Result); + + Settings.UpdateSettings(queryResponseModel.SettingsChange); + + return results; + } + + protected void ExecuteFlowLauncherAPI(string method, object[] parameters) + { + var parametersTypeArray = parameters.Select(param => param.GetType()).ToArray(); + var methodInfo = typeof(IPublicAPI).GetMethod(method, parametersTypeArray); + + if (methodInfo == null) + { + return; + } + + try + { + methodInfo.Invoke(Context.API, parameters); + } + catch (Exception) + { +#if (DEBUG) + throw; +#endif + } + } + + public async Task> QueryAsync(Query query, CancellationToken token) + { + var request = new JsonRPCRequestModel(RequestId++, + "query", + new object[] + { + query.Search + }, + Settings.Inner); + + return await QueryRequestAsync(request, token); + + } + + + private async Task InitSettingAsync() + { + if (!File.Exists(SettingConfigurationPath)) + return; + + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); + var configuration = deserializer.Deserialize(await File.ReadAllTextAsync(SettingConfigurationPath)); + + Settings ??= new PortableSettings + { + Configuration = configuration, + SettingPath = SettingPath, + API = Context.API + }; + + } + + public virtual async Task InitAsync(PluginInitContext context) + { + this.Context = context; + await InitSettingAsync(); + } + + public void Save() + { + Settings.Save(); + } + public Control CreateSettingPanel() + { + return Settings.CreateSettingPanel(); + } + } + +} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs index 0df090cd0..3bef2f191 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs @@ -64,8 +64,8 @@ namespace Flow.Launcher.Core.Plugin query }); await InputMessageChannel.Writer.WriteAsync(message, token); - await Task.Delay(50); - await InputStream.FlushAsync(); + await Task.Delay(50, token); + await InputStream.FlushAsync(token); var task = new TaskCompletionSource(); RequestTaskDictionary[currentRequestId] = task; var result = await task.Task; diff --git a/Flow.Launcher.Core/Plugin/PortableSettings.cs b/Flow.Launcher.Core/Plugin/PortableSettings.cs new file mode 100644 index 000000000..36d09c3f1 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/PortableSettings.cs @@ -0,0 +1,402 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using Flow.Launcher.Infrastructure.Storage; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin +{ + public class PortableSettings + { + public required JsonRpcConfigurationModel Configuration { get; init; } + + public required string SettingPath { get; init; } + public Dictionary SettingControls { get; } = new(); + + public IReadOnlyDictionary Inner => Settings; + protected Dictionary Settings { get; set; } + public required IPublicAPI API { get; init; } + + private JsonStorage> _storage; + + // maybe move to resource? + private static readonly Thickness settingControlMargin = new(0, 9, 18, 9); + private static readonly Thickness settingCheckboxMargin = new(0, 9, 9, 9); + private static readonly Thickness settingPanelMargin = new(0, 0, 0, 0); + private static readonly Thickness settingTextBlockMargin = new(70, 9, 18, 9); + private static readonly Thickness settingLabelPanelMargin = new(70, 9, 18, 9); + private static readonly Thickness settingLabelMargin = new(0, 0, 0, 0); + private static readonly Thickness settingDescMargin = new(0, 2, 0, 0); + private static readonly Thickness settingSepMargin = new(0, 0, 0, 2); + + public async Task InitializeAsync() + { + _storage = new JsonStorage>(SettingPath); + Settings = await _storage.LoadAsync(); + } + + + public void UpdateSettings(IReadOnlyDictionary settings) + { + if (settings == null || settings.Count == 0) + return; + + foreach (var (key, value) in settings) + { + if (Settings.ContainsKey(key)) + { + Settings[key] = value; + } + + if (SettingControls.TryGetValue(key, out var control)) + { + switch (control) + { + case TextBox textBox: + textBox.Dispatcher.Invoke(() => textBox.Text = value as string ?? string.Empty); + break; + case PasswordBox passwordBox: + passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string ?? string.Empty); + break; + case ComboBox comboBox: + comboBox.Dispatcher.Invoke(() => comboBox.SelectedItem = value); + break; + case CheckBox checkBox: + checkBox.Dispatcher.Invoke(() => checkBox.IsChecked = value is bool isChecked ? isChecked : bool.Parse(value as string ?? string.Empty)); + break; + } + } + } + } + + public async Task SaveAsync() + { + await _storage.SaveAsync(); + } + + public void Save() + { + _storage.Save(); + } + + public Control CreateSettingPanel() + { + if (Settings == null) + return new(); + + var settingWindow = new UserControl(); + var mainPanel = new Grid + { + Margin = settingPanelMargin, VerticalAlignment = VerticalAlignment.Center + }; + + ColumnDefinition gridCol1 = new ColumnDefinition(); + ColumnDefinition gridCol2 = new ColumnDefinition(); + + gridCol1.Width = new GridLength(70, GridUnitType.Star); + gridCol2.Width = new GridLength(30, GridUnitType.Star); + mainPanel.ColumnDefinitions.Add(gridCol1); + mainPanel.ColumnDefinitions.Add(gridCol2); + settingWindow.Content = mainPanel; + int rowCount = 0; + + foreach (var (type, attribute) in Configuration.Body) + { + Separator sep = new Separator(); + sep.VerticalAlignment = VerticalAlignment.Top; + sep.Margin = settingSepMargin; + sep.SetResourceReference(Separator.BackgroundProperty, "Color03B"); /* for theme change */ + var panel = new StackPanel + { + Orientation = Orientation.Vertical, + VerticalAlignment = VerticalAlignment.Center, + Margin = settingLabelPanelMargin + }; + + RowDefinition gridRow = new RowDefinition(); + mainPanel.RowDefinitions.Add(gridRow); + var name = new TextBlock() + { + Text = attribute.Label, + VerticalAlignment = VerticalAlignment.Center, + Margin = settingLabelMargin, + TextWrapping = TextWrapping.WrapWithOverflow + }; + + var desc = new TextBlock() + { + Text = attribute.Description, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + Margin = settingDescMargin, + TextWrapping = TextWrapping.WrapWithOverflow + }; + + desc.SetResourceReference(TextBlock.ForegroundProperty, "Color04B"); + + if (attribute.Description == null) /* if no description, hide */ + desc.Visibility = Visibility.Collapsed; + + + if (type != "textBlock") /* if textBlock, hide desc */ + { + panel.Children.Add(name); + panel.Children.Add(desc); + } + + + Grid.SetColumn(panel, 0); + Grid.SetRow(panel, rowCount); + + FrameworkElement contentControl; + + switch (type) + { + case "textBlock": + { + contentControl = new TextBlock + { + Text = attribute.Description.Replace("\\r\\n", "\r\n"), + Margin = settingTextBlockMargin, + Padding = new Thickness(0, 0, 0, 0), + HorizontalAlignment = System.Windows.HorizontalAlignment.Left, + TextAlignment = TextAlignment.Left, + TextWrapping = TextWrapping.Wrap + }; + + Grid.SetColumn(contentControl, 0); + Grid.SetColumnSpan(contentControl, 2); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "input": + { + var textBox = new TextBox() + { + Text = Settings[attribute.Name] as string ?? string.Empty, + Margin = settingControlMargin, + HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, + ToolTip = attribute.Description + }; + + textBox.TextChanged += (_, _) => + { + Settings[attribute.Name] = textBox.Text; + }; + + contentControl = textBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "inputWithFileBtn": + { + var textBox = new TextBox() + { + Margin = new Thickness(10, 0, 0, 0), + Text = Settings[attribute.Name] as string ?? string.Empty, + HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, + ToolTip = attribute.Description + }; + + textBox.TextChanged += (_, _) => + { + Settings[attribute.Name] = textBox.Text; + }; + + var Btn = new System.Windows.Controls.Button() + { + Margin = new Thickness(10, 0, 0, 0), Content = "Browse" + }; + + var dockPanel = new DockPanel() + { + Margin = settingControlMargin + }; + + DockPanel.SetDock(Btn, Dock.Right); + dockPanel.Children.Add(Btn); + dockPanel.Children.Add(textBox); + contentControl = dockPanel; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "textarea": + { + var textBox = new TextBox() + { + Height = 120, + Margin = settingControlMargin, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.WrapWithOverflow, + AcceptsReturn = true, + HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, + Text = Settings[attribute.Name] as string ?? string.Empty, + ToolTip = attribute.Description + }; + + textBox.TextChanged += (sender, _) => + { + Settings[attribute.Name] = ((TextBox)sender).Text; + }; + + contentControl = textBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "passwordBox": + { + var passwordBox = new PasswordBox() + { + Margin = settingControlMargin, + Password = Settings[attribute.Name] as string ?? string.Empty, + PasswordChar = attribute.passwordChar == default ? '*' : attribute.passwordChar, + HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, + ToolTip = attribute.Description + }; + + passwordBox.PasswordChanged += (sender, _) => + { + Settings[attribute.Name] = ((PasswordBox)sender).Password; + }; + + contentControl = passwordBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "dropdown": + { + var comboBox = new System.Windows.Controls.ComboBox() + { + ItemsSource = attribute.Options, + SelectedItem = Settings[attribute.Name], + Margin = settingControlMargin, + HorizontalAlignment = System.Windows.HorizontalAlignment.Right, + ToolTip = attribute.Description + }; + + comboBox.SelectionChanged += (sender, _) => + { + Settings[attribute.Name] = (string)((System.Windows.Controls.ComboBox)sender).SelectedItem; + }; + + contentControl = comboBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + } + case "checkbox": + var checkBox = new CheckBox + { + IsChecked = Settings[attribute.Name] is bool isChecked ? isChecked : bool.Parse(attribute.DefaultValue), + Margin = settingCheckboxMargin, + HorizontalAlignment = System.Windows.HorizontalAlignment.Right, + ToolTip = attribute.Description + }; + + checkBox.Click += (sender, _) => + { + Settings[attribute.Name] = ((CheckBox)sender).IsChecked; + }; + + contentControl = checkBox; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + case "hyperlink": + var hyperlink = new Hyperlink + { + ToolTip = attribute.Description, NavigateUri = attribute.url + }; + + var linkbtn = new System.Windows.Controls.Button + { + HorizontalAlignment = System.Windows.HorizontalAlignment.Right, Margin = settingControlMargin + }; + + linkbtn.Content = attribute.urlLabel; + + contentControl = linkbtn; + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + if (rowCount != 0) + mainPanel.Children.Add(sep); + + Grid.SetRow(sep, rowCount); + Grid.SetColumn(sep, 0); + Grid.SetColumnSpan(sep, 2); + + break; + default: + continue; + } + + if (type != "textBlock") + SettingControls[attribute.Name] = contentControl; + + mainPanel.Children.Add(panel); + mainPanel.Children.Add(contentControl); + rowCount++; + + } + + return settingWindow; + } + + + } +} diff --git a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs index 45456ddeb..7181ae225 100644 --- a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs @@ -3,6 +3,7 @@ using System; using System.Globalization; using System.IO; using System.Text.Json; +using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; namespace Flow.Launcher.Infrastructure.Storage @@ -26,6 +27,82 @@ namespace Flow.Launcher.Infrastructure.Storage protected string DirectoryPath { get; init; } = null!; + // Let the derived class to set the file path + protected JsonStorage() + { + } + public JsonStorage(string filePath) + { + FilePath = filePath; + } + + public async Task LoadAsync() + { + if (Data != null) + return Data; + + string? serialized = null; + + if (File.Exists(FilePath)) + { + serialized = await File.ReadAllTextAsync(FilePath); + } + + if (!string.IsNullOrEmpty(serialized)) + { + try + { + Data = JsonSerializer.Deserialize(serialized) ?? await LoadBackupOrDefaultAsync(); + } + catch (JsonException) + { + Data = await LoadBackupOrDefaultAsync(); + } + } + else + { + Data = await LoadBackupOrDefaultAsync(); + } + + return Data.NonNull(); + } + + private async ValueTask LoadBackupOrDefaultAsync() + { + var backup = await TryLoadBackupAsync(); + + return backup ?? LoadDefault(); + } + + private async ValueTask TryLoadBackupAsync() + { + if (!File.Exists(BackupFilePath)) + return default; + + try + { + await using var source = File.OpenRead(BackupFilePath); + var data = await JsonSerializer.DeserializeAsync(source) ?? default; + + if (data != null) + RestoreBackup(); + + return data; + } + catch (JsonException) + { + return default; + } + } + private void RestoreBackup() + { + Log.Info($"|JsonStorage.Load|Failed to load settings.json, {BackupFilePath} restored successfully"); + + if (File.Exists(FilePath)) + File.Replace(BackupFilePath, FilePath, null); + else + File.Move(BackupFilePath, FilePath); + } public T Load() { @@ -75,18 +152,9 @@ namespace Flow.Launcher.Infrastructure.Storage var data = JsonSerializer.Deserialize(File.ReadAllText(BackupFilePath)); if (data != null) - { - Log.Info($"|JsonStorage.Load|Failed to load settings.json, {BackupFilePath} restored successfully"); - - if(File.Exists(FilePath)) - File.Replace(BackupFilePath, FilePath, null); - else - File.Move(BackupFilePath, FilePath); + RestoreBackup(); - return data; - } - - return default; + return data; } catch (JsonException) { @@ -115,6 +183,20 @@ namespace Flow.Launcher.Infrastructure.Storage File.WriteAllText(TempFilePath, serialized); + AtomicWriteSetting(); + } + public async Task SaveAsync() + { + var tempOutput = File.OpenWrite(TempFilePath); + await JsonSerializer.SerializeAsync(tempOutput, Data, + new JsonSerializerOptions + { + WriteIndented = true + }); + AtomicWriteSetting(); + } + private void AtomicWriteSetting() + { if (!File.Exists(FilePath)) { File.Move(TempFilePath, FilePath); @@ -124,5 +206,6 @@ namespace Flow.Launcher.Infrastructure.Storage File.Replace(TempFilePath, FilePath, BackupFilePath); } } + } }