Files
trail-into-darkness/Packages/com.jovian.logger/Runtime/RemoteLogSender.cs

271 lines
10 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
namespace Jovian.Logger {
/// <summary>
/// Sends log messages to the Custom Console editor window over TCP.
/// Add this component to a GameObject in your scene (or create one at runtime)
/// to enable remote logging from device builds.
/// </summary>
public class RemoteLogSender : MonoBehaviour {
[Tooltip("IP address of the machine running the Unity Editor")]
[SerializeField] private string editorHost = "127.0.0.1";
[Tooltip("Port the Custom Console is listening on")]
[SerializeField] private int editorPort = 9876;
[Tooltip("Automatically connect on start")]
[SerializeField] private bool autoConnect = true;
[Tooltip("Retry connection every N seconds when disconnected")]
[SerializeField] private float reconnectInterval = 5f;
private TcpClient client;
private NetworkStream stream;
private readonly ConcurrentQueue<string> sendQueue = new ConcurrentQueue<string>();
private Thread sendThread;
private volatile bool running;
private volatile bool connected;
private float reconnectTimer;
private static RemoteLogSender instance;
/// <summary>Whether the sender is currently connected to the editor.</summary>
public bool IsConnected => connected;
/// <summary>The editor host address.</summary>
public string EditorHost {
get => editorHost;
set => editorHost = value;
}
/// <summary>The editor port.</summary>
public int EditorPort {
get => editorPort;
set => editorPort = value;
}
private void Awake() {
if (instance != null && instance != this) {
Destroy(gameObject);
return;
}
instance = this;
DontDestroyOnLoad(gameObject);
}
private void OnEnable() {
Application.logMessageReceivedThreaded += OnUnityLogReceived;
LoggerUtility.FormattedLogCallback += OnJovianLogReceived;
LoggerUtility.WatchCallback += OnWatch;
LoggerUtility.UnwatchCallback += OnUnwatch;
if (autoConnect) {
Connect();
}
}
private void OnDisable() {
Application.logMessageReceivedThreaded -= OnUnityLogReceived;
LoggerUtility.FormattedLogCallback -= OnJovianLogReceived;
LoggerUtility.WatchCallback -= OnWatch;
LoggerUtility.UnwatchCallback -= OnUnwatch;
Disconnect();
}
private void Update() {
if (!connected && autoConnect) {
reconnectTimer += Time.unscaledDeltaTime;
if (reconnectTimer >= reconnectInterval) {
reconnectTimer = 0f;
Connect();
}
}
}
/// <summary>Connect to the Custom Console editor.</summary>
public void Connect() {
if (connected) return;
try {
client = new TcpClient();
client.NoDelay = true;
client.SendTimeout = 2000;
var result = client.BeginConnect(editorHost, editorPort, null, null);
bool success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(2));
if (!success || !client.Connected) {
client.Close();
client = null;
return;
}
client.EndConnect(result);
stream = client.GetStream();
connected = true;
running = true;
sendThread = new Thread(SendLoop) {
IsBackground = true,
Name = "RemoteLogSender"
};
sendThread.Start();
// Send handshake
EnqueueMessage("{\"handshake\":true,\"app\":\"" + Application.productName + "\",\"platform\":\"" + Application.platform + "\"}");
Debug.Log($"[RemoteLog] Connected to editor at {editorHost}:{editorPort}");
} catch (Exception e) {
Debug.LogWarning($"[RemoteLog] Failed to connect: {e.Message}");
CleanupConnection();
}
}
/// <summary>Disconnect from the editor.</summary>
public void Disconnect() {
running = false;
CleanupConnection();
}
private void CleanupConnection() {
connected = false;
try { stream?.Close(); } catch { }
try { client?.Close(); } catch { }
stream = null;
client = null;
}
private void OnJovianLogReceived((JovianLogType logType, LogCategory logCategory, string message) log) {
if (!connected) return;
var json = BuildLogJson(log.message, "", (int)log.logType, (int)log.logCategory, true,
DateTime.Now.ToString("HH:mm:ss.fff"), Time.frameCount);
EnqueueMessage(json);
}
private static readonly string[] LoggerPrefixes = {
"INFO -> ", "ERROR -> ", "WARNING -> ",
"EXCEPTION -> ", "ASSERT -> ", "SPAM -> "
};
private static bool IsLoggerFormattedMessage(string condition) {
foreach (var prefix in LoggerPrefixes) {
if (condition.StartsWith(prefix, StringComparison.Ordinal)) return true;
}
if (condition.StartsWith("<color=#", StringComparison.Ordinal) && condition.Length > 22) {
var afterTag = condition.AsSpan(15);
foreach (var prefix in LoggerPrefixes) {
if (afterTag.StartsWith(prefix.AsSpan(), StringComparison.Ordinal)) return true;
}
}
return false;
}
private void OnUnityLogReceived(string condition, string stackTrace, UnityEngine.LogType type) {
if (!connected) return;
// Skip logger-formatted messages — those come via OnCustomLog
if (IsLoggerFormattedMessage(condition)) return;
int loggerTyper = type switch {
LogType.Error => (int)JovianLogType.Error,
LogType.Assert => (int)JovianLogType.Assert,
LogType.Warning => (int)JovianLogType.Warning,
LogType.Exception => (int)JovianLogType.Exception,
_ => (int)JovianLogType.Info,
};
string message = condition;
if (type is LogType.Error or LogType.Assert or LogType.Exception && !string.IsNullOrEmpty(stackTrace)) {
message = $"{condition}\n{stackTrace}";
}
var json = BuildLogJson(message, stackTrace ?? "", loggerTyper, (int)LogCategory.General, false,
DateTime.Now.ToString("HH:mm:ss.fff"), Time.frameCount);
EnqueueMessage(json);
}
private void OnWatch((string key, string value, LogCategory category) watch) {
if (!connected) return;
var sb = new StringBuilder(128);
sb.Append("{\"watch\":true,\"wk\":");
AppendJsonString(sb, watch.key);
sb.Append(",\"wv\":");
AppendJsonString(sb, watch.value);
sb.Append(",\"c\":").Append((int)watch.category);
sb.Append(",\"ts\":");
AppendJsonString(sb, DateTime.Now.ToString("HH:mm:ss.fff"));
sb.Append(",\"fc\":").Append(Time.frameCount);
sb.Append('}');
EnqueueMessage(sb.ToString());
}
private void OnUnwatch(string key) {
if (!connected) return;
var sb = new StringBuilder(64);
sb.Append("{\"unwatch\":true,\"wk\":");
AppendJsonString(sb, key);
sb.Append('}');
EnqueueMessage(sb.ToString());
}
private static string BuildLogJson(string message, string stackTrace, int type, int category, bool isCustom, string timestamp, int frame) {
// Manual JSON building to avoid allocations from JsonUtility
var sb = new StringBuilder(256);
sb.Append("{\"m\":");
AppendJsonString(sb, message);
sb.Append(",\"s\":");
AppendJsonString(sb, stackTrace);
sb.Append(",\"t\":").Append(type);
sb.Append(",\"c\":").Append(category);
sb.Append(",\"f\":").Append(isCustom ? "true" : "false");
sb.Append(",\"ts\":");
AppendJsonString(sb, timestamp);
sb.Append(",\"fc\":").Append(frame);
sb.Append('}');
return sb.ToString();
}
private static void AppendJsonString(StringBuilder sb, string value) {
if (value == null) { sb.Append("\"\""); return; }
sb.Append('"');
foreach (char c in value) {
switch (c) {
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default: sb.Append(c); break;
}
}
sb.Append('"');
}
private void EnqueueMessage(string json) {
// Cap queue size to prevent memory issues if sending is slow
if (sendQueue.Count < 10000) {
sendQueue.Enqueue(json);
}
}
private void SendLoop() {
while (running) {
try {
if (sendQueue.TryDequeue(out string json)) {
byte[] data = Encoding.UTF8.GetBytes(json + "\n");
stream.Write(data, 0, data.Length);
} else {
Thread.Sleep(5);
}
} catch (Exception) {
running = false;
connected = false;
break;
}
}
}
private void OnDestroy() {
if (instance == this) instance = null;
Disconnect();
}
}
}