forked from Shardstone/trail-into-darkness
271 lines
10 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|