Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions BetterGenshinImpact/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ public partial class App : Application
"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");
}

var httpLogServerService = new HttpLogServerService(configService);
services.AddSingleton(httpLogServerService);
loggerConfiguration.WriteTo.Sink(httpLogServerService);

Log.Logger = loggerConfiguration.CreateLogger();
services.AddSingleton<IMissingTranslationReporter, SupabaseMissingTranslationReporter>();
services.AddSingleton<ITranslationService, JsonTranslationService>();
Expand Down
16 changes: 16 additions & 0 deletions BetterGenshinImpact/Core/Config/OtherConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ public partial class OtherConfig : ObservableObject
//OCR配置
[ObservableProperty]
private Ocr _ocrConfig = new();

[ObservableProperty]
private HttpLogServer _httpLogServerConfig = new();


public partial class HttpLogServer : ObservableObject
{
[ObservableProperty]
private bool _enabled = false;

[ObservableProperty]
private int _port = 8080;

[ObservableProperty]
private string _listenAddress = "0.0.0.0";
}


public partial class AutoRestart : ObservableObject
Expand Down
312 changes: 312 additions & 0 deletions BetterGenshinImpact/Service/HttpLogServerService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BetterGenshinImpact.Core.Config;
using BetterGenshinImpact.GameTask;
using BetterGenshinImpact.Service.Interface;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Display;

namespace BetterGenshinImpact.Service;

public class HttpLogServerService : ILogEventSink, IDisposable
{
private readonly IConfigService _configService;
private HttpListener _listener;
private CancellationTokenSource _cts;
private readonly ConcurrentQueue<string> _logBuffer = new();
private const int MaxLogLines = 1000;
private readonly MessageTemplateTextFormatter _formatter;
private readonly ConcurrentDictionary<HttpListenerResponse, bool> _clients = new();

public HttpLogServerService(IConfigService configService)
{
_configService = configService;
_formatter = new MessageTemplateTextFormatter("[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");

_configService.Get().OtherConfig.HttpLogServerConfig.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(OtherConfig.HttpLogServer.Enabled) ||
e.PropertyName == nameof(OtherConfig.HttpLogServer.Port) ||
e.PropertyName == nameof(OtherConfig.HttpLogServer.ListenAddress))
{
RestartServer();
}
};

StartServer();
}

public void Emit(LogEvent logEvent)
{
if (logEvent.Level < LogEventLevel.Information) return;

using var writer = new StringWriter();
_formatter.Format(logEvent, writer);
var logMessage = writer.ToString();

var levelStr = logEvent.Level.ToString();
var color = levelStr switch
{
"Warning" => "#ffcc00",
"Error" => "#ff3333",
"Fatal" => "#ff0000",
_ => "#d4d4d4"
};

var logData = new { message = logMessage, color = color };
var json = System.Text.Json.JsonSerializer.Serialize(logData);

_logBuffer.Enqueue(json);
while (_logBuffer.Count > MaxLogLines)
{
_logBuffer.TryDequeue(out _);
}

BroadcastLog(json);
}

private void BroadcastLog(string json)
{
var data = $"data: {Uri.EscapeDataString(json)}\n\n";
var buffer = Encoding.UTF8.GetBytes(data);

foreach (var client in _clients.Keys)
{
try
{
client.OutputStream.Write(buffer, 0, buffer.Length);
client.OutputStream.Flush();
}
catch
{
_clients.TryRemove(client, out _);
}
}
Comment on lines +75 to +91
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

同步广播会阻塞日志链路。

Emit() 调用链中对每个 SSE 客户端执行同步 Write/Flush(Line 84-85),慢客户端会拖慢甚至阻塞产生日志的线程。建议改为后台异步发送并加写超时/淘汰策略。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@BetterGenshinImpact/Service/HttpLogServerService.cs` around lines 75 - 91,
BroadcastLog is doing synchronous Write/Flush to each SSE client
(client.OutputStream.Write/Flush) which can block the logging thread; change it
to perform non-blocking background sends using asynchronous APIs (e.g.,
WriteAsync/FlushAsync) and avoid iterating _clients.Keys directly by
snapshotting the client list; run each send in a Task with a
CancellationToken/timeout and remove the client via _clients.TryRemove(client,
out _) on exception or timeout to implement eviction. Locate BroadcastLog and
replace sync calls to client.OutputStream.Write/Flush with asynchronous send
tasks, apply a per-client timeout/ cancellation policy, and ensure failed or
slow clients are removed from _clients.

}

private void StartServer()
{
var config = _configService.Get().OtherConfig.HttpLogServerConfig;
if (!config.Enabled) return;

_cts = new CancellationTokenSource();
_listener = new HttpListener();

var address = string.IsNullOrWhiteSpace(config.ListenAddress) ? "0.0.0.0" : config.ListenAddress;
var prefix = address == "0.0.0.0" ? "+" : address;

if (address != "0.0.0.0" && address != "localhost" && address != "127.0.0.1" && !System.Net.IPAddress.TryParse(address, out _))
{
Log.Error($"Invalid listen address: {address}. Using 0.0.0.0 instead.");
prefix = "+";
address = "0.0.0.0";
}

_listener.Prefixes.Add($"http://{prefix}:{config.Port}/");

try
{
_listener.Start();
Task.Run(() => ListenAsync(_cts.Token));
Log.Information($"日志服务器已启动 {address}:{config.Port}");
}
catch (Exception ex)
{
Log.Error(ex, "启动日志服务器失败。请检查端口是否被占用,或是否需要 0.0.0.0 的管理员权限。");
}
Comment on lines +94 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Prefixes.Add 放在 try 外会导致启动异常逃逸。

Line 112 的前缀注册在 try 之外,配置异常时会绕过你当前的错误处理逻辑。请先做端口边界校验,并把 Prefixes.Add 放入同一个 try

🧯 建议修复
     private void StartServer()
     {
         var config = _configService.Get().OtherConfig.HttpLogServerConfig;
         if (!config.Enabled) return;
+        if (config.Port is < 1 or > 65535)
+        {
+            Log.Error("Invalid port: {Port}. Expected range: 1-65535.", config.Port);
+            return;
+        }

         _cts = new CancellationTokenSource();
         _listener = new HttpListener();

         var address = string.IsNullOrWhiteSpace(config.ListenAddress) ? "0.0.0.0" : config.ListenAddress;
         var prefix = address == "0.0.0.0" ? "+" : address;
@@
-        _listener.Prefixes.Add($"http://{prefix}:{config.Port}/");
-
         try
         {
+            _listener.Prefixes.Add($"http://{prefix}:{config.Port}/");
             _listener.Start();
             Task.Run(() => ListenAsync(_cts.Token));
             Log.Information($"日志服务器已启动 {address}:{config.Port}");
         }
         catch (Exception ex)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void StartServer()
{
var config = _configService.Get().OtherConfig.HttpLogServerConfig;
if (!config.Enabled) return;
_cts = new CancellationTokenSource();
_listener = new HttpListener();
var address = string.IsNullOrWhiteSpace(config.ListenAddress) ? "0.0.0.0" : config.ListenAddress;
var prefix = address == "0.0.0.0" ? "+" : address;
if (address != "0.0.0.0" && address != "localhost" && address != "127.0.0.1" && !System.Net.IPAddress.TryParse(address, out _))
{
Log.Error($"Invalid listen address: {address}. Using 0.0.0.0 instead.");
prefix = "+";
address = "0.0.0.0";
}
_listener.Prefixes.Add($"http://{prefix}:{config.Port}/");
try
{
_listener.Start();
Task.Run(() => ListenAsync(_cts.Token));
Log.Information($"日志服务器已启动 {address}:{config.Port}");
}
catch (Exception ex)
{
Log.Error(ex, "启动日志服务器失败。请检查端口是否被占用,或是否需要 0.0.0.0 的管理员权限。");
}
private void StartServer()
{
var config = _configService.Get().OtherConfig.HttpLogServerConfig;
if (!config.Enabled) return;
if (config.Port is < 1 or > 65535)
{
Log.Error("Invalid port: {Port}. Expected range: 1-65535.", config.Port);
return;
}
_cts = new CancellationTokenSource();
_listener = new HttpListener();
var address = string.IsNullOrWhiteSpace(config.ListenAddress) ? "0.0.0.0" : config.ListenAddress;
var prefix = address == "0.0.0.0" ? "+" : address;
if (address != "0.0.0.0" && address != "localhost" && address != "127.0.0.1" && !System.Net.IPAddress.TryParse(address, out _))
{
Log.Error($"Invalid listen address: {address}. Using 0.0.0.0 instead.");
prefix = "+";
address = "0.0.0.0";
}
try
{
_listener.Prefixes.Add($"http://{prefix}:{config.Port}/");
_listener.Start();
Task.Run(() => ListenAsync(_cts.Token));
Log.Information($"日志服务器已启动 {address}:{config.Port}");
}
catch (Exception ex)
{
Log.Error(ex, "启动日志服务器失败。请检查端口是否被占用,或是否需要 0.0.0.0 的管理员权限。");
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@BetterGenshinImpact/Service/HttpLogServerService.cs` around lines 94 - 123,
In StartServer(), the call to _listener.Prefixes.Add is currently outside the
try/catch so any exceptions escape your handler; move the Prefixes.Add call into
the same try block that calls _listener.Start() and
Task.Run(()=>ListenAsync(...)) and perform port validation (e.g., ensure
config.Port is within valid TCP port range) before constructing the prefix, so
any prefix/port-related exceptions are caught by the existing catch; update
references to prefix/address construction and keep ListenAsync, _listener.Start
and the catch block unchanged.

}

