diff --git a/.idea/.idea.Blacklight/.idea/avalonia.xml b/.idea/.idea.Blacklight/.idea/avalonia.xml index f8515eba138f299b94761bc1e7faae9151455ef6..6dab72c4346600c9550bce9c1c32463e1940ebbb 100644 --- a/.idea/.idea.Blacklight/.idea/avalonia.xml +++ b/.idea/.idea.Blacklight/.idea/avalonia.xml @@ -20,7 +20,7 @@ <entry key="Blacklight/Views/MainView.axaml" value="Blacklight/Blacklight.csproj" /> <entry key="Blacklight/Views/MainWindow.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/Tools/BeltRight.axaml" value="Blacklight/Blacklight.csproj" /> - <entry key="Blacklight/Views/Tools/ResourceExplorerView.axaml" value="Blacklight/Blacklight.csproj" /> + <entry key="Blacklight/Views/Tools/ResourceExplorerView.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/Utility/MarkdownText.axaml" value="Blacklight.Desktop/Blacklight.Desktop.csproj" /> <entry key="Blacklight/Views/Views/ClientView.axaml" value="Blacklight/Blacklight.csproj" /> <entry key="Blacklight/Views/Views/SettingsView.axaml" value="Blacklight/Blacklight.csproj" /> diff --git a/Blacklight/App.axaml.cs b/Blacklight/App.axaml.cs index 06f9727bc02a2cc8da5346066ae7ba54da7711ae..cb7d2e5471e976f8639956e696babf9462d6e2ab 100644 --- a/Blacklight/App.axaml.cs +++ b/Blacklight/App.axaml.cs @@ -11,6 +11,7 @@ using Blacklight.Views; using Blacklight.Views.Login; using Lightquark.NET; using Microsoft.Extensions.DependencyInjection; +using Serilog; namespace Blacklight; @@ -26,10 +27,18 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .WriteTo.File(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "blacklight.log"), + rollingInterval: RollingInterval.Day, + fileSizeLimitBytes: 2_000_000) + .CreateLogger(); if (Services == null) { var services = new ServiceCollection(); + services.AddSingleton(Log.Logger); services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<ViewNavigationService>(); services.AddSingleton<Client>(); @@ -51,7 +60,7 @@ public partial class App : Application var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); try { - Console.WriteLine("Deleting login.json (1)"); + Log.Debug("Deleting login.json (1)"); File.Delete(path); } catch (Exception ex) @@ -62,7 +71,7 @@ public partial class App : Application } else { - Console.Error.WriteLine("Services not null when entering OnFrameworkInitializationCompleted"); + Log.Error("Services not null when entering OnFrameworkInitializationCompleted"); } var mainWindowViewModel = Services.GetRequiredService<MainWindowViewModel>(); diff --git a/Blacklight/Blacklight.csproj b/Blacklight/Blacklight.csproj index 8e96b249df97cc95269cc5b0919a2c73892fbfbd..44b879eb95e16045143269a8a1797023956b8507 100644 --- a/Blacklight/Blacklight.csproj +++ b/Blacklight/Blacklight.csproj @@ -25,6 +25,9 @@ <PackageReference Include="Markdig" Version="0.37.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> + <PackageReference Include="Serilog" Version="4.0.1" /> + <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> + <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> </ItemGroup> diff --git a/Blacklight/Util/ViewNavigationService.cs b/Blacklight/Util/ViewNavigationService.cs index 380401c1afaf5838c2a0abb6488645f4f9108bd6..df27a00d31e6de1071a2958d17d8505c9cd11c5d 100644 --- a/Blacklight/Util/ViewNavigationService.cs +++ b/Blacklight/Util/ViewNavigationService.cs @@ -1,6 +1,7 @@ using System; using Blacklight.ViewModels; using Microsoft.Extensions.DependencyInjection; +using Serilog; namespace Blacklight.Util; @@ -11,7 +12,7 @@ public class ViewNavigationService public ViewNavigationService(IServiceProvider serviceProvider) { - Console.WriteLine($"Nav init!"); + Log.Debug($"Nav init!"); _serviceProvider = serviceProvider; } diff --git a/Blacklight/ViewModels/LoginViewModel.cs b/Blacklight/ViewModels/LoginViewModel.cs index ea9fc3c7aaa806a3b800d7e7beb7472fa7061ce2..108a2b3bf93ad52d73a7c340a709544e58ff54eb 100644 --- a/Blacklight/ViewModels/LoginViewModel.cs +++ b/Blacklight/ViewModels/LoginViewModel.cs @@ -12,6 +12,7 @@ using CommunityToolkit.Mvvm.Input; using Lightquark.NET; using Lightquark.Types; using Newtonsoft.Json; +using Serilog; namespace Blacklight.ViewModels; @@ -85,19 +86,19 @@ public class LoginViewModel : ViewModelBase { try { - Console.WriteLine("Found login.json, loading existing data"); + Log.Debug("Found login.json, loading existing data"); var json = File.ReadAllText(path); var persistentData = JsonConvert.DeserializeObject<LoginViewModelPersistent>(json); LoadExistingData(persistentData!); } catch (Exception ex) { - Console.Error.WriteLine(ex); + Log.Error(ex, "Failed to load login.json"); } } else { - Console.WriteLine("No login.json"); + Log.Debug("No login.json"); SwitchNetwork(); _currentControl = new MainLoginPrompt(); } @@ -108,16 +109,17 @@ public class LoginViewModel : ViewModelBase try { - Console.WriteLine($"Connecting to {persistentData.NetworkBase} from login.json"); + Log.Debug("Connecting to {PersistentDataNetworkBase} from login.json", persistentData.NetworkBase); LoadingText = "Connecting to network"; Network = persistentData.NetworkBase; var network = await Client.GetNetworkAsync(new Uri(persistentData.NetworkBase)); - Console.WriteLine($"Fetch successful? {network.success} ({network.error})"); + Log.Debug("Fetch successful? {NetworkSuccess} ({NetworkError})", network.success, network.error); if (network.success) { + LoadingText = "Signing in"; Client.NetworkInformation = network.networkInformation; Client.UseToken(persistentData.AccessToken, persistentData.RefreshToken); - Console.WriteLine("Found token, showing ClientView"); + Log.Debug("Found token, showing ClientView"); EnterClient(); } else @@ -128,7 +130,7 @@ public class LoginViewModel : ViewModelBase } catch (UriFormatException ex) { - Console.Error.WriteLine(ex); + Log.Error(ex, "Failed to load network"); LoadingText = $"{ex.Message}"; LoadingError = true; } @@ -168,12 +170,12 @@ public class LoginViewModel : ViewModelBase var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); try { - Console.WriteLine("Deleting login.json (2)"); + Log.Debug("Deleting login.json (2)"); File.Delete(path); } catch (Exception ex) { - Console.Error.WriteLine(ex); + Log.Error(ex, "Failed to delete login.json"); } UseSolstice(); } @@ -193,7 +195,7 @@ public class LoginViewModel : ViewModelBase if (res.success) { await SavePersist(res.accessToken!, res.refreshToken!); - Console.WriteLine("Logged in, entering client"); + Log.Debug("Logged in, entering client"); EnterClient(); } else @@ -205,10 +207,12 @@ public class LoginViewModel : ViewModelBase private async void EnterClient() { + Log.Debug("EnterClient"); LoadingText = "Waiting for gateway connection"; LoadingError = false; _currentControl = new Loading(); - Client.GatewayConnectionEvent.Wait(); + Log.Debug("GatewayConnection Wait"); + await Client.GatewayConnectionEvent.WaitAsync(); LoadingText = "Getting user data"; await Client.GetCurrentUser(); _nav.ShowView<ClientViewModel>(); @@ -218,7 +222,6 @@ public class LoginViewModel : ViewModelBase private async Task SavePersist(string accessToken, string refreshToken) { - Console.WriteLine($"Saving token {accessToken}"); var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await File.WriteAllTextAsync(path, JsonConvert.SerializeObject(new LoginViewModelPersistent @@ -227,7 +230,7 @@ public class LoginViewModel : ViewModelBase AccessToken = accessToken, RefreshToken = refreshToken })); - Console.WriteLine("Token saved!"); + Log.Debug("Token saved!"); } private void EnterNetworkSwitcher() diff --git a/Blacklight/ViewModels/MainWindowViewModel.cs b/Blacklight/ViewModels/MainWindowViewModel.cs index f25cbbcc5e2b812525109e75efa460cd3683c8cb..554c73c5b9b47f9f920bcbcea5fe764286d09b41 100644 --- a/Blacklight/ViewModels/MainWindowViewModel.cs +++ b/Blacklight/ViewModels/MainWindowViewModel.cs @@ -7,6 +7,7 @@ using Blacklight.Util; using Blacklight.Views.Login; using Lightquark.NET; using Microsoft.Extensions.DependencyInjection; +using Serilog; using Path = System.IO.Path; namespace Blacklight.ViewModels; @@ -18,7 +19,7 @@ public class MainWindowViewModel : ViewModelBase get => _currentViewModel; set { - Console.WriteLine($"Changed view model to {value.GetType().Name}"); + Log.Debug("Changed view model to {Name}", value.GetType().Name); SetProperty(ref _currentViewModel, value); } } diff --git a/Blacklight/Views/Utility/MarkdownText.axaml.cs b/Blacklight/Views/Utility/MarkdownText.axaml.cs index 5ba74a1bb57507993ef5d66358ff747d02d59edf..dfbf9f605f60adf7be8c8dc5ad7449416278c46a 100644 --- a/Blacklight/Views/Utility/MarkdownText.axaml.cs +++ b/Blacklight/Views/Utility/MarkdownText.axaml.cs @@ -13,6 +13,7 @@ using Markdig; using Markdig.Syntax; using Markdig.Syntax.Inlines; using Microsoft.Extensions.DependencyInjection; +using Serilog; using TheArtOfDev.HtmlRenderer.Avalonia; using TheArtOfDev.HtmlRenderer.Core.Entities; @@ -57,21 +58,21 @@ public partial class MarkdownText : UserControl } else { - Console.WriteLine("Not a network! Opening link..."); + Log.Debug("Not a network! Opening link..."); var topLevel = TopLevel.GetTopLevel(this); if (topLevel == null) { - Console.WriteLine("Top level null"); + Log.Debug("Top level null"); return; } await Dispatcher.UIThread.InvokeAsync(async () => { var success = await topLevel.Launcher.LaunchUriAsync(new Uri(e.Event.Link)); - Console.WriteLine($"Opened? {success}"); + Log.Debug("Opened? {Success}", success); if (!success) { - Console.WriteLine($"Fallback method..."); + Log.Debug($"Fallback method..."); Process.Start(new ProcessStartInfo { FileName = e.Event.Link, diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs index e0d22cfda5d521f9b50e95f2a8a42c3b90e0998b..c018410b3aa49094e3e509d16f05ac568d7412f1 100644 --- a/Lightquark.NET/Client.cs +++ b/Lightquark.NET/Client.cs @@ -8,6 +8,7 @@ using Lightquark.NET.Util.Converters; using MongoDB.Bson; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +using Serilog; namespace Lightquark.NET; @@ -20,7 +21,6 @@ public enum GatewayConnectState public partial class Client : ObservableObject { - private const string Version = "v4"; public ObservableCollection<ObjectId> QuarkList { get; set; } = []; public ObservableCollection<Quark> Quarks { get; set; } = []; @@ -64,8 +64,9 @@ public partial class Client : ObservableObject private User? _currentUser; private IMapper _mapper; - public Client() + public Client(ILogger? logger = null) { + Log.Logger = logger ?? new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); JsonConvert.DefaultSettings = () => new JsonSerializerSettings { Formatting = Formatting.Indented, @@ -90,7 +91,7 @@ public partial class Client : ObservableObject var expiryTime = Base36Converter.ConvertFrom(expirationTimePart); var origin = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); var expiryDate = origin.AddMilliseconds(expiryTime); - Console.WriteLine($"Token expires at {expiryDate}"); + Log.Debug("Token expires at {ExpiryDate}", expiryDate); var refreshTime = expiryDate.Subtract(TimeSpan.FromMinutes(15)); var timeUntilRefresh = refreshTime - DateTimeOffset.Now; if (timeUntilRefresh < TimeSpan.FromSeconds(1)) @@ -114,11 +115,11 @@ public partial class Client : ObservableObject } } } - Console.WriteLine($"Detected {e.PropertyName} change"); + Log.Debug("Detected {EPropertyName} change", e.PropertyName); if (e.PropertyName == "RefreshToken" && RefreshToken == null) { - Console.WriteLine("Detected LogOut"); + Log.Debug("Detected LogOut"); RunLogOut(); } base.OnPropertyChanged(e); diff --git a/Lightquark.NET/ClientMethods/Gatekeeper.cs b/Lightquark.NET/ClientMethods/Gatekeeper.cs new file mode 100644 index 0000000000000000000000000000000000000000..19998b87a7f4e1f852ad7eadc427f33e3e9b413d --- /dev/null +++ b/Lightquark.NET/ClientMethods/Gatekeeper.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using System.Net.WebSockets; +using Lightquark.NET.Util; +using Newtonsoft.Json; +using Serilog; +using Websocket.Client; + +namespace Lightquark.NET; + +public partial class Client +{ + private async void GatekeeperMeasure(MeasureGatewayMessage measureMessage) + { + _gatekeeperSession = measureMessage.SessionId; + // List<Task> gatewayMeasureTasks + List<LatencyResult> gatewayLatencies = []; + List<LatencyResult> appServerLatencies = []; + foreach (var gateway in measureMessage.Gateways) + { + List<double> latencies = []; + Log.Information("Measuring latency to gw {GatewayInstanceId}", gateway.InstanceId); + for (var i = 0; i < 5; i++) + { + var latency = await MeasureGateway(gateway.GatewayUri); + if (latency != null) + { + latencies.Add(latency.Value); + } + + Log.Information("gw {GatewayInstanceId} measurement {I}: {Latency}ms", gateway.InstanceId, i + 1, latency); + } + + if (latencies.Count > 0) + { + var latency = MedianFinder.GetMedian(latencies.ToArray()); + gatewayLatencies.Add(new LatencyResult + { + InstanceId = gateway.InstanceId, + Latency = latency + }); + Log.Information("gw {GatewayInstanceId}: {Latency}ms", gateway.InstanceId, latency); + } + else + { + Log.Information("gw {GatewayInstanceId}: Unreachable", gateway.InstanceId); + } + } + foreach (var appServer in measureMessage.AppServers) + { + + List<double> latencies = []; + Log.Information("Measuring latency to as {AppServerInstanceId}", appServer.InstanceId); + for (var i = 0; i < 5; i++) + { + var latency = await MeasureAppServer(appServer.BaseUri); + if (latency != null) + { + latencies.Add(latency.Value); + } + + Log.Information("as {AppServerInstanceId} measurement {I}: {Latency}ms", appServer.InstanceId, i + 1, latency); + } + + if (latencies.Count > 0) + { + var latency = MedianFinder.GetMedian(latencies.ToArray()); + appServerLatencies.Add(new LatencyResult + { + InstanceId = appServer.InstanceId, + Latency = latency + }); + Log.Information("as {AppServerInstanceId}: {Latency}ms", appServer.InstanceId, latency); + } + else + { + Log.Information("as {AppServerInstanceId}: Unreachable", appServer.InstanceId); + } + } + + SendGatewayMessage(new + { + @event = "gatekeeperMeasure", + gateways = gatewayLatencies, + appServers = appServerLatencies + }); + } + + private async Task<double?> MeasureAppServer(Uri baseUri) + { + try + { + var timer = new Stopwatch(); + var finalUri = new Uri(baseUri, "v4/ping"); + timer.Start(); + var res = await _httpClient.GetAsync(finalUri); + res.EnsureSuccessStatusCode(); + timer.Stop(); + return timer.ElapsedMilliseconds; + } + catch (Exception ex) + { + Log.Error(ex, "Failed AS measurement"); + return null; + } + } + + private async Task<double?> MeasureGateway(Uri gatewayUri) + { + try + { + var sem = new SemaphoreSlim(0, 1); + using var tempGw = new WebsocketClient(gatewayUri); + double? result = null; + var state = Guid.NewGuid().ToString(); + var timer = new Stopwatch(); + tempGw.DisconnectionHappened.Subscribe(_ => { sem.Release(); }); + tempGw.ErrorReconnectTimeout = null; + tempGw.ReconnectTimeout = null; + tempGw.ReconnectionHappened.Subscribe(_ => + { + timer.Start(); + SendGatewayMessage(new { @event = "ping", state }, tempGw); + }); + tempGw.MessageReceived.Subscribe(msg => + { + var message = ParseGateway<BaseGatewayMessage>(msg.Text!); + if (message.State != state) return; + timer.Stop(); + result = timer.ElapsedMilliseconds; + sem.Release(); + }); + await tempGw.Start(); + await sem.WaitAsync(); + await tempGw.Stop(WebSocketCloseStatus.NormalClosure, "Latency test finished"); + return result; + } + catch (Exception ex) + { + Log.Error(ex, "Failed GW measurement"); + return null; + } + } + + private async void GatekeeperSelection(SelectionGatewayMessage selectionMessage) + { + Log.Information("Using {AppServer} AppServer", selectionMessage.AppServerUri); + NetworkInformation!.BaseUrl = selectionMessage.AppServerUri.ToString(); + Log.Information("Waiting for {InFlightRpcCount} RPC calls to finish", _inFlightRpcs.Count); + await _noRpcInFlight.Task; // Wait until all RPC have finished + if (selectionMessage.GatewayUri.ToString() != NetworkInformation.Gateway) + { + Log.Information("Using {Gateway} Gateway", selectionMessage.GatewayUri); + NetworkInformation.Gateway = selectionMessage.GatewayUri.ToString(); + _gateway!.Url = selectionMessage.GatewayUri; + await _gateway.Reconnect(); + } + else + { + Log.Information("Already using correct gateway {Gateway}", selectionMessage.GatewayUri); + } + } +} + +public record SelectionGatewayMessage : BaseGatewayMessage +{ + public required string SessionId { get; init; } + [JsonProperty("gateway")] + public required Uri GatewayUri { get; init; } + [JsonProperty("appServer")] + public required Uri AppServerUri { get; init; } +} + +public record MeasureGatewayMessage : BaseGatewayMessage +{ + public required string SessionId { get; init; } + public required GatewayServer[] Gateways { get; init; } + public required AppServer[] AppServers { get; init; } +} + +public record GatewayServer +{ + public required string InstanceId { get; init; } + [JsonProperty("gateway")] + public required Uri GatewayUri { get; init; } +} + +public record AppServer +{ + public required string InstanceId { get; init; } + [JsonProperty("baseUrl")] + public required Uri BaseUri { get; init; } +} + +public record LatencyResult +{ + public required string InstanceId { get; init; } + public required double Latency { get; init; } +} diff --git a/Lightquark.NET/ClientMethods/Gateway.cs b/Lightquark.NET/ClientMethods/Gateway.cs index e9baf1bfdb1ebd46733777f268a0ae408824e612..574734109538a9c729047597af5782e4791f14fc 100644 --- a/Lightquark.NET/ClientMethods/Gateway.cs +++ b/Lightquark.NET/ClientMethods/Gateway.cs @@ -1,6 +1,9 @@ -using Lightquark.NET.Objects; +using System.Diagnostics; +using Lightquark.NET.Objects; using Lightquark.NET.Objects.Reply; +using Lightquark.NET.Util; using Newtonsoft.Json; +using Serilog; using Websocket.Client; namespace Lightquark.NET; @@ -10,28 +13,37 @@ public partial class Client private WebsocketClient? _gateway; private Timer? _heartbeatTimer; private string? _gatekeeperSession; - public ManualResetEventSlim GatewayConnectionEvent = new(false); + public readonly AsyncManualResetEvent GatewayConnectionEvent = new(false); + private volatile TaskCompletionSource<bool> _noRpcInFlight = new(); + private List<string> _inFlightRpcs = []; + private string? _authStateString; private async void InitialConnectGateway() { if (NetworkInformation?.Gateway == null) { - await Console.Error.WriteLineAsync("Network gateway uri null"); + Log.Error("Network gateway uri null"); return; } GatewayStatus = GatewayConnectState.Connecting; _gateway = new WebsocketClient(new Uri(NetworkInformation.Gateway)); _gateway.ReconnectTimeout = null; + _gateway.ErrorReconnectTimeout = TimeSpan.FromSeconds(5); _gateway.DisconnectionHappened.Subscribe(info => { + _heartbeatTimer?.Dispose(); GatewayConnectionEvent.Reset(); GatewayStatus = GatewayConnectState.NotConnected; - Console.Error.WriteLine($"Disconnection from gateway, type {info.Type}"); + Log.Error("Disconnection from gateway, type {InfoType} {Message}", info.Type, info.Exception); }); _gateway.ReconnectionHappened.Subscribe(info => { - Console.Error.WriteLine($"Reconnection to gateway, type {info.Type}"); - SendGatewayMessage(new { @event = "authenticate", gatekeeperSession = _gatekeeperSession, token = AccessToken }); + Log.Error("Reconnection to gateway, type {InfoType}", info.Type); + if (!_noRpcInFlight.Task.IsCompleted) _noRpcInFlight.SetResult(true); // No rpc in flight when we just connected + Debug.Assert(_inFlightRpcs.Count == 0, "Expected 0 in flight rpc when reconnecting"); + _inFlightRpcs = []; + _authStateString = Guid.NewGuid().ToString(); + SendGatewayMessage(new { @event = "authenticate", gatekeeperSession = _gatekeeperSession, token = AccessToken, state = _authStateString }); }); _gateway.MessageReceived.Subscribe(msg => { @@ -40,7 +52,7 @@ public partial class Client var message = ParseGateway<BaseGatewayMessage>(msg.Text!); if (message.Event != "heartbeat") { - Console.WriteLine(msg.Text); + Log.Debug("{Text}", msg.Text); } EventBus.Publish(new GatewayBusEvent { @@ -51,26 +63,33 @@ public partial class Client { case "authenticate": var authMessage = ParseGateway<AuthGatewayMessage>(msg.Text!); - switch (authMessage.Code) + if (authMessage.Code == 200) { - case 200: + GatewayStatus = GatewayConnectState.Connected; + GatewayConnectionEvent.Set(); + Log.Debug("Connected to gateway!"); + _heartbeatTimer?.Dispose(); + _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(15)); + } + else + { + Log.Error("Auth failure to gateway! Code {AuthMessageCode} {ObjText}", authMessage.Code, msg.Text!); + } + break; + case "error": + if (message.State == _authStateString) + { + var errorMessage = ParseGateway<ErrorGatewayMessage>(msg.Text!); + if (errorMessage.Code == 401) { - GatewayStatus = GatewayConnectState.Connected; - GatewayConnectionEvent.Set(); - Console.WriteLine("Connected to gateway!"); - if (_heartbeatTimer == null) - { - _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15)); - } - - break; - } - case 401: + Log.Error("Failed gw auth! {ErrorMessageCode}, {ErrorMessageMessage}", errorMessage.Code, errorMessage.Message); LogOut(); - break; - default: - Console.Error.WriteLine($"Auth failure to gateway! Code {authMessage.Code}"); - break; + } + } + else + { + Log.Error("Gw reported error!\\n{ObjText}", msg.Text!); } break; case "rpc": @@ -81,13 +100,20 @@ public partial class Client { CurrentUserFromObject(userUpdateMessage.User); } - + break; + case "gatekeeperMeasure": + var measureMessage = ParseGateway<MeasureGatewayMessage>(msg.Text!); + GatekeeperMeasure(measureMessage); + break; + case "gatekeeperSelection": + var selectionMessage = ParseGateway<SelectionGatewayMessage>(msg.Text!); + GatekeeperSelection(selectionMessage); break; } } catch (Exception ex) { - Console.Error.WriteLine(ex); + Log.Error(ex, "Failed handling incoming message"); } }); @@ -101,42 +127,61 @@ public partial class Client public async Task<Reply<T>> CallRpc<T>(string method, string route, object? body) where T : BaseReplyResponse { - if (GatewayStatus != GatewayConnectState.Connected) throw new Exception("RPC Call when Gateway not connected!"); + await GatewayConnectionEvent.WaitAsync(); // Wait until connected just in case + if (GatewayStatus != GatewayConnectState.Connected) throw new Exception("RPC Call when Gateway not connected!"); // ??? var sem = new SemaphoreSlim(0, 1); var state = Guid.NewGuid().ToString(); - RpcGatewayMessage<T>? responseBody = null; - var subscriptionId = EventBus.Subscribe<GatewayBusEvent>(busEvent => + _inFlightRpcs.Add(state); + if (_noRpcInFlight.Task.IsCompleted) { - if (busEvent.BaseMessage.State != state) return; - responseBody = ParseGateway<RpcGatewayMessage<T>>(busEvent.RawMessageString); - sem.Release(); - }); - SendGatewayMessage(new + _noRpcInFlight = new TaskCompletionSource<bool>(); + } + try { - @event = "rpc", - token = AccessToken, - state, - route, - method, - body - }); - await sem.WaitAsync(); - EventBus.Unsubscribe<GatewayBusEvent>(subscriptionId); - - return responseBody!.Body; + RpcGatewayMessage<T>? responseBody = null; + var subscriptionId = EventBus.Subscribe<GatewayBusEvent>(busEvent => + { + if (busEvent.BaseMessage.State != state) return; + responseBody = ParseGateway<RpcGatewayMessage<T>>(busEvent.RawMessageString); + sem.Release(); + }); + SendGatewayMessage(new + { + @event = "rpc", + token = AccessToken, + state, + route, + method, + body + }); + await sem.WaitAsync(); + EventBus.Unsubscribe<GatewayBusEvent>(subscriptionId); + _inFlightRpcs.Remove(state); + if (_inFlightRpcs.Count == 0) _noRpcInFlight.SetResult(true); + return responseBody!.Body; + } + catch (Exception ex) + { + _inFlightRpcs.Remove(state); + if (_inFlightRpcs.Count == 0) _noRpcInFlight.SetResult(true); + Log.Error(ex, "Failed RPC call :("); + throw; + } } - private void SendGatewayMessage(object message) + private void SendGatewayMessage(object message, WebsocketClient? gateway = null) { - _gateway.Send(JsonConvert.SerializeObject(message)); + gateway ??= _gateway; + gateway.Send(JsonConvert.SerializeObject(message)); } - private T ParseGateway<T>(string message) + private T ParseGateway<T>(string message) where T : BaseGatewayMessage { return JsonConvert.DeserializeObject<T>(message)!; } } + public record BaseGatewayMessage { [JsonProperty("event")] @@ -153,6 +198,15 @@ public record AuthGatewayMessage : BaseGatewayMessage public required int Code { get; init; } } + +public record ErrorGatewayMessage : BaseGatewayMessage +{ + [JsonProperty("code")] + public required int Code { get; init; } + [JsonProperty("message")] + public required string Message { get; init; } +} + public record UserUpdateGatewayMessage : BaseGatewayMessage { [JsonProperty("user")] diff --git a/Lightquark.NET/ClientMethods/Login.cs b/Lightquark.NET/ClientMethods/Login.cs index 3bab6b27ec1af79ee143c5efc2d5e6215e0fa7c8..21f050254528bbbb09a184271e2687cc8426433f 100644 --- a/Lightquark.NET/ClientMethods/Login.cs +++ b/Lightquark.NET/ClientMethods/Login.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using Lightquark.NET.Objects.Reply; using Newtonsoft.Json; +using Serilog; namespace Lightquark.NET; @@ -14,7 +15,7 @@ public partial class Client try { var finalUri = new Uri(new Uri(NetworkInformation!.BaseUrl!), $"{Version}/auth/token"); - Console.WriteLine($"{finalUri}"); + Log.Debug("{FinalUri}", finalUri); var res = await _httpClient.PostAsync(finalUri, new StringContent(JsonConvert.SerializeObject(new { email, password @@ -54,11 +55,11 @@ public partial class Client private async void AcquireToken(object? state = null) { - Console.WriteLine($"Refreshing token {AccessToken} {RefreshToken}"); + // Log.Debug("Refreshing token {AccessToken} {RefreshToken}", AccessToken, RefreshToken); try { var finalUri = new Uri(new Uri(NetworkInformation!.BaseUrl!), $"{Version}/auth/refresh"); - Console.WriteLine($"{finalUri}"); + Log.Debug("{FinalUri}", finalUri); var res = await _httpClient.PostAsync(finalUri, new StringContent(JsonConvert.SerializeObject(new { accessToken = AccessToken, refreshToken = RefreshToken @@ -75,13 +76,12 @@ public partial class Client { case HttpStatusCode.OK: AccessToken = parsedRes.Response.AccessToken; - Console.WriteLine("Token refreshed! Happy days."); + Log.Information("Token refreshed! Happy days"); var refreshHandlerTask = OnRefresh?.Invoke(parsedRes.Response.AccessToken!, RefreshToken!); if (refreshHandlerTask != null) await refreshHandlerTask; break; default: - await Console.Error.WriteLineAsync( - $"Failed to refresh ({res.StatusCode}) ({parsedRes?.Response?.Message})"); + Log.Error("Failed to refresh ({ResStatusCode}) ({ResponseMessage})", res.StatusCode, parsedRes?.Response?.Message); AccessToken = null; RefreshToken = null; break; @@ -90,22 +90,21 @@ public partial class Client } catch (Exception ex) { - Console.Error.WriteLine(ex); - await Console.Error.WriteLineAsync($"Failed to log in ({ex.Message})"); + Log.Error(ex, "Failed to refresh token"); } } public void LogOut() { - Console.WriteLine("LogOut"); + Log.Debug("LogOut"); AccessToken = null; RefreshToken = null; - _gateway.Dispose(); + _gateway?.Dispose(); } public void RunLogOut() { - Console.WriteLine("Logging out!"); + Log.Debug("Logging out!"); OnLogOut?.Invoke(); _refreshTimer?.Dispose(); } diff --git a/Lightquark.NET/ClientMethods/Network.cs b/Lightquark.NET/ClientMethods/Network.cs index 5527ad2ff4b38fee7b2216353228ba68fdc0ab43..e2a43ec3345a6c163d2f53539ac5634db3586513 100644 --- a/Lightquark.NET/ClientMethods/Network.cs +++ b/Lightquark.NET/ClientMethods/Network.cs @@ -1,6 +1,7 @@ using Avalonia.Media.Imaging; using Lightquark.NET.Objects; using Newtonsoft.Json; +using Serilog; namespace Lightquark.NET; @@ -12,7 +13,7 @@ public partial class Client try { var finalUri = new Uri(networkUri, $"{Version}/network"); - Console.WriteLine($"{finalUri}"); + Log.Debug("{FinalUri}", finalUri); var res = await _httpClient.GetAsync(finalUri); if (!res.IsSuccessStatusCode) return (false, $"Could not retrieve network information (code {res.StatusCode})", null); @@ -22,19 +23,19 @@ public partial class Client return (false, "Malformed network information response. Are you sure this is a Lightquark network?", null); - Console.WriteLine(networkInformation.IconUrl); + Log.Debug("{IconUrl}",networkInformation.IconUrl); var iconRes = await _httpClient.GetAsync(networkInformation.IconUrl); networkInformation.Icon = new Bitmap(await iconRes.Content.ReadAsStreamAsync()); return (true, string.Empty, networkInformation); } catch (JsonReaderException ex) { - Console.WriteLine(ex); + Log.Information(ex, "Failed to parse network information"); return (false, "Malformed network information response. Are you sure this is a Lightquark network?", null); } catch (Exception ex) { - Console.Error.WriteLine(ex); + Log.Error(ex, "Failed to fetch network information"); return (false, $"Error retrieving network information ({ex.Message})", null); } } diff --git a/Lightquark.NET/ClientMethods/User.cs b/Lightquark.NET/ClientMethods/User.cs index 4c32f1b463b519b0910f1c392f5ec342ffd888cb..8c716ab00fc6d98b8e4f7a37419e9bf179149436 100644 --- a/Lightquark.NET/ClientMethods/User.cs +++ b/Lightquark.NET/ClientMethods/User.cs @@ -1,6 +1,7 @@ using Avalonia.Media.Imaging; using Lightquark.NET.Objects; using Lightquark.NET.Objects.Reply; +using Serilog; namespace Lightquark.NET; @@ -22,10 +23,8 @@ public partial class Client } catch (Exception ex) { - Console.Error.WriteLine(ex); + Log.Error(ex, "Failed to get current user from provided object {@User}", user); } CurrentUser = _mapper.Map(user, CurrentUser); - Console.WriteLine(CurrentUser.Status.PrimaryText); - Console.WriteLine(CurrentUser.AvatarUriGetter); } } \ No newline at end of file diff --git a/Lightquark.NET/Lightquark.NET.csproj b/Lightquark.NET/Lightquark.NET.csproj index 2cb7d5bd3070f714c6e970c08cef75f5e60b5c38..3b3b9bf9fa613ad7233670cd4cf13ac2cea5660e 100644 --- a/Lightquark.NET/Lightquark.NET.csproj +++ b/Lightquark.NET/Lightquark.NET.csproj @@ -12,6 +12,8 @@ <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" /> <PackageReference Include="Lightquark.Types" Version="2024.9.15.139" /> <PackageReference Include="Markdig" Version="0.37.0" /> + <PackageReference Include="Serilog" Version="4.0.1" /> + <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Websocket.Client" Version="5.1.2" /> </ItemGroup> diff --git a/Lightquark.NET/Util/AsyncManualResetEvent.cs b/Lightquark.NET/Util/AsyncManualResetEvent.cs new file mode 100644 index 0000000000000000000000000000000000000000..029d0ccf391e45ee81a1e1675763c38b0bbd2495 --- /dev/null +++ b/Lightquark.NET/Util/AsyncManualResetEvent.cs @@ -0,0 +1,26 @@ +namespace Lightquark.NET.Util; + +public class AsyncManualResetEvent +{ + private volatile TaskCompletionSource<bool> _mTcs = new(); + + public Task WaitAsync() { return _mTcs.Task; } + + public AsyncManualResetEvent(bool initialState) + { + if (initialState) Set(); + } + + public void Set() { _mTcs.TrySetResult(true); } + + public void Reset() + { + while (true) + { + var tcs = _mTcs; + if (!tcs.Task.IsCompleted || + Interlocked.CompareExchange(ref _mTcs, new TaskCompletionSource<bool>(), tcs) == tcs) + return; + } + } +} \ No newline at end of file diff --git a/Lightquark.NET/Util/MedianFinder.cs b/Lightquark.NET/Util/MedianFinder.cs new file mode 100644 index 0000000000000000000000000000000000000000..4acb0fde24557df4655fe7c6269a682ae0ff3406 --- /dev/null +++ b/Lightquark.NET/Util/MedianFinder.cs @@ -0,0 +1,18 @@ +namespace Lightquark.NET.Util; + +// https://stackoverflow.com/a/8328226 +public class MedianFinder +{ + public static double GetMedian(double[] sourceNumbers) { + if (sourceNumbers is not { Length: not 0 }) + throw new Exception("Median of empty array not defined."); + + var sortedPNumbers = (double[])sourceNumbers.Clone(); + Array.Sort(sortedPNumbers); + + var size = sortedPNumbers.Length; + var mid = size / 2; + var median = (size % 2 != 0) ? sortedPNumbers[mid] : (sortedPNumbers[mid] + sortedPNumbers[mid - 1]) / 2; + return median; + } +} \ No newline at end of file