Replace custom socket implementation with SignalR.
Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework Also some UI redesign to accommodate different login experience.
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
namespace Shogi.UI.Shared
|
||||
{
|
||||
public static class Events
|
||||
{
|
||||
public delegate Task AsyncEventHandler();
|
||||
public delegate Task AsyncEventHandler<TArgs>(TArgs args);
|
||||
}
|
||||
}
|
||||
59
Shogi.UI/Shared/GameHubNode.cs
Normal file
59
Shogi.UI/Shared/GameHubNode.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Shogi.UI.Identity;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Shogi.UI.Shared;
|
||||
|
||||
public class GameHubNode : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection hubConnection;
|
||||
|
||||
public GameHubNode()
|
||||
{
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(new Uri("https://localhost:5001/gamehub", UriKind.Absolute), options =>
|
||||
{
|
||||
options.HttpMessageHandlerFactory = handler => new CookieCredentialsMessageHandler { InnerHandler = handler };
|
||||
options.SkipNegotiation = true;
|
||||
options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
public bool IsConnected => this.hubConnection.State == HubConnectionState.Connected;
|
||||
|
||||
public async Task BeginListen()
|
||||
{
|
||||
if (!this.IsConnected)
|
||||
{
|
||||
await this.hubConnection.StartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Subscribe(string sessionId)
|
||||
{
|
||||
await this.hubConnection.SendAsync("Subscribe", sessionId);
|
||||
}
|
||||
|
||||
public async Task Unsubscribe(string sessionId)
|
||||
{
|
||||
await this.hubConnection.SendAsync("Unsubscribe", sessionId);
|
||||
}
|
||||
|
||||
|
||||
public IDisposable OnSessionJoined(Func<Task> func)
|
||||
{
|
||||
return this.hubConnection.On("SessionJoined", func);
|
||||
}
|
||||
|
||||
public IDisposable OnPieceMoved(Func<Task> func)
|
||||
{
|
||||
return this.hubConnection.On("PieceMoved", func);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
return this.hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
7
Shogi.UI/Shared/Icons/ChevronDownIcon.razor
Normal file
7
Shogi.UI/Shared/Icons/ChevronDownIcon.razor
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708" />
|
||||
</svg>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
7
Shogi.UI/Shared/Icons/ChevronUpIcon.razor
Normal file
7
Shogi.UI/Shared/Icons/ChevronUpIcon.razor
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z" />
|
||||
</svg>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
@@ -2,50 +2,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Shogi.UI.Shared
|
||||
namespace Shogi.UI.Shared;
|
||||
|
||||
public class LocalStorage : ILocalStorage
|
||||
{
|
||||
public class LocalStorage : ILocalStorage
|
||||
private readonly JsonSerializerOptions jsonOptions;
|
||||
private readonly IJSRuntime jSRuntime;
|
||||
|
||||
public LocalStorage(IJSRuntime jSRuntime)
|
||||
{
|
||||
private readonly JsonSerializerOptions jsonOptions;
|
||||
private readonly IJSRuntime jSRuntime;
|
||||
this.jsonOptions = new JsonSerializerOptions();
|
||||
this.jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
this.jSRuntime = jSRuntime;
|
||||
}
|
||||
|
||||
public LocalStorage(IJSRuntime jSRuntime)
|
||||
public ValueTask Set<T>(string key, T value)
|
||||
{
|
||||
var serialized = JsonSerializer.Serialize(value);
|
||||
return this.jSRuntime.InvokeVoidAsync("localStorage.setItem", key, serialized);
|
||||
}
|
||||
|
||||
public async ValueTask<T?> Get<T>(string key) where T : struct
|
||||
{
|
||||
|
||||
var value = await this.jSRuntime.InvokeAsync<string>("localStorage.getItem", key);
|
||||
try
|
||||
{
|
||||
jsonOptions = new JsonSerializerOptions();
|
||||
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
this.jSRuntime = jSRuntime;
|
||||
return JsonSerializer.Deserialize<T>(value, this.jsonOptions);
|
||||
}
|
||||
|
||||
public ValueTask Set<T>(string key, T value)
|
||||
catch (ArgumentNullException)
|
||||
{
|
||||
var serialized = JsonSerializer.Serialize(value);
|
||||
return jSRuntime.InvokeVoidAsync("localStorage.setItem", key, serialized);
|
||||
}
|
||||
|
||||
public async ValueTask<T?> Get<T>(string key) where T : struct
|
||||
{
|
||||
|
||||
var value = await jSRuntime.InvokeAsync<string>("localStorage.getItem", key);
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(value, jsonOptions);
|
||||
}
|
||||
catch (ArgumentNullException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask Delete(string key)
|
||||
{
|
||||
return jSRuntime.InvokeVoidAsync("localStorage.removeItem", key);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ILocalStorage
|
||||
public ValueTask Delete(string key)
|
||||
{
|
||||
ValueTask Delete(string key);
|
||||
ValueTask<T?> Get<T>(string key) where T : struct;
|
||||
ValueTask Set<T>(string key, T value);
|
||||
return this.jSRuntime.InvokeVoidAsync("localStorage.removeItem", key);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ILocalStorage
|
||||
{
|
||||
ValueTask Delete(string key);
|
||||
ValueTask<T?> Get<T>(string key) where T : struct;
|
||||
ValueTask Set<T>(string key, T value);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@Body
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
}
|
||||
74
Shogi.UI/Shared/ShogiApi.cs
Normal file
74
Shogi.UI/Shared/ShogiApi.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.Contracts.Types;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection.Metadata.Ecma335;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Shogi.UI.Shared;
|
||||
|
||||
public class ShogiApi(HttpClient httpClient)
|
||||
{
|
||||
private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task Register(string email, string password)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync(Relative("register"), new { email, password });
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task LoginEventArgs(string email, string password)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync("login?useCookies=true", new { email, password });
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task Logout()
|
||||
{
|
||||
var response = await httpClient.PutAsync(Relative("User/GuestLogout"), null);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<Session?> GetSession(string name)
|
||||
{
|
||||
var response = await httpClient.GetAsync(Relative($"Sessions/{name}"));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (await response.Content.ReadFromJsonAsync<Session>(this.serializerOptions));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<SessionMetadata[]> GetAllSessionsMetadata()
|
||||
{
|
||||
var response = await httpClient.GetAsync(Relative("Sessions"));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (await response.Content.ReadFromJsonAsync<SessionMetadata[]>(this.serializerOptions))!;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns false if the move was not accepted by the server.
|
||||
/// </summary>
|
||||
public async Task<bool> Move(string sessionName, MovePieceCommand command)
|
||||
{
|
||||
var response = await httpClient.PatchAsync(Relative($"Sessions/{sessionName}/Move"), JsonContent.Create(command));
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<string?> PostSession()
|
||||
{
|
||||
var response = await httpClient.PostAsync(Relative("Sessions"), null);
|
||||
var sessionId = response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync() : null;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> PatchJoinGame(string name)
|
||||
{
|
||||
return httpClient.PatchAsync(Relative($"Sessions/{name}/Join"), null);
|
||||
}
|
||||
|
||||
private static Uri Relative(string path) => new(path, UriKind.Relative);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Shogi.Contracts.Socket;
|
||||
using Shogi.Contracts.Types;
|
||||
using System.Buffers;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using static Shogi.UI.Shared.Events;
|
||||
|
||||
namespace Shogi.UI.Shared;
|
||||
|
||||
public class ShogiSocket : IDisposable
|
||||
{
|
||||
public event AsyncEventHandler? OnSessionCreated;
|
||||
public event AsyncEventHandler<SessionJoinedByPlayerSocketMessage>? OnSessionJoined;
|
||||
public event AsyncEventHandler<PlayerHasMovedMessage>? OnPlayerMoved;
|
||||
|
||||
private ClientWebSocket socket;
|
||||
private readonly JsonSerializerOptions serializerOptions;
|
||||
private readonly string baseUrl;
|
||||
private readonly CancellationTokenSource cancelToken;
|
||||
private readonly IMemoryOwner<byte> memoryOwner;
|
||||
private bool disposedValue;
|
||||
|
||||
public ShogiSocket(IConfiguration configuration, JsonSerializerOptions serializerOptions)
|
||||
{
|
||||
this.socket = new ClientWebSocket();
|
||||
this.serializerOptions = serializerOptions;
|
||||
this.baseUrl = configuration["SocketUrl"] ?? throw new InvalidOperationException("SocketUrl configuration is missing.");
|
||||
this.cancelToken = new CancellationTokenSource();
|
||||
this.memoryOwner = MemoryPool<byte>.Shared.Rent(1024 * 2);
|
||||
}
|
||||
|
||||
public async Task OpenAsync(string token)
|
||||
{
|
||||
if (this.socket.State == WebSocketState.Open)
|
||||
{
|
||||
await this.socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing before opening a new connection.", CancellationToken.None);
|
||||
}
|
||||
if (this.socket.State == WebSocketState.Closed)
|
||||
{
|
||||
this.socket.Dispose();
|
||||
this.socket = new ClientWebSocket(); // Because you can't reopen a closed socket.
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Opening socket and existing socket state is " + this.socket.State.ToString());
|
||||
}
|
||||
|
||||
var uri = new Uri(QueryHelpers.AddQueryString(this.baseUrl, "token", token), UriKind.Absolute);
|
||||
await socket.ConnectAsync(uri, cancelToken.Token);
|
||||
// Fire and forget! I'm way too lazy to write my own javascript interop to a web worker. Nooo thanks.
|
||||
_ = Listen()
|
||||
.ContinueWith(async antecedent =>
|
||||
{
|
||||
this.cancelToken.Cancel();
|
||||
await this.socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Page was probably closed or refresh.", CancellationToken.None);
|
||||
if (antecedent.Exception != null)
|
||||
{
|
||||
throw antecedent.Exception;
|
||||
}
|
||||
}, TaskContinuationOptions.OnlyOnFaulted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task Listen()
|
||||
{
|
||||
while (socket.State == WebSocketState.Open && !cancelToken.IsCancellationRequested)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(this.memoryOwner.Memory, cancelToken.Token);
|
||||
var memory = this.memoryOwner.Memory[..result.Count].ToArray();
|
||||
var action = JsonDocument
|
||||
.Parse(memory)
|
||||
.RootElement
|
||||
.GetProperty(nameof(ISocketMessage.Action))
|
||||
.Deserialize<SocketAction>();
|
||||
|
||||
Console.WriteLine($"Socket action: {action}");
|
||||
switch (action)
|
||||
{
|
||||
case SocketAction.SessionCreated:
|
||||
if (this.OnSessionCreated is not null)
|
||||
{
|
||||
await this.OnSessionCreated();
|
||||
}
|
||||
break;
|
||||
case SocketAction.SessionJoined:
|
||||
if (this.OnSessionJoined is not null)
|
||||
{
|
||||
var args = JsonSerializer.Deserialize<SessionJoinedByPlayerSocketMessage>(memory, serializerOptions);
|
||||
await this.OnSessionJoined(args!);
|
||||
}
|
||||
break;
|
||||
case SocketAction.PieceMoved:
|
||||
if (this.OnPlayerMoved is not null)
|
||||
{
|
||||
var args = JsonSerializer.Deserialize<PlayerHasMovedMessage>(memory, serializerOptions);
|
||||
await this.OnPlayerMoved(args!);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException($"Socket message for action:{action} is not implemented.");
|
||||
}
|
||||
}
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Socket closed because cancellation token was cancelled.", CancellationToken.None);
|
||||
if (!cancelToken.IsCancellationRequested)
|
||||
{
|
||||
throw new InvalidOperationException("Stopped socket listening without cancelling.");
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
//socket.Dispose(); // This is handled by the DI container.
|
||||
cancelToken.Cancel();
|
||||
memoryOwner.Dispose();
|
||||
}
|
||||
disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user