private void StopServer()
{
_cts?.Cancel();
if (_listener != null && _listener.IsListening)
{
_listener.Stop();
_listener.Close();
}

foreach (var client in _clients.Keys)
{
try { client.Close(); } catch { }
}
_clients.Clear();
}

private void RestartServer()
{
StopServer();
StartServer();
}

private async Task ListenAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), token);
}
catch (HttpListenerException)
{
break;
}
catch (Exception ex)
{
Log.Error(ex, "Error accepting HTTP request");
}
}
}

private async Task HandleRequestAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;

try
{
if (request.Url.AbsolutePath == "/")
{
ServeHtml(response);
}
else if (request.Url.AbsolutePath == "/logs")
{
await ServeLogsSseAsync(response);
}
else if (request.Url.AbsolutePath == "/screenshot")
{
ServeScreenshot(response);
}
Comment on lines +168 to +186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

缺少鉴权会暴露日志与截图。

/logs/screenshot 当前无访问控制,只要能连到该端口就可读取运行日志和游戏画面。建议至少增加访问令牌(query/header)或明确的 IP 白名单策略。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@BetterGenshinImpact/Service/HttpLogServerService.cs` around lines 168 - 186,
The endpoints ServeLogsSseAsync and ServeScreenshot are exposed without auth
inside HandleRequestAsync; add an access check early in HandleRequestAsync
(e.g., call a new ValidateAccess(HttpListenerRequest) helper) that verifies a
configured bearer token from a header or query parameter and/or checks
context.Request.RemoteEndPoint against an IP whitelist, and return a 401/403
response if validation fails; update calls to ServeLogsSseAsync and
ServeScreenshot to only execute after ValidateAccess passes and keep ServeHtml
publicly accessible or similarly protect it if desired.

else
{
response.StatusCode = 404;
response.Close();
}
}
catch (Exception ex)
{
Log.Error(ex, "Error handling HTTP request");
try { response.StatusCode = 500; response.Close(); } catch { }
}
}

private void ServeHtml(HttpListenerResponse response)
{
var html = @"<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>BetterGI Log Server</title>
<style>
body { font-family: Consolas, monospace; background: #1e1e1e; color: #d4d4d4; margin: 0; padding: 10px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
#controls { margin-bottom: 10px; }
button { padding: 5px 15px; background: #0e639c; color: white; border: none; cursor: pointer; }
button:hover { background: #1177bb; }
#log-container { flex: 1; overflow-y: auto; background: #252526; padding: 10px; border: 1px solid #333; white-space: pre-wrap; word-wrap: break-word; }
#screenshot-container { margin-top: 10px; text-align: center; }
#screenshot { max-width: 100%; max-height: 400px; display: none; }
</style>
</head>
<body>
<div id='controls'>
<button onclick='getScreenshot()'>获取游戏截图</button>
<label><input type='checkbox' id='auto-scroll' checked> 自动滚动到底部</label>
</div>
<div id='log-container'></div>
<div id='screenshot-container'>
<img id='screenshot' alt='Game Screenshot' />
</div>

<script>
const logContainer = document.getElementById('log-container');
const autoScroll = document.getElementById('auto-scroll');
const screenshot = document.getElementById('screenshot');

const evtSource = new EventSource('/logs');
evtSource.onmessage = function(e) {
const jsonStr = decodeURIComponent(e.data);
try {
const logData = JSON.parse(jsonStr);
const span = document.createElement('span');
span.style.color = logData.color;
span.textContent = logData.message;
logContainer.appendChild(span);
} catch (err) {
logContainer.appendChild(document.createTextNode(jsonStr));
}
if (autoScroll.checked) {
logContainer.scrollTop = logContainer.scrollHeight;
}
};

function getScreenshot() {
screenshot.src = '/screenshot?t=' + new Date().getTime();
screenshot.style.display = 'inline-block';
}
</script>
</body>
</html>";
var buffer = Encoding.UTF8.GetBytes(html);
response.ContentType = "text/html; charset=utf-8";
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
response.Close();
}

private async Task ServeLogsSseAsync(HttpListenerResponse response)
{
response.ContentType = "text/event-stream";
response.Headers.Add("Cache-Control", "no-cache");
response.Headers.Add("Connection", "keep-alive");

_clients.TryAdd(response, true);

foreach (var log in _logBuffer)
{
var data = $"data: {Uri.EscapeDataString(log)}\n\n";
var buffer = Encoding.UTF8.GetBytes(data);
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
}
await response.OutputStream.FlushAsync();
}

private void ServeScreenshot(HttpListenerResponse response)
{
try
{
using var mat = TaskTriggerDispatcher.GlobalGameCapture?.Capture();
if (mat != null && !mat.Empty())
{
var bytes = mat.ImEncode(".jpg");
response.ContentType = "image/jpeg";
response.ContentLength64 = bytes.Length;
response.OutputStream.Write(bytes, 0, bytes.Length);
}
else
{
response.StatusCode = 404;
}
}
catch (Exception ex)
{
Log.Error(ex, "Error capturing screenshot for HTTP server");
response.StatusCode = 500;
}
finally
{
response.Close();
}
}

public void Dispose()
{
StopServer();
}
}
6 changes: 6 additions & 0 deletions BetterGenshinImpact/User/I18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@
"纳塔": "Nata",
"挪德卡莱": "Nod-Krai",
"软件设置": "Software settings",
"日志服务器": "Log Server",
"通过浏览器查看实时日志和游戏截图": "View real-time logs and game screenshots via browser",
"监听地址": "Listen Address",
"默认 0.0.0.0 (允许所有设备访问)": "Default 0.0.0.0 (allows access from all devices)",
"端口": "Port",
"默认 8080": "Default 8080",
"通用功能设置": "General feature settings",
"帮助": "Help",
"查看": "View",
Expand Down
Loading