From 94753c3105b010769b49a6cd81f1aab9dddbe257 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:21:27 +0700 Subject: [PATCH] feat(ipc): add NamedPipe contracts, secure IPC server, and client --- .../Services/TerminalIpc.cs | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 apps/KronaStaffTerminal/Services/TerminalIpc.cs diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs new file mode 100644 index 0000000..0a5efb3 --- /dev/null +++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs @@ -0,0 +1,204 @@ +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using KronaStaffTerminal.Models; + +namespace KronaStaffTerminal.Services; + +public sealed class IpcRequest +{ + public string Command { get; set; } = ""; + public string? Payload { get; set; } +} + +public sealed class IpcResponse +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public IpcServiceStatus? Status { get; set; } + public int? SyncedCount { get; set; } +} + +public sealed class IpcServiceStatus +{ + public bool ServiceRunning { get; set; } = true; + public string ServiceVersion { get; set; } = "0.2.5"; + public bool ScannerInSession0Supported { get; set; } + public string ScannerDiagnostic { get; set; } = ""; + public int PendingSyncCount { get; set; } + public DateTimeOffset? LastSyncUtc { get; set; } + public string? LastSyncError { get; set; } + public string LicenseStatus { get; set; } = "Active"; +} + +public static class TerminalIpcConstants +{ + public const string PipeName = "KronaStaffTerminalPipe"; +} + +public sealed class TerminalIpcClient +{ + public async Task IsServiceAvailableAsync(CancellationToken cancellationToken = default) + { + var response = await SendCommandAsync("Ping", null, cancellationToken); + return response?.Success == true; + } + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var response = await SendCommandAsync("GetStatus", null, cancellationToken); + return response?.Status; + } + + public async Task EnqueueAttendanceAsync(AttendanceRecord record, CancellationToken cancellationToken = default) + { + var payload = JsonSerializer.Serialize(record); + var response = await SendCommandAsync("EnqueueAttendance", payload, cancellationToken); + return response?.Success == true; + } + + public async Task TriggerSyncAsync(CancellationToken cancellationToken = default) + { + var response = await SendCommandAsync("TriggerSync", null, cancellationToken); + if (response?.Success == true && !string.IsNullOrWhiteSpace(response.Payload)) + { + return JsonSerializer.Deserialize(response.Payload); + } + return null; + } + + public async Task SendCommandAsync(string command, string? payload, CancellationToken cancellationToken = default) + { + try + { + await using var pipe = new NamedPipeClientStream(".", TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(2)); + + await pipe.ConnectAsync(timeoutCts.Token); + + var request = new IpcRequest { Command = command, Payload = payload }; + var json = JsonSerializer.Serialize(request) + "\n"; + var bytes = Encoding.UTF8.GetBytes(json); + await pipe.WriteAsync(bytes, cancellationToken); + await pipe.FlushAsync(cancellationToken); + + using var reader = new StreamReader(pipe, Encoding.UTF8); + var responseJson = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(responseJson)) return null; + + return JsonSerializer.Deserialize(responseJson); + } + catch (Exception ex) when (ex is IOException or TimeoutException or OperationCanceledException) + { + // Service not running or unreachable + return null; + } + } +} + +public sealed class TerminalIpcServer : IDisposable +{ + private readonly Func> _handler; + private CancellationTokenSource? _cancellation; + private Task? _listenTask; + private bool _disposed; + + public TerminalIpcServer(Func> handler) + { + _handler = handler; + } + + public void Start() + { + _cancellation = new CancellationTokenSource(); + _listenTask = Task.Run(() => ListenLoopAsync(_cancellation.Token)); + } + + private async Task ListenLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + NamedPipeServerStream? server = null; + try + { + server = CreatePipeServer(); + await server.WaitForConnectionAsync(token); + + // Handle client in background task so server can accept next client + var clientStream = server; + _ = Task.Run(() => HandleClientAsync(clientStream, token), token); + } + catch (OperationCanceledException) + { + server?.Dispose(); + break; + } + catch (Exception ex) + { + CrashLogService.WriteDiagnostic($"IPC server loop error: {ex.Message}"); + server?.Dispose(); + await Task.Delay(500, token); + } + } + } + + private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken token) + { + await using (stream) + { + try + { + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + var line = await reader.ReadLineAsync(token); + if (string.IsNullOrWhiteSpace(line)) return; + + var request = JsonSerializer.Deserialize(line); + var response = request is not null + ? await _handler(request) + : new IpcResponse { Success = false, ErrorMessage = "Invalid request" }; + + var responseJson = JsonSerializer.Serialize(response) + "\n"; + var responseBytes = Encoding.UTF8.GetBytes(responseJson); + await stream.WriteAsync(responseBytes, token); + await stream.FlushAsync(token); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + CrashLogService.WriteDiagnostic($"IPC client handle error: {ex.Message}"); + } + } + } + + private static NamedPipeServerStream CreatePipeServer() + { + var pipeSecurity = new PipeSecurity(); + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null); + + pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow)); + pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow)); + pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow)); + + return NamedPipeServerStreamAcl.Create( + TerminalIpcConstants.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + inBufferSize: 4096, + outBufferSize: 4096, + pipeSecurity); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cancellation?.Cancel(); + _cancellation?.Dispose(); + } +}