diff --git a/Needlework.Net.Core.Tests/Needlework.Net.Core.Tests.csproj b/Needlework.Net.Core.Tests/Needlework.Net.Core.Tests.csproj index 99ec30c..48930a3 100644 --- a/Needlework.Net.Core.Tests/Needlework.Net.Core.Tests.csproj +++ b/Needlework.Net.Core.Tests/Needlework.Net.Core.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0-windows enable enable diff --git a/Needlework.Net.Core.Tests/ResourcesTest.cs b/Needlework.Net.Core.Tests/ResourcesTest.cs index e0ceb0f..22ef6a0 100644 --- a/Needlework.Net.Core.Tests/ResourcesTest.cs +++ b/Needlework.Net.Core.Tests/ResourcesTest.cs @@ -1,5 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; using Xunit.Abstractions; namespace Needlework.Net.Core.Tests; diff --git a/Needlework.Net.Core/LcuConnector.cs b/Needlework.Net.Core/LcuConnector.cs deleted file mode 100644 index 475c1b7..0000000 --- a/Needlework.Net.Core/LcuConnector.cs +++ /dev/null @@ -1,10 +0,0 @@ -using BlossomiShymae.GrrrLCU; - -namespace Needlework.Net.Core; - -public static class LcuConnector -{ - public static Func GetProcessInfo { get; } = Connector.GetProcessInfo; - public static Func GetLeagueClientUri { get; } = Connector.GetLeagueClientUri; - public static Func> SendAsync { get; } = Connector.SendAsync; -} \ No newline at end of file diff --git a/Needlework.Net.Core/LcuSchemaHandler.cs b/Needlework.Net.Core/LcuSchemaHandler.cs index 5025ed0..ab910a8 100644 --- a/Needlework.Net.Core/LcuSchemaHandler.cs +++ b/Needlework.Net.Core/LcuSchemaHandler.cs @@ -4,46 +4,69 @@ namespace Needlework.Net.Core; public class LcuSchemaHandler { - internal OpenApiDocument OpenApiDocument { get; } + internal OpenApiDocument OpenApiDocument { get; } - public SortedDictionary Plugins { get; } = []; + public SortedDictionary> Plugins { get; } public OpenApiInfo Info => OpenApiDocument.Info; + public List Paths => [.. OpenApiDocument.Paths.Keys]; + public LcuSchemaHandler(OpenApiDocument openApiDocument) { OpenApiDocument = openApiDocument; + var plugins = new SortedDictionary>(); - // Group paths by plugins - foreach (var tag in OpenApiDocument.Tags) + foreach ((var path, var pathItem) in openApiDocument.Paths) { - foreach (var path in OpenApiDocument.Paths) + foreach ((var method, var operation) in pathItem.Operations) { - var containsTag = false; - var sentinelTag = string.Empty; + var operations = new List(); + var pluginsKey = "_unknown"; - foreach (var operation in path.Value.Operations) + // Process and group endpoints into the following formats: + // "_unknown" - group that should not be possible + // "default" - no tags + // "builtin" - 'builtin' not associated with an endpoint + // "lol-summoner" etc. - 'plugin' associated with an endpoint + // "performance", "tracing", etc. + if (operation.Tags.Count == 0) { - foreach (var operationTag in operation.Value.Tags) + pluginsKey = "default"; + if (plugins.TryGetValue(pluginsKey, out var p)) + p.Add(new(method.ToString(), path, operation)); + else { - var lhs = tag.Name.Replace("Plugin ", string.Empty); - var rhs = operationTag.Name.Replace("Plugin ", string.Empty); - - if (lhs.Equals(rhs, StringComparison.OrdinalIgnoreCase)) + operations.Add(new(method.ToString(), path, operation)); + plugins[pluginsKey] = operations; + } + } + else + { + foreach (var tag in operation.Tags) + { + var lowercaseTag = tag.Name.ToLower(); + if (lowercaseTag == "plugins") + continue; + else if (lowercaseTag.Contains("plugin ")) + pluginsKey = lowercaseTag.Replace("plugin ", ""); + else + pluginsKey = lowercaseTag; + + if (plugins.TryGetValue(pluginsKey, out var p)) + p.Add(new(method.ToString(), path, operation)); + else { - containsTag = true; - sentinelTag = lhs.ToLower(); - break; // Break early since all operations in a path share the same tags + operations.Add(new(method.ToString(), path, operation)); + plugins[pluginsKey] = operations; } } - - if (containsTag) - break; // Ditto } - - if (containsTag) - Plugins[sentinelTag] = path.Value; } } + + Plugins = plugins; } -} \ No newline at end of file +} + +public record PathOperation(string Method, string Path, OpenApiOperation Operation); \ No newline at end of file diff --git a/Needlework.Net.Core/Needlework.Net.Core.csproj b/Needlework.Net.Core/Needlework.Net.Core.csproj index c561f21..9fcb20c 100644 --- a/Needlework.Net.Core/Needlework.Net.Core.csproj +++ b/Needlework.Net.Core/Needlework.Net.Core.csproj @@ -1,13 +1,12 @@  - net8.0 + net8.0-windows enable enable - diff --git a/Needlework.Net.Desktop/App.axaml b/Needlework.Net.Desktop/App.axaml index b7d474f..14ba17e 100644 --- a/Needlework.Net.Desktop/App.axaml +++ b/Needlework.Net.Desktop/App.axaml @@ -3,6 +3,7 @@ x:Class="Needlework.Net.Desktop.App" RequestedThemeVariant="Dark" xmlns:local="using:Needlework.Net.Desktop" + xmlns:converters="using:Needlework.Net.Desktop.Converters" xmlns:sukiUi="clr-namespace:SukiUI;assembly=SukiUI" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"> @@ -12,8 +13,14 @@ - + + + + + + + \ No newline at end of file diff --git a/Needlework.Net.Desktop/App.axaml.cs b/Needlework.Net.Desktop/App.axaml.cs index 932f95f..2ec30c9 100644 --- a/Needlework.Net.Desktop/App.axaml.cs +++ b/Needlework.Net.Desktop/App.axaml.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Microsoft.Extensions.DependencyInjection; @@ -15,9 +16,14 @@ public partial class App(IServiceProvider serviceProvider) : Application public static JsonSerializerOptions JsonSerializerOptions { get; } = new() { - WriteIndented = true + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + public static readonly int MaxCharacters = 10_000; + + public static Window? MainWindow; + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -31,8 +37,11 @@ public partial class App(IServiceProvider serviceProvider) : Application { DataContext = _serviceProvider.GetRequiredService() }; + MainWindow = desktop.MainWindow; } + + base.OnFrameworkInitializationCompleted(); } } \ No newline at end of file diff --git a/Needlework.Net.Desktop/Assets/app.ico b/Needlework.Net.Desktop/Assets/app.ico new file mode 100644 index 0000000..fd479da Binary files /dev/null and b/Needlework.Net.Desktop/Assets/app.ico differ diff --git a/Needlework.Net.Desktop/Assets/app.png b/Needlework.Net.Desktop/Assets/app.png new file mode 100644 index 0000000..944d940 Binary files /dev/null and b/Needlework.Net.Desktop/Assets/app.png differ diff --git a/Needlework.Net.Desktop/Converters/EnumerableBoolConverter.cs b/Needlework.Net.Desktop/Converters/EnumerableBoolConverter.cs new file mode 100644 index 0000000..0a0a19b --- /dev/null +++ b/Needlework.Net.Desktop/Converters/EnumerableBoolConverter.cs @@ -0,0 +1,22 @@ +using Avalonia.Data.Converters; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Needlework.Net.Desktop.Converters +{ + public class EnumerableBoolConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is IEnumerable values) return values.Any(); + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Needlework.Net.Desktop/Converters/NullBoolConverter.cs b/Needlework.Net.Desktop/Converters/NullBoolConverter.cs new file mode 100644 index 0000000..6fda3d2 --- /dev/null +++ b/Needlework.Net.Desktop/Converters/NullBoolConverter.cs @@ -0,0 +1,19 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; + +namespace Needlework.Net.Desktop.Converters +{ + public class NullBoolConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value != null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Needlework.Net.Desktop/Extensions/TextEditorExtensions.cs b/Needlework.Net.Desktop/Extensions/TextEditorExtensions.cs new file mode 100644 index 0000000..273a6d3 --- /dev/null +++ b/Needlework.Net.Desktop/Extensions/TextEditorExtensions.cs @@ -0,0 +1,29 @@ +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.Indentation.CSharp; + +namespace Needlework.Net.Desktop.Extensions +{ + public static class TextEditorExtensions + { + public static void ApplyJsonEditorSettings(this TextEditor textEditor) + { + textEditor.TextArea.IndentationStrategy = new CSharpIndentationStrategy(textEditor.Options); + textEditor.TextArea.RightClickMovesCaret = true; + textEditor.TextArea.Options.EnableHyperlinks = false; + textEditor.TextArea.Options.EnableEmailHyperlinks = false; + textEditor.SyntaxHighlighting = HighlightingManager.Instance.GetDefinition("Json"); + + var purple = Color.FromRgb(189, 147, 249); + var yellow = Color.FromRgb(241, 250, 140); + var cyan = Color.FromRgb(139, 233, 253); + textEditor.SyntaxHighlighting.GetNamedColor("Bool").Foreground = new SimpleHighlightingBrush(purple); + textEditor.SyntaxHighlighting.GetNamedColor("Number").Foreground = new SimpleHighlightingBrush(purple); + textEditor.SyntaxHighlighting.GetNamedColor("String").Foreground = new SimpleHighlightingBrush(yellow); + textEditor.SyntaxHighlighting.GetNamedColor("Null").Foreground = new SimpleHighlightingBrush(purple); + textEditor.SyntaxHighlighting.GetNamedColor("FieldName").Foreground = new SimpleHighlightingBrush(cyan); + textEditor.SyntaxHighlighting.GetNamedColor("Punctuation").Foreground = new SimpleHighlightingBrush(yellow); + } + } +} diff --git a/Needlework.Net.Desktop/Messages/ContentRequestMessage.cs b/Needlework.Net.Desktop/Messages/ContentRequestMessage.cs new file mode 100644 index 0000000..d4180d9 --- /dev/null +++ b/Needlework.Net.Desktop/Messages/ContentRequestMessage.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace Needlework.Net.Desktop.Messages +{ + public class ContentRequestMessage : RequestMessage + { + } +} diff --git a/Needlework.Net.Desktop/Messages/DataReadyMessage.cs b/Needlework.Net.Desktop/Messages/DataReadyMessage.cs new file mode 100644 index 0000000..4e27679 --- /dev/null +++ b/Needlework.Net.Desktop/Messages/DataReadyMessage.cs @@ -0,0 +1,9 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; +using Needlework.Net.Core; + +namespace Needlework.Net.Desktop.Messages +{ + public class DataReadyMessage(LcuSchemaHandler handler) : ValueChangedMessage(handler) + { + } +} diff --git a/Needlework.Net.Desktop/Messages/DataRequestMessage.cs b/Needlework.Net.Desktop/Messages/DataRequestMessage.cs new file mode 100644 index 0000000..235f73f --- /dev/null +++ b/Needlework.Net.Desktop/Messages/DataRequestMessage.cs @@ -0,0 +1,9 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; +using Needlework.Net.Core; + +namespace Needlework.Net.Desktop.Messages +{ + public class DataRequestMessage : RequestMessage + { + } +} diff --git a/Needlework.Net.Desktop/Messages/EditorUpdateMessage.cs b/Needlework.Net.Desktop/Messages/EditorUpdateMessage.cs new file mode 100644 index 0000000..c201985 --- /dev/null +++ b/Needlework.Net.Desktop/Messages/EditorUpdateMessage.cs @@ -0,0 +1,20 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace Needlework.Net.Desktop.Messages +{ + public class EditorUpdateMessage(EditorUpdate editorUpdate) : ValueChangedMessage(editorUpdate) + { + } + + public class EditorUpdate + { + public string Text { get; } + public string Key { get; } + + public EditorUpdate(string text, string key) + { + Text = text; + Key = key; + } + } +} diff --git a/Needlework.Net.Desktop/Messages/HostDocumentRequestMessage.cs b/Needlework.Net.Desktop/Messages/HostDocumentRequestMessage.cs new file mode 100644 index 0000000..de7f98b --- /dev/null +++ b/Needlework.Net.Desktop/Messages/HostDocumentRequestMessage.cs @@ -0,0 +1,9 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; +using Microsoft.OpenApi.Models; + +namespace Needlework.Net.Desktop.Messages +{ + public class HostDocumentRequestMessage : RequestMessage + { + } +} diff --git a/Needlework.Net.Desktop/Messages/OopsiesWindowCanceledMessage.cs b/Needlework.Net.Desktop/Messages/OopsiesWindowCanceledMessage.cs new file mode 100644 index 0000000..9b766ff --- /dev/null +++ b/Needlework.Net.Desktop/Messages/OopsiesWindowCanceledMessage.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace Needlework.Net.Desktop.Messages +{ + public class OopsiesWindowCanceledMessage(object? data) : ValueChangedMessage(data) + { + } +} diff --git a/Needlework.Net.Desktop/Messages/OopsiesWindowRequestedMessage.cs b/Needlework.Net.Desktop/Messages/OopsiesWindowRequestedMessage.cs new file mode 100644 index 0000000..8288e7a --- /dev/null +++ b/Needlework.Net.Desktop/Messages/OopsiesWindowRequestedMessage.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace Needlework.Net.Desktop.Messages +{ + public class OopsiesWindowRequestedMessage(string text) : ValueChangedMessage(text) + { + } +} diff --git a/Needlework.Net.Desktop/Messages/ResponseUpdatedMessage.cs b/Needlework.Net.Desktop/Messages/ResponseUpdatedMessage.cs new file mode 100644 index 0000000..9ff78f3 --- /dev/null +++ b/Needlework.Net.Desktop/Messages/ResponseUpdatedMessage.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace Needlework.Net.Desktop.Messages +{ + public class ResponseUpdatedMessage(string data) : ValueChangedMessage(data) + { + } +} diff --git a/Needlework.Net.Desktop/Needlework.Net.Desktop.csproj b/Needlework.Net.Desktop/Needlework.Net.Desktop.csproj index b0bd5f6..65f45be 100644 --- a/Needlework.Net.Desktop/Needlework.Net.Desktop.csproj +++ b/Needlework.Net.Desktop/Needlework.Net.Desktop.csproj @@ -1,7 +1,7 @@  WinExe - net8.0-windows10.0.17763.0 + net8.0-windows enable true app.manifest @@ -9,7 +9,7 @@ False - 10.0.17763.0 + app.ico 0.1.0.0 0.1.0.0 False @@ -18,12 +18,15 @@ + + + @@ -45,4 +48,17 @@ + + + + EndpointView.axaml + + + OopsiesWindow.axaml + + + + + + diff --git a/Needlework.Net.Desktop/Program.cs b/Needlework.Net.Desktop/Program.cs index 2dba233..d18f44a 100644 --- a/Needlework.Net.Desktop/Program.cs +++ b/Needlework.Net.Desktop/Program.cs @@ -35,7 +35,7 @@ class Program var builder = new ServiceCollection(); builder.AddSingleton(); - builder.AddSingleton(); + builder.AddSingleton(); // Dynamically add ViewModels var types = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(s => s.GetTypes()) diff --git a/Needlework.Net.Desktop/Services/DialogService.cs b/Needlework.Net.Desktop/Services/DialogService.cs deleted file mode 100644 index b8f68e4..0000000 --- a/Needlework.Net.Desktop/Services/DialogService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Needlework.Net.Desktop.ViewModels; -using Needlework.Net.Desktop.Views; -using SukiUI.Controls; -using System; -using System.Collections.Generic; - -namespace Needlework.Net.Desktop.Services -{ - public class DialogService - { - public IServiceProvider ServiceProvider { get; } - - public Dictionary Dialogs { get; } = []; - - public DialogService(IServiceProvider serviceProvider) - { - ServiceProvider = serviceProvider; - } - - public void ShowEndpoint(string endpoint) - { - if (!Dialogs.TryGetValue(endpoint, out var _)) - { - var dialog = new EndpointView(); - dialog.DataContext = new EndpointViewModel(endpoint); - dialog.Show(); - dialog.Closed += OnDialogClosed; - Dialogs[endpoint] = dialog; - } - } - - private void OnDialogClosed(object? sender, EventArgs e) - { - if (sender == null) - return; - - var dialog = (SukiWindow)sender; - if (dialog.DataContext is EndpointViewModel vm) - { - Dialogs.Remove(vm.Endpoint); - dialog.DataContext = null; - } - - dialog.Closed -= OnDialogClosed; - } - } -} diff --git a/Needlework.Net.Desktop/Services/LcuService.cs b/Needlework.Net.Desktop/Services/LcuService.cs deleted file mode 100644 index aa1acd3..0000000 --- a/Needlework.Net.Desktop/Services/LcuService.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Avalonia.Media; -using CommunityToolkit.Mvvm.ComponentModel; -using Needlework.Net.Core; -using System.Net.Http; -using System.Threading.Tasks; - -namespace Needlework.Net.Desktop.Services -{ - public partial class LcuService : ObservableObject - { - public HttpClient HttpClient { get; } - - public LcuSchemaHandler LcuSchemaHandler { get; } - - [ObservableProperty] private string _statusText = "Offline"; - [ObservableProperty] private IBrush _statusColor = new SolidColorBrush(Colors.Red.ToUInt32()); - [ObservableProperty] private string _statusAddress = "N/A"; - - public LcuService(HttpClient httpClient) - { - HttpClient = httpClient; - - Task.Run(ProcessBackground); - } - - private void ProcessBackground() - { - - } - } -} diff --git a/Needlework.Net.Desktop/Services/WindowService.cs b/Needlework.Net.Desktop/Services/WindowService.cs new file mode 100644 index 0000000..a177ac6 --- /dev/null +++ b/Needlework.Net.Desktop/Services/WindowService.cs @@ -0,0 +1,53 @@ +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Messages; +using Needlework.Net.Desktop.ViewModels; +using Needlework.Net.Desktop.Views; +using SukiUI.Controls; +using System; +using System.Collections.Generic; + +namespace Needlework.Net.Desktop.Services +{ + public class WindowService : IRecipient + { + public IServiceProvider ServiceProvider { get; } + + public Dictionary EndpointWindows { get; } = []; // Workaround memory leak by storing and reusing windows. + // Figure out why creating and closing windows leaks memory. + + public OopsiesWindow? OopsiesWindow { get; set; } + + public WindowService(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + + WeakReferenceMessenger.Default.Register(this); + } + + public void ShowOopsiesWindow(string text) + { + if (OopsiesWindow != null) OopsiesWindow!.Close(); + + var window = new OopsiesWindow(); + window.DataContext = new OopsiesWindowViewModel(text); + window.Show(App.MainWindow!); + window.Closed += OnOopsiesWindowClosed; + OopsiesWindow = window; + } + + public void OnOopsiesWindowClosed(object? sender, EventArgs e) + { + if (sender == null) return; + + var window = (OopsiesWindow)sender; + window.DataContext = null; + window.Closed -= OnOopsiesWindowClosed; + OopsiesWindow = null; + } + + public void Receive(OopsiesWindowCanceledMessage message) + { + if (OopsiesWindow is OopsiesWindow window) window.Close(); + } + } +} diff --git a/Needlework.Net.Desktop/TextUpdatedEventArgs.cs b/Needlework.Net.Desktop/TextUpdatedEventArgs.cs deleted file mode 100644 index 246efe2..0000000 --- a/Needlework.Net.Desktop/TextUpdatedEventArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace Needlework.Net.Desktop -{ - public class TextUpdatedEventArgs(string text) : EventArgs - { - public string Text { get; } = text; - } -} diff --git a/Needlework.Net.Desktop/ViewLocator.cs b/Needlework.Net.Desktop/ViewLocator.cs index 3f8142b..6d14bf6 100644 --- a/Needlework.Net.Desktop/ViewLocator.cs +++ b/Needlework.Net.Desktop/ViewLocator.cs @@ -1,7 +1,7 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; -using Needlework.Net.Desktop.ViewModels; using System; +using System.ComponentModel; namespace Needlework.Net.Desktop { @@ -9,27 +9,20 @@ namespace Needlework.Net.Desktop { public Control? Build(object? param) { - if (param is null) - return new TextBlock { Text = "data was null" }; + if (param is null) return new TextBlock { Text = "data was null" }; - var name = param.GetType().FullName!.Replace("ViewModels", "Views") + var name = param.GetType().FullName! + .Replace("ViewModels", "Views") .Replace("ViewModel", "View"); var type = Type.GetType(name); - if (type != null) - { - return (Control)Activator.CreateInstance(type)!; - } - else - { - return new TextBlock { Text = "Not Found: " + name }; - } + if (type != null) return (Control)Activator.CreateInstance(type)!; + else return new TextBlock { Text = "Not Found: " + name }; } public bool Match(object? data) { - if (data is PageBase) return true; - return false; + return data is INotifyPropertyChanged; } } } diff --git a/Needlework.Net.Desktop/ViewModels/ConsoleViewModel.cs b/Needlework.Net.Desktop/ViewModels/ConsoleViewModel.cs index 4226126..4787a58 100644 --- a/Needlework.Net.Desktop/ViewModels/ConsoleViewModel.cs +++ b/Needlework.Net.Desktop/ViewModels/ConsoleViewModel.cs @@ -2,17 +2,25 @@ using BlossomiShymae.GrrrLCU; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Messages; +using Needlework.Net.Desktop.Services; +using Needlework.Net.Desktop.Views; using SukiUI.Controls; using System; using System.Net.Http; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace Needlework.Net.Desktop.ViewModels { - public partial class ConsoleViewModel : PageBase + public partial class ConsoleViewModel : PageBase, IRecipient, IRecipient { public IAvaloniaReadOnlyList RequestMethods { get; } = new AvaloniaList(["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS", "TRACE"]); + [ObservableProperty] private bool _isBusy = true; + [ObservableProperty] private bool _isRequestBusy = false; + [ObservableProperty] private IAvaloniaReadOnlyList _requestPaths = new AvaloniaList(); [ObservableProperty] private string? _requestMethodSelected = "GET"; [ObservableProperty] private string? _requestPath = null; [ObservableProperty] private string? _requestBody = null; @@ -20,10 +28,14 @@ namespace Needlework.Net.Desktop.ViewModels [ObservableProperty] private string? _responseStatus = null; [ObservableProperty] private string? _responseAuthentication = null; - public event EventHandler? ResponseBodyUpdated; + public WindowService WindowService { get; } - public ConsoleViewModel() : base("Console", Material.Icons.MaterialIconKind.Console, -100) + public ConsoleViewModel(WindowService windowService) : base("Console", Material.Icons.MaterialIconKind.Console, -200) { + WindowService = windowService; + + WeakReferenceMessenger.Default.Register(this, nameof(ConsoleView)); + WeakReferenceMessenger.Default.Register(this); } [RelayCommand] @@ -31,6 +43,7 @@ namespace Needlework.Net.Desktop.ViewModels { try { + IsRequestBusy = true; if (string.IsNullOrEmpty(RequestPath)) throw new Exception("Path is empty."); var method = RequestMethodSelected switch @@ -47,29 +60,43 @@ namespace Needlework.Net.Desktop.ViewModels }; var processInfo = Connector.GetProcessInfo(); - var response = await Connector.SendAsync(method, RequestPath) ?? throw new Exception("Response is null."); + var requestBody = WeakReferenceMessenger.Default.Send(new ContentRequestMessage(), "ConsoleRequestEditor").Response; + var content = new StringContent(Regex.Replace(requestBody, @"\s+", ""), new System.Net.Http.Headers.MediaTypeHeaderValue("application/json")); + var response = await Connector.SendAsync(method, RequestPath, content) ?? throw new Exception("Response is null."); var riotAuthentication = new RiotAuthentication(processInfo.RemotingAuthToken); var body = await response.Content.ReadAsStringAsync(); - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - ResponseStatus = response.StatusCode.ToString(); - ResponsePath = $"https://127.0.0.1/{processInfo.AppPort}{RequestPath}"; - ResponseAuthentication = riotAuthentication.Value; - ResponseBodyUpdated?.Invoke(this, new(body)); - }); + ResponseStatus = response.StatusCode.ToString(); + ResponsePath = $"https://127.0.0.1:{processInfo.AppPort}{RequestPath}"; + ResponseAuthentication = $"Basic {riotAuthentication.Value}"; + WeakReferenceMessenger.Default.Send(new ResponseUpdatedMessage(body), nameof(ConsoleViewModel)); } catch (Exception ex) { await SukiHost.ShowToast("Request Failed", ex.Message, SukiUI.Enums.NotificationType.Error); - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - ResponseStatus = null; - ResponsePath = null; - ResponseAuthentication = null; - ResponseBodyUpdated?.Invoke(this, new(string.Empty)); - }); + ResponseStatus = null; + ResponsePath = null; + ResponseAuthentication = null; + WeakReferenceMessenger.Default.Send(new ResponseUpdatedMessage(string.Empty), nameof(ConsoleViewModel)); } + finally + { + IsRequestBusy = false; + } + } + + public void Receive(OopsiesWindowRequestedMessage message) + { + WindowService.ShowOopsiesWindow(message.Value); + } + + public void Receive(DataReadyMessage message) + { + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + RequestPaths = new AvaloniaList([.. message.Value.Paths]); + IsBusy = false; + }); } } } diff --git a/Needlework.Net.Desktop/ViewModels/EndpointViewModel.cs b/Needlework.Net.Desktop/ViewModels/EndpointViewModel.cs index 608fcfa..fe47892 100644 --- a/Needlework.Net.Desktop/ViewModels/EndpointViewModel.cs +++ b/Needlework.Net.Desktop/ViewModels/EndpointViewModel.cs @@ -1,10 +1,26 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Messages; +using SukiUI.Controls; +using System.Linq; namespace Needlework.Net.Desktop.ViewModels { - public partial class EndpointViewModel(string endpoint) : ObservableObject + public partial class EndpointViewModel : ObservableObject, ISukiStackPageTitleProvider { - public string Endpoint { get; } = endpoint; - public string Title => $"Needlework.Net - {Endpoint}"; + public string Endpoint { get; } + public string Title => Endpoint; + + [ObservableProperty] private IAvaloniaReadOnlyList _pathOperations; + [ObservableProperty] private PathOperationViewModel? _selectedPathOperation; + + public EndpointViewModel(string endpoint) + { + Endpoint = endpoint; + + var handler = WeakReferenceMessenger.Default.Send().Response; + PathOperations = new AvaloniaList(handler.Plugins[endpoint].Select(x => new PathOperationViewModel(x))); + } } } diff --git a/Needlework.Net.Desktop/ViewModels/EndpointsContainerViewModel.cs b/Needlework.Net.Desktop/ViewModels/EndpointsContainerViewModel.cs new file mode 100644 index 0000000..a7e1791 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/EndpointsContainerViewModel.cs @@ -0,0 +1,21 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SukiUI.Controls; +using System.Net.Http; + +namespace Needlework.Net.Desktop.ViewModels +{ + public partial class EndpointsContainerViewModel : PageBase + { + [ObservableProperty] private ISukiStackPageTitleProvider _activeViewModel; + + public EndpointsContainerViewModel(HttpClient httpClient) : base("Endpoints", Material.Icons.MaterialIconKind.Hub, -500) + { + _activeViewModel = new EndpointsViewModel(httpClient, OnClicked); + } + + private void OnClicked(ISukiStackPageTitleProvider viewModel) + { + ActiveViewModel = viewModel; + } + } +} diff --git a/Needlework.Net.Desktop/ViewModels/EndpointsViewModel.cs b/Needlework.Net.Desktop/ViewModels/EndpointsViewModel.cs index a2ad6e1..f23971f 100644 --- a/Needlework.Net.Desktop/ViewModels/EndpointsViewModel.cs +++ b/Needlework.Net.Desktop/ViewModels/EndpointsViewModel.cs @@ -1,57 +1,55 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using Needlework.Net.Core; -using Needlework.Net.Desktop.Services; -using System.Collections.Generic; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Messages; +using SukiUI.Controls; +using System; using System.Linq; using System.Net.Http; -using System.Threading.Tasks; namespace Needlework.Net.Desktop.ViewModels { - public partial class EndpointsViewModel : PageBase + public partial class EndpointsViewModel : ObservableObject, IRecipient, ISukiStackPageTitleProvider { public HttpClient HttpClient { get; } - public DialogService DialogService { get; } + public string Title => "Endpoints"; + public Action OnClicked; - [ObservableProperty] private List _plugins = []; + [ObservableProperty] private IAvaloniaReadOnlyList _plugins = new AvaloniaList(); [ObservableProperty] private bool _isBusy = true; [ObservableProperty] private string _search = string.Empty; - [ObservableProperty] private List _query = []; + [ObservableProperty] private IAvaloniaReadOnlyList _query = new AvaloniaList(); [ObservableProperty] private string? _selectedQuery = string.Empty; - public EndpointsViewModel(HttpClient httpClient, DialogService dialogService) : base("Endpoints", Material.Icons.MaterialIconKind.Hub, -500) + public EndpointsViewModel(HttpClient httpClient, Action onClicked) { HttpClient = httpClient; - DialogService = dialogService; + OnClicked = onClicked; - Task.Run(InitializeAsync); + WeakReferenceMessenger.Default.Register(this); } - private async Task InitializeAsync() + public void Receive(DataReadyMessage message) { - var handler = new LcuSchemaHandler(await Resources.GetOpenApiDocumentAsync(HttpClient)); - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - Plugins = [.. handler.Plugins.Keys]; - Query = [.. Plugins]; - IsBusy = false; - }); + IsBusy = false; + Plugins = new AvaloniaList([.. message.Value.Plugins.Keys]); + Query = new AvaloniaList([.. Plugins]); } partial void OnSearchChanged(string value) { if (!string.IsNullOrEmpty(Search)) - Query = Plugins.Where(x => x.Contains(value)).ToList(); + Query = new AvaloniaList(Plugins.Where(x => x.Contains(value))); else Query = Plugins; } partial void OnSelectedQueryChanged(string? value) { - if (string.IsNullOrEmpty(value)) - return; - DialogService.ShowEndpoint(value); + if (string.IsNullOrEmpty(value)) return; + + OnClicked.Invoke(new EndpointViewModel(value)); } } } diff --git a/Needlework.Net.Desktop/ViewModels/HomeViewModel.cs b/Needlework.Net.Desktop/ViewModels/HomeViewModel.cs index c46dbba..87ad2e6 100644 --- a/Needlework.Net.Desktop/ViewModels/HomeViewModel.cs +++ b/Needlework.Net.Desktop/ViewModels/HomeViewModel.cs @@ -4,7 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using System; using System.Diagnostics; -using System.Threading.Tasks; +using System.Threading; namespace Needlework.Net.Desktop.ViewModels { @@ -16,27 +16,40 @@ namespace Needlework.Net.Desktop.ViewModels public HomeViewModel() : base("Home", Material.Icons.MaterialIconKind.Home, int.MinValue) { - Task.Run(async () => { while (true) { SetStatus(); await Task.Delay(TimeSpan.FromSeconds(5)); } }); + StartProcessing(); } - private void SetStatus() + private void StartProcessing() { - void Set(string text, Color color, string address) + var thread = new Thread(() => { - StatusText = text; - StatusForeground = new SolidColorBrush(color.ToUInt32()); - StatusAddress = address; - } + while (true) + { + void Set(string text, Color color, string address) + { + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + StatusText = text; + StatusForeground = new SolidColorBrush(color.ToUInt32()); + StatusAddress = address; + }); + } - try - { - var processInfo = Connector.GetProcessInfo(); - Avalonia.Threading.Dispatcher.UIThread.Post(() => Set("Online", Colors.Green, $"https://127.0.0.1:{processInfo.AppPort}/")); - } - catch (InvalidOperationException) - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => Set("Offline", Colors.Red, "N/A")); - } + try + { + var processInfo = Connector.GetProcessInfo(); + Set("Online", Colors.Green, $"https://127.0.0.1:{processInfo.AppPort}/"); + } + catch (InvalidOperationException) + { + Set("Offline", Colors.Red, "N/A"); + } + + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + }) + { IsBackground = true }; + thread.Start(); } [RelayCommand] diff --git a/Needlework.Net.Desktop/ViewModels/MainWindowViewModel.cs b/Needlework.Net.Desktop/ViewModels/MainWindowViewModel.cs index 71dd498..63ce3c0 100644 --- a/Needlework.Net.Desktop/ViewModels/MainWindowViewModel.cs +++ b/Needlework.Net.Desktop/ViewModels/MainWindowViewModel.cs @@ -1,22 +1,60 @@ using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.OpenApi.Models; +using Needlework.Net.Core; +using Needlework.Net.Desktop.Messages; +using SukiUI.Controls; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Net.Http; using System.Reflection; +using System.Threading.Tasks; namespace Needlework.Net.Desktop.ViewModels { - public partial class MainWindowViewModel : ObservableObject + public partial class MainWindowViewModel : ObservableObject, IRecipient, IRecipient { public IAvaloniaReadOnlyList Pages { get; } - public string Version { get; } = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0"; - public MainWindowViewModel(IEnumerable pages) + public HttpClient HttpClient { get; } + public LcuSchemaHandler? LcuSchemaHandler { get; set; } + public OpenApiDocument? HostDocument { get; set; } + + [ObservableProperty] private bool _isBusy = true; + + public MainWindowViewModel(IEnumerable pages, HttpClient httpClient) { Pages = new AvaloniaList(pages.OrderBy(x => x.Index).ThenBy(x => x.DisplayName)); + HttpClient = httpClient; + + WeakReferenceMessenger.Default.RegisterAll(this); + Task.Run(FetchDataAsync); + } + + private async Task FetchDataAsync() + { + var document = await Resources.GetOpenApiDocumentAsync(HttpClient); + HostDocument = document; + var handler = new LcuSchemaHandler(document); + LcuSchemaHandler = handler; + WeakReferenceMessenger.Default.Send(new DataReadyMessage(handler)); + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => await SukiHost.ShowToast("OpenAPI Data Processed", "Some pages can now be used.", SukiUI.Enums.NotificationType.Success, TimeSpan.FromSeconds(5))); + IsBusy = false; + } + + public void Receive(DataRequestMessage message) + { + message.Reply(LcuSchemaHandler!); + } + + public void Receive(HostDocumentRequestMessage message) + { + message.Reply(HostDocument!); } [RelayCommand] diff --git a/Needlework.Net.Desktop/ViewModels/OopsiesWindowViewModel.cs b/Needlework.Net.Desktop/ViewModels/OopsiesWindowViewModel.cs new file mode 100644 index 0000000..71c1915 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/OopsiesWindowViewModel.cs @@ -0,0 +1,29 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Messages; +using System.Diagnostics; +using System.IO; + +namespace Needlework.Net.Desktop.ViewModels +{ + public partial class OopsiesWindowViewModel(string text) : ObservableObject + { + public string Text { get; } = text; + + [RelayCommand] + private void OpenDefaultEditor() + { + var temp = Path.GetTempFileName().Replace(".tmp", ".json"); + File.WriteAllText(temp, Text); + Process.Start("explorer", "\"" + temp + "\""); + CloseDialog(); + } + + [RelayCommand] + private void CloseDialog() + { + WeakReferenceMessenger.Default.Send(new OopsiesWindowCanceledMessage(null)); + } + } +} diff --git a/Needlework.Net.Desktop/ViewModels/OperationViewModel.cs b/Needlework.Net.Desktop/ViewModels/OperationViewModel.cs new file mode 100644 index 0000000..865e7bc --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/OperationViewModel.cs @@ -0,0 +1,153 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.OpenApi.Models; +using Needlework.Net.Desktop.Messages; +using System.Collections.Generic; + +namespace Needlework.Net.Desktop.ViewModels +{ + public partial class OperationViewModel : ObservableObject + { + public string Summary { get; } + public string Description { get; } + public string ReturnType { get; } + public bool IsRequestBody { get; } + public string? RequestBodyType { get; } + public IAvaloniaReadOnlyList RequestClasses { get; } + public IAvaloniaReadOnlyList ResponseClasses { get; } + public IAvaloniaReadOnlyList PathParameters { get; } + public IAvaloniaReadOnlyList QueryParameters { get; } + + public OperationViewModel(OpenApiOperation operation) + { + Summary = operation.Summary ?? string.Empty; + Description = operation.Description ?? string.Empty; + IsRequestBody = operation.RequestBody != null; + ReturnType = GetReturnType(operation.Responses); + RequestClasses = GetRequestClasses(operation.RequestBody); + ResponseClasses = GetResponseClasses(operation.Responses); + PathParameters = GetParameters(operation.Parameters, ParameterLocation.Path); + QueryParameters = GetParameters(operation.Parameters, ParameterLocation.Query); + RequestBodyType = GetRequestBodyType(operation.RequestBody); + } + + private string? GetRequestBodyType(OpenApiRequestBody? requestBody) + { + if (requestBody == null) return null; + if (requestBody.Content.TryGetValue("application/json", out var media)) + { + var schema = media.Schema; + return GetSchemaType(schema); + } + return null; + } + + private AvaloniaList GetParameters(IList parameters, ParameterLocation location) + { + var pathParameters = new AvaloniaList(); + foreach (var parameter in parameters) + { + if (parameter.In != location) break; + pathParameters.Add(new ParameterViewModel(parameter.Name, parameter.Schema.Type, parameter.Required)); + } + + return pathParameters; + } + + private AvaloniaList GetResponseClasses(OpenApiResponses responses) + { + if (responses.TryGetValue("2XX", out var response) + && response.Content.TryGetValue("application/json", out var media)) + { + var document = WeakReferenceMessenger.Default.Send(new HostDocumentRequestMessage()).Response; + var schema = media.Schema; + AvaloniaList propertyClasses = []; + WalkSchema(schema, propertyClasses, document); + return propertyClasses; + } + return []; + } + + private void WalkSchema(OpenApiSchema schema, AvaloniaList propertyClasses, OpenApiDocument document) + { + var type = GetSchemaType(schema); + if (IsComponent(type)) + { + string componentId = GetComponentId(schema); + var componentSchema = document.Components.Schemas[componentId]; + var responseClass = new PropertyClassViewModel(componentId, componentSchema.Properties, componentSchema.Enum); + propertyClasses.Add(responseClass); + + foreach ((var _, var property) in componentSchema.Properties) + // Check for self-references like "LolLootLootOddsResponse" + // I blame dubble + if (IsComponent(GetSchemaType(property)) && componentId != GetComponentId(property)) + WalkSchema(property, propertyClasses, document); + } + } + + private static string GetComponentId(OpenApiSchema schema) + { + string componentId; + if (schema.Reference != null) componentId = schema.Reference.Id; + else if (schema.Items != null) componentId = schema.Items.Reference.Id; + else componentId = schema.AdditionalProperties.Reference.Id; + return componentId; + } + + private static bool IsComponent(string type) + { + return !(type.Contains("object") + || type.Contains("array") + || type.Contains("bool") + || type.Contains("string") + || type.Contains("integer") + || type.Contains("number")); + } + + private AvaloniaList GetRequestClasses(OpenApiRequestBody? requestBody) + { + if (requestBody == null) return []; + if (requestBody.Content.TryGetValue("application/json", out var media)) + { + var document = WeakReferenceMessenger.Default.Send(new HostDocumentRequestMessage()).Response; + var schema = media.Schema; + if (schema == null) return []; + + var type = GetSchemaType(media.Schema); + if (IsComponent(type)) + { + var componentId = GetComponentId(schema); + var componentSchema = document.Components.Schemas[componentId]; + AvaloniaList propertyClasses = []; + WalkSchema(componentSchema, propertyClasses, document); + return propertyClasses; + } + } + return []; + } + + private string GetReturnType(OpenApiResponses responses) + { + if (responses.TryGetValue("2XX", out var response) + && response.Content.TryGetValue("application/json", out var media)) + { + var schema = media.Schema; + return GetSchemaType(schema); + } + return "none"; + } + + public static string GetSchemaType(OpenApiSchema schema) + { + if (schema.Reference != null) return schema.Reference.Id; + if (schema.Type == "object" && schema.AdditionalProperties?.Reference != null) return schema.AdditionalProperties.Reference.Id; + if (schema.Type == "integer" || schema.Type == "number") return $"{schema.Type}:{schema.Format}"; + if (schema.Type == "array" && schema.Items.Reference != null) return $"{schema.Items.Reference.Id}[]"; + if (schema.Type == "array" && (schema.Items.Type == "integer" || schema.Items.Type == "number")) return $"{schema.Items.Type}:{schema.Items.Format}[]"; + if (schema.Type == "array") return $"{schema.Items.Type}[]"; + return schema.Type; + } + } +} \ No newline at end of file diff --git a/Needlework.Net.Desktop/ViewModels/ParameterViewModel.cs b/Needlework.Net.Desktop/ViewModels/ParameterViewModel.cs new file mode 100644 index 0000000..a574d7a --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/ParameterViewModel.cs @@ -0,0 +1,20 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Needlework.Net.Desktop.ViewModels +{ + public partial class ParameterViewModel : ObservableObject + { + public string Name { get; } + public string Type { get; } + public bool IsRequired { get; } + [ObservableProperty] private string? _value = null; + + public ParameterViewModel(string name, string type, bool isRequired, string? value = null) + { + Name = name; + Type = type; + IsRequired = isRequired; + Value = value; + } + } +} diff --git a/Needlework.Net.Desktop/ViewModels/PathOperationViewModel.cs b/Needlework.Net.Desktop/ViewModels/PathOperationViewModel.cs new file mode 100644 index 0000000..5d9fe03 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/PathOperationViewModel.cs @@ -0,0 +1,134 @@ +using Avalonia.Media; +using BlossomiShymae.GrrrLCU; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Core; +using Needlework.Net.Desktop.Messages; +using SukiUI.Controls; +using System; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Needlework.Net.Desktop.ViewModels +{ + public partial class PathOperationViewModel : ObservableObject + { + public string Method { get; } + public SolidColorBrush Color { get; } + public string Path { get; } + public OperationViewModel Operation { get; } + public ProcessInfo? ProcessInfo { get; } + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string? _responsePath; + [ObservableProperty] private string? _responseStatus; + [ObservableProperty] private string? _responseAuthentication; + [ObservableProperty] private string? _responseUsername; + [ObservableProperty] private string? _responsePassword; + [ObservableProperty] private string? _responseAuthorization; + + public PathOperationViewModel(PathOperation pathOperation) + { + Method = pathOperation.Method.ToUpper(); + Color = new SolidColorBrush(GetColor(pathOperation.Method.ToUpper())); + Path = pathOperation.Path; + Operation = new OperationViewModel(pathOperation.Operation); + ProcessInfo = GetProcessInfo(); + ResponsePath = ProcessInfo != null ? $"https://127.0.0.1:{ProcessInfo.AppPort}{Path}" : null; + ResponseUsername = ProcessInfo != null ? new RiotAuthentication(ProcessInfo.RemotingAuthToken).Username : null; + ResponsePassword = ProcessInfo != null ? new RiotAuthentication(ProcessInfo.RemotingAuthToken).Password : null; + ResponseAuthorization = ProcessInfo != null ? $"Basic {new RiotAuthentication(ProcessInfo.RemotingAuthToken).Value}" : null; + } + + private ProcessInfo? GetProcessInfo() + { + try + { + var processInfo = Connector.GetProcessInfo(); + return processInfo; + } + catch (Exception ex) + { + Task.Run(async () => await SukiHost.ShowToast("Error", ex.Message, SukiUI.Enums.NotificationType.Error)); + } + return null; + } + + [RelayCommand] + public async Task SendRequest() + { + try + { + IsBusy = true; + + var method = Method.ToUpper() switch + { + "GET" => HttpMethod.Get, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "DELETE" => HttpMethod.Delete, + "HEAD" => HttpMethod.Head, + "PATCH" => HttpMethod.Patch, + "OPTIONS" => HttpMethod.Options, + "TRACE" => HttpMethod.Trace, + _ => throw new Exception("Method is missing.") + }; + + var processInfo = Connector.GetProcessInfo(); + var path = Path; + foreach (var pathParameter in Operation.PathParameters) + { + path = path.Replace($"{{{pathParameter.Name}}}", pathParameter.Value); + } + + var query = ""; + foreach (var queryParameter in Operation.QueryParameters) + { + if (query.Length != 0 && !string.IsNullOrWhiteSpace(queryParameter.Value)) + query += $"&{queryParameter.Name}={Uri.EscapeDataString(queryParameter.Value)}"; + else if (query.Length == 0 && !string.IsNullOrWhiteSpace(queryParameter.Value)) + query += $"?{queryParameter.Name}={Uri.EscapeDataString(queryParameter.Value)}"; + } + var uri = $"{path}{query}"; + + var requestBody = WeakReferenceMessenger.Default.Send(new ContentRequestMessage(), "EndpointRequestEditor").Response; + var content = new StringContent(Regex.Replace(requestBody, @"\s+", ""), new System.Net.Http.Headers.MediaTypeHeaderValue("application/json")); + + var response = await Connector.SendAsync(method, $"{uri}", content) ?? throw new Exception("Response is null."); + var riotAuthentication = new RiotAuthentication(processInfo.RemotingAuthToken); + var responseBody = await response.Content.ReadAsStringAsync(); + responseBody = !string.IsNullOrEmpty(responseBody) ? JsonSerializer.Serialize(JsonSerializer.Deserialize(responseBody), App.JsonSerializerOptions) : string.Empty; + + ResponseStatus = $"{(int)response.StatusCode} {response.StatusCode}"; + ResponsePath = $"https://127.0.0.1:{processInfo.AppPort}{uri}"; + ResponseAuthentication = $"Basic {riotAuthentication.Value}"; + ResponseUsername = riotAuthentication.Username; + ResponsePassword = riotAuthentication.Password; + WeakReferenceMessenger.Default.Send(new EditorUpdateMessage(new(responseBody, "EndpointResponseEditor"))); + } + catch (Exception ex) + { + await SukiHost.ShowToast("Request Failed", ex.Message, SukiUI.Enums.NotificationType.Error); + WeakReferenceMessenger.Default.Send(new EditorUpdateMessage(new(string.Empty, "EndpointResponseEditor"))); + } + finally + { + IsBusy = false; + } + } + + public static Color GetColor(string method) => method switch + { + "GET" => Avalonia.Media.Color.FromRgb(95, 99, 186), + "POST" => Avalonia.Media.Color.FromRgb(103, 186, 95), + "PUT" => Avalonia.Media.Color.FromRgb(186, 139, 95), + "DELETE" => Avalonia.Media.Color.FromRgb(186, 95, 95), + "HEAD" => Avalonia.Media.Color.FromRgb(136, 95, 186), + "PATCH" => Avalonia.Media.Color.FromRgb(95, 186, 139), + _ => throw new InvalidOperationException("Method does not have assigned color.") + }; + } +} diff --git a/Needlework.Net.Desktop/ViewModels/PluginViewModel.cs b/Needlework.Net.Desktop/ViewModels/PluginViewModel.cs deleted file mode 100644 index 29d759e..0000000 --- a/Needlework.Net.Desktop/ViewModels/PluginViewModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Needlework.Net.Desktop.ViewModels -{ - public class PluginViewModel - { - public PluginViewModel() { } - } -} diff --git a/Needlework.Net.Desktop/ViewModels/PropertyClassViewModel.cs b/Needlework.Net.Desktop/ViewModels/PropertyClassViewModel.cs new file mode 100644 index 0000000..23f4b15 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/PropertyClassViewModel.cs @@ -0,0 +1,36 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using System.Collections.Generic; +using System.Linq; + +namespace Needlework.Net.Desktop.ViewModels +{ + public class PropertyClassViewModel : ObservableObject + { + public string Id { get; } + public IAvaloniaReadOnlyList PropertyFields { get; } = new AvaloniaList(); + public IAvaloniaReadOnlyList PropertyEnums { get; } = new AvaloniaList(); + + public PropertyClassViewModel(string id, IDictionary properties, IList enumValue) + { + AvaloniaList propertyFields = []; + AvaloniaList propertyEnums = []; + foreach ((var propertyName, var propertySchema) in properties) + { + var type = OperationViewModel.GetSchemaType(propertySchema); + var field = new PropertyFieldViewModel(propertyName, type); + propertyFields.Add(field); + } + if (enumValue != null && enumValue.Any()) + { + var propertyEnum = new PropertyEnumViewModel(enumValue); + propertyEnums.Add(propertyEnum); + } + PropertyFields = propertyFields; + PropertyEnums = propertyEnums; + Id = id; + } + } +} \ No newline at end of file diff --git a/Needlework.Net.Desktop/ViewModels/PropertyEnumViewModel.cs b/Needlework.Net.Desktop/ViewModels/PropertyEnumViewModel.cs new file mode 100644 index 0000000..5b08cf2 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/PropertyEnumViewModel.cs @@ -0,0 +1,17 @@ +using Microsoft.OpenApi.Any; +using System.Collections.Generic; +using System.Linq; + +namespace Needlework.Net.Desktop.ViewModels +{ + public class PropertyEnumViewModel + { + public string Type { get; } = "Enum"; + public string Values { get; } + + public PropertyEnumViewModel(IList enumValue) + { + Values = $"[{string.Join(", ", enumValue.Select(x => ((OpenApiString)x).Value).ToList())}]"; + } + } +} \ No newline at end of file diff --git a/Needlework.Net.Desktop/ViewModels/PropertyFieldViewModel.cs b/Needlework.Net.Desktop/ViewModels/PropertyFieldViewModel.cs new file mode 100644 index 0000000..8a448e0 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/PropertyFieldViewModel.cs @@ -0,0 +1,14 @@ +namespace Needlework.Net.Desktop.ViewModels +{ + public class PropertyFieldViewModel + { + public string Name { get; } + public string Type { get; } + + public PropertyFieldViewModel(string name, string type) + { + Name = name; + Type = type; + } + } +} \ No newline at end of file diff --git a/Needlework.Net.Desktop/ViewModels/WebsocketViewModel.cs b/Needlework.Net.Desktop/ViewModels/WebsocketViewModel.cs new file mode 100644 index 0000000..6778e55 --- /dev/null +++ b/Needlework.Net.Desktop/ViewModels/WebsocketViewModel.cs @@ -0,0 +1,103 @@ +using BlossomiShymae.GrrrLCU; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Material.Icons; +using Needlework.Net.Desktop.Messages; +using Needlework.Net.Desktop.Services; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Websocket.Client; + +namespace Needlework.Net.Desktop.ViewModels +{ + public partial class WebsocketViewModel : PageBase + { + [NotifyPropertyChangedFor(nameof(FilteredEventLog))] + [ObservableProperty] private ObservableCollection _eventLog = []; + [NotifyPropertyChangedFor(nameof(FilteredEventLog))] + [ObservableProperty] private string _search = string.Empty; + [ObservableProperty] private bool _isAttach = true; + [ObservableProperty] private bool _isTail = false; + [ObservableProperty] private string? _selectedEventLog = null; + + private Dictionary _events = []; + + public WindowService WindowService { get; } + + public List FilteredEventLog => string.IsNullOrWhiteSpace(Search) ? [.. EventLog] : [.. EventLog.Where(x => x.ToLower().Contains(Search.ToLower()))]; + + public WebsocketViewModel(WindowService windowService) : base("Event Viewer", MaterialIconKind.Connection, -100) + { + WindowService = windowService; + + var client = Connector.CreateLcuWebsocketClient(); + client.EventReceived.Subscribe(OnMessage); + client.DisconnectionHappened.Subscribe(OnDisconnection); + client.ReconnectionHappened.Subscribe(OnReconnection); + + client.Start(); + client.Send(new EventMessage(RequestType.Subscribe, EventMessage.Kinds.OnJsonApiEvent)); + } + + [RelayCommand] + private void Clear() + { + EventLog = []; + } + + partial void OnSelectedEventLogChanged(string? value) + { + if (value == null) return; + if (_events.TryGetValue(value, out var message)) + { + var text = JsonSerializer.Serialize(message, App.JsonSerializerOptions); + if (text.Length >= App.MaxCharacters) WindowService.ShowOopsiesWindow(text); + else WeakReferenceMessenger.Default.Send(new ResponseUpdatedMessage(text), nameof(WebsocketViewModel)); + } + } + + private void OnReconnection(ReconnectionInfo info) + { + Trace.WriteLine($"-- Reconnection --\n{JsonSerializer.Serialize(info, App.JsonSerializerOptions)}"); + } + + private void OnDisconnection(DisconnectionInfo info) + { + Trace.WriteLine($"-- Disconnection --\n{JsonSerializer.Serialize(info, App.JsonSerializerOptions)}"); + } + + private void OnMessage(EventMessage message) + { + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + if (!IsAttach) return; + + var line = $"{DateTime.Now:HH:mm:ss.fff} {message.Data?.EventType.ToUpper()} {message.Data?.Uri}"; + var log = EventLog.ToList(); + Trace.WriteLine($"Message: {line}"); + if (log.Count < 1000) + { + log.Add(line); + _events[line] = message; + } + else + { + var key = $"{log[0]}"; + log.RemoveAt(0); + _events.Remove(key); + + log.Add(line); + _events[line] = message; + } + + EventLog = []; // This is a hack needed to update for ListBox + EventLog = new ObservableCollection(log); + }); + } + } +} diff --git a/Needlework.Net.Desktop/Views/AboutView.axaml b/Needlework.Net.Desktop/Views/AboutView.axaml index 67d9051..5b1712d 100644 --- a/Needlework.Net.Desktop/Views/AboutView.axaml +++ b/Needlework.Net.Desktop/Views/AboutView.axaml @@ -8,8 +8,10 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Needlework.Net.Desktop.Views.AboutView" x:DataType="vm:AboutViewModel"> - - + @@ -27,13 +29,13 @@ - Needlework.Net is the sister project of Needlework. Like Needlework, this project is inspired by - LCU Explorer. This tool was made to help others with LCU development. Feel free to ask any questions + Needlework.Net is .NET rewrite of Needlework. Like Needlework, this project is inspired by + LCU Explorer. This tool was made to help others with LCU development. Feel free to ask any questions or help contribute to the project! 💜 - + diff --git a/Needlework.Net.Desktop/Views/ConsoleView.axaml b/Needlework.Net.Desktop/Views/ConsoleView.axaml index 4739d6e..877a15d 100644 --- a/Needlework.Net.Desktop/Views/ConsoleView.axaml +++ b/Needlework.Net.Desktop/Views/ConsoleView.axaml @@ -9,57 +9,78 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Needlework.Net.Desktop.Views.ConsoleView" x:DataType="vm:ConsoleViewModel"> - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - + FontSize="12" + Height="100" + Grid.Row="1" + Grid.Column="0" + Grid.ColumnSpan="2"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Needlework.Net.Desktop/Views/ConsoleView.axaml.cs b/Needlework.Net.Desktop/Views/ConsoleView.axaml.cs index 72856ff..18896b3 100644 --- a/Needlework.Net.Desktop/Views/ConsoleView.axaml.cs +++ b/Needlework.Net.Desktop/Views/ConsoleView.axaml.cs @@ -3,9 +3,10 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Styling; using AvaloniaEdit; -using AvaloniaEdit.Highlighting; -using AvaloniaEdit.Indentation.CSharp; using AvaloniaEdit.TextMate; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Extensions; +using Needlework.Net.Desktop.Messages; using Needlework.Net.Desktop.ViewModels; using SukiUI; using System.Text.Json; @@ -13,35 +14,58 @@ using TextMateSharp.Grammars; namespace Needlework.Net.Desktop.Views; -public partial class ConsoleView : UserControl +public partial class ConsoleView : UserControl, IRecipient, IRecipient { private TextEditor? _responseEditor; + private TextEditor? _requestEditor; public ConsoleView() { InitializeComponent(); } + public void Receive(ResponseUpdatedMessage message) + { + if (!string.IsNullOrEmpty(message.Value)) + { + var text = JsonSerializer.Serialize(JsonSerializer.Deserialize(message.Value), App.JsonSerializerOptions); + if (text.Length >= App.MaxCharacters) + { + WeakReferenceMessenger.Default.Send(new OopsiesWindowRequestedMessage(text), nameof(ConsoleView)); + _responseEditor!.Text = string.Empty; + } + else _responseEditor!.Text = text; + } + else _responseEditor!.Text = message.Value; + } + + public void Receive(ContentRequestMessage message) + { + message.Reply(_requestEditor!.Text); + } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); _responseEditor = this.FindControl("ResponseEditor"); - _responseEditor!.TextArea.IndentationStrategy = new CSharpIndentationStrategy(_responseEditor.Options); - _responseEditor!.TextArea.RightClickMovesCaret = true; - _responseEditor!.SyntaxHighlighting = HighlightingManager.Instance.GetDefinition("JavaScript"); + _requestEditor = this.FindControl("RequestEditor"); + _responseEditor?.ApplyJsonEditorSettings(); + _requestEditor?.ApplyJsonEditorSettings(); - ((ConsoleViewModel)DataContext!)!.ResponseBodyUpdated += ConsoleView_ResponseBodyUpdated; + WeakReferenceMessenger.Default.Register(this, nameof(ConsoleViewModel)); + WeakReferenceMessenger.Default.Register(this, "ConsoleRequestEditor"); OnBaseThemeChanged(Application.Current!.ActualThemeVariant); SukiTheme.GetInstance().OnBaseThemeChanged += OnBaseThemeChanged; } - private void ConsoleView_ResponseBodyUpdated(object? sender, TextUpdatedEventArgs e) + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { - if (!string.IsNullOrEmpty(e.Text)) - _responseEditor!.Text = JsonSerializer.Serialize(JsonSerializer.Deserialize(e.Text), App.JsonSerializerOptions); - else _responseEditor!.Text = e.Text; + base.OnDetachedFromVisualTree(e); + + WeakReferenceMessenger.Default.UnregisterAll(this); + SukiTheme.GetInstance().OnBaseThemeChanged -= OnBaseThemeChanged; } private void OnBaseThemeChanged(ThemeVariant currentTheme) @@ -49,8 +73,11 @@ public partial class ConsoleView : UserControl var registryOptions = new RegistryOptions( currentTheme == ThemeVariant.Dark ? ThemeName.DarkPlus : ThemeName.LightPlus); - var textMateInstallation = _responseEditor.InstallTextMate(registryOptions); - textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions + var responseTmi = _responseEditor.InstallTextMate(registryOptions); + responseTmi.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions + .GetLanguageByExtension(".json").Id)); + var requestTmi = _requestEditor.InstallTextMate(registryOptions); + requestTmi.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions .GetLanguageByExtension(".json").Id)); } } \ No newline at end of file diff --git a/Needlework.Net.Desktop/Views/EndpointView.axaml b/Needlework.Net.Desktop/Views/EndpointView.axaml index 15038c8..1806f03 100644 --- a/Needlework.Net.Desktop/Views/EndpointView.axaml +++ b/Needlework.Net.Desktop/Views/EndpointView.axaml @@ -1,19 +1,313 @@ - - - - - - + x:DataType="vm:EndpointViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Username + + + + Password + + + + Authorization + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Needlework.Net.Desktop/Views/OopsiesWindow.axaml.cs b/Needlework.Net.Desktop/Views/OopsiesWindow.axaml.cs new file mode 100644 index 0000000..6f5802b --- /dev/null +++ b/Needlework.Net.Desktop/Views/OopsiesWindow.axaml.cs @@ -0,0 +1,11 @@ +using SukiUI.Controls; + +namespace Needlework.Net.Desktop.Views; + +public partial class OopsiesWindow : SukiWindow +{ + public OopsiesWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Needlework.Net.Desktop/Views/PluginView.axaml b/Needlework.Net.Desktop/Views/PluginView.axaml deleted file mode 100644 index 39831bb..0000000 --- a/Needlework.Net.Desktop/Views/PluginView.axaml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - diff --git a/Needlework.Net.Desktop/Views/PluginView.axaml.cs b/Needlework.Net.Desktop/Views/PluginView.axaml.cs deleted file mode 100644 index 0f1f41b..0000000 --- a/Needlework.Net.Desktop/Views/PluginView.axaml.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Avalonia.Controls; - -namespace Needlework.Net.Desktop.Views -{ - public partial class PluginView : UserControl - { - public PluginView() - { - InitializeComponent(); - } - } -} diff --git a/Needlework.Net.Desktop/Views/WebsocketView.axaml b/Needlework.Net.Desktop/Views/WebsocketView.axaml new file mode 100644 index 0000000..8e20b09 --- /dev/null +++ b/Needlework.Net.Desktop/Views/WebsocketView.axaml @@ -0,0 +1,68 @@ + + + + + + + + + + + Attach + + + + Tail + + + + + + + + + + + + + + diff --git a/Needlework.Net.Desktop/Views/WebsocketView.axaml.cs b/Needlework.Net.Desktop/Views/WebsocketView.axaml.cs new file mode 100644 index 0000000..3821c10 --- /dev/null +++ b/Needlework.Net.Desktop/Views/WebsocketView.axaml.cs @@ -0,0 +1,57 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Styling; +using AvaloniaEdit; +using AvaloniaEdit.TextMate; +using CommunityToolkit.Mvvm.Messaging; +using Needlework.Net.Desktop.Extensions; +using Needlework.Net.Desktop.Messages; +using Needlework.Net.Desktop.ViewModels; +using SukiUI; +using TextMateSharp.Grammars; + +namespace Needlework.Net.Desktop.Views; + +public partial class WebsocketView : UserControl, IRecipient +{ + private TextEditor? _responseEditor; + + public WebsocketView() + { + InitializeComponent(); + } + + public void Receive(ResponseUpdatedMessage message) + { + _responseEditor!.Text = message.Value; + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + var vm = (WebsocketViewModel)DataContext!; + var viewer = this.FindControl("EventViewer"); + viewer!.PropertyChanged += (s, e) => { if (vm.IsTail) viewer.ScrollIntoView(vm.EventLog.Count - 1); }; + + _responseEditor = this.FindControl("ResponseEditor"); + _responseEditor?.ApplyJsonEditorSettings(); + + WeakReferenceMessenger.Default.Register(this, nameof(WebsocketViewModel)); + + OnBaseThemeChanged(Application.Current!.ActualThemeVariant); + SukiTheme.GetInstance().OnBaseThemeChanged += OnBaseThemeChanged; + } + + private void OnBaseThemeChanged(ThemeVariant currentTheme) + { + + var registryOptions = new RegistryOptions( + currentTheme == ThemeVariant.Dark ? ThemeName.DarkPlus : ThemeName.LightPlus); + + var responseTmi = _responseEditor.InstallTextMate(registryOptions); + responseTmi.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions + .GetLanguageByExtension(".json").Id)); + } +} \ No newline at end of file diff --git a/Needlework.Net.Desktop/app.ico b/Needlework.Net.Desktop/app.ico new file mode 100644 index 0000000..fd479da Binary files /dev/null and b/Needlework.Net.Desktop/app.ico differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d894117 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Needlework.Net + +![App preview](app-preview.gif) + +Needlework.Net is an open-source helper tool for the LCU that provides documented endpoints and can send requests without any code setup. Created using .NET! 🌠 + +## Download + +[Needlework can be downloaded from the latest release for Windows!](https://github.com/BlossomiShymae/Needlework/releases) + +## Contributors + + + + + +## Credits + +### LCU Explorer + +This project was inspired by LCU Explorer, an application created by the HextechDocs team! 💚 +- [Repository](https://github.com/HextechDocs/lcu-explorer) + +### hasagi-types + +Endpoints and schemas are provided by dysolix's [generated OpenAPI file.](https://raw.githubusercontent.com/dysolix/hasagi-types/main/swagger.json) Thank you! +- [Repository](https://github.com/dysolix/hasagi-types) + +## Disclaimer + +THE PROGRAM IS PROVIDED “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE IMPLIED WARRANTIES OF MERCHANTABILITY, NONINFRINGMENT, OR OF FITNESS FOR A PARTICULAR PURPOSE. LICENSOR DOES NOT WARRANT THAT THE FUNCTIONS CONTAINED IN THE PROGRAM WILL MEET YOUR REQUIREMENTS OR THAT OPERATION WILL BE UNINTERRUPTED OR ERROR FREE. LICENSOR MAKES NO WARRANTIES RESPECTING ANY HARM THAT MAY BE CAUSED BY MALICIOUS USE OF THIS SOFTWARE. LICENSOR FURTHER EXPRESSLY DISCLAIMS ANY WARRANTY OR REPRESENTATION TO AUTHORIZED USERS OR TO ANY THIRD PARTY. + + +Needlework isn't endorsed by Riot Games and doesn't +reflect the views or opinions of Riot Games or anyone officially +involved in producing or managing Riot Games properties. Riot Games, +and all associated properties are trademarks or registered +trademarks of Riot Games, Inc. diff --git a/app-preview.png b/app-preview.png new file mode 100644 index 0000000..48f55e0 Binary files /dev/null and b/app-preview.png differ