修改提交

This commit is contained in:
Bob.Song
2026-03-09 17:50:20 +08:00
parent 68beeb3417
commit 27b85fd875
228 changed files with 30829 additions and 1509 deletions

View File

@@ -0,0 +1,315 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Manages long-running asynchronous operation states (compilation, UPM, baking, etc.)
/// Provides operation ID generation, status tracking, and timeout management.
/// </summary>
public static class AsyncOperationTracker
{
/// <summary>
/// Job status enum matching MCP protocol.
/// </summary>
public enum JobStatus
{
Pending,
Complete,
Error
}
/// <summary>
/// Job type enum for categorizing operations.
/// </summary>
public enum JobType
{
Compilation,
UpmPackage,
NavMeshBake,
LightingBake,
Custom
}
/// <summary>
/// Represents a tracked job/operation.
/// </summary>
public class Job
{
public string OpId { get; set; }
public JobType Type { get; set; }
public JobStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string Message { get; set; }
public object Data { get; set; }
public string ErrorMessage { get; set; }
public float Progress { get; set; } // 0.0 to 1.0
}
// Job storage
private static readonly Dictionary<string, Job> _jobs = new Dictionary<string, Job>();
private static readonly object _jobsLock = new object();
// Default timeout in seconds
private const int DefaultTimeoutSeconds = 300;
/// <summary>
/// Creates a new job with a unique op_id and registers it.
/// </summary>
public static Job CreateJob(JobType type, string message = null)
{
var job = new Job
{
OpId = GenerateOpId(),
Type = type,
Status = JobStatus.Pending,
CreatedAt = DateTime.UtcNow,
Message = message ?? $"{type} operation started",
Progress = 0.0f
};
lock (_jobsLock)
{
_jobs[job.OpId] = job;
}
return job;
}
/// <summary>
/// Gets a job by op_id.
/// </summary>
public static Job GetJob(string opId)
{
lock (_jobsLock)
{
return _jobs.TryGetValue(opId, out var job) ? job : null;
}
}
/// <summary>
/// Updates job status to Complete.
/// </summary>
public static void CompleteJob(string opId, string message = null, object data = null)
{
lock (_jobsLock)
{
if (_jobs.TryGetValue(opId, out var job))
{
job.Status = JobStatus.Complete;
job.CompletedAt = DateTime.UtcNow;
job.Progress = 1.0f;
if (message != null) job.Message = message;
if (data != null) job.Data = data;
}
}
}
/// <summary>
/// Updates job status to Error.
/// </summary>
public static void FailJob(string opId, string errorMessage)
{
lock (_jobsLock)
{
if (_jobs.TryGetValue(opId, out var job))
{
job.Status = JobStatus.Error;
job.CompletedAt = DateTime.UtcNow;
job.ErrorMessage = errorMessage;
job.Message = $"Operation failed: {errorMessage}";
}
}
}
/// <summary>
/// Updates job progress (0.0 to 1.0).
/// </summary>
public static void UpdateProgress(string opId, float progress, string message = null)
{
lock (_jobsLock)
{
if (_jobs.TryGetValue(opId, out var job))
{
job.Progress = Mathf.Clamp01(progress);
if (message != null) job.Message = message;
}
}
}
/// <summary>
/// Removes a job from tracking.
/// </summary>
public static void RemoveJob(string opId)
{
lock (_jobsLock)
{
_jobs.Remove(opId);
}
}
/// <summary>
/// Gets all pending jobs of a specific type.
/// </summary>
public static List<Job> GetPendingJobs(JobType? type = null)
{
lock (_jobsLock)
{
return _jobs.Values
.Where(j => j.Status == JobStatus.Pending && (!type.HasValue || j.Type == type.Value))
.ToList();
}
}
/// <summary>
/// Cleans up old jobs that have been completed or timed out.
/// Should be called periodically.
/// </summary>
public static void CleanupOldJobs(int maxAgeSeconds = 3600)
{
var cutoff = DateTime.UtcNow.AddSeconds(-maxAgeSeconds);
lock (_jobsLock)
{
var toRemove = _jobs
.Where(kv => kv.Value.CompletedAt.HasValue && kv.Value.CompletedAt.Value < cutoff)
.Select(kv => kv.Key)
.ToList();
foreach (var opId in toRemove)
{
_jobs.Remove(opId);
}
if (toRemove.Count > 0)
{
Debug.Log($"[AsyncOperationTracker] Cleaned up {toRemove.Count} old jobs");
}
}
}
/// <summary>
/// Checks if a job has timed out.
/// </summary>
public static bool IsJobTimedOut(string opId, int timeoutSeconds = DefaultTimeoutSeconds)
{
lock (_jobsLock)
{
if (_jobs.TryGetValue(opId, out var job))
{
if (job.Status == JobStatus.Pending)
{
var elapsed = (DateTime.UtcNow - job.CreatedAt).TotalSeconds;
return elapsed > timeoutSeconds;
}
}
}
return false;
}
/// <summary>
/// Creates a Pending async operation response for a job.
/// Protocol: status/poll_interval/op_id
/// </summary>
public static object CreatePendingResponse(Job job, object stateDelta = null)
{
var response = new Dictionary<string, object>
{
["status"] = "pending",
["poll_interval"] = 1.0, // Poll every 1 second
["op_id"] = job.OpId,
["success"] = true,
["message"] = job.Message,
["data"] = new
{
type = job.Type.ToString(),
progress = job.Progress,
createdAt = job.CreatedAt.ToString("o")
}
};
// Add operations state delta showing the new pending operation
var opDelta = StateComposer.CreateOperationsDelta(new[] {
new { id = job.OpId, type = job.Type.ToString(), progress = job.Progress, message = job.Message }
});
response["state_delta"] = stateDelta != null
? StateComposer.MergeStateDeltas(opDelta, stateDelta)
: opDelta;
return response;
}
/// <summary>
/// Creates a Complete async operation response for a job.
/// Protocol: status/op_id
/// </summary>
public static object CreateCompleteResponse(Job job, object stateDelta = null)
{
var response = new Dictionary<string, object>
{
["status"] = "complete",
["op_id"] = job.OpId,
["success"] = true,
["message"] = job.Message,
["data"] = job.Data
};
// Include state_delta if provided
if (stateDelta != null)
{
response["state_delta"] = stateDelta;
}
return response;
}
/// <summary>
/// Creates an Error async operation response for a job.
/// Protocol: status/op_id
/// </summary>
public static object CreateErrorResponse(Job job, object stateDelta = null)
{
var response = new Dictionary<string, object>
{
["status"] = "error",
["op_id"] = job.OpId,
["success"] = false,
["message"] = job.Message,
["error"] = job.ErrorMessage
};
// Include state_delta if provided
if (stateDelta != null)
{
response["state_delta"] = stateDelta;
}
return response;
}
/// <summary>
/// Generates a unique operation ID.
/// </summary>
private static string GenerateOpId()
{
return Guid.NewGuid().ToString("N");
}
/// <summary>
/// Gets count of all jobs by status.
/// </summary>
public static Dictionary<JobStatus, int> GetJobCounts()
{
lock (_jobsLock)
{
return _jobs.Values
.GroupBy(j => j.Status)
.ToDictionary(g => g.Key, g => g.Count());
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ccdab4cb337ac174395ef110bfa8f3b1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,157 @@
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Helper class for handling binary framed TCP communication
/// </summary>
public static class BinaryFrameHelper
{
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
/// <summary>
/// Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks
/// </summary>
public static async Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default)
{
byte[] buffer = new byte[count];
int offset = 0;
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
while (offset < count)
{
int remaining = count - offset;
int remainingTimeout = timeoutMs <= 0
? Timeout.Infinite
: timeoutMs - (int)stopwatch.ElapsedMilliseconds;
// If a finite timeout is configured and already elapsed, fail immediately
if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0)
{
throw new System.IO.IOException("Read timed out");
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel);
if (remainingTimeout != Timeout.Infinite)
{
cts.CancelAfter(remainingTimeout);
}
try
{
#if NETSTANDARD2_1 || NET6_0_OR_GREATER
int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false);
#else
int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false);
#endif
if (read == 0)
{
throw new System.IO.IOException("Connection closed before reading expected bytes");
}
offset += read;
}
catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
{
throw new System.IO.IOException("Read timed out");
}
}
return buffer;
}
/// <summary>
/// Write a framed payload to the stream with default timeout
/// </summary>
public static async Task WriteFrameAsync(NetworkStream stream, byte[] payload)
{
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
await WriteFrameAsync(stream, payload, cts.Token);
}
/// <summary>
/// Write a framed payload to the stream with cancellation support
/// </summary>
public static async Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel)
{
if (payload == null)
{
throw new System.ArgumentNullException(nameof(payload));
}
if ((ulong)payload.LongLength > MaxFrameBytes)
{
throw new System.IO.IOException($"Frame too large: {payload.LongLength}");
}
byte[] header = new byte[8];
WriteUInt64BigEndian(header, (ulong)payload.LongLength);
#if NETSTANDARD2_1 || NET6_0_OR_GREATER
await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false);
await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false);
#else
await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false);
await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false);
#endif
}
/// <summary>
/// Read a framed UTF-8 string from the stream
/// </summary>
public static async Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
{
byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
ulong payloadLen = ReadUInt64BigEndian(header);
if (payloadLen > MaxFrameBytes)
{
throw new System.IO.IOException($"Invalid framed length: {payloadLen}");
}
if (payloadLen == 0UL)
throw new System.IO.IOException("Zero-length frames are not allowed");
if (payloadLen > int.MaxValue)
{
throw new System.IO.IOException("Frame too large for buffer");
}
int count = (int)payloadLen;
byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
return System.Text.Encoding.UTF8.GetString(payload);
}
/// <summary>
/// Read a UInt64 from a byte array in big-endian format
/// </summary>
public static ulong ReadUInt64BigEndian(byte[] buffer)
{
if (buffer == null || buffer.Length < 8) return 0UL;
return ((ulong)buffer[0] << 56)
| ((ulong)buffer[1] << 48)
| ((ulong)buffer[2] << 40)
| ((ulong)buffer[3] << 32)
| ((ulong)buffer[4] << 24)
| ((ulong)buffer[5] << 16)
| ((ulong)buffer[6] << 8)
| buffer[7];
}
/// <summary>
/// Write a UInt64 to a byte array in big-endian format
/// </summary>
public static void WriteUInt64BigEndian(byte[] dest, ulong value)
{
if (dest == null || dest.Length < 8)
{
throw new System.ArgumentException("Destination buffer too small for UInt64");
}
dest[0] = (byte)(value >> 56);
dest[1] = (byte)(value >> 48);
dest[2] = (byte)(value >> 40);
dest[3] = (byte)(value >> 32);
dest[4] = (byte)(value >> 24);
dest[5] = (byte)(value >> 16);
dest[6] = (byte)(value >> 8);
dest[7] = (byte)(value);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9addeba67f678854eada157f672b975d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,232 @@
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Helper class for Unity compilation status checking and error tracking
/// </summary>
public static class CompilationHelper
{
// Track last known compilation error/warning counts
// IMPORTANT: Keep these nullable. Returning 0 when counts are unknown is misleading
// (it can be interpreted as "validated: no errors/warnings").
private static int? _lastErrorCount = null;
private static int? _lastWarningCount = null;
private static bool _trackingInitialized = false;
/// <summary>
/// Helper to check compilation status across Unity versions
/// </summary>
public static bool IsCompiling()
{
if (EditorApplication.isCompiling)
{
return true;
}
try
{
System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (prop != null)
{
return (bool)prop.GetValue(null);
}
}
catch { }
return false;
}
/// <summary>
/// Gets the count of compilation errors from the console.
/// This is an approximation based on console log entries.
/// </summary>
public static int? GetCompilationErrors()
{
try
{
// Try to get error count from LogEntries (internal API)
var logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries");
if (logEntriesType != null)
{
var getCountMethod = logEntriesType.GetMethod(
"GetCount",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic
);
// Get count with error filter (mode = 1 for errors)
var getCountByTypeMethod = logEntriesType.GetMethod(
"GetCountsByType",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic
);
if (getCountByTypeMethod != null)
{
// GetCountsByType returns counts for errors, warnings, logs
var counts = new int[3];
getCountByTypeMethod.Invoke(null, new object[] { counts });
_lastErrorCount = counts[0]; // Errors
return _lastErrorCount;
}
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[CompilationHelper] Failed to get error count: {e.Message}");
}
return _lastErrorCount;
}
/// <summary>
/// Gets the count of compilation warnings from the console.
/// This is an approximation based on console log entries.
/// </summary>
public static int? GetCompilationWarnings()
{
try
{
// Try to get warning count from LogEntries (internal API)
var logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries");
if (logEntriesType != null)
{
var getCountByTypeMethod = logEntriesType.GetMethod(
"GetCountsByType",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic
);
if (getCountByTypeMethod != null)
{
// GetCountsByType returns counts for errors, warnings, logs
var counts = new int[3];
getCountByTypeMethod.Invoke(null, new object[] { counts });
_lastWarningCount = counts[1]; // Warnings
return _lastWarningCount;
}
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[CompilationHelper] Failed to get warning count: {e.Message}");
}
return _lastWarningCount;
}
/// <summary>
/// Resets tracked error/warning counts.
/// Should be called before starting a new compilation.
/// </summary>
public static void ResetCounts()
{
_lastErrorCount = null;
_lastWarningCount = null;
}
/// <summary>
/// Starts a standard compilation pipeline:
/// 1. Clears console and gets since_token
/// 2. Requests compilation
/// 3. Returns pending response with token for later log reading
///
/// This is the recommended pattern after any script modification.
/// </summary>
public static object StartCompilationPipeline()
{
try
{
// Step 1: Clear console and get since_token
var clearMethod = typeof(UnityTcp.Editor.Tools.ReadConsole).GetMethod(
"HandleCommand",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public
);
string sinceToken = null;
if (clearMethod != null)
{
var clearParams = new Codely.Newtonsoft.Json.Linq.JObject
{
["action"] = "clear"
};
var clearResult = clearMethod.Invoke(null, new object[] { clearParams });
// Extract since_token from result
if (clearResult != null)
{
var resultType = clearResult.GetType();
var dataProp = resultType.GetProperty("data");
if (dataProp != null)
{
var data = dataProp.GetValue(clearResult);
if (data != null)
{
var tokenProp = data.GetType().GetProperty("sinceToken");
sinceToken = tokenProp?.GetValue(data)?.ToString();
}
}
}
}
// Fallback: get token from StateComposer
if (string.IsNullOrEmpty(sinceToken))
{
sinceToken = StateComposer.GetCurrentConsoleToken();
}
// Step 2: Reset error counts
ResetCounts();
// Step 3: Create compilation job
var job = AsyncOperationTracker.CreateJob(
AsyncOperationTracker.JobType.Compilation,
"Script compilation pipeline started"
);
// Step 4: Request compilation
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
// Step 5: Return pending response with token and structured pipeline hints
var response = AsyncOperationTracker.CreatePendingResponse(job) as System.Collections.Generic.Dictionary<string, object>;
if (response != null)
{
response["since_token"] = sinceToken;
response["pipeline"] = new
{
step = "compiling",
sinceToken = sinceToken
};
response["pipeline_kind"] = "compile";
response["requires_console_validation"] = true;
}
return response ?? AsyncOperationTracker.CreatePendingResponse(job);
}
catch (System.Exception e)
{
Debug.LogError($"[CompilationHelper] StartCompilationPipeline failed: {e}");
return Response.Error($"Failed to start compilation pipeline: {e.Message}");
}
}
/// <summary>
/// Gets a summary of the last compilation result.
/// </summary>
public static object GetCompilationSummary()
{
var errors = GetCompilationErrors();
var warnings = GetCompilationWarnings();
// Only include fields that are actually known; returning 0 is misleading.
var result = new System.Collections.Generic.Dictionary<string, object>
{
["isCompiling"] = IsCompiling()
};
if (errors.HasValue) result["errors"] = errors.Value;
if (warnings.HasValue) result["warnings"] = warnings.Value;
if (errors.HasValue) result["success"] = errors.Value == 0;
return result;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96c791cc231905b4ca2231ba84bc1f4f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,274 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Runtime.InteropServices;
using UnityEditor;
namespace UnityTcp.Editor.Helpers
{
internal static class ExecPath
{
private const string PrefClaude = "UnityTcp.ClaudeCliPath";
// Resolve Claude CLI absolute path. Pref → env → common locations → PATH.
internal static string ResolveClaude()
{
try
{
string pref = EditorPrefs.GetString(PrefClaude, string.Empty);
if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref;
}
catch { }
string env = Environment.GetEnvironmentVariable("CLAUDE_CLI");
if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
Path.Combine(home, ".local", "bin", "claude"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
#else
return null;
#endif
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
#if UNITY_EDITOR_WIN
// Common npm global locations
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
string[] candidates =
{
// Prefer .cmd (most reliable from non-interactive processes)
Path.Combine(appData, "npm", "claude.cmd"),
Path.Combine(localAppData, "npm", "claude.cmd"),
// Fall back to PowerShell shim if only .ps1 is present
Path.Combine(appData, "npm", "claude.ps1"),
Path.Combine(localAppData, "npm", "claude.ps1"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude");
if (!string.IsNullOrEmpty(fromWhere)) return fromWhere;
#endif
return null;
}
// Linux
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
"/usr/local/bin/claude",
"/usr/bin/claude",
Path.Combine(home, ".local", "bin", "claude"),
};
foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/usr/local/bin:/usr/bin:/bin");
#else
return null;
#endif
}
}
// Attempt to resolve claude from NVM-managed Node installations, choosing the newest version
private static string ResolveClaudeFromNvm(string home)
{
try
{
if (string.IsNullOrEmpty(home)) return null;
string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node");
if (!Directory.Exists(nvmNodeDir)) return null;
string bestPath = null;
Version bestVersion = null;
foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir))
{
string name = Path.GetFileName(versionDir);
if (string.IsNullOrEmpty(name)) continue;
if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
// Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0
string versionStr = name.Substring(1);
int dashIndex = versionStr.IndexOf('-');
if (dashIndex > 0)
{
versionStr = versionStr.Substring(0, dashIndex);
}
if (Version.TryParse(versionStr, out Version parsed))
{
string candidate = Path.Combine(versionDir, "bin", "claude");
if (File.Exists(candidate))
{
if (bestVersion == null || parsed > bestVersion)
{
bestVersion = parsed;
bestPath = candidate;
}
}
}
}
}
return bestPath;
}
catch { return null; }
}
// Explicitly set the Claude CLI absolute path override in EditorPrefs
internal static void SetClaudeCliPath(string absolutePath)
{
try
{
if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath))
{
EditorPrefs.SetString(PrefClaude, absolutePath);
}
}
catch { }
}
// Clear any previously set Claude CLI override path
internal static void ClearClaudeCliPath()
{
try
{
if (EditorPrefs.HasKey(PrefClaude))
{
EditorPrefs.DeleteKey(PrefClaude);
}
}
catch { }
}
internal static bool TryRun(
string file,
string args,
string workingDir,
out string stdout,
out string stderr,
int timeoutMs = 15000,
string extraPathPrepend = null)
{
stdout = string.Empty;
stderr = string.Empty;
try
{
// Handle PowerShell scripts on Windows by invoking through powershell.exe
bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase);
var psi = new ProcessStartInfo
{
FileName = isPs1 ? "powershell.exe" : file,
Arguments = isPs1
? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim()
: args,
WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
if (!string.IsNullOrEmpty(extraPathPrepend))
{
string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath)
? extraPathPrepend
: (extraPathPrepend + System.IO.Path.PathSeparator + currentPath);
}
using var process = new Process { StartInfo = psi, EnableRaisingEvents = false };
var so = new StringBuilder();
var se = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); };
if (!process.Start()) return false;
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (!process.WaitForExit(timeoutMs))
{
try { process.Kill(); } catch { }
return false;
}
// Ensure async buffers are flushed
process.WaitForExit();
stdout = so.ToString();
stderr = se.ToString();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
private static string Which(string exe, string prependPath)
{
try
{
var psi = new ProcessStartInfo("/usr/bin/which", exe)
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path);
using var p = Process.Start(psi);
string output = p?.StandardOutput.ReadToEnd().Trim();
p?.WaitForExit(1500);
return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null;
}
catch { return null; }
}
#endif
#if UNITY_EDITOR_WIN
private static string Where(string exe)
{
try
{
var psi = new ProcessStartInfo("where", exe)
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
using var p = Process.Start(psi);
string first = p?.StandardOutput.ReadToEnd()
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
p?.WaitForExit(1500);
return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null;
}
catch { return null; }
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a4a955ce7b85e184597177720c6d46b3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,536 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Codely.Newtonsoft.Json;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Serialization; // For Converters
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Handles serialization of GameObjects and Components for MCP responses.
/// Includes reflection helpers and caching for performance.
/// </summary>
public static class GameObjectSerializer
{
// --- Data Serialization ---
/// <summary>
/// Creates a serializable representation of a GameObject.
/// </summary>
public static object GetGameObjectData(GameObject go)
{
if (go == null)
return null;
return new
{
name = go.name,
instanceID = go.GetInstanceID(),
tag = go.tag,
layer = go.layer,
activeSelf = go.activeSelf,
activeInHierarchy = go.activeInHierarchy,
isStatic = go.isStatic,
scenePath = go.scene.path, // Identify which scene it belongs to
transform = new // Serialize transform components carefully to avoid JSON issues
{
// Serialize Vector3 components individually to prevent self-referencing loops.
// The default serializer can struggle with properties like Vector3.normalized.
position = new
{
x = go.transform.position.x,
y = go.transform.position.y,
z = go.transform.position.z,
},
localPosition = new
{
x = go.transform.localPosition.x,
y = go.transform.localPosition.y,
z = go.transform.localPosition.z,
},
rotation = new
{
x = go.transform.rotation.eulerAngles.x,
y = go.transform.rotation.eulerAngles.y,
z = go.transform.rotation.eulerAngles.z,
},
localRotation = new
{
x = go.transform.localRotation.eulerAngles.x,
y = go.transform.localRotation.eulerAngles.y,
z = go.transform.localRotation.eulerAngles.z,
},
scale = new
{
x = go.transform.localScale.x,
y = go.transform.localScale.y,
z = go.transform.localScale.z,
},
forward = new
{
x = go.transform.forward.x,
y = go.transform.forward.y,
z = go.transform.forward.z,
},
up = new
{
x = go.transform.up.x,
y = go.transform.up.y,
z = go.transform.up.z,
},
right = new
{
x = go.transform.right.x,
y = go.transform.right.y,
z = go.transform.right.z,
},
},
parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent
// Optionally include components, but can be large
// components = go.GetComponents<Component>().Select(c => GetComponentData(c)).ToList()
// Or just component names:
componentNames = go.GetComponents<Component>()
.Select(c => c.GetType().FullName)
.ToList(),
};
}
// --- Metadata Caching for Reflection ---
private class CachedMetadata
{
public readonly List<PropertyInfo> SerializableProperties;
public readonly List<FieldInfo> SerializableFields;
public CachedMetadata(List<PropertyInfo> properties, List<FieldInfo> fields)
{
SerializableProperties = properties;
SerializableFields = fields;
}
}
// Key becomes Tuple<Type, bool>
private static readonly Dictionary<Tuple<Type, bool>, CachedMetadata> _metadataCache = new Dictionary<Tuple<Type, bool>, CachedMetadata>();
// --- End Metadata Caching ---
/// <summary>
/// Creates a serializable representation of a Component, attempting to serialize
/// public properties and fields using reflection, with caching and control over non-public fields.
/// </summary>
// Add the flag parameter here
public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true)
{
// --- Add Early Logging ---
// Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})");
// --- End Early Logging ---
if (c == null) return null;
Type componentType = c.GetType();
// --- Special handling for Transform to avoid reflection crashes and problematic properties ---
if (componentType == typeof(Transform))
{
Transform tr = c as Transform;
// Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})");
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", tr.GetInstanceID() },
// Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'.
{ "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, // Use Euler angles
{ "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
{ "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 },
{ "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 },
{ "childCount", tr.childCount },
// Include standard Object/Component properties
{ "name", tr.name },
{ "tag", tr.tag },
{ "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 }
};
}
// --- End Special handling for Transform ---
// --- Special handling for Camera to avoid matrix-related crashes ---
if (componentType == typeof(Camera))
{
Camera cam = c as Camera;
var cameraProperties = new Dictionary<string, object>();
// List of safe properties to serialize
var safeProperties = new Dictionary<string, Func<object>>
{
{ "nearClipPlane", () => cam.nearClipPlane },
{ "farClipPlane", () => cam.farClipPlane },
{ "fieldOfView", () => cam.fieldOfView },
{ "renderingPath", () => (int)cam.renderingPath },
{ "actualRenderingPath", () => (int)cam.actualRenderingPath },
{ "allowHDR", () => cam.allowHDR },
{ "allowMSAA", () => cam.allowMSAA },
{ "allowDynamicResolution", () => cam.allowDynamicResolution },
{ "forceIntoRenderTexture", () => cam.forceIntoRenderTexture },
{ "orthographicSize", () => cam.orthographicSize },
{ "orthographic", () => cam.orthographic },
{ "opaqueSortMode", () => (int)cam.opaqueSortMode },
{ "transparencySortMode", () => (int)cam.transparencySortMode },
{ "depth", () => cam.depth },
{ "aspect", () => cam.aspect },
{ "cullingMask", () => cam.cullingMask },
{ "eventMask", () => cam.eventMask },
{ "backgroundColor", () => cam.backgroundColor },
{ "clearFlags", () => (int)cam.clearFlags },
{ "stereoEnabled", () => cam.stereoEnabled },
{ "stereoSeparation", () => cam.stereoSeparation },
{ "stereoConvergence", () => cam.stereoConvergence },
{ "enabled", () => cam.enabled },
{ "name", () => cam.name },
{ "tag", () => cam.tag },
{ "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } }
};
foreach (var prop in safeProperties)
{
try
{
var value = prop.Value();
if (value != null)
{
AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value);
}
}
catch (Exception)
{
// Silently skip any property that fails
continue;
}
}
return new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", cam.GetInstanceID() },
{ "properties", cameraProperties }
};
}
// --- End Special handling for Camera ---
var data = new Dictionary<string, object>
{
{ "typeName", componentType.FullName },
{ "instanceID", c.GetInstanceID() }
};
// --- Get Cached or Generate Metadata (using new cache key) ---
Tuple<Type, bool> cacheKey = new Tuple<Type, bool>(componentType, includeNonPublicSerializedFields);
if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData))
{
var propertiesToCache = new List<PropertyInfo>();
var fieldsToCache = new List<FieldInfo>();
// Traverse the hierarchy from the component type up to MonoBehaviour
Type currentType = componentType;
while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object))
{
// Get properties declared only at the current type level
BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
foreach (var propInfo in currentType.GetProperties(propFlags))
{
// Basic filtering (readable, not indexer, not transform which is handled elsewhere)
if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue;
// Add if not already added (handles overrides - keep the most derived version)
if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) {
propertiesToCache.Add(propInfo);
}
}
// Get fields declared only at the current type level (both public and non-public)
BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
var declaredFields = currentType.GetFields(fieldFlags);
// Process the declared Fields for caching
foreach (var fieldInfo in declaredFields)
{
if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields
// Add if not already added (handles hiding - keep the most derived version)
if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
bool shouldInclude = false;
if (includeNonPublicSerializedFields)
{
// If TRUE, include Public OR NonPublic with [SerializeField]
shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false));
}
else // includeNonPublicSerializedFields is FALSE
{
// If FALSE, include ONLY if it is explicitly Public.
shouldInclude = fieldInfo.IsPublic;
}
if (shouldInclude)
{
fieldsToCache.Add(fieldInfo);
}
}
// Move to the base type
currentType = currentType.BaseType;
}
// --- End Hierarchy Traversal ---
cachedData = new CachedMetadata(propertiesToCache, fieldsToCache);
_metadataCache[cacheKey] = cachedData; // Add to cache with combined key
}
// --- End Get Cached or Generate Metadata ---
// --- Use cached metadata ---
var serializablePropertiesOutput = new Dictionary<string, object>();
// --- Add Logging Before Property Loop ---
// Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}...");
// --- End Logging Before Property Loop ---
// Use cached properties
foreach (var propInfo in cachedData.SerializableProperties)
{
string propName = propInfo.Name;
// --- Skip known obsolete/problematic Component shortcut properties ---
bool skipProperty = false;
if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" ||
propName == "light" || propName == "animation" || propName == "constantForce" ||
propName == "renderer" || propName == "audio" || propName == "networkView" ||
propName == "collider" || propName == "collider2D" || propName == "hingeJoint" ||
propName == "particleSystem" ||
// Also skip potentially problematic Matrix properties prone to cycles/errors
propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")
{
// Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
skipProperty = true;
}
// --- End Skip Generic Properties ---
// --- Skip Renderer.material / materials to avoid instantiating materials in edit mode ---
if (!skipProperty &&
(typeof(Renderer).IsAssignableFrom(componentType)) &&
(propName == "material" || propName == "materials"))
{
skipProperty = true;
}
// --- End skip Renderer material properties ---
// --- Skip specific potentially problematic Camera properties ---
if (componentType == typeof(Camera) &&
(propName == "pixelRect" ||
propName == "rect" ||
propName == "cullingMatrix" ||
propName == "useOcclusionCulling" ||
propName == "worldToCameraMatrix" ||
propName == "projectionMatrix" ||
propName == "nonJitteredProjectionMatrix" ||
propName == "previousViewProjectionMatrix" ||
propName == "cameraToWorldMatrix"))
{
// Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}");
skipProperty = true;
}
// --- End Skip Camera Properties ---
// --- Skip specific potentially problematic Transform properties ---
if (componentType == typeof(Transform) &&
(propName == "lossyScale" ||
propName == "rotation" ||
propName == "worldToLocalMatrix" ||
propName == "localToWorldMatrix"))
{
// Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}");
skipProperty = true;
}
// --- End Skip Transform Properties ---
// Skip if flagged
if (skipProperty)
{
continue;
}
try
{
// --- Add detailed logging ---
// Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}");
// --- End detailed logging ---
object value = propInfo.GetValue(c);
Type propType = propInfo.PropertyType;
AddSerializableValue(serializablePropertiesOutput, propName, propType, value);
}
catch (Exception)
{
// Debug.LogWarning($"Could not read property {propName} on {componentType.Name}");
}
}
// --- Add Logging Before Field Loop ---
// Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}...");
// --- End Logging Before Field Loop ---
// Use cached fields
foreach (var fieldInfo in cachedData.SerializableFields)
{
try
{
// --- Add detailed logging for fields ---
// Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}");
// --- End detailed logging for fields ---
object value = fieldInfo.GetValue(c);
string fieldName = fieldInfo.Name;
Type fieldType = fieldInfo.FieldType;
AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value);
}
catch (Exception)
{
// Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}");
}
}
// --- End Use cached metadata ---
if (serializablePropertiesOutput.Count > 0)
{
data["properties"] = serializablePropertiesOutput;
}
return data;
}
// Helper function to decide how to serialize different types
private static void AddSerializableValue(Dictionary<string, object> dict, string name, Type type, object value)
{
// Simplified: Directly use CreateTokenFromValue which uses the serializer
if (value == null)
{
dict[name] = null;
return;
}
try
{
// Use the helper that employs our custom serializer settings
JToken token = CreateTokenFromValue(value, type);
if (token != null) // Check if serialization succeeded in the helper
{
// Convert JToken back to a basic object structure for the dictionary
dict[name] = ConvertJTokenToPlainObject(token);
}
// If token is null, it means serialization failed and a warning was logged.
}
catch (Exception e)
{
// Catch potential errors during JToken conversion or addition to dictionary
Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping.");
}
}
// Helper to convert JToken back to basic object structure
private static object ConvertJTokenToPlainObject(JToken token)
{
if (token == null) return null;
switch (token.Type)
{
case JTokenType.Object:
var objDict = new Dictionary<string, object>();
foreach (var prop in ((JObject)token).Properties())
{
objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value);
}
return objDict;
case JTokenType.Array:
var list = new List<object>();
foreach (var item in (JArray)token)
{
list.Add(ConvertJTokenToPlainObject(item));
}
return list;
case JTokenType.Integer:
return token.ToObject<long>(); // Use long for safety
case JTokenType.Float:
return token.ToObject<double>(); // Use double for safety
case JTokenType.String:
return token.ToObject<string>();
case JTokenType.Boolean:
return token.ToObject<bool>();
case JTokenType.Date:
return token.ToObject<DateTime>();
case JTokenType.Guid:
return token.ToObject<Guid>();
case JTokenType.Uri:
return token.ToObject<Uri>();
case JTokenType.TimeSpan:
return token.ToObject<TimeSpan>();
case JTokenType.Bytes:
return token.ToObject<byte[]>();
case JTokenType.Null:
return null;
case JTokenType.Undefined:
return null; // Treat undefined as null
default:
// Fallback for simple value types not explicitly listed
if (token is JValue jValue && jValue.Value != null)
{
return jValue.Value;
}
// Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null.");
return null;
}
}
// --- Define custom JsonSerializerSettings for OUTPUT ---
private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
new Vector3Converter(),
new Vector2Converter(),
new QuaternionConverter(),
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new UnityEngineObjectConverter() // Handles serialization of references
},
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
// ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed
};
private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings);
// --- End Define custom JsonSerializerSettings ---
// Helper to create JToken using the output serializer
private static JToken CreateTokenFromValue(object value, Type type)
{
if (value == null) return JValue.CreateNull();
try
{
// Use the pre-configured OUTPUT serializer instance
return JToken.FromObject(value, _outputSerializer);
}
catch (JsonSerializationException e)
{
Debug.LogWarning($"[GameObjectSerializer] Codely.Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field.");
return null; // Indicate serialization failure
}
catch (Exception e) // Catch other unexpected errors
{
Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field.");
return null; // Indicate serialization failure
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 29be5222623c0324ca01f6b7ffaaa602
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,67 @@
using System;
using System.Linq;
using Codely.Newtonsoft.Json.Linq;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Helper class for JSON command processing utilities
/// </summary>
public static class JsonCommandHelper
{
/// <summary>
/// Helper method to check if a string is valid JSON
/// </summary>
public static bool IsValidJson(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
text = text.Trim();
if (
(text.StartsWith("{") && text.EndsWith("}"))
|| // Object
(text.StartsWith("[") && text.EndsWith("]"))
) // Array
{
try
{
JToken.Parse(text);
return true;
}
catch
{
return false;
}
}
return false;
}
/// <summary>
/// Helper method to get a summary of parameters for error reporting
/// </summary>
public static string GetParamsSummary(JObject @params)
{
try
{
return @params == null || !@params.HasValues
? "No parameters"
: string.Join(
", ",
@params
.Properties()
.Select(static p =>
$"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}"
)
);
}
catch
{
return "Could not summarize parameters";
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a17149005eb51642ab32aa65be61cc7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,86 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Helper class for main thread operations
/// </summary>
public static class MainThreadHelper
{
private static int mainThreadId;
/// <summary>
/// Initialize the main thread ID for safe thread checks
/// Call this from the main thread during static constructor
/// </summary>
public static void InitializeMainThreadId()
{
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
}
/// <summary>
/// Invoke the given function on the Unity main thread and wait up to timeoutMs for the result.
/// Returns null on timeout or error; caller should provide a fallback error response.
/// </summary>
public static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs)
{
if (func == null) return null;
try
{
// If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor.
if (mainThreadId == 0)
{
try { return func(); }
catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); }
}
// If we are already on the main thread, execute directly to avoid deadlocks
try
{
if (Thread.CurrentThread.ManagedThreadId == mainThreadId)
{
return func();
}
}
catch { }
object result = null;
Exception captured = null;
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
EditorApplication.delayCall += () =>
{
try
{
result = func();
}
catch (Exception ex)
{
captured = ex;
}
finally
{
try { tcs.TrySetResult(true); } catch { }
}
};
// Wait for completion with timeout (Editor thread will pump delayCall)
bool completed = tcs.Task.Wait(timeoutMs);
if (!completed)
{
return null; // timeout
}
if (captured != null)
{
throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured);
}
return result;
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f24f49a4ec33ffe448b5c69d381dfa9a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,479 @@
using System;
using System.IO;
using UnityEditor;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Codely.Newtonsoft.Json;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Manages dynamic port allocation and persistent storage for Codely Bridge connections
/// </summary>
public static class PortManager
{
private static bool IsDebugEnabled()
{
try { return EditorPrefs.GetBool("UnityTcp.DebugLogs", false); }
catch { return false; }
}
private const int DefaultPort = 25916;
private const int MaxPortAttempts = 100;
private const string RegistryFileName = ".com-unity-codely.json";
[Serializable]
public class PortConfig
{
public int unity_port;
public string created_date;
public string project_path;
// Status/heartbeat fields
public bool reloading;
public string reason;
public int seq;
public string last_heartbeat;
}
/// <summary>
/// Get the port to use - either from storage or discover a new one
/// Will try stored port first, then fallback to discovering new port
/// </summary>
/// <returns>Port number to use</returns>
public static int GetPortWithFallback()
{
// Try to load stored port first, but only if it's from the current project
var storedConfig = GetStoredPortConfig();
if (storedConfig != null &&
storedConfig.unity_port > 0 &&
string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) &&
IsPortAvailable(storedConfig.unity_port))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Using stored port {storedConfig.unity_port} for current project");
return storedConfig.unity_port;
}
// If stored port exists but is currently busy, wait briefly for release
if (storedConfig != null && storedConfig.unity_port > 0)
{
if (WaitForPortRelease(storedConfig.unity_port, 1500))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Stored port {storedConfig.unity_port} became available after short wait");
return storedConfig.unity_port;
}
// Prefer sticking to the same port; let the caller handle bind retries/fallbacks
return storedConfig.unity_port;
}
// If no valid stored port, find a new one and save it
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
}
/// <summary>
/// Discover and save a new available port (used by Auto-Connect button)
/// </summary>
/// <returns>New available port</returns>
public static int DiscoverNewPort()
{
int newPort = FindAvailablePort();
SavePort(newPort);
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Discovered and saved new port: {newPort}");
return newPort;
}
/// <summary>
/// Find an available port starting from the default port
/// </summary>
/// <returns>Available port number</returns>
private static int FindAvailablePort()
{
// Always try default port first
if (IsPortAvailable(DefaultPort))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Using default port {DefaultPort}");
return DefaultPort;
}
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Default port {DefaultPort} is in use, searching for alternative...");
// Search for alternatives
for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++)
{
if (IsPortAvailable(port))
{
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Found available port {port}");
return port;
}
}
throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}");
}
/// <summary>
/// Check if a specific port is available for binding
/// Uses same socket options as the actual TCP listener to ensure consistent behavior
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port is available</returns>
public static bool IsPortAvailable(int port)
{
TcpListener testListener = null;
try
{
testListener = new TcpListener(IPAddress.Loopback, port);
// Use same socket options as the actual listener for consistent checking
#if UNITY_EDITOR_WIN
// On Windows: no reuse + exclusive access for strict isolation
testListener.Server.SetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress,
false
);
try
{
testListener.ExclusiveAddressUse = true; // Require exclusive access for availability check
}
catch { }
#else
// On macOS/Linux: Disable port reuse
try
{
testListener.Server.SetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress,
false
);
}
catch { }
#endif
// Minimize TIME_WAIT by sending RST on close (same as actual listener)
try
{
testListener.Server.LingerState = new LingerOption(true, 0);
}
catch (Exception)
{
// Ignore if not supported on platform
}
testListener.Start();
testListener.Stop();
return true;
}
catch (SocketException)
{
return false;
}
finally
{
try { testListener?.Stop(); } catch { }
}
}
/// <summary>
/// Check if a port is currently being used by Codely Bridge server
/// This helps avoid unnecessary port changes when Unity itself is using the port
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port appears to be used by Codely Bridge server</returns>
public static bool IsPortUsedByUnityTcp(int port)
{
try
{
// Try to make a quick connection to see if it's a Codely Bridge server
using var client = new TcpClient();
var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
if (connectTask.Wait(100)) // 100ms timeout
{
// If connection succeeded, it's likely the Codely Bridge server
return client.Connected;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Detect if another Codely Bridge instance is already using this port
/// Provides better error reporting for port conflicts
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>Detailed information about port usage</returns>
public static (bool inUse, string description) CheckPortConflict(int port)
{
// First check basic availability with exclusive access
if (IsPortAvailable(port))
{
return (false, "Port is available");
}
// Port is in use, try to determine what's using it
if (IsPortUsedByUnityTcp(port))
{
return (true, "Port is in use by another Codely Bridge Bridge instance");
}
// Port is in use by something else
return (true, "Port is in use by another process");
}
/// <summary>
/// Wait for a port to become available for a limited amount of time.
/// Used to bridge the gap during domain reload when the old listener
/// hasn't released the socket yet.
/// </summary>
private static bool WaitForPortRelease(int port, int timeoutMs)
{
int waited = 0;
const int step = 100;
while (waited < timeoutMs)
{
if (IsPortAvailable(port))
{
return true;
}
// If the port is in use by a Codely Bridge instance, continue waiting briefly
if (!IsPortUsedByUnityTcp(port))
{
// In use by something else; don't keep waiting
return false;
}
Thread.Sleep(step);
waited += step;
}
return IsPortAvailable(port);
}
/// <summary>
/// Save port to persistent storage, preserving existing status information
/// </summary>
/// <param name="port">Port to save</param>
private static void SavePort(int port)
{
try
{
// Load existing config to preserve status information
var existingConfig = GetStoredPortConfig();
var portConfig = new PortConfig
{
unity_port = port,
created_date = existingConfig?.created_date ?? DateTime.UtcNow.ToString("O"),
project_path = Application.dataPath,
// Preserve existing status fields
reloading = existingConfig?.reloading ?? false,
reason = existingConfig?.reason ?? "ready",
seq = existingConfig?.seq ?? 0,
last_heartbeat = existingConfig?.last_heartbeat
};
SavePortConfig(portConfig);
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>Codely Bridge</color></b>: Saved port {port} to storage");
}
catch (Exception ex)
{
Debug.LogWarning($"Could not save port to storage: {ex.Message}");
}
}
/// <summary>
/// Save port configuration to persistent storage
/// </summary>
/// <param name="portConfig">Port configuration to save</param>
public static void SavePortConfig(PortConfig portConfig)
{
try
{
string registryDir = GetRegistryDirectory();
Directory.CreateDirectory(registryDir);
string registryFile = GetRegistryFilePath();
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
// Write to project root config file
File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false));
// Also maintain backwards compatibility by writing to legacy location
try
{
string legacyDir = GetLegacyRegistryDirectory();
Directory.CreateDirectory(legacyDir);
string legacyFile = Path.Combine(legacyDir, "unity-tcp-port.json");
File.WriteAllText(legacyFile, json, new System.Text.UTF8Encoding(false));
}
catch
{
// Ignore legacy write failures
}
}
catch (Exception ex)
{
Debug.LogWarning($"Could not save port config to storage: {ex.Message}");
throw;
}
}
/// <summary>
/// Load port from persistent storage
/// </summary>
/// <returns>Stored port number, or 0 if not found</returns>
private static int LoadStoredPort()
{
try
{
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy locations
// First try the new legacy location in project root
string projectLegacy = Path.Combine(GetRegistryDirectory(), "unity-tcp-port.json");
if (File.Exists(projectLegacy))
{
registryFile = projectLegacy;
}
else
{
// Then try the old user home location
string userHomeLegacy = Path.Combine(GetLegacyRegistryDirectory(), "unity-tcp-port.json");
if (File.Exists(userHomeLegacy))
{
registryFile = userHomeLegacy;
}
else
{
// Also check hash-based files in user home
string hashBased = Path.Combine(GetLegacyRegistryDirectory(), $"unity-tcp-port-{ComputeProjectHash(Application.dataPath)}.json");
if (File.Exists(hashBased))
{
registryFile = hashBased;
}
else
{
return 0;
}
}
}
}
string json = File.ReadAllText(registryFile);
var portConfig = JsonConvert.DeserializeObject<PortConfig>(json);
return portConfig?.unity_port ?? 0;
}
catch (Exception ex)
{
Debug.LogWarning($"Could not load port from storage: {ex.Message}");
return 0;
}
}
/// <summary>
/// Get the current stored port configuration
/// </summary>
/// <returns>Port configuration if exists, null otherwise</returns>
public static PortConfig GetStoredPortConfig()
{
try
{
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy locations
// First try the new legacy location in project root
string projectLegacy = Path.Combine(GetRegistryDirectory(), "unity-tcp-port.json");
if (File.Exists(projectLegacy))
{
registryFile = projectLegacy;
}
else
{
// Then try the old user home location
string userHomeLegacy = Path.Combine(GetLegacyRegistryDirectory(), "unity-tcp-port.json");
if (File.Exists(userHomeLegacy))
{
registryFile = userHomeLegacy;
}
else
{
// Also check hash-based files in user home
string hashBased = Path.Combine(GetLegacyRegistryDirectory(), $"unity-tcp-port-{ComputeProjectHash(Application.dataPath)}.json");
if (File.Exists(hashBased))
{
registryFile = hashBased;
}
else
{
return null;
}
}
}
}
string json = File.ReadAllText(registryFile);
return JsonConvert.DeserializeObject<PortConfig>(json);
}
catch (Exception ex)
{
Debug.LogWarning($"Could not load port config: {ex.Message}");
return null;
}
}
private static string GetRegistryDirectory()
{
// Use project root directory (parent of Assets folder)
string assetsPath = Application.dataPath;
string projectRoot = Directory.GetParent(assetsPath)?.FullName ?? assetsPath;
return projectRoot;
}
private static string GetLegacyRegistryDirectory()
{
// Legacy location in user home directory
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-tcp");
}
private static string GetRegistryFilePath()
{
string dir = GetRegistryDirectory();
return Path.Combine(dir, RegistryFileName);
}
private static string ComputeProjectHash(string input)
{
try
{
using SHA1 sha1 = SHA1.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString()[..8]; // short, sufficient for filenames
}
catch
{
return "default";
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db27ee87f1c170b47928576e31cc2c9a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Provides static methods for creating standardized success and error response objects.
/// Ensures consistent JSON structure for communication back to the Codely client.
///
/// Response format aligns with the OpenAPI spec:
/// - ImmediateResponse: { success, message, data?, state?, state_delta? }
/// - PendingResponse: { _mcp_status, op_id, poll_interval, message, state?, state_delta? }
/// </summary>
public static class Response
{
/// <summary>
/// Creates a standardized success response object with optional state.
/// </summary>
/// <param name="message">A message describing the successful operation.</param>
/// <param name="data">Optional additional data to include in the response.</param>
/// <param name="includeState">Whether to include full state snapshot (default: false).</param>
/// <param name="stateDelta">Optional state delta for incremental updates.</param>
/// <returns>An object representing the success response.</returns>
public static object Success(string message, object data = null, bool includeState = false, object stateDelta = null)
{
var response = new Dictionary<string, object>
{
{ "success", true },
{ "message", message },
// Always include current state revision so clients can keep client_state_rev in sync
{ "rev", StateComposer.GetCurrentRevision() }
};
if (data != null)
{
response["data"] = data;
}
// Include state if explicitly requested
if (includeState)
{
response["state"] = StateComposer.BuildFullState();
}
// Include state_delta if provided
if (stateDelta != null)
{
response["state_delta"] = stateDelta;
}
return response;
}
/// <summary>
/// Creates a standardized success response with automatic state delta.
/// Use this for write operations that modify Unity state.
/// </summary>
public static object SuccessWithDelta(string message, object data = null, object stateDelta = null)
{
StateComposer.IncrementRevision();
return Success(message, data, includeState: false, stateDelta: stateDelta);
}
/// <summary>
/// Creates a standardized success response with full state snapshot.
/// Use this for operations that require the client to have the latest state.
/// </summary>
public static object SuccessWithState(string message, object data = null)
{
StateComposer.IncrementRevision();
return Success(message, data, includeState: true);
}
/// <summary>
/// Creates a standardized error response object.
/// </summary>
/// <param name="errorCodeOrMessage">A message describing the error.</param>
/// <param name="data">Optional additional data (e.g., error details) to include.</param>
/// <param name="includeState">Whether to include full state snapshot for recovery (default: false).</param>
/// <returns>An object representing the error response.</returns>
public static object Error(string errorCodeOrMessage, object data = null, bool includeState = false)
{
var response = new Dictionary<string, object>
{
{ "success", false },
{ "code", errorCodeOrMessage },
{ "error", errorCodeOrMessage }
};
if (data != null)
{
response["data"] = data;
}
// Include state on error for recovery scenarios
if (includeState)
{
response["state"] = StateComposer.BuildFullState();
}
return response;
}
/// <summary>
/// Creates a conflict response for state revision mismatches.
/// This is returned when client_state_rev doesn't match server's revision.
/// </summary>
/// <param name="clientRev">The client's provided revision.</param>
/// <param name="serverRev">The server's current revision.</param>
/// <returns>A conflict response with full state for synchronization.</returns>
public static object Conflict(int clientRev, int serverRev)
{
return new Dictionary<string, object>
{
{ "success", false },
{ "code", "state_revision_conflict" },
{ "error", $"State revision mismatch. Client: {clientRev}, Server: {serverRev}. Please refresh state." },
{ "state", StateComposer.BuildFullState() }
};
}
/// <summary>
/// Legacy overload for backward compatibility.
/// </summary>
[Obsolete("Use Success(message, data, includeState, stateDelta) instead.")]
public static object SuccessLegacy(string message, object data = null)
{
if (data != null)
{
return new
{
success = true,
message = message,
data = data,
};
}
else
{
return new { success = true, message = message };
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 635f93405037a114993e3a9a44c54745
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,76 @@
using System;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
public static class ServerPathResolver
{
/// <summary>
/// Attempts to locate the package root directory for cn.tuanjie.codely.bridge.
/// Returns true if found and sets packagePath to the package root folder.
/// </summary>
public static bool TryFindPackageRoot(out string packagePath, bool warnOnLegacyPackageId = true)
{
// Resolve via local package info (no network). Fall back to Client.List on older editors.
try
{
#if UNITY_2021_2_OR_NEWER
// Primary: the package that owns this assembly
var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly);
if (owner != null)
{
if (TryResolvePackage(owner, out packagePath, warnOnLegacyPackageId))
{
return true;
}
}
// Secondary: scan all registered packages locally
foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages())
{
if (TryResolvePackage(p, out packagePath, warnOnLegacyPackageId))
{
return true;
}
}
#else
// Older Unity versions: use Package Manager Client.List as a fallback
var list = UnityEditor.PackageManager.Client.List();
while (!list.IsCompleted) { }
if (list.Status == UnityEditor.PackageManager.StatusCode.Success)
{
foreach (var pkg in list.Result)
{
if (TryResolvePackage(pkg, out packagePath, warnOnLegacyPackageId))
{
return true;
}
}
}
#endif
}
catch { /* ignore */ }
packagePath = null;
return false;
}
private static bool TryResolvePackage(UnityEditor.PackageManager.PackageInfo p, out string packagePath, bool warnOnLegacyPackageId)
{
const string CurrentId = "cn.tuanjie.codely.bridge";
packagePath = null;
if (p == null || p.name != CurrentId)
{
return false;
}
packagePath = p.resolvedPath;
return !string.IsNullOrEmpty(packagePath) && Directory.Exists(packagePath);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,783 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Centralized state composition and revision tracking for Unity Editor state.
/// Provides consistent state snapshots and incremental state_delta generation.
/// </summary>
public static class StateComposer
{
// Global state revision counter (incremented on every state change)
private static int _globalRevision = 0;
private static readonly object _revisionLock = new object();
// Console state tracking (shared with ReadConsole)
private static string _currentConsoleToken = null;
private static int _consoleUnreadCount = 0;
private static readonly List<object> _lastConsoleErrors = new List<object>();
private static readonly object _consoleLock = new object();
// Touched assets tracking
private static readonly List<object> _touchedAssets = new List<object>();
private static readonly object _assetsLock = new object();
// Pending operations tracking
private static readonly List<object> _pendingOperations = new List<object>();
private static readonly object _operationsLock = new object();
/// <summary>
/// Increment and return the next global revision number.
/// Thread-safe.
/// </summary>
public static int IncrementRevision()
{
lock (_revisionLock)
{
return ++_globalRevision;
}
}
/// <summary>
/// Get current global revision without incrementing.
/// </summary>
public static int GetCurrentRevision()
{
lock (_revisionLock)
{
return _globalRevision;
}
}
/// <summary>
/// Builds a complete Unity state snapshot with current revision.
/// Note: Does NOT auto-increment revision - caller should decide when to increment.
/// </summary>
public static object BuildFullState()
{
int currentRev;
lock (_revisionLock)
{
currentRev = _globalRevision;
}
var state = new
{
editor = BuildEditorState(),
project = BuildProjectState(),
scene = BuildSceneState(),
selection = BuildSelectionState(),
console = BuildConsoleState(),
assets = BuildAssetsState(),
operations = BuildOperationsState(),
policy = BuildPolicyState(),
rev = currentRev
};
return state;
}
/// <summary>
/// Builds a complete Unity state snapshot and increments revision.
/// Use this for read operations that need to return fresh state.
/// </summary>
public static object BuildFullStateAndIncrement()
{
int newRev = IncrementRevision();
var state = new
{
editor = BuildEditorState(),
project = BuildProjectState(),
scene = BuildSceneState(),
selection = BuildSelectionState(),
console = BuildConsoleState(),
assets = BuildAssetsState(),
operations = BuildOperationsState(),
policy = BuildPolicyState(),
rev = newRev
};
return state;
}
/// <summary>
/// Builds editor-specific state.
/// </summary>
public static object BuildEditorState()
{
var playMode = EditorApplication.isPlaying ? "playing" :
(EditorApplication.isPaused ? "paused" : "stopped");
// Get focused window
string focusedWindow = null;
if (EditorWindow.focusedWindow != null)
{
focusedWindow = EditorWindow.focusedWindow.GetType().Name;
}
// Determine if operations require focus
// This is a heuristic - some operations need the editor to be focused
bool requiresFocusForOperations = DetermineIfFocusRequired();
return new
{
playMode = playMode,
focusedWindow = focusedWindow,
requiresFocusForOperations = requiresFocusForOperations,
isCompiling = EditorApplication.isCompiling,
isUpdating = EditorApplication.isUpdating,
lastCompilation = BuildLastCompilationState(),
timeSinceStartup = (float)EditorApplication.timeSinceStartup
};
}
/// <summary>
/// Builds last compilation state.
///
/// NOTE:
/// - This is intentionally minimal and only reports whether Unity is
/// currently compiling ("started" vs "idle").
/// - It is NOT a per-compilation snapshot and does NOT expose error/
/// warning counts for any specific pipeline.
/// - For accurate diagnostics (including error/warning counts), callers
/// must use:
/// * Compilation deltas from StateComposer.CreateCompilationDelta
/// (returned by wait_for_compile), and
/// * The Unity console (read_console / unity_console) with sinceToken.
/// </summary>
private static object BuildLastCompilationState()
{
var status = EditorApplication.isCompiling ? "started" : "idle";
return new
{
status = status
};
}
/// <summary>
/// Determines if current operations require focus.
/// </summary>
private static bool DetermineIfFocusRequired()
{
// Heuristic: Some operations need focus, especially during Play mode
// or when performing visual operations like scene manipulation
if (EditorApplication.isPlaying || EditorApplication.isPaused)
{
return true;
}
// Check if SceneView needs focus for certain operations
var sceneView = EditorWindow.focusedWindow as SceneView;
if (sceneView != null)
{
return false; // Already focused
}
return false; // Default: focus not strictly required
}
/// <summary>
/// Builds project-specific state.
/// </summary>
public static object BuildProjectState()
{
// Detect Render Pipeline
string srp = "builtin";
var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline;
if (currentRP != null)
{
string rpName = currentRP.GetType().Name.ToLowerInvariant();
if (rpName.Contains("urp") || rpName.Contains("universal"))
{
srp = "urp";
}
else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition"))
{
srp = "hdrp";
}
}
return new
{
srp = srp,
defineSymbols = GetScriptingDefineSymbols(),
packages = GetInstalledPackages(),
dirty = false // Would track if project settings are modified
};
}
private static string[] GetScriptingDefineSymbols()
{
// Get scripting define symbols for current build target
var buildTargetGroup = EditorUserBuildSettings.selectedBuildTargetGroup;
var symbols = PlayerSettings.GetScriptingDefineSymbolsForGroup(buildTargetGroup);
return string.IsNullOrEmpty(symbols) ?
new string[0] :
symbols.Split(';', StringSplitOptions.RemoveEmptyEntries);
}
private static string[] GetInstalledPackages()
{
// Simplified - in production would use PackageManager API
return new string[0];
}
/// <summary>
/// Builds scene-specific state.
/// </summary>
public static object BuildSceneState()
{
var activeScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
return new
{
activeScenePath = activeScene.path,
dirty = activeScene.isDirty,
hasNavMeshData = HasNavMeshData(),
hasLightingData = HasLightingData()
};
}
private static bool HasNavMeshData()
{
// Check if current scene has NavMesh data using runtime reflection
try
{
// First, try to check NavMeshSurface components (com.unity.ai.navigation package)
Type navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation");
if (navMeshSurfaceType == null)
{
// Fallback: search in loaded assemblies
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
{
navMeshSurfaceType = assembly.GetType("Unity.AI.Navigation.NavMeshSurface");
if (navMeshSurfaceType != null) break;
}
}
if (navMeshSurfaceType != null)
{
// Check NavMeshSurface components for navMeshData
var activeSurfacesProperty = navMeshSurfaceType.GetProperty("activeSurfaces", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (activeSurfacesProperty != null)
{
var activeSurfaces = activeSurfacesProperty.GetValue(null);
if (activeSurfaces is System.Collections.IList surfaceList && surfaceList.Count > 0)
{
var navMeshDataProperty = navMeshSurfaceType.GetProperty("navMeshData");
if (navMeshDataProperty != null)
{
foreach (var surface in surfaceList)
{
if (surface != null)
{
var navMeshData = navMeshDataProperty.GetValue(surface);
if (navMeshData != null)
{
return true;
}
}
}
}
}
}
// Also check all NavMeshSurface components in the scene (including inactive)
var allSurfaces = Resources.FindObjectsOfTypeAll(navMeshSurfaceType);
if (allSurfaces != null && allSurfaces.Length > 0)
{
var navMeshDataProperty = navMeshSurfaceType.GetProperty("navMeshData");
if (navMeshDataProperty != null)
{
foreach (var surface in allSurfaces)
{
if (surface != null)
{
var navMeshData = navMeshDataProperty.GetValue(surface);
if (navMeshData != null)
{
return true;
}
}
}
}
}
}
// Fallback: Try to find NavMesh type using reflection (for built-in NavMesh)
Type navMeshType = Type.GetType("UnityEngine.AI.NavMesh, UnityEngine.AIModule");
if (navMeshType == null)
{
// Fallback: search in loaded assemblies
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
{
navMeshType = assembly.GetType("UnityEngine.AI.NavMesh");
if (navMeshType != null) break;
}
}
if (navMeshType == null)
return false;
// Get CalculateTriangulation method
MethodInfo calculateTriangulationMethod = navMeshType.GetMethod("CalculateTriangulation", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (calculateTriangulationMethod == null)
return false;
// Call CalculateTriangulation using reflection
var triangulation = calculateTriangulationMethod.Invoke(null, null);
if (triangulation == null)
return false;
// Get vertices property
var verticesProperty = triangulation.GetType().GetProperty("vertices");
if (verticesProperty == null)
return false;
var vertices = verticesProperty.GetValue(triangulation) as Array;
return vertices != null && vertices.Length > 0;
}
catch
{
// If any error occurs, assume no NavMesh data
return false;
}
}
private static bool HasLightingData()
{
// Check if current scene has baked lighting
return Lightmapping.giWorkflowMode == Lightmapping.GIWorkflowMode.OnDemand ||
Lightmapping.lightingDataAsset != null;
}
/// <summary>
/// Builds selection state.
/// </summary>
public static object BuildSelectionState()
{
var activeObject = Selection.activeGameObject;
object activeObjectInfo = null;
if (activeObject != null)
{
activeObjectInfo = new
{
id = activeObject.GetInstanceID(),
name = activeObject.name,
hierarchy_path = GetHierarchyPath(activeObject)
};
}
return new
{
activeObject = activeObjectInfo
};
}
private static string GetHierarchyPath(GameObject go)
{
if (go == null) return "";
var path = go.name;
var parent = go.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
/// <summary>
/// Builds console state with real tracking data.
/// </summary>
public static object BuildConsoleState()
{
lock (_consoleLock)
{
return new
{
sinceToken = _currentConsoleToken,
unreadCount = _consoleUnreadCount,
lastErrors = _lastConsoleErrors.ToArray()
};
}
}
/// <summary>
/// Updates console state tracking. Called by ReadConsole.
/// </summary>
public static void UpdateConsoleState(string sinceToken, int unreadCount = 0, object[] lastErrors = null)
{
lock (_consoleLock)
{
_currentConsoleToken = sinceToken;
_consoleUnreadCount = unreadCount;
_lastConsoleErrors.Clear();
if (lastErrors != null)
{
_lastConsoleErrors.AddRange(lastErrors);
}
}
}
/// <summary>
/// Gets the current console token.
/// </summary>
public static string GetCurrentConsoleToken()
{
lock (_consoleLock)
{
return _currentConsoleToken;
}
}
/// <summary>
/// Builds assets state with tracked touched assets.
/// </summary>
public static object BuildAssetsState()
{
lock (_assetsLock)
{
return new
{
touched = _touchedAssets.ToArray()
};
}
}
/// <summary>
/// Adds a touched asset to tracking. Called by asset operations.
/// </summary>
public static void AddTouchedAsset(string path, bool imported = false, bool hasMeta = true)
{
lock (_assetsLock)
{
_touchedAssets.Add(new { path, imported, hasMeta });
// Keep only last 100 entries
while (_touchedAssets.Count > 100)
{
_touchedAssets.RemoveAt(0);
}
}
}
/// <summary>
/// Clears touched assets list.
/// </summary>
public static void ClearTouchedAssets()
{
lock (_assetsLock)
{
_touchedAssets.Clear();
}
}
/// <summary>
/// Builds pending operations state from AsyncOperationTracker.
/// </summary>
public static object BuildOperationsState()
{
// Get pending operations from AsyncOperationTracker
var pendingJobs = AsyncOperationTracker.GetPendingJobs();
var pending = pendingJobs.Select(job => new
{
id = job.OpId,
type = job.Type.ToString(),
progress = job.Progress,
message = job.Message
}).ToArray();
return new
{
pending = pending
};
}
/// <summary>
/// Builds policy state.
/// </summary>
public static object BuildPolicyState()
{
return new
{
writeGuardInPlayMode = "deny", // Default: deny writes in Play mode
refreshMode = "debounced",
consoleReadPolicy = "must_clear_before_read"
};
}
/// <summary>
/// Creates a Console state delta.
/// </summary>
public static object CreateConsoleDelta(string sinceToken = null, int? unreadCount = null, object[] lastErrors = null)
{
var consoleDelta = new Dictionary<string, object>();
if (sinceToken != null) consoleDelta["sinceToken"] = sinceToken;
if (unreadCount.HasValue) consoleDelta["unreadCount"] = unreadCount.Value;
if (lastErrors != null) consoleDelta["lastErrors"] = lastErrors;
return new { console = consoleDelta };
}
/// <summary>
/// Creates a Compilation state delta.
/// </summary>
public static object CreateCompilationDelta(bool? isCompiling = null, string status = null, int? errors = null, int? warnings = null)
{
var editorDelta = new Dictionary<string, object>();
var compilationDelta = new Dictionary<string, object>();
if (isCompiling.HasValue) editorDelta["isCompiling"] = isCompiling.Value;
if (status != null) compilationDelta["status"] = status;
if (errors.HasValue) compilationDelta["errors"] = errors.Value;
if (warnings.HasValue) compilationDelta["warnings"] = warnings.Value;
if (compilationDelta.Count > 0)
{
editorDelta["lastCompilation"] = compilationDelta;
}
return new { editor = editorDelta };
}
/// <summary>
/// Creates a Scene state delta.
/// </summary>
public static object CreateSceneDelta(string activeScenePath = null, bool? dirty = null)
{
var sceneDelta = new Dictionary<string, object>();
if (activeScenePath != null) sceneDelta["activeScenePath"] = activeScenePath;
if (dirty.HasValue) sceneDelta["dirty"] = dirty.Value;
return new { scene = sceneDelta };
}
/// <summary>
/// Creates an Asset state delta.
/// </summary>
public static object CreateAssetDelta(object[] touchedAssets)
{
return new
{
assets = new
{
touched = touchedAssets
}
};
}
/// <summary>
/// Creates an Editor state delta.
/// </summary>
public static object CreateEditorDelta(string focusedWindow = null, bool? isUpdating = null)
{
var editorDelta = new Dictionary<string, object>();
if (focusedWindow != null) editorDelta["focusedWindow"] = focusedWindow;
if (isUpdating.HasValue) editorDelta["isUpdating"] = isUpdating.Value;
return new { editor = editorDelta };
}
/// <summary>
/// Creates an Operations state delta.
/// </summary>
public static object CreateOperationsDelta(object[] pendingOperations)
{
return new
{
operations = new
{
pending = pendingOperations
}
};
}
/// <summary>
/// Validates client state revision and returns conflict response if mismatched.
/// Returns null if validation passes.
/// </summary>
public static object ValidateClientRevision(int? clientRev)
{
if (!clientRev.HasValue)
{
// No client revision provided - accept but don't enforce
return null;
}
int currentRev = GetCurrentRevision();
if (clientRev.Value != currentRev)
{
// State mismatch - return 409-like conflict response with fresh state
return new
{
success = false,
message = $"State revision mismatch. Client: {clientRev.Value}, Server: {currentRev}. Please refresh state.",
code = "state_revision_conflict",
state = BuildFullStateAndIncrement()
};
}
return null; // Validation passed
}
/// <summary>
/// Validates client state revision from JObject params.
/// Returns null if validation passes, error response if conflict.
/// </summary>
public static object ValidateClientRevisionFromParams(Codely.Newtonsoft.Json.Linq.JObject @params)
{
int? clientRev = @params?["client_state_rev"]?.ToObject<int?>();
return ValidateClientRevision(clientRev);
}
/// <summary>
/// Merges multiple state deltas into one combined delta.
/// </summary>
public static object MergeStateDeltas(params object[] deltas)
{
if (deltas == null || deltas.Length == 0) return null;
if (deltas.Length == 1) return deltas[0];
// Preserve legacy behavior: if only one non-null delta is provided, return it as-is.
int nonNullCount = 0;
object single = null;
foreach (var d in deltas)
{
if (d == null) continue;
nonNullCount++;
single = d;
if (nonNullCount > 1) break;
}
if (nonNullCount == 0) return null;
if (nonNullCount == 1) return single;
var merged = new Dictionary<string, object>();
foreach (var delta in deltas)
{
if (delta == null) continue;
// Prefer a JSON/dictionary representation to avoid reflection issues
// (e.g., when a state_delta is already a JObject/JToken).
Dictionary<string, object> deltaDict = null;
try
{
// Codely.Newtonsoft.Json.Linq types (JObject / JToken)
if (delta is Codely.Newtonsoft.Json.Linq.JObject jObj)
{
deltaDict = jObj.ToObject<Dictionary<string, object>>();
}
else if (delta is Codely.Newtonsoft.Json.Linq.JToken jTok &&
jTok.Type == Codely.Newtonsoft.Json.Linq.JTokenType.Object)
{
var asObj = jTok as Codely.Newtonsoft.Json.Linq.JObject;
deltaDict = (asObj ?? Codely.Newtonsoft.Json.Linq.JObject.FromObject(jTok))
.ToObject<Dictionary<string, object>>();
}
else if (delta is IDictionary<string, object> iDict)
{
deltaDict = new Dictionary<string, object>(iDict);
}
else
{
// Last resort: serialize arbitrary objects into a JObject then into a dictionary.
var obj = Codely.Newtonsoft.Json.Linq.JObject.FromObject(delta);
deltaDict = obj.ToObject<Dictionary<string, object>>();
}
}
catch
{
deltaDict = null;
}
if (deltaDict != null)
{
foreach (var kv in deltaDict)
{
if (kv.Value == null) continue;
if (merged.ContainsKey(kv.Key))
{
// Merge nested dictionaries (one level deep, consistent with legacy behavior)
var existingDict = merged[kv.Key] as Dictionary<string, object>;
var newDict = kv.Value as Dictionary<string, object>;
if (existingDict != null && newDict != null)
{
foreach (var nk in newDict)
{
existingDict[nk.Key] = nk.Value;
}
}
else
{
merged[kv.Key] = kv.Value;
}
}
else
{
merged[kv.Key] = kv.Value;
}
}
continue;
}
// Fallback: reflection-based merge (skip indexer properties to avoid invocation errors)
try
{
var props = delta.GetType().GetProperties();
foreach (var prop in props)
{
if (prop.GetIndexParameters().Length > 0) continue;
object value = null;
try { value = prop.GetValue(delta); } catch { continue; }
if (value == null) continue;
if (merged.ContainsKey(prop.Name))
{
// Merge nested dictionaries
if (merged[prop.Name] is Dictionary<string, object> existingDict &&
value is Dictionary<string, object> newDict)
{
foreach (var kv in newDict)
{
existingDict[kv.Key] = kv.Value;
}
}
else
{
merged[prop.Name] = value;
}
}
else
{
merged[prop.Name] = value;
}
}
}
catch
{
// Ignore merge errors from unexpected delta shapes.
}
}
return merged.Count > 0 ? merged : null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6e6177725a55072419d7584603153d01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,94 @@
using System;
using System.IO;
using Codely.Newtonsoft.Json;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Helper class for status and heartbeat management
/// </summary>
public static class StatusHelper
{
/// <summary>
/// Write heartbeat status to the main config file
/// </summary>
public static void WriteHeartbeat(int currentUnityPort, bool reloading, int heartbeatSeq, string reason = null)
{
try
{
// Load existing config or create new one
var existingConfig = PortManager.GetStoredPortConfig();
var portConfig = new PortManager.PortConfig
{
unity_port = currentUnityPort,
created_date = existingConfig?.created_date ?? DateTime.UtcNow.ToString("O"),
project_path = Application.dataPath,
// Update status fields
reloading = reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
PortManager.SavePortConfig(portConfig);
// Also maintain backwards compatibility by writing to legacy status location
try
{
// Allow override of status directory (useful in CI/containers)
string legacyDir = Environment.GetEnvironmentVariable("UNITY_TCP_STATUS_DIR");
if (string.IsNullOrWhiteSpace(legacyDir))
{
legacyDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-tcp");
}
Directory.CreateDirectory(legacyDir);
string legacyFilePath = Path.Combine(legacyDir, $"unity-tcp-status-{ComputeProjectHash(Application.dataPath)}.json");
var legacyPayload = new
{
unity_port = currentUnityPort,
reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
project_path = Application.dataPath,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
File.WriteAllText(legacyFilePath, JsonConvert.SerializeObject(legacyPayload), new System.Text.UTF8Encoding(false));
}
catch
{
// Ignore legacy write failures
}
}
catch (Exception)
{
// Best-effort only
}
}
/// <summary>
/// Compute a short hash of the project path for unique identification
/// </summary>
public static string ComputeProjectHash(string input)
{
try
{
using var sha1 = System.Security.Cryptography.SHA1.Create();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new System.Text.StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString()[..8];
}
catch
{
return "default";
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12eba3a4a35da834eba2ca7f63decd97
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,33 @@
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
internal static class TcpLog
{
private const string Prefix = "<b><color=#2EA3FF>Codely Bridge</color></b>:";
private static bool IsDebugEnabled()
{
try { return EditorPrefs.GetBool("UnityTcp.DebugLogs", false); } catch { return false; }
}
public static void Info(string message, bool always = true)
{
if (!always && !IsDebugEnabled()) return;
Debug.Log($"{Prefix} {message}");
}
public static void Warn(string message)
{
Debug.LogWarning($"<color=#cc7a00>{Prefix} {message}</color>");
}
public static void Error(string message)
{
Debug.LogError($"<color=#cc3333>{Prefix} {message}</color>");
}
}
}

View File

@@ -0,0 +1,13 @@
fileFormatVersion: 2
guid: a1da5b6ee62708c4a9df5c2a0f624f1c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,224 @@
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Unity Bridge telemetry helper for collecting usage analytics
/// Following privacy-first approach with easy opt-out mechanisms
/// </summary>
public static class TelemetryHelper
{
private const string TELEMETRY_DISABLED_KEY = "UnityTcp.TelemetryDisabled";
private const string CUSTOMER_UUID_KEY = "UnityTcp.CustomerUUID";
private static Action<Dictionary<string, object>> s_sender;
/// <summary>
/// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs)
/// </summary>
public static bool IsEnabled
{
get
{
// Check environment variables first
var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(envDisable) &&
(envDisable.ToLower() == "true" || envDisable == "1"))
{
return false;
}
var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(unityMcpDisable) &&
(unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1"))
{
return false;
}
// Honor protocol-wide opt-out as well
var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY");
if (!string.IsNullOrEmpty(mcpDisable) &&
(mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1"))
{
return false;
}
// Check EditorPrefs
return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false);
}
}
/// <summary>
/// Get or generate customer UUID for anonymous tracking
/// </summary>
public static string GetCustomerUUID()
{
var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, "");
if (string.IsNullOrEmpty(uuid))
{
uuid = System.Guid.NewGuid().ToString();
UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid);
}
return uuid;
}
/// <summary>
/// Disable telemetry (stored in EditorPrefs)
/// </summary>
public static void DisableTelemetry()
{
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true);
}
/// <summary>
/// Enable telemetry (stored in EditorPrefs)
/// </summary>
public static void EnableTelemetry()
{
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false);
}
/// <summary>
/// Send telemetry data to Python server for processing
/// This is a lightweight bridge - the actual telemetry logic is in Python
/// </summary>
public static void RecordEvent(string eventType, Dictionary<string, object> data = null)
{
if (!IsEnabled)
return;
try
{
var telemetryData = new Dictionary<string, object>
{
["event_type"] = eventType,
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["customer_uuid"] = GetCustomerUUID(),
["unity_version"] = Application.unityVersion,
["platform"] = Application.platform.ToString(),
["source"] = "unity_bridge"
};
if (data != null)
{
telemetryData["data"] = data;
}
// Send to Python server via existing bridge communication
// The Python server will handle actual telemetry transmission
SendTelemetryToPythonServer(telemetryData);
}
catch (Exception e)
{
// Never let telemetry errors interfere with functionality
if (IsDebugEnabled())
{
Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}");
}
}
}
/// <summary>
/// Allows the bridge to register a concrete sender for telemetry payloads.
/// </summary>
public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender)
{
Interlocked.Exchange(ref s_sender, sender);
}
public static void UnregisterTelemetrySender()
{
Interlocked.Exchange(ref s_sender, null);
}
/// <summary>
/// Record bridge startup event
/// </summary>
public static void RecordBridgeStartup()
{
RecordEvent("bridge_startup", new Dictionary<string, object>
{
["bridge_version"] = "3.0.2",
["auto_connect"] = "unknown" // TODO: we have no such field
});
}
/// <summary>
/// Record bridge connection event
/// </summary>
public static void RecordBridgeConnection(bool success, string error = null)
{
var data = new Dictionary<string, object>
{
["success"] = success
};
if (!string.IsNullOrEmpty(error))
{
data["error"] = error.Substring(0, Math.Min(200, error.Length));
}
RecordEvent("bridge_connection", data);
}
/// <summary>
/// Record tool execution from Unity side
/// </summary>
public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null)
{
var data = new Dictionary<string, object>
{
["tool_name"] = toolName,
["success"] = success,
["duration_ms"] = Math.Round(durationMs, 2)
};
if (!string.IsNullOrEmpty(error))
{
data["error"] = error.Substring(0, Math.Min(200, error.Length));
}
RecordEvent("tool_execution_unity", data);
}
private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData)
{
var sender = Volatile.Read(ref s_sender);
if (sender != null)
{
try
{
sender(telemetryData);
return;
}
catch (Exception e)
{
if (IsDebugEnabled())
{
Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}");
}
}
}
// Fallback: log when debug is enabled
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}");
}
}
private static bool IsDebugEnabled()
{
try
{
return UnityEditor.EditorPrefs.GetBool("UnityTcp.DebugLogs", false);
}
catch
{
return false;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 75e6d1e08c44ad84d91f82a4baeea37e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Hook mechanism for external tools to notify Unity when they've modified files
/// that may affect Unity's state (scripts, assets, scenes, etc.).
/// This is NOT exposed as a tool to LLM - it's an internal notification system.
/// </summary>
public static class UnityStateDirtyHook
{
/// <summary>
/// File change types that can affect Unity state.
/// </summary>
public enum FileChangeType
{
ScriptModified, // .cs files modified
AssetModified, // Asset files (.prefab, .mat, .asset, etc.) modified
SceneModified, // .unity scene files modified
ShaderModified, // .shader files modified
ConfigModified, // Project settings, package.json, etc. modified
UIModified, // .uxml, .uss files modified
Unknown // Other file types
}
private static readonly Queue<DirtyNotification> _pendingNotifications = new Queue<DirtyNotification>();
private static readonly object _notificationLock = new object();
private static bool _refreshScheduled = false;
/// <summary>
/// Notification record for dirty file changes.
/// </summary>
public class DirtyNotification
{
public DateTime Timestamp { get; set; }
public FileChangeType ChangeType { get; set; }
public string FilePath { get; set; }
public string ToolName { get; set; }
public bool RequiresReimport { get; set; }
public bool RequiresCompilation { get; set; }
}
/// <summary>
/// Called by external agentic tools (edit, write, etc.) to notify Unity of file changes.
/// This is the main entry point for the hook system.
/// </summary>
/// <param name="filePath">Path to the file that was modified</param>
/// <param name="toolName">Name of the tool that made the change (for logging)</param>
public static void NotifyFileChanged(string filePath, string toolName = "unknown")
{
if (string.IsNullOrEmpty(filePath))
return;
try
{
// Normalize path
filePath = filePath.Replace('\\', '/');
// Determine change type and required actions
var changeType = DetermineChangeType(filePath);
bool requiresReimport = ShouldReimport(filePath, changeType);
bool requiresCompilation = RequiresCompilation(filePath, changeType);
var notification = new DirtyNotification
{
Timestamp = DateTime.UtcNow,
ChangeType = changeType,
FilePath = filePath,
ToolName = toolName,
RequiresReimport = requiresReimport,
RequiresCompilation = requiresCompilation
};
lock (_notificationLock)
{
_pendingNotifications.Enqueue(notification);
// Schedule refresh on next editor update
if (!_refreshScheduled)
{
_refreshScheduled = true;
EditorApplication.delayCall += ProcessPendingNotifications;
}
}
Debug.Log($"[UnityStateDirtyHook] Notified: {changeType} - {filePath} (from {toolName})");
}
catch (Exception e)
{
Debug.LogWarning($"[UnityStateDirtyHook] Failed to process notification for {filePath}: {e.Message}");
}
}
/// <summary>
/// Process all pending dirty notifications and trigger appropriate Unity actions.
/// </summary>
private static void ProcessPendingNotifications()
{
List<DirtyNotification> toProcess;
lock (_notificationLock)
{
if (_pendingNotifications.Count == 0)
{
_refreshScheduled = false;
return;
}
toProcess = new List<DirtyNotification>(_pendingNotifications);
_pendingNotifications.Clear();
_refreshScheduled = false;
}
// Group by action type
var needsReimport = toProcess.Where(n => n.RequiresReimport).Select(n => n.FilePath).Distinct().ToList();
var needsCompilation = toProcess.Any(n => n.RequiresCompilation);
// Process reimports
if (needsReimport.Count > 0)
{
Debug.Log($"[UnityStateDirtyHook] Reimporting {needsReimport.Count} assets...");
foreach (var path in needsReimport)
{
// Convert to Unity-relative path if needed
string unityPath = ConvertToUnityPath(path);
if (!string.IsNullOrEmpty(unityPath))
{
AssetDatabase.ImportAsset(unityPath, ImportAssetOptions.ForceUpdate);
}
}
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
}
// Increment state revision
StateComposer.IncrementRevision();
// Log summary
var summary = new
{
processed = toProcess.Count,
reimported = needsReimport.Count,
needsCompilation = needsCompilation,
byType = toProcess.GroupBy(n => n.ChangeType).ToDictionary(g => g.Key.ToString(), g => g.Count())
};
Debug.Log($"[UnityStateDirtyHook] Processed notifications: {Codely.Newtonsoft.Json.JsonConvert.SerializeObject(summary)}");
// If compilation is needed, it will happen automatically via Unity's asset pipeline
if (needsCompilation)
{
Debug.Log("[UnityStateDirtyHook] Script changes detected - Unity will trigger compilation automatically");
}
}
/// <summary>
/// Determine the type of change based on file extension.
/// </summary>
private static FileChangeType DetermineChangeType(string filePath)
{
string ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
switch (ext)
{
case ".cs":
return FileChangeType.ScriptModified;
case ".unity":
case ".scene":
return FileChangeType.SceneModified;
case ".shader":
case ".shadergraph":
case ".shadersubgraph":
return FileChangeType.ShaderModified;
case ".prefab":
case ".mat":
case ".asset":
case ".png":
case ".jpg":
case ".jpeg":
case ".psd":
case ".fbx":
case ".obj":
case ".mp3":
case ".wav":
case ".anim":
case ".controller":
return FileChangeType.AssetModified;
case ".uxml":
case ".uss":
return FileChangeType.UIModified;
case ".json":
case ".asmdef":
case ".asmref":
return FileChangeType.ConfigModified;
default:
return FileChangeType.Unknown;
}
}
/// <summary>
/// Determine if a file change requires reimporting in Unity.
/// </summary>
private static bool ShouldReimport(string filePath, FileChangeType changeType)
{
// Check if file is in Assets/ folder
if (!IsInAssetsFolder(filePath))
return false;
switch (changeType)
{
case FileChangeType.ScriptModified:
case FileChangeType.ShaderModified:
case FileChangeType.AssetModified:
case FileChangeType.SceneModified:
case FileChangeType.UIModified:
return true;
case FileChangeType.ConfigModified:
return filePath.Contains("package.json") || filePath.Contains(".asmdef");
default:
return false;
}
}
/// <summary>
/// Determine if a file change requires script compilation.
/// </summary>
private static bool RequiresCompilation(string filePath, FileChangeType changeType)
{
return changeType == FileChangeType.ScriptModified && IsInAssetsFolder(filePath);
}
/// <summary>
/// Check if a file path is within the Unity Assets folder.
/// </summary>
private static bool IsInAssetsFolder(string filePath)
{
string normalizedPath = filePath.Replace('\\', '/');
return normalizedPath.Contains("/Assets/") || normalizedPath.StartsWith("Assets/");
}
/// <summary>
/// Convert an absolute or relative path to Unity-relative path (Assets/...).
/// </summary>
private static string ConvertToUnityPath(string filePath)
{
string normalized = filePath.Replace('\\', '/');
// Already Unity-relative
if (normalized.StartsWith("Assets/"))
return normalized;
// Extract Assets/... portion
int assetsIndex = normalized.IndexOf("/Assets/");
if (assetsIndex >= 0)
return normalized.Substring(assetsIndex + 1); // Skip the leading /
// Check if it's relative to project root
string projectRoot = Application.dataPath.Replace("/Assets", "").Replace('\\', '/');
if (normalized.StartsWith(projectRoot))
{
string relativePath = normalized.Substring(projectRoot.Length).TrimStart('/');
if (relativePath.StartsWith("Assets/"))
return relativePath;
}
return null;
}
/// <summary>
/// Get statistics about recent dirty notifications (for debugging).
/// </summary>
public static object GetStatistics()
{
lock (_notificationLock)
{
return new
{
pending = _pendingNotifications.Count,
refreshScheduled = _refreshScheduled
};
}
}
/// <summary>
/// Clear all pending notifications (for testing/debugging).
/// </summary>
public static void ClearPendingNotifications()
{
lock (_notificationLock)
{
_pendingNotifications.Clear();
_refreshScheduled = false;
}
Debug.Log("[UnityStateDirtyHook] Cleared all pending notifications");
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 15dbae89d2b872442a3021294af9f5bd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using Codely.Newtonsoft.Json.Linq;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Helper class for Vector3 operations
/// </summary>
public static class Vector3Helper
{
/// <summary>
/// Parses a JArray into a Vector3
/// </summary>
/// <param name="array">The array containing x, y, z coordinates</param>
/// <returns>A Vector3 with the parsed coordinates</returns>
/// <exception cref="System.Exception">Thrown when array is invalid</exception>
public static Vector3 ParseVector3(JArray array)
{
if (array == null || array.Count != 3)
throw new System.Exception("Vector3 must be an array of 3 floats [x, y, z].");
return new Vector3((float)array[0], (float)array[1], (float)array[2]);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f8514fd42f23cb641a36e52550825b35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,196 @@
using System;
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Write protection guard for Unity Editor operations.
/// Prevents unsafe modifications during Play mode or other restricted states.
/// </summary>
public static class WriteGuard
{
/// <summary>
/// Write guard policy enum.
/// </summary>
public enum Policy
{
/// <summary>
/// Deny all writes in Play/Paused mode (default, safest)
/// </summary>
Deny,
/// <summary>
/// Allow writes but log warnings (experimental, use with caution)
/// </summary>
AllowWithWarning
}
// Current policy (default: Deny)
private static Policy _currentPolicy = Policy.Deny;
/// <summary>
/// Gets the current write guard policy.
/// </summary>
public static Policy CurrentPolicy
{
get => _currentPolicy;
set => _currentPolicy = value;
}
/// <summary>
/// Checks if write operations are allowed in current editor state.
/// Returns null if allowed, or an error response object if blocked.
/// </summary>
/// <param name="operationName">Name of the operation being attempted (for logging)</param>
/// <returns>Error response if blocked, null if allowed</returns>
public static object CheckWriteAllowed(string operationName = "write operation")
{
// Check if we're in Play or Paused mode
bool inPlayMode = EditorApplication.isPlaying || EditorApplication.isPaused;
if (!inPlayMode)
{
// Not in Play mode - writes always allowed
return null;
}
// In Play mode - check policy
switch (_currentPolicy)
{
case Policy.Deny:
// Block the write and return error
string errorMessage = $"Cannot perform {operationName} in Play/Paused mode. " +
"Stop Play mode first, or change write guard policy to 'allow_with_warning' (experimental).";
Debug.LogWarning($"[WriteGuard] {errorMessage}");
return Response.Error("write_blocked_in_play_mode", new
{
code = "write_blocked_in_play_mode",
message = errorMessage,
currentPlayMode = EditorApplication.isPlaying ? "playing" : "paused",
policy = "deny",
suggestion = "Stop Play mode or change policy to 'allow_with_warning'"
});
case Policy.AllowWithWarning:
// Allow but warn
string warningMessage = $"[EXPERIMENTAL] Performing {operationName} in Play/Paused mode. " +
"This may cause unexpected behavior or data loss!";
Debug.LogWarning($"[WriteGuard] {warningMessage}");
// Log to audit trail
LogAuditEvent(operationName, "allowed_with_warning");
// Return null to allow operation
return null;
default:
return Response.Error("Invalid write guard policy");
}
}
/// <summary>
/// Force-checks if write operations are blocked (returns true if blocked).
/// </summary>
public static bool IsWriteBlocked()
{
if (!EditorApplication.isPlaying && !EditorApplication.isPaused)
{
return false; // Not in Play mode - not blocked
}
return _currentPolicy == Policy.Deny;
}
/// <summary>
/// Sets the write guard policy.
/// </summary>
/// <param name="policy">Policy to set ("deny" or "allow_with_warning")</param>
/// <returns>Success or error response</returns>
public static object SetPolicy(string policy)
{
if (string.IsNullOrEmpty(policy))
{
return Response.Error("Policy parameter is required");
}
string lowerPolicy = policy.ToLowerInvariant();
switch (lowerPolicy)
{
case "deny":
_currentPolicy = Policy.Deny;
Debug.Log("[WriteGuard] Write guard policy set to: Deny");
return Response.Success($"Write guard policy set to 'deny'.", new { policy = "deny" });
case "allow_with_warning":
_currentPolicy = Policy.AllowWithWarning;
Debug.LogWarning("[WriteGuard] Write guard policy set to: AllowWithWarning (EXPERIMENTAL)");
return Response.Success($"Write guard policy set to 'allow_with_warning' (experimental).",
new { policy = "allow_with_warning", warning = "This is experimental and may cause issues" });
default:
return Response.Error($"Invalid policy: '{policy}'. Valid policies are: 'deny', 'allow_with_warning'");
}
}
/// <summary>
/// Gets the current policy as a string.
/// </summary>
public static string GetPolicyString()
{
return _currentPolicy == Policy.Deny ? "deny" : "allow_with_warning";
}
/// <summary>
/// Logs audit events for write operations in Play mode.
/// </summary>
private static void LogAuditEvent(string operationName, string action)
{
// In production, this could write to a file or telemetry system
var auditEntry = new
{
timestamp = DateTime.UtcNow.ToString("o"),
operation = operationName,
action = action,
playMode = EditorApplication.isPlaying ? "playing" : (EditorApplication.isPaused ? "paused" : "stopped"),
scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name
};
Debug.Log($"[WriteGuard Audit] {Codely.Newtonsoft.Json.JsonConvert.SerializeObject(auditEntry)}");
}
/// <summary>
/// Creates a write-blocked error response with detailed information.
/// </summary>
public static object CreateBlockedResponse(string operationName, string additionalInfo = null)
{
string message = $"Write operation '{operationName}' blocked in Play/Paused mode.";
if (!string.IsNullOrEmpty(additionalInfo))
{
message += $" {additionalInfo}";
}
return Response.Error("write_blocked_in_play_mode", new
{
code = "write_blocked_in_play_mode",
operation = operationName,
currentMode = EditorApplication.isPlaying ? "playing" : "paused",
policy = GetPolicyString(),
message = message,
remediation = new
{
options = new[]
{
"Stop Play mode (recommended)",
"Change write guard policy to 'allow_with_warning' (experimental, use with caution)"
}
}
});
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 593f57416aee1924495c4971200b544b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: