From 4027e7246780b2cdde9315aa31304d4ed83df72e Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Mon, 29 Jan 2024 02:24:08 -0500 Subject: [PATCH 1/2] Add tray icon component to show notifications to user --- .../ExcludeFiles.xslt | 2 +- .../Lanpartyseating.Desktop.Installer.wixproj | 17 +- .../Lanpartyseating.Desktop.Installer.wxs | 74 ++++- .../BaseMessage.cs | 10 + .../JsonMessageSerializer.cs | 16 ++ ...anpartyseating.Desktop.Abstractions.csproj | 9 + .../ReservationStateRequest.cs | 5 + .../ReservationStateResponse.cs | 8 + .../TextMessage.cs | 6 + .../ApplicationManifest.xml | 9 + .../Lanpartyseating.Desktop.Tray.csproj | 29 ++ .../Lanpartyseating.Desktop.Tray.csproj.user | 8 + Lanpartyseating.Desktop.Tray/Program.cs | 27 ++ .../ToastNotificationService.cs | 139 ++++++++++ Lanpartyseating.Desktop.Tray/TrayIcon.cs | 44 +++ .../TrayIconService.cs | 35 +++ Lanpartyseating.Desktop.Tray/trayicon.ico | Bin 0 -> 15086 bytes Lanpartyseating.Desktop.sln | 12 + .../Business/INamedPipeServerService.cs | 8 + .../Business/NamedPipeServerHostedService.cs | 256 ++++++++++++++++++ .../Business/PhoenixChannelReactorService.cs | 11 +- .../Business/ReservationManager.cs | 27 ++ .../Business/Timekeeper.cs | 59 +++- .../Lanpartyseating.Desktop.csproj | 1 + Lanpartyseating.Desktop/Program.cs | 6 +- Lanpartyseating.Desktop/Worker.cs | 1 + .../appsettings.Development.json | 4 - 27 files changed, 796 insertions(+), 27 deletions(-) create mode 100644 Lanpartyseating.Desktop.Abstractions/BaseMessage.cs create mode 100644 Lanpartyseating.Desktop.Abstractions/JsonMessageSerializer.cs create mode 100644 Lanpartyseating.Desktop.Abstractions/Lanpartyseating.Desktop.Abstractions.csproj create mode 100644 Lanpartyseating.Desktop.Abstractions/ReservationStateRequest.cs create mode 100644 Lanpartyseating.Desktop.Abstractions/ReservationStateResponse.cs create mode 100644 Lanpartyseating.Desktop.Abstractions/TextMessage.cs create mode 100644 Lanpartyseating.Desktop.Tray/ApplicationManifest.xml create mode 100644 Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj create mode 100644 Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj.user create mode 100644 Lanpartyseating.Desktop.Tray/Program.cs create mode 100644 Lanpartyseating.Desktop.Tray/ToastNotificationService.cs create mode 100644 Lanpartyseating.Desktop.Tray/TrayIcon.cs create mode 100644 Lanpartyseating.Desktop.Tray/TrayIconService.cs create mode 100644 Lanpartyseating.Desktop.Tray/trayicon.ico create mode 100644 Lanpartyseating.Desktop/Business/INamedPipeServerService.cs create mode 100644 Lanpartyseating.Desktop/Business/NamedPipeServerHostedService.cs create mode 100644 Lanpartyseating.Desktop/Business/ReservationManager.cs diff --git a/LanpartySeating.Desktop.Installer/ExcludeFiles.xslt b/LanpartySeating.Desktop.Installer/ExcludeFiles.xslt index 61008dd..0b2e45c 100644 --- a/LanpartySeating.Desktop.Installer/ExcludeFiles.xslt +++ b/LanpartySeating.Desktop.Installer/ExcludeFiles.xslt @@ -18,7 +18,7 @@ 1.0.0 - $(DefineConstants);Version=$(Version);PublishOutput=..\Lanpartyseating.Desktop\bin\$(Configuration)\net8.0-windows\win-$(InstallerPlatform)\publish + $(DefineConstants);Version=$(Version);ServicePublishOutput=..\Lanpartyseating.Desktop\bin\$(Configuration)\net8.0-windows\win-$(InstallerPlatform)\publish;TrayPublishOutput=..\Lanpartyseating.Desktop.Tray\bin\$(Configuration)\net8.0-windows10.0.22621.0\win-$(InstallerPlatform)\publish package net8.0-windows @@ -11,19 +11,26 @@ LanpartySeating.Desktop.Installer-$(InstallerPlatform) + + TRAYINSTALLFOLDER + TrayApplicationFilesComponentGroup + true + true + var.TrayPublishOutput + - INSTALLFOLDER - ApplicationFilesComponentGroup + SERVICEINSTALLFOLDER + ServiceApplicationFilesComponentGroup true true ExcludeFiles.xslt - var.PublishOutput + var.ServicePublishOutput - + diff --git a/LanpartySeating.Desktop.Installer/Lanpartyseating.Desktop.Installer.wxs b/LanpartySeating.Desktop.Installer/Lanpartyseating.Desktop.Installer.wxs index 764c795..fd936a5 100644 --- a/LanpartySeating.Desktop.Installer/Lanpartyseating.Desktop.Installer.wxs +++ b/LanpartySeating.Desktop.Installer/Lanpartyseating.Desktop.Installer.wxs @@ -1,7 +1,8 @@  - + + @@ -16,22 +17,54 @@ - + + + + + + - + + + + - + + + + + + + + + + + + + + + + + + + + + @@ -47,15 +80,15 @@ - - + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Abstractions/BaseMessage.cs b/Lanpartyseating.Desktop.Abstractions/BaseMessage.cs new file mode 100644 index 0000000..5b15289 --- /dev/null +++ b/Lanpartyseating.Desktop.Abstractions/BaseMessage.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Lanpartyseating.Desktop.Abstractions; + +[JsonDerivedType(typeof(ReservationStateRequest), typeDiscriminator: "sessionstaterequest")] +[JsonDerivedType(typeof(ReservationStateResponse), typeDiscriminator: "sessionstateresponse")] +[JsonDerivedType(typeof(TextMessage), typeDiscriminator: "textmessage")] +public abstract class BaseMessage +{ +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Abstractions/JsonMessageSerializer.cs b/Lanpartyseating.Desktop.Abstractions/JsonMessageSerializer.cs new file mode 100644 index 0000000..c5fea73 --- /dev/null +++ b/Lanpartyseating.Desktop.Abstractions/JsonMessageSerializer.cs @@ -0,0 +1,16 @@ +namespace Lanpartyseating.Desktop.Abstractions; + +using System.Text.Json; + +public static class JsonMessageSerializer +{ + public static T Deserialize(string json) where T : BaseMessage + { + return JsonSerializer.Deserialize(json); + } + + public static string Serialize(BaseMessage message) + { + return JsonSerializer.Serialize(message); + } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Abstractions/Lanpartyseating.Desktop.Abstractions.csproj b/Lanpartyseating.Desktop.Abstractions/Lanpartyseating.Desktop.Abstractions.csproj new file mode 100644 index 0000000..3a63532 --- /dev/null +++ b/Lanpartyseating.Desktop.Abstractions/Lanpartyseating.Desktop.Abstractions.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Lanpartyseating.Desktop.Abstractions/ReservationStateRequest.cs b/Lanpartyseating.Desktop.Abstractions/ReservationStateRequest.cs new file mode 100644 index 0000000..9082372 --- /dev/null +++ b/Lanpartyseating.Desktop.Abstractions/ReservationStateRequest.cs @@ -0,0 +1,5 @@ +namespace Lanpartyseating.Desktop.Abstractions; + +public class ReservationStateRequest : BaseMessage +{ +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Abstractions/ReservationStateResponse.cs b/Lanpartyseating.Desktop.Abstractions/ReservationStateResponse.cs new file mode 100644 index 0000000..b430b85 --- /dev/null +++ b/Lanpartyseating.Desktop.Abstractions/ReservationStateResponse.cs @@ -0,0 +1,8 @@ +namespace Lanpartyseating.Desktop.Abstractions; + +public class ReservationStateResponse : BaseMessage +{ + public bool IsSessionActive { get; set; } + public DateTimeOffset ReservationStart { get; set; } + public DateTimeOffset ReservationEnd { get; set; } +} diff --git a/Lanpartyseating.Desktop.Abstractions/TextMessage.cs b/Lanpartyseating.Desktop.Abstractions/TextMessage.cs new file mode 100644 index 0000000..52f8d23 --- /dev/null +++ b/Lanpartyseating.Desktop.Abstractions/TextMessage.cs @@ -0,0 +1,6 @@ +namespace Lanpartyseating.Desktop.Abstractions; + +public class TextMessage : BaseMessage +{ + public string Content { get; set; } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/ApplicationManifest.xml b/Lanpartyseating.Desktop.Tray/ApplicationManifest.xml new file mode 100644 index 0000000..b37df9d --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/ApplicationManifest.xml @@ -0,0 +1,9 @@ + + + + + true + PerMonitorV2 + + + \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj b/Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj new file mode 100644 index 0000000..baa7674 --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net8.0-windows10.0.22621.0 + enable + true + trayicon.ico + enable + ApplicationManifest.xml + Otakuthon PC Gaming + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj.user b/Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj.user new file mode 100644 index 0000000..7814ea2 --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/Lanpartyseating.Desktop.Tray.csproj.user @@ -0,0 +1,8 @@ + + + + + Form + + + diff --git a/Lanpartyseating.Desktop.Tray/Program.cs b/Lanpartyseating.Desktop.Tray/Program.cs new file mode 100644 index 0000000..9838b1a --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/Program.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Lanpartyseating.Desktop.Tray; + +static class Program +{ + [DllImport("shell32.dll", SetLastError = true)] + static extern void SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string AppID); + + static async Task Main(string[] args) + { + // This code snippet uses the Shell32 API to create a shortcut and set the AppUserModelID + var appUserModelId = "Otakuthon.Lanpartyseating.Desktop"; // Replace with your actual ID + SetCurrentProcessExplicitAppUserModelID(appUserModelId); + + var hostBuilder = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + }); + await hostBuilder.RunConsoleAsync();; + } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/ToastNotificationService.cs b/Lanpartyseating.Desktop.Tray/ToastNotificationService.cs new file mode 100644 index 0000000..498040c --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/ToastNotificationService.cs @@ -0,0 +1,139 @@ +using System.IO.Pipes; +using Lanpartyseating.Desktop.Abstractions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Toolkit.Uwp.Notifications; + +namespace Lanpartyseating.Desktop.Tray; + +public class ToastNotificationService : BackgroundService +{ + private readonly ILogger _logger; + private readonly TrayIcon _trayIcon; + private const string PipeName = "Lanpartyseating.Desktop"; + + public ToastNotificationService(ILogger logger, TrayIcon trayIcon) + { + _logger = logger; + _trayIcon = trayIcon; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + stoppingToken.Register(() => _logger.LogInformation($"{PipeName} ToastNotificationService is stopping.")); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut); + _logger.LogInformation($"Connecting to {PipeName} server..."); + + await client.ConnectAsync(stoppingToken); + _logger.LogInformation("Connected to server."); + + // Send the ReservationStateRequest message once after connecting + await SendInitialMessageAsync(client, stoppingToken); + + // After sending the initial message, keep the connection open and listen for messages from the server + await ListenForServerMessagesAsync(client, stoppingToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Connection attempt was canceled."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error connecting to or communicating with the named pipe server."); + } + finally + { + _logger.LogInformation("Client disconnected from server."); + } + } + } + + private void ShowInitialTaskDialog(string heading, string text) + { + var page = new TaskDialogPage + { + Caption = "Welcome to Otakuthon PC Gaming", + Heading = heading, + Text = text, + Icon = TaskDialogIcon.Information, + Buttons = new TaskDialogButtonCollection { TaskDialogButton.OK } + }; + + TaskDialog.ShowDialog(page); + } + + private async Task SendInitialMessageAsync(NamedPipeClientStream client, CancellationToken stoppingToken) + { + await using var writer = new StreamWriter(client, leaveOpen: true); + var request = new ReservationStateRequest(); + var jsonRequest = JsonMessageSerializer.Serialize(request); + await writer.WriteLineAsync(jsonRequest); + await writer.FlushAsync(stoppingToken); + } + + private async Task ListenForServerMessagesAsync(NamedPipeClientStream client, CancellationToken stoppingToken) + { + using var reader = new StreamReader(client); + while (!stoppingToken.IsCancellationRequested && client.IsConnected) + { + try + { + // Asynchronously wait for a message from the server + var jsonResponse = await reader.ReadLineAsync(stoppingToken); + + // If the read operation is canceled or returns null, break the loop + if (jsonResponse == null) break; + + var baseMessage = JsonMessageSerializer.Deserialize(jsonResponse); + ProcessReceivedMessage(baseMessage); + } + catch (IOException ex) + { + _logger.LogError(ex, "The pipe was broken or disconnected."); + break; // Exit the loop if the pipe is broken or disconnected + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while listening for server messages."); + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + } + } + } + + private void ProcessReceivedMessage(BaseMessage message) + { + if (message is TextMessage textMessage) + { + // TODO: Make the protocol better and update tray icon tooltip + ShowToast(textMessage.Content); + } + else if (message is ReservationStateResponse reservationStateResponse) + { + var minutesUntilEnd = (reservationStateResponse.ReservationEnd - DateTimeOffset.UtcNow).TotalMinutes; + var formattedLocalEndTime = reservationStateResponse.ReservationEnd.ToLocalTime().ToString("t"); + + // Create a dedicated STA thread for the task dialog + var staThread = new Thread(() => + { + _trayIcon.UpdateText($"PC Gaming - You will be logged out at {formattedLocalEndTime}"); + ShowInitialTaskDialog($"Your session will end {minutesUntilEnd:0} minutes after badge scan.", + $"You will automatically be logged out at {formattedLocalEndTime}."); + }); + + staThread.SetApartmentState(ApartmentState.STA); + staThread.Start(); + } + } + + private static void ShowToast(string message) + { + new ToastContentBuilder() + .AddText(message) + .Show(); // Ensure this runs on the UI thread if necessary + } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/TrayIcon.cs b/Lanpartyseating.Desktop.Tray/TrayIcon.cs new file mode 100644 index 0000000..bc60650 --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/TrayIcon.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Hosting; + +namespace Lanpartyseating.Desktop.Tray; + +public class TrayIcon +{ + private readonly IHostApplicationLifetime _hostApplicationLifetime; + public NotifyIcon _trayIcon { get; private set; } + + public TrayIcon(IHostApplicationLifetime hostApplicationLifetime) + { + _hostApplicationLifetime = hostApplicationLifetime; + } + + /// + /// Must be called from STAThread + /// + public void Initialize() + { + _trayIcon = new NotifyIcon + { + Icon = new Icon(typeof(Program), "trayicon.ico"), + Visible = true, + Text = "Lanparty Seating Desktop Client" + }; + + var trayMenu = new ContextMenuStrip(); + trayMenu.Items.Add("Exit", null, OnTrayIconExit!); + _trayIcon.ContextMenuStrip = trayMenu; + } + + public void UpdateText(string newText) + { + if (_trayIcon != null) + { + _trayIcon.Text = newText; + } + } + + private void OnTrayIconExit(object sender, EventArgs e) + { + _hostApplicationLifetime.StopApplication(); + } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/TrayIconService.cs b/Lanpartyseating.Desktop.Tray/TrayIconService.cs new file mode 100644 index 0000000..4c17d0d --- /dev/null +++ b/Lanpartyseating.Desktop.Tray/TrayIconService.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Hosting; + +namespace Lanpartyseating.Desktop.Tray; + +public class TrayIconService : BackgroundService +{ + private readonly TrayIcon _trayIcon; + + public TrayIconService(TrayIcon trayIcon) + { + _trayIcon = trayIcon; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + // Create a dedicated STA thread for the tray icon + var staThread = new Thread(() => + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + _trayIcon.Initialize(); + using var trayIcon = _trayIcon._trayIcon; + + Application.Run(); + }); + + staThread.SetApartmentState(ApartmentState.STA); + staThread.Start(); + + stoppingToken.Register(Application.Exit); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop.Tray/trayicon.ico b/Lanpartyseating.Desktop.Tray/trayicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..804982a46305f49dcb47cca27727eb412e9c4611 GIT binary patch literal 15086 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 M<>Fl)`qJ3{0(T message, CancellationToken cancellationToken) where T : BaseMessage; +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop/Business/NamedPipeServerHostedService.cs b/Lanpartyseating.Desktop/Business/NamedPipeServerHostedService.cs new file mode 100644 index 0000000..7f66c47 --- /dev/null +++ b/Lanpartyseating.Desktop/Business/NamedPipeServerHostedService.cs @@ -0,0 +1,256 @@ +using System.ComponentModel; +using Lanpartyseating.Desktop.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using Lanpartyseating.Desktop.Business; +using Microsoft.Win32.SafeHandles; + +namespace Lanpartyseating.Desktop; + +public class NamedPipeServerHostedService : BackgroundService, INamedPipeServerService +{ + private readonly ILogger _logger; + private readonly ReservationManager _reservationManager; + private const string PipeName = "Lanpartyseating.Desktop"; + private NamedPipeServerStream? _server; + + public NamedPipeServerHostedService(ILogger logger, ReservationManager reservationManager) + { + _logger = logger; + _reservationManager = reservationManager; + _server = null; + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern SafePipeHandle CreateNamedPipe(string lpName, uint dwOpenMode, uint dwPipeMode, uint nMaxInstances, uint nOutBufferSize, uint nInBufferSize, uint nDefaultTimeOut, SECURITY_ATTRIBUTES lpSecurityAttributes); + + [StructLayout(LayoutKind.Sequential)] + public class SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public int bInheritHandle; + } + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor( + string StringSecurityDescriptor, + uint StringSDRevision, + out IntPtr SecurityDescriptor, + out uint SecurityDescriptorSize); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr LocalFree(IntPtr hMem); + + // Pipe open modes + const uint PIPE_ACCESS_DUPLEX = 0x00000003; + const uint PIPE_ACCESS_INBOUND = 0x00000001; + const uint PIPE_ACCESS_OUTBOUND = 0x00000002; + +// Pipe modes + const uint PIPE_TYPE_BYTE = 0x00000000; + const uint PIPE_TYPE_MESSAGE = 0x00000004; + const uint PIPE_READMODE_BYTE = 0x00000000; + const uint PIPE_READMODE_MESSAGE = 0x00000002; + const uint PIPE_WAIT = 0x00000000; + const uint PIPE_NOWAIT = 0x00000001; + +// Additional flags + const uint FILE_FLAG_OVERLAPPED = 0x40000000; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + stoppingToken.Register(() => _logger.LogInformation("Service is stopping.")); + + while (!stoppingToken.IsCancellationRequested) + { + InitializePipeServer(); + + _logger.LogInformation("Waiting for client connection..."); + + try + { + var waitTask = _server.WaitForConnectionAsync(stoppingToken); + var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); // Adjust the timeout as needed + + if (await Task.WhenAny(waitTask, timeoutTask) == timeoutTask) + { + _logger.LogDebug("Timeout while waiting for a client connection. Reconnecting in 3 seconds..."); + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + } + else + { + _logger.LogInformation("Client connected."); + + await ProcessClientConnectionAsync(stoppingToken); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Operation canceled by stoppingToken."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while waiting for a client connection."); + } + finally + { + if (_server.IsConnected) + { + _server.Disconnect(); // Ensure the server is disconnected after handling a connection + } + } + } + } + + private void InitializePipeServer() + { + // Dispose of the existing server if it's already been created + _server?.Dispose(); + + var pipeSecurity = new PipeSecurity(); + var authenticatedUsers = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); + var pipeAccessRule = new PipeAccessRule(authenticatedUsers, PipeAccessRights.ReadWrite, AccessControlType.Allow); + pipeSecurity.AddAccessRule(pipeAccessRule); + + // Convert the PipeSecurity object to a SECURITY_ATTRIBUTES structure + IntPtr sd = ConvertPipeSecurityToSecurityDescriptor(pipeSecurity); + var sa = new SECURITY_ATTRIBUTES + { + nLength = Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES)), + lpSecurityDescriptor = sd, + bInheritHandle = 1 // True + }; + + // Create the named pipe + var pipeHandle = CreateNamedPipe( + @"\\.\pipe\" + PipeName, + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, // Max instances + 4096, // Out buffer size + 4096, // In buffer size + 0, // Default timeout + sa); + + if (pipeHandle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // Create the NamedPipeServerStream instance + _server = new NamedPipeServerStream(PipeDirection.InOut, false, true, pipeHandle); + + // Free the security descriptor memory + if (sd != IntPtr.Zero) + { + LocalFree(sd); + } + } + + private async Task ProcessClientConnectionAsync(CancellationToken stoppingToken) + { + if (_server == null || !_server.IsConnected) + { + _logger.LogWarning("Server is not connected or has been disposed."); + return; // Exit early if the server is not ready + } + + try + { + using var reader = new StreamReader(_server, leaveOpen: true); + await using var writer = new StreamWriter(_server, leaveOpen: true); + + while (!stoppingToken.IsCancellationRequested && _server.IsConnected) + { + string json = null; + + try + { + if (_server.CanRead) + { + json = await reader.ReadLineAsync(); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Pipe connection was lost or pipe is broken."); + break; // Exit the loop if the pipe is broken or the connection is lost + } + + if (json != null) + { + var baseMessage = JsonMessageSerializer.Deserialize(json); + if (baseMessage is ReservationStateRequest) + { + var response = new ReservationStateResponse + { + IsSessionActive = _reservationManager.IsReservationActive, + ReservationStart = _reservationManager.ReservationStart, + ReservationEnd = _reservationManager.ReservationEnd, + }; + await writer.WriteLineAsync(JsonMessageSerializer.Serialize(response)); + await writer.FlushAsync(); + } + + // Check for cancellation again after processing the message + stoppingToken.ThrowIfCancellationRequested(); + } + + // Introduce a short delay to prevent a tight loop when no data is available + await Task.Delay(100, stoppingToken); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Operation canceled."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred."); + } + } + + public async Task SendMessageAsync(T message, CancellationToken cancellationToken) where T : BaseMessage + { + if (_server is null) + { + throw new InvalidOperationException("The server is not connected."); + } + + if (!_server.IsConnected) + { + _logger.LogWarning("No client is connected to send a message."); + return; + } + + try + { + await using var writer = new StreamWriter(_server, leaveOpen: true); + await writer.WriteLineAsync(JsonMessageSerializer.Serialize(message)); + await writer.FlushAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send message to client."); + } + } + + public static IntPtr ConvertPipeSecurityToSecurityDescriptor(PipeSecurity pipeSecurity) + { + string stringSecurityDescriptor = pipeSecurity.GetSecurityDescriptorSddlForm(AccessControlSections.Access); + + IntPtr securityDescriptor = IntPtr.Zero; + uint securityDescriptorSize = 0; + if (!ConvertStringSecurityDescriptorToSecurityDescriptor(stringSecurityDescriptor, 1, out securityDescriptor, out securityDescriptorSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return securityDescriptor; + } +} diff --git a/Lanpartyseating.Desktop/Business/PhoenixChannelReactorService.cs b/Lanpartyseating.Desktop/Business/PhoenixChannelReactorService.cs index 8c4bdf6..a416478 100644 --- a/Lanpartyseating.Desktop/Business/PhoenixChannelReactorService.cs +++ b/Lanpartyseating.Desktop/Business/PhoenixChannelReactorService.cs @@ -1,16 +1,24 @@ using Lanpartyseating.Desktop.Config; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Phoenix; using PhoenixTests.WebSocketImpl; +using ILogger = Phoenix.ILogger; namespace Lanpartyseating.Desktop.Business; public class PhoenixChannelReactorService { + private readonly IOptions _options; + private readonly ILogger _logger; private readonly Channel _desktopChannel; private readonly Socket _socket; - public PhoenixChannelReactorService(IOptions options, Callbacks callbacks) + public PhoenixChannelReactorService(IOptions options, + ILogger logger, + Callbacks callbacks) { + _options = options; + _logger = logger; var socketOptions = new Socket.Options(new JsonMessageSerializer()); var socketAddress = options.Value.WebsocketEndpoint; var socketFactory = new WebsocketSharpFactory(); @@ -25,6 +33,7 @@ public PhoenixChannelReactorService(IOptions options, Callbacks public void Connect() { + _logger.LogInformation($"Connecting to Phoenix channel at endpoint \"{_options.Value.WebsocketEndpoint}\""); _socket.Connect(); _desktopChannel.Join(); } diff --git a/Lanpartyseating.Desktop/Business/ReservationManager.cs b/Lanpartyseating.Desktop/Business/ReservationManager.cs new file mode 100644 index 0000000..92f9a8f --- /dev/null +++ b/Lanpartyseating.Desktop/Business/ReservationManager.cs @@ -0,0 +1,27 @@ +namespace Lanpartyseating.Desktop.Business; + +public class ReservationManager +{ + public DateTimeOffset ReservationStart { get; private set; } + public DateTimeOffset ReservationEnd { get; private set; } + public bool IsReservationActive { get; private set; } + + public void StartReservation(DateTimeOffset reservationStart, DateTimeOffset reservationEnd) + { + ReservationStart = reservationStart; + ReservationEnd = reservationEnd; + IsReservationActive = true; + } + + public void EndReservation() + { + ReservationStart = DateTimeOffset.MinValue; + ReservationEnd = DateTimeOffset.MinValue; + IsReservationActive = false; + } + + public void ExtendReservation(DateTimeOffset reservationEnd) + { + ReservationEnd = reservationEnd; + } +} \ No newline at end of file diff --git a/Lanpartyseating.Desktop/Business/Timekeeper.cs b/Lanpartyseating.Desktop/Business/Timekeeper.cs index 03ba27d..146db90 100644 --- a/Lanpartyseating.Desktop/Business/Timekeeper.cs +++ b/Lanpartyseating.Desktop/Business/Timekeeper.cs @@ -1,23 +1,32 @@ -using Microsoft.Extensions.Logging; +using Lanpartyseating.Desktop.Abstractions; +using Microsoft.Extensions.Logging; namespace Lanpartyseating.Desktop.Business; -using System; -using System.Threading; - public class Timekeeper : IDisposable { private readonly ILogger _logger; private readonly ISessionManager _sessionManager; - private Timer _timer; + private readonly INamedPipeServerService _pipeServer; + private readonly ReservationManager _reservationManager; + private readonly Timer _timer; private DateTimeOffset _sessionEndTime; private readonly object _lock = new(); + private readonly Timer _10MinuteWarningTimer; + private readonly Timer _2MinuteWarningTimer; - public Timekeeper(ILogger logger, ISessionManager sessionManager) + public Timekeeper(ILogger logger, + ISessionManager sessionManager, + INamedPipeServerService pipeServer, + ReservationManager reservationManager) { _logger = logger; _sessionManager = sessionManager; - _timer = new Timer(SessionEnded, null, Timeout.Infinite, Timeout.Infinite); + _pipeServer = pipeServer; + _reservationManager = reservationManager; + _timer = new Timer(SessionEnded!, null, Timeout.Infinite, Timeout.Infinite); + _10MinuteWarningTimer = new Timer(ShowMinuteWarning!, 10, Timeout.Infinite, Timeout.Infinite); + _2MinuteWarningTimer = new Timer(ShowMinuteWarning!, 2, Timeout.Infinite, Timeout.Infinite); } public void StartSession(DateTimeOffset startTime, DateTimeOffset endTime) @@ -35,6 +44,8 @@ public void StartSession(DateTimeOffset startTime, DateTimeOffset endTime) } _sessionEndTime = endTime; + var _2MinutesBeforeEnd = endTime.AddMinutes(-2); + var _10MinutesBeforeEnd = endTime.AddMinutes(-10); var duration = endTime - DateTimeOffset.UtcNow; // If the start time is in the future, delay the timer start @@ -46,6 +57,17 @@ public void StartSession(DateTimeOffset startTime, DateTimeOffset endTime) { _timer.Change(duration, Timeout.InfiniteTimeSpan); } + + if (_2MinutesBeforeEnd > DateTimeOffset.UtcNow) + { + _2MinuteWarningTimer.Change(_2MinutesBeforeEnd - DateTimeOffset.UtcNow, Timeout.InfiniteTimeSpan); + } + if (_10MinutesBeforeEnd > DateTimeOffset.UtcNow) + { + _10MinuteWarningTimer.Change(_10MinutesBeforeEnd - DateTimeOffset.UtcNow, Timeout.InfiniteTimeSpan); + } + + _reservationManager.StartReservation(startTime, endTime); _logger.LogInformation($"Session started. Will end at {endTime}."); _sessionManager.SignInGamerAccount(); @@ -63,10 +85,25 @@ public void ExtendSession(DateTimeOffset newEndTime) if (newEndTime > _sessionEndTime) { + var _2MinutesBeforeEnd = newEndTime.AddMinutes(-2); + var _10MinutesBeforeEnd = newEndTime.AddMinutes(-10); + if (_2MinutesBeforeEnd > DateTimeOffset.UtcNow) + { + _2MinuteWarningTimer.Change(_2MinutesBeforeEnd - DateTimeOffset.UtcNow, Timeout.InfiniteTimeSpan); + } + if (_10MinutesBeforeEnd > DateTimeOffset.UtcNow) + { + _10MinuteWarningTimer.Change(_10MinutesBeforeEnd - DateTimeOffset.UtcNow, Timeout.InfiniteTimeSpan); + } + var deltaMinutes = Convert.ToInt32((newEndTime - _sessionEndTime).TotalMinutes); _sessionEndTime = newEndTime; var duration = newEndTime - DateTimeOffset.UtcNow; + var minutesUntilEnd = Convert.ToInt32(duration.TotalMinutes); _timer.Change(duration, Timeout.InfiniteTimeSpan); + _reservationManager.ExtendReservation(newEndTime); _logger.LogInformation($"Session extended. New end time: {newEndTime}."); + _pipeServer.SendMessageAsync(new TextMessage{ Content = $"Session extended by {deltaMinutes} minutes. Your session will end in {minutesUntilEnd} minutes." }, CancellationToken.None).Wait(); + _logger.LogInformation("Time extension message sent down pipe."); } else { @@ -74,6 +111,13 @@ public void ExtendSession(DateTimeOffset newEndTime) } } } + + private void ShowMinuteWarning(object? state) + { + var minutes = (int) state!; + _pipeServer.SendMessageAsync(new TextMessage{ Content = $"Your session will end in {minutes} minutes." }, CancellationToken.None).Wait(); + _logger.LogInformation($"Sent {minutes} minute warning."); + } public void EndSession() { @@ -81,6 +125,7 @@ public void EndSession() { _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); _sessionEndTime = DateTimeOffset.MinValue; + _reservationManager.EndReservation(); _logger.LogInformation("Session forcibly ended."); _sessionManager.SignOut(); } diff --git a/Lanpartyseating.Desktop/Lanpartyseating.Desktop.csproj b/Lanpartyseating.Desktop/Lanpartyseating.Desktop.csproj index c431dd6..bbde3b3 100644 --- a/Lanpartyseating.Desktop/Lanpartyseating.Desktop.csproj +++ b/Lanpartyseating.Desktop/Lanpartyseating.Desktop.csproj @@ -15,6 +15,7 @@ + diff --git a/Lanpartyseating.Desktop/Program.cs b/Lanpartyseating.Desktop/Program.cs index 66e36c6..1baca3c 100644 --- a/Lanpartyseating.Desktop/Program.cs +++ b/Lanpartyseating.Desktop/Program.cs @@ -43,8 +43,12 @@ static void Main(string[] args) services.AddSingleton(); } services.AddSingleton(); - services.AddSingleton(); services.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); }) .Build(); diff --git a/Lanpartyseating.Desktop/Worker.cs b/Lanpartyseating.Desktop/Worker.cs index df76d89..2076366 100644 --- a/Lanpartyseating.Desktop/Worker.cs +++ b/Lanpartyseating.Desktop/Worker.cs @@ -1,3 +1,4 @@ +using System.IO.Pipes; using Lanpartyseating.Desktop.Business; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/Lanpartyseating.Desktop/appsettings.Development.json b/Lanpartyseating.Desktop/appsettings.Development.json index 31e0e5e..b62a5a6 100644 --- a/Lanpartyseating.Desktop/appsettings.Development.json +++ b/Lanpartyseating.Desktop/appsettings.Development.json @@ -5,10 +5,6 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "Debug": { - "ReactToAllStations": true, - "UseDummySessionManager": true - }, "Seating": { "WebsocketEndpoint": "ws://localhost:4000/desktop", "GamerAccountUsername": "gamer", From 63a6bae703e0ba73eda61b6ced89e8c769a8588b Mon Sep 17 00:00:00 2001 From: Tristan Date: Mon, 29 Jan 2024 22:42:51 -0500 Subject: [PATCH 2/2] Activate debug options in development --- Lanpartyseating.Desktop/appsettings.Development.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lanpartyseating.Desktop/appsettings.Development.json b/Lanpartyseating.Desktop/appsettings.Development.json index b62a5a6..31e0e5e 100644 --- a/Lanpartyseating.Desktop/appsettings.Development.json +++ b/Lanpartyseating.Desktop/appsettings.Development.json @@ -5,6 +5,10 @@ "Microsoft.Hosting.Lifetime": "Information" } }, + "Debug": { + "ReactToAllStations": true, + "UseDummySessionManager": true + }, "Seating": { "WebsocketEndpoint": "ws://localhost:4000/desktop", "GamerAccountUsername": "gamer",