Skip to content

Commit

Permalink
Add a websocket API to GTerm
Browse files Browse the repository at this point in the history
  • Loading branch information
Ryan committed Oct 10, 2024
1 parent b11995e commit 99019c2
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 4 deletions.
22 changes: 21 additions & 1 deletion Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ internal class JsonConfig
public bool ArchiveLogs { get; set; }
public bool MonitorGmod { get; set; }
public bool StartAsGmod { get; set; }
public bool? API { get; set; }
public string? APISecret { get; set; }
public int? APIPort { get; set; }
}

internal class Config
Expand All @@ -19,6 +22,9 @@ internal class Config
internal bool ArchiveLogs { get; set; } = true;
internal bool MonitorGmod { get; set; } = true;
internal bool StartAsGmod { get; set; } = false;
internal bool API { get; set; } = false;
internal string? APISecret { get; set; }
internal int APIPort { get; set; }

internal Config() { }

Expand Down Expand Up @@ -55,6 +61,9 @@ private void ProcessConfig(JsonConfig cfg)
this.ArchiveLogs = cfg.ArchiveLogs;
this.MonitorGmod = cfg.MonitorGmod;
this.StartAsGmod = cfg.StartAsGmod;
this.API = cfg.API ?? false;
this.APIPort = cfg.APIPort ?? 27512;
this.APISecret = cfg.APISecret;

