krona-staff/apps/KronaStaffTerminal/Services/TerminalIpc.cs

239 lines
8.8 KiB
C#

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 string? Payload { get; set; }
}
public sealed class IpcServiceStatus
{
public bool ServiceRunning { get; set; } = true;
public string ServiceVersion { get; set; } = "0.3.0";
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 const string PipeServerName = ".";
}
public sealed class TerminalIpcClient
{
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("Ping", null, cancellationToken);
return response?.Success == true;
}
public async Task<IpcServiceStatus?> GetStatusAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("GetStatus", null, cancellationToken);
return response?.Status;
}
public async Task<bool> 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<AttendanceSyncResult?> TriggerSyncAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("TriggerSync", null, cancellationToken);
if (response?.Success == true && !string.IsNullOrWhiteSpace(response.Payload))
{
return JsonSerializer.Deserialize<AttendanceSyncResult>(response.Payload);
}
return null;
}
public async Task<IpcResponse?> SendCommandAsync(string command, string? payload, CancellationToken cancellationToken = default)
{
try
{
await using var pipe = new NamedPipeClientStream(TerminalIpcConstants.PipeServerName, 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);
var bytes = Encoding.UTF8.GetBytes(json);
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
await pipe.WriteAsync(lengthPrefix, cancellationToken);
await pipe.WriteAsync(bytes, cancellationToken);
await pipe.FlushAsync(cancellationToken);
var respLenBytes = new byte[4];
var read = await pipe.ReadAsync(respLenBytes, cancellationToken);
if (read < 4) return null;
var respLen = BitConverter.ToInt32(respLenBytes);
if (respLen is <= 0 or > 10 * 1024 * 1024) return null;
var respBytes = new byte[respLen];
var totalRead = 0;
while (totalRead < respLen)
{
var r = await pipe.ReadAsync(respBytes.AsMemory(totalRead), cancellationToken);
if (r == 0) return null;
totalRead += r;
}
var responseJson = Encoding.UTF8.GetString(respBytes);
if (string.IsNullOrWhiteSpace(responseJson)) return null;
return JsonSerializer.Deserialize<IpcResponse>(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<IpcRequest, Task<IpcResponse>> _handler;
private CancellationTokenSource? _cancellation;
private Task? _listenTask;
private bool _disposed;
public TerminalIpcServer(Func<IpcRequest, Task<IpcResponse>> 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
{
var reqLenBytes = new byte[4];
var read = await stream.ReadAsync(reqLenBytes, token);
if (read < 4) return;
var reqLen = BitConverter.ToInt32(reqLenBytes);
if (reqLen is <= 0 or > 10 * 1024 * 1024) return;
var reqBytes = new byte[reqLen];
var totalRead = 0;
while (totalRead < reqLen)
{
var r = await stream.ReadAsync(reqBytes.AsMemory(totalRead), token);
if (r == 0) return;
totalRead += r;
}
var line = Encoding.UTF8.GetString(reqBytes);
if (string.IsNullOrWhiteSpace(line)) return;
var request = JsonSerializer.Deserialize<IpcRequest>(line);
var response = request is not null
? await _handler(request)
: new IpcResponse { Success = false, ErrorMessage = "Invalid request" };
var responseJson = JsonSerializer.Serialize(response);
var responseBytes = Encoding.UTF8.GetBytes(responseJson);
var lengthPrefix = BitConverter.GetBytes(responseBytes.Length);
await stream.WriteAsync(lengthPrefix, token);
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(
@"global\" + 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();
}
}