using System; using System.Collections.Concurrent; using System.Net.Sockets; using System.Text; using System.Threading; using UnityEngine; namespace Jovian.Logger { /// /// 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. /// 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 sendQueue = new ConcurrentQueue(); private Thread sendThread; private volatile bool running; private volatile bool connected; private float reconnectTimer; private static RemoteLogSender instance; /// Whether the sender is currently connected to the editor. public bool IsConnected => connected; /// The editor host address. public string EditorHost { get => editorHost; set => editorHost = value; } /// The editor port. 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(); } } } /// Connect to the Custom Console editor. 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(); } } /// Disconnect from the editor. 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(" 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(); } } }