if (cfg.ExclusionPatterns != null)
{
Expand Down Expand Up @@ -132,7 +141,7 @@ private static void ProcessOptions(Dictionary<string, List<string>> options, ref
{
case Type t when t == typeof(bool):
bool value = true;
if (option.Value.Count > 0 && int.TryParse(option.Value.Last(), out int parsedValue))
if (option.Value.Count > 0 && int.TryParse(string.Join(' ', option.Value), out int parsedValue))
value = parsedValue > 0;

prop.SetValue(curCfg, value);
Expand All @@ -142,6 +151,17 @@ private static void ProcessOptions(Dictionary<string, List<string>> options, ref
prop.SetValue(curCfg, option.Value.ToArray());
break;

case Type t when t == typeof(int):
int number = 0;
if (option.Value.Count > 0 && int.TryParse(string.Join(' ', option.Value), out int parsedNumber))
number = parsedNumber;
prop.SetValue(curCfg, number);
break;

case Type t when t == typeof(string):
prop.SetValue(curCfg, string.Join(' ', option.Value));
break;

default:
break;
}
Expand Down
5 changes: 4 additions & 1 deletion Config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"ExclusionPatterns": [],
"ArchiveLogs": true,
"MonitorGmod": true,
"StartAsGmod": false
"StartAsGmod": false,
"API": false,
"APIPort": 27512,
"APISecret": "cool_secret"
}
6 changes: 6 additions & 0 deletions GTerm.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@
<Folder Include="Properties\" />
</ItemGroup>

<ItemGroup>
<None Update="Config.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
18 changes: 16 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ class Program
private static readonly StringBuilder MarkupBuffer = new();
private static readonly StringBuilder InputBuffer = new();
private static readonly Thread UserInputThread = new(ProcessUserInput);

private static readonly ILogListener Listener = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? new WindowsLogListener()
: new UnixLogListener();

private static string ArchivePath = string.Empty;
private static Config Config = new();
private static WebSocketAPI? API;

static void Main(string[] args)
{
Expand Down Expand Up @@ -88,7 +90,7 @@ static void Main(string[] args)
}
}

GmodInterop.InstallXConsole(); // try to install xconsole
Task.Run(GmodInterop.InstallXConsole); // try to install xconsole

if (Config.StartAsGmod)
{
Expand Down Expand Up @@ -139,6 +141,12 @@ static void Main(string[] args)
Listener.Start();

UserInputThread.Start();

if (Config.API)
{
API = new WebSocketAPI(Listener, Config.APIPort, Config.APISecret);
Task.Run(API.Start);
}
}

private static void SetMetadata()
Expand Down Expand Up @@ -275,7 +283,8 @@ private static void OnLog(object sender, LogEventArgs args)
{
lock (Locker)
{
string timeStamp = DateTime.Now.ToString("hh:mm:ss");
DateTime now = DateTime.Now;
string timeStamp = now.ToString("hh:mm:ss");
System.Drawing.Color col = IsBlack(args.Color) ? System.Drawing.Color.White : args.Color;
string msg = args.Message;
int newLineIndex = msg.IndexOf('\n');
Expand All @@ -285,19 +294,22 @@ private static void OnLog(object sender, LogEventArgs args)
{
MarkupBuffer.Append($"[#ffaf00]{timeStamp}[/] | ");
LogBuffer.Append($"{timeStamp} | ");
API?.StartData(now);
}

string chunk = string.Concat(msg.AsSpan(0, newLineIndex), "\n");
string nextChunk = msg.Length > newLineIndex + 1 ? msg[chunk.Length..] : string.Empty;

LogBuffer.Append(chunk);
MarkupBuffer.Append($"[rgb({col.R},{col.G},{col.B})]{SanitizeLogMessage(chunk)}[/]");
API?.AppendData(col, chunk);

string mk = MarkupBuffer.ToString();
string log = LogBuffer.ToString();

MarkupBuffer.Clear();
LogBuffer.Clear();
API?.FinishDataAsync();

string logChunk = log.Contains('|', StringComparison.CurrentCulture) ? string.Join("|", log.Split('|').Skip(1).ToArray()) : log; // there should always be 1
if (!string.IsNullOrWhiteSpace(logChunk))
Expand Down Expand Up @@ -344,12 +356,14 @@ private static void OnLog(object sender, LogEventArgs args)
{
MarkupBuffer.Append($"[#ffaf00]{timeStamp}[/] | ");
LogBuffer.Append($"{timeStamp} | ");
API?.StartData(now);
}

if (msg.Length > 0)
{
LogBuffer.Append(msg);
MarkupBuffer.Append($"[rgb({col.R},{col.G},{col.B})]{SanitizeLogMessage(msg)}[/]");
API?.AppendData(col, msg);
}
}
}
Expand Down
189 changes: 189 additions & 0 deletions WebSocketAPI.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using GTerm.Listeners;
using System.Collections.Concurrent;
using System.Collections.Specialized;
using System.Drawing;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;

namespace GTerm
{
internal class LineChunkColor
{
public int R { get; set; } = 255;
public int G { get; set; } = 255;
public int B { get; set; } = 255;
public int A { get; set; } = 255;
}

internal class LineChunkData
{
public LineChunkColor Color { get; set; } = new();
public string Text { get; set; } = string.Empty;
}

internal class LineData
{
public int Time { get; set; }
public List<LineChunkData> Data { get; set; } = [];

public LineData(DateTime time) {
int unixTimestamp = (int)time.ToUniversalTime().Subtract(new DateTime(1970, 1, 1)).TotalSeconds;
this.Time = unixTimestamp;
}
}

internal class WebSocketAPI
{
private readonly ILogListener Listener;
private readonly ConcurrentDictionary<Guid, WebSocket> Clients = new();
private readonly int Port;
private readonly string? Secret;

private bool ShouldStop = false;
private LineData? CurrentLine;

internal WebSocketAPI(ILogListener listener, int? port, string? secret)
{
this.Listener = listener;
this.Port = port ?? 27512;
this.Secret = secret;
}

internal async Task Start()
{
this.ShouldStop = false;

HttpListener httpListener = new();
httpListener.Prefixes.Add($"http://localhost:{this.Port}/ws/");
httpListener.Start();

while (!this.ShouldStop)
{
HttpListenerContext listenerContext = await httpListener.GetContextAsync();
if (listenerContext.Request.IsWebSocketRequest)
{
// Verify client by checking the secret in query parameters
if (!this.IsValidClient(listenerContext.Request))
{
listenerContext.Response.StatusCode = 403; // Forbidden
listenerContext.Response.Close();
continue;
}

WebSocketContext webSocketContext = await listenerContext.AcceptWebSocketAsync(null);
WebSocket webSocket = webSocketContext.WebSocket;
Guid clientId = Guid.NewGuid();
this.Clients.TryAdd(clientId, webSocket);

_ = Task.Run(() => this.HandleClientAsync(clientId, webSocket));
}
else
{
listenerContext.Response.StatusCode = 400;
listenerContext.Response.Close();
}
}

this.Clients.Clear();
}

internal void Stop() => this.ShouldStop = true;

private bool IsValidClient(HttpListenerRequest request)
{
if (string.IsNullOrWhiteSpace(this.Secret)) return true;

NameValueCollection? queryParams = request.QueryString;
string? clientSecret = queryParams["secret"];
return clientSecret == this.Secret;
}

private async Task HandleClientAsync(Guid clientId, WebSocket webSocket)
{
try
{
await this.ReceiveMessagesAsync(webSocket);
}
finally
{
// Remove the client and close the connection when done
this.Clients.TryRemove(clientId, out _);
if (webSocket.State != WebSocketState.Closed)
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
}

webSocket.Dispose();
}
}

private async Task ReceiveMessagesAsync(WebSocket webSocket)
{
byte[] buffer = new byte[1024 * 4];
ArraySegment<byte> segment = new(buffer);

while (webSocket.State == WebSocketState.Open)
{
WebSocketReceiveResult result = await webSocket.ReceiveAsync(segment, CancellationToken.None);
string message = Encoding.UTF8.GetString(buffer, 0, result.Count);
await this.Listener.WriteMessage(message);

if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
}
}
}

private async Task SendDataAsync<T>(T data)
{
string jsonData = JsonSerializer.Serialize(data);
byte[] buffer = Encoding.UTF8.GetBytes(jsonData);
ArraySegment<byte> segment = new(buffer);

foreach (KeyValuePair<Guid, WebSocket> client in this.Clients)
{
WebSocket? webSocket = client.Value;
if (webSocket == null)
{
this.Clients.TryRemove(client.Key, out _);
continue;
}

if (webSocket.State == WebSocketState.Open)
{
try
{
await webSocket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (WebSocketException)
{
this.Clients.TryRemove(client.Key, out _);
}
}
}
}

internal async Task FinishDataAsync()
{
await this.SendDataAsync(this.CurrentLine);
this.CurrentLine = null;
}

internal void StartData(DateTime time)
{
this.CurrentLine = new LineData(time);
}

internal void AppendData(Color color, string text)
{
this.CurrentLine?.Data.Add(new LineChunkData
{
Color = new LineChunkColor { R = color.R, G = color.G, B = color.B, A = color.A },
Text = text,
});
}
}
}

0 comments on commit 99019c2

Please sign in to comment.