chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1b9a8f05a3f041e2b4960ecd070c408
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates Assembly Definition (.asmdef) configuration before compilation.
|
||||
/// </summary>
|
||||
public class AssemblyDefinitionDuplicationValidationService
|
||||
{
|
||||
private static readonly Regex AsmdefNameRegex =
|
||||
new("\"name\"\\s*:\\s*\"(?<name>[^\"]+)\"", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex DuplicateAsmdefConsoleRegex = new(
|
||||
"^Assembly with name '(?<name>[^']+)' already exists \\((?<path>[^)]+)\\)$",
|
||||
RegexOptions.Compiled
|
||||
);
|
||||
|
||||
public ValidationResult ValidateNoDuplicateAsmdefNamesFromConsoleErrors()
|
||||
{
|
||||
LogRetrievalService retrievalService = new();
|
||||
LogDisplayDto logData = retrievalService.GetLogsWithSearch(
|
||||
McpLogType.Error,
|
||||
"Assembly with name '",
|
||||
useRegex: false,
|
||||
searchInStackTrace: false
|
||||
);
|
||||
|
||||
Dictionary<string, List<string>> pathsByAsmName = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (LogEntryDto entry in logData.LogEntries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Message))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match = DuplicateAsmdefConsoleRegex.Match(entry.Message.Trim());
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string asmName = match.Groups["name"].Value;
|
||||
string assetPath = match.Groups["path"].Value;
|
||||
if (string.IsNullOrEmpty(asmName) || string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent false positives from stale console logs by verifying the asset still exists.
|
||||
UnityEngine.Object obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
|
||||
if (obj == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pathsByAsmName.TryGetValue(asmName, out List<string> paths))
|
||||
{
|
||||
paths = new List<string>();
|
||||
pathsByAsmName.Add(asmName, paths);
|
||||
}
|
||||
|
||||
if (!paths.Contains(assetPath))
|
||||
{
|
||||
paths.Add(assetPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (pathsByAsmName.Count == 0)
|
||||
{
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
|
||||
string details = string.Join(
|
||||
"\n",
|
||||
pathsByAsmName
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.Take(5)
|
||||
.Select(d =>
|
||||
{
|
||||
string paths = string.Join("\n ", d.Value.Take(8));
|
||||
return $"- {d.Key}\n {paths}";
|
||||
})
|
||||
);
|
||||
|
||||
string message =
|
||||
$"{McpConstants.ERROR_MESSAGE_DUPLICATE_ASMDEF}\n" +
|
||||
"Detected from Console errors:\n" +
|
||||
$"{details}\n" +
|
||||
"Fix: ensure each .asmdef has a unique \"name\".";
|
||||
|
||||
return ValidationResult.Failure(message);
|
||||
}
|
||||
|
||||
public ValidationResult ValidateNoDuplicateAsmdefNames()
|
||||
{
|
||||
string[] asmdefGuids = AssetDatabase.FindAssets("t:AssemblyDefinitionAsset");
|
||||
Dictionary<string, List<string>> pathsByAsmName = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (string guid in asmdefGuids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
AssemblyDefinitionAsset asmdef = AssetDatabase.LoadAssetAtPath<AssemblyDefinitionAsset>(assetPath);
|
||||
if (asmdef == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string asmName = GetAsmdefName(asmdef);
|
||||
if (!pathsByAsmName.TryGetValue(asmName, out List<string> paths))
|
||||
{
|
||||
paths = new List<string>();
|
||||
pathsByAsmName.Add(asmName, paths);
|
||||
}
|
||||
|
||||
paths.Add(assetPath);
|
||||
}
|
||||
|
||||
KeyValuePair<string, List<string>>[] duplicates = pathsByAsmName
|
||||
.Where(kvp => kvp.Value.Count > 1)
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (duplicates.Length == 0)
|
||||
{
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
|
||||
string details = string.Join(
|
||||
"\n",
|
||||
duplicates
|
||||
.Take(5)
|
||||
.Select(d =>
|
||||
{
|
||||
string paths = string.Join("\n ", d.Value.Take(8));
|
||||
return $"- {d.Key}\n {paths}";
|
||||
})
|
||||
);
|
||||
|
||||
string message =
|
||||
$"{McpConstants.ERROR_MESSAGE_DUPLICATE_ASMDEF}\n" +
|
||||
"Duplicates (first 5 groups):\n" +
|
||||
$"{details}\n" +
|
||||
"Fix: ensure each .asmdef has a unique \"name\".";
|
||||
|
||||
return ValidationResult.Failure(message);
|
||||
}
|
||||
|
||||
private static string GetAsmdefName(AssemblyDefinitionAsset asmdef)
|
||||
{
|
||||
string json = asmdef.text;
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return asmdef.name;
|
||||
}
|
||||
|
||||
Match match = AsmdefNameRegex.Match(json);
|
||||
if (match.Success)
|
||||
{
|
||||
string name = match.Groups["name"].Value;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return asmdef.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b32dc95c226e4b38a7d42dcbf289199
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for client notification processing
|
||||
/// Single responsibility: Sending notifications to MCP clients
|
||||
/// Related classes: McpBridgeServer, CustomToolManager
|
||||
/// </summary>
|
||||
public static class ClientNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Send tool change notification to clients
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This notification serves dual purposes in this project:
|
||||
/// 1. Original MCP purpose: Notify that available tools have changed
|
||||
/// 2. uLoopMCP additional purpose: Signal that Unity is ready after Domain Reload
|
||||
///
|
||||
/// After Domain Reload completes, this notification is sent to indicate Unity
|
||||
/// has finished initialization and can reliably process requests. TypeScript side
|
||||
/// uses this as a "Unity ready" signal. The name doesn't perfectly match this
|
||||
/// secondary purpose, but it avoids adding custom notification complexity.
|
||||
/// </remarks>
|
||||
public static void SendToolsChangedNotification()
|
||||
{
|
||||
McpBridgeServer currentServer = McpServerController.CurrentServer;
|
||||
if (currentServer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Send only MCP standard notification
|
||||
var notificationParams = new
|
||||
{
|
||||
timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
|
||||
message = "Unity tools have been updated"
|
||||
};
|
||||
|
||||
var mcpNotification = new
|
||||
{
|
||||
jsonrpc = McpServerConfig.JSONRPC_VERSION,
|
||||
method = "notifications/tools/list_changed",
|
||||
@params = notificationParams
|
||||
};
|
||||
|
||||
string mcpNotificationJson = JsonConvert.SerializeObject(mcpNotification);
|
||||
currentServer.SendNotificationToClients(mcpNotificationJson);
|
||||
|
||||
// Log recording
|
||||
System.Diagnostics.StackTrace stackTrace = new System.Diagnostics.StackTrace(true);
|
||||
string callerInfo = stackTrace.GetFrame(1)?.GetMethod()?.Name ?? "Unknown";
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"tools_list_changed_notification",
|
||||
"Sent tools changed notification to MCP clients",
|
||||
new { caller = callerInfo, timestamp = notificationParams.timestamp }
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if server is running and send tool change notification
|
||||
/// </summary>
|
||||
public static void TriggerToolChangeNotification()
|
||||
{
|
||||
if (McpServerController.IsServerRunning)
|
||||
{
|
||||
SendToolsChangedNotification();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send tool notification with frame delay after compilation
|
||||
/// </summary>
|
||||
/// <returns>Task for notification sending process</returns>
|
||||
public static async Task SendToolNotificationAfterCompilationAsync()
|
||||
{
|
||||
// Frame delay for Unity editor stabilization after Domain Reload
|
||||
await EditorDelay.DelayFrame(1);
|
||||
|
||||
CustomToolManager.NotifyToolChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification to specific clients
|
||||
/// </summary>
|
||||
/// <param name="notification">JSON data of notification to send</param>
|
||||
public static void SendNotificationToClients(string notification)
|
||||
{
|
||||
McpBridgeServer currentServer = McpServerController.CurrentServer;
|
||||
if (currentServer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
currentServer.SendNotificationToClients(notification);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log before server stop
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Tracking ID for related operations</param>
|
||||
/// <param name="port">Port number of server to stop</param>
|
||||
public static void LogServerStoppingBeforeDomainReload(string correlationId, int port)
|
||||
{
|
||||
VibeLogger.LogInfo(
|
||||
"domain_reload_server_stopping",
|
||||
"Stopping MCP server before domain reload",
|
||||
new { port = port },
|
||||
correlationId
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log server stop completion
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Tracking ID for related operations</param>
|
||||
public static void LogServerStoppedAfterDomainReload(string correlationId)
|
||||
{
|
||||
VibeLogger.LogInfo(
|
||||
"domain_reload_server_stopped",
|
||||
"MCP server stopped successfully",
|
||||
new { tcp_port_released = true },
|
||||
correlationId
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log server stop error
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Tracking ID for related operations</param>
|
||||
/// <param name="ex">Exception that occurred</param>
|
||||
/// <param name="port">Port number attempted to stop</param>
|
||||
public static void LogServerShutdownError(string correlationId, System.Exception ex, int port)
|
||||
{
|
||||
VibeLogger.LogException(
|
||||
"domain_reload_server_shutdown_error",
|
||||
ex,
|
||||
new
|
||||
{
|
||||
port = port,
|
||||
server_was_running = true
|
||||
},
|
||||
correlationId,
|
||||
"Critical error during server shutdown before assembly reload. This may cause port conflicts on restart.",
|
||||
"Investigate server shutdown process and ensure proper TCP port release."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d310cf0c71df949279b413297c985681
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Compilation execution service
|
||||
/// Single function: Execute Unity project compilation
|
||||
/// Related classes: CompileController, CompileUseCase, CompileTool
|
||||
/// </summary>
|
||||
public class CompilationExecutionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute compilation asynchronously
|
||||
/// </summary>
|
||||
/// <param name="forceRecompile">Force recompile flag</param>
|
||||
/// <returns>Compilation result</returns>
|
||||
public async Task<CompileResult> ExecuteCompilationAsync(bool forceRecompile, CancellationToken ct)
|
||||
{
|
||||
using CompileController compileController = new();
|
||||
return await compileController.TryCompileAsync(forceRecompile, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 827a060642b094394a80d172dfa403f6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Compilation;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for compilation lock file management.
|
||||
/// Single responsibility: Create/delete lock file during compilation for CLI detection.
|
||||
/// Related classes: DomainReloadDetectionService (similar pattern for domain reload)
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class CompilationLockService
|
||||
{
|
||||
private const string LOCK_FILE_NAME = "compiling.lock";
|
||||
|
||||
private static string LockFilePath => Path.Combine(UnityEngine.Application.dataPath, "..", "Temp", LOCK_FILE_NAME);
|
||||
|
||||
static CompilationLockService()
|
||||
{
|
||||
CompilationPipeline.compilationStarted += OnCompilationStarted;
|
||||
CompilationPipeline.compilationFinished += OnCompilationFinished;
|
||||
}
|
||||
|
||||
private static void OnCompilationStarted(object context)
|
||||
{
|
||||
CreateLockFile();
|
||||
}
|
||||
|
||||
private static void OnCompilationFinished(object context)
|
||||
{
|
||||
DeleteLockFile();
|
||||
}
|
||||
|
||||
private static void CreateLockFile()
|
||||
{
|
||||
string lockPath = LockFilePath;
|
||||
string tempDir = Path.GetDirectoryName(lockPath);
|
||||
|
||||
if (!Directory.Exists(tempDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(lockPath, System.DateTime.UtcNow.ToString("o"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete lock file. Called on server startup to handle crash recovery.
|
||||
/// </summary>
|
||||
public static void DeleteLockFile()
|
||||
{
|
||||
string lockPath = LockFilePath;
|
||||
if (File.Exists(lockPath))
|
||||
{
|
||||
File.Delete(lockPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7cba3d0bab93f4b8a9c90c2c18bbbf1b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Compilation state validation service
|
||||
/// Single function: Validate state before compilation execution
|
||||
/// Related classes: CompileTool, CompileUseCase, McpSessionManager
|
||||
/// </summary>
|
||||
public class CompilationStateValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate state before compilation execution
|
||||
/// </summary>
|
||||
/// <returns>Validation result</returns>
|
||||
public ValidationResult ValidateCompilationState()
|
||||
{
|
||||
if (EditorApplication.isCompiling)
|
||||
{
|
||||
return ValidationResult.Failure(
|
||||
"Compilation is already in progress. Please wait for the current compilation to finish."
|
||||
);
|
||||
}
|
||||
|
||||
if (McpEditorSettings.GetIsDomainReloadInProgress())
|
||||
{
|
||||
return ValidationResult.Failure(
|
||||
"Cannot compile while domain reload is in progress. Please wait for the domain reload to complete."
|
||||
);
|
||||
}
|
||||
|
||||
if (EditorApplication.isUpdating)
|
||||
{
|
||||
return ValidationResult.Failure(
|
||||
"Cannot compile while editor is updating. Please wait for the update to complete."
|
||||
);
|
||||
}
|
||||
|
||||
AssemblyDefinitionDuplicationValidationService asmdefValidationService = new();
|
||||
ValidationResult asmdefValidation = asmdefValidationService.ValidateNoDuplicateAsmdefNamesFromConsoleErrors();
|
||||
if (!asmdefValidation.IsValid)
|
||||
{
|
||||
return asmdefValidation;
|
||||
}
|
||||
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 02db968680595492b8928dc6bc856413
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Persist compile responses for delayed retrieval after domain reload.
|
||||
/// </summary>
|
||||
public static class CompileResultPersistenceService
|
||||
{
|
||||
// Concurrent clients may still be waiting on recent result files.
|
||||
// Only delete files older than this threshold (longer than the 90-second wait timeout)
|
||||
// to avoid destroying results that active waiters need.
|
||||
private static readonly TimeSpan StaleResultThreshold = TimeSpan.FromMinutes(2);
|
||||
|
||||
private static string ProjectRootPath => Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
private static string CompileResultDirectoryPath => Path.Combine(
|
||||
ProjectRootPath,
|
||||
McpConstants.TEMP_DIR,
|
||||
McpConstants.ULOOPMCP_DIR,
|
||||
McpConstants.COMPILE_RESULTS_DIR
|
||||
);
|
||||
|
||||
public static void ClearStaleResults()
|
||||
{
|
||||
if (!Directory.Exists(CompileResultDirectoryPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string searchPattern = $"*{McpConstants.JSON_FILE_EXTENSION}";
|
||||
string[] resultFiles = Directory.GetFiles(CompileResultDirectoryPath, searchPattern);
|
||||
DateTime staleThreshold = DateTime.UtcNow - StaleResultThreshold;
|
||||
|
||||
foreach (string resultFilePath in resultFiles)
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(resultFilePath);
|
||||
if (fileInfo.LastWriteTimeUtc < staleThreshold)
|
||||
{
|
||||
File.Delete(resultFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void SaveResult(string requestId, CompileResponse response)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or empty");
|
||||
Debug.Assert(response != null, "response must not be null");
|
||||
|
||||
if (!Directory.Exists(CompileResultDirectoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(CompileResultDirectoryPath);
|
||||
}
|
||||
|
||||
string sanitizedFileName = Path.GetFileName(requestId);
|
||||
Debug.Assert(sanitizedFileName == requestId,
|
||||
$"requestId must not contain path separators: '{requestId}'");
|
||||
|
||||
string resultJson = JsonConvert.SerializeObject(response, Formatting.None);
|
||||
string fileName = $"{sanitizedFileName}{McpConstants.JSON_FILE_EXTENSION}";
|
||||
string filePath = Path.Combine(CompileResultDirectoryPath, fileName);
|
||||
File.WriteAllText(filePath, resultJson, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: baa3261503cd245b09d0570f38d02c63
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,438 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for monitoring connected LLM tools
|
||||
/// Single responsibility: Track connected/disconnected tools and persist state
|
||||
/// Related classes: McpBridgeServer, McpEditorSettings, ConnectedLLMToolData
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class ConnectedToolsMonitoringService
|
||||
{
|
||||
private static List<ConnectedLLMToolData> _connectedTools = new();
|
||||
private static List<ConnectedLLMToolData> _toolsBackup;
|
||||
private static CancellationTokenSource _cleanupCancellationTokenSource;
|
||||
|
||||
// Events for UI notification
|
||||
public static event System.Action OnConnectedToolsChanged;
|
||||
|
||||
static ConnectedToolsMonitoringService()
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the monitoring service
|
||||
/// </summary>
|
||||
private static void Initialize()
|
||||
{
|
||||
SubscribeToServerEvents();
|
||||
if (ShouldRestorePersistedToolsForReconnect())
|
||||
{
|
||||
RestoreConnectedToolsFromSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
SynchronizeConnectedToolsWithCurrentServer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to server lifecycle events
|
||||
/// </summary>
|
||||
private static void SubscribeToServerEvents()
|
||||
{
|
||||
McpBridgeServer.OnServerStopping += OnServerStopping;
|
||||
McpBridgeServer.OnServerStarted += OnServerStarted;
|
||||
McpBridgeServer.OnToolConnected += OnToolConnected;
|
||||
McpBridgeServer.OnToolDisconnected += OnToolDisconnected;
|
||||
McpBridgeServer.OnAllToolsCleared += OnAllToolsCleared;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle server stopping event - keep the last live snapshot for reconnecting UI
|
||||
/// </summary>
|
||||
private static void OnServerStopping()
|
||||
{
|
||||
_toolsBackup = _connectedTools
|
||||
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name) && tool.Name != McpConstants.UNKNOWN_CLIENT_NAME)
|
||||
.ToList();
|
||||
|
||||
SyncConnectedToolsToSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle server started event - restore the reconnecting snapshot first, then cleanup stale tools
|
||||
/// </summary>
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
if (_toolsBackup != null && _toolsBackup.Count > 0)
|
||||
{
|
||||
RestoreConnectedTools(_toolsBackup);
|
||||
_toolsBackup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldRestorePersistedToolsForReconnect())
|
||||
{
|
||||
ConnectedLLMToolData[] persistedTools = McpEditorSettings.GetConnectedLLMTools();
|
||||
if (persistedTools != null && persistedTools.Length > 0)
|
||||
{
|
||||
RestoreConnectedTools(persistedTools.ToList());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
SynchronizeConnectedToolsWithCurrentServer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle tool connected event - add tool to connected list
|
||||
/// </summary>
|
||||
private static void OnToolConnected(ConnectedClient client)
|
||||
{
|
||||
AddConnectedTool(client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle tool disconnected event - remove tool from connected list
|
||||
/// </summary>
|
||||
private static void OnToolDisconnected(string toolName)
|
||||
{
|
||||
RemoveConnectedTool(toolName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle all tools cleared event - clear all connected tools
|
||||
/// </summary>
|
||||
private static void OnAllToolsCleared()
|
||||
{
|
||||
ClearConnectedTools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a connected LLM tool
|
||||
/// </summary>
|
||||
public static void AddConnectedTool(ConnectedClient client)
|
||||
{
|
||||
if (client.ClientName == McpConstants.UNKNOWN_CLIENT_NAME)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing tool if present, then add
|
||||
_connectedTools.RemoveAll(tool => tool.Name == client.ClientName);
|
||||
|
||||
ConnectedLLMToolData toolData = new(
|
||||
client.ClientName,
|
||||
client.Endpoint,
|
||||
client.Port,
|
||||
client.ConnectedAt
|
||||
);
|
||||
_connectedTools.Add(toolData);
|
||||
|
||||
// Persist to settings
|
||||
McpEditorSettings.AddConnectedLLMTool(toolData);
|
||||
|
||||
// Notify UI
|
||||
OnConnectedToolsChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a connected LLM tool
|
||||
/// </summary>
|
||||
public static void RemoveConnectedTool(string toolName)
|
||||
{
|
||||
_connectedTools.RemoveAll(tool => tool.Name == toolName);
|
||||
|
||||
// Persist to settings
|
||||
McpEditorSettings.RemoveConnectedLLMTool(toolName);
|
||||
|
||||
// Notify UI
|
||||
OnConnectedToolsChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all connected LLM tools
|
||||
/// </summary>
|
||||
public static void ClearConnectedTools()
|
||||
{
|
||||
_connectedTools.Clear();
|
||||
|
||||
// Persist to settings
|
||||
McpEditorSettings.ClearConnectedLLMTools();
|
||||
|
||||
// Notify UI
|
||||
OnConnectedToolsChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get connected tools as ConnectedClient objects for UI display, sorted by name
|
||||
/// </summary>
|
||||
public static IEnumerable<ConnectedClient> GetConnectedToolsAsClients()
|
||||
{
|
||||
return _connectedTools.OrderBy(tool => tool.Name).Select(tool => ConvertToConnectedClient(tool));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert stored tool data to ConnectedClient for UI display
|
||||
/// </summary>
|
||||
private static ConnectedClient ConvertToConnectedClient(ConnectedLLMToolData toolData)
|
||||
{
|
||||
return new ConnectedClient(toolData.Endpoint, null, toolData.Port, toolData.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore connected tools from backup after server restart.
|
||||
/// The reconnecting UI should keep the last live snapshot until clients rejoin.
|
||||
/// </summary>
|
||||
private static void RestoreConnectedTools(List<ConnectedLLMToolData> backup)
|
||||
{
|
||||
if (backup == null || backup.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreConnectedToolsImmediately(backup);
|
||||
|
||||
_cleanupCancellationTokenSource?.Cancel();
|
||||
_cleanupCancellationTokenSource?.Dispose();
|
||||
_cleanupCancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
DelayedCleanupAsync(_cleanupCancellationTokenSource.Token).ContinueWith(task =>
|
||||
{
|
||||
if (task.IsFaulted && !task.IsCanceled)
|
||||
{
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
Debug.LogError($"[uLoopMCP] Failed to perform delayed cleanup: {task.Exception?.GetBaseException().Message}");
|
||||
};
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
private static void RestoreConnectedToolsImmediately(IEnumerable<ConnectedLLMToolData> tools)
|
||||
{
|
||||
List<ConnectedLLMToolData> restoredTools = tools
|
||||
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name) && tool.Name != McpConstants.UNKNOWN_CLIENT_NAME)
|
||||
.GroupBy(tool => tool.Name)
|
||||
.Select(group => group.Last())
|
||||
.OrderBy(tool => tool.Name)
|
||||
.ToList();
|
||||
|
||||
_connectedTools = restoredTools;
|
||||
SyncConnectedToolsToSettings();
|
||||
OnConnectedToolsChanged?.Invoke();
|
||||
}
|
||||
|
||||
private static async Task DelayedCleanupAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TimerDelay.Wait(8000, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!McpServerController.IsServerRunning || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyCollection<ConnectedClient> actualConnectedClients = McpServerController.CurrentServer?.GetConnectedClients();
|
||||
if (actualConnectedClients == null || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HashSet<string> actualClientNames = new HashSet<string>(
|
||||
actualConnectedClients
|
||||
.Where(client => client.ClientName != McpConstants.UNKNOWN_CLIENT_NAME)
|
||||
.Select(client => client.ClientName)
|
||||
);
|
||||
|
||||
List<ConnectedLLMToolData> toolsToRemove = _connectedTools
|
||||
.Where(tool => !actualClientNames.Contains(tool.Name))
|
||||
.ToList();
|
||||
|
||||
foreach (ConnectedLLMToolData tool in toolsToRemove)
|
||||
{
|
||||
RemoveConnectedTool(tool.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild connected tools from the current live server state.
|
||||
/// The connected tools UI must reflect live MCP connections only.
|
||||
/// </summary>
|
||||
private static void SynchronizeConnectedToolsWithCurrentServer()
|
||||
{
|
||||
IReadOnlyCollection<ConnectedClient> liveClients = GetLiveConnectedClients();
|
||||
ReplaceConnectedTools(liveClients);
|
||||
}
|
||||
|
||||
private static bool ShouldRestorePersistedToolsForReconnect()
|
||||
{
|
||||
return McpEditorSettings.GetShowReconnectingUI() || McpEditorSettings.GetShowPostCompileReconnectingUI();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get live clients from the current server, ignoring persisted settings.
|
||||
/// </summary>
|
||||
private static IReadOnlyCollection<ConnectedClient> GetLiveConnectedClients()
|
||||
{
|
||||
if (!McpServerController.IsServerRunning)
|
||||
{
|
||||
return Array.Empty<ConnectedClient>();
|
||||
}
|
||||
|
||||
IReadOnlyCollection<ConnectedClient> connectedClients = McpServerController.CurrentServer?.GetConnectedClients();
|
||||
return connectedClients ?? Array.Empty<ConnectedClient>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the in-memory and persisted connected tools state with the current live clients.
|
||||
/// </summary>
|
||||
private static void ReplaceConnectedTools(IEnumerable<ConnectedClient> connectedClients)
|
||||
{
|
||||
List<ConnectedLLMToolData> tools = connectedClients
|
||||
.Where(client => client != null && client.ClientName != McpConstants.UNKNOWN_CLIENT_NAME)
|
||||
.Select(client => new ConnectedLLMToolData(
|
||||
client.ClientName,
|
||||
client.Endpoint,
|
||||
client.Port,
|
||||
client.ConnectedAt
|
||||
))
|
||||
.GroupBy(tool => tool.Name)
|
||||
.Select(group => group.Last())
|
||||
.OrderBy(tool => tool.Name)
|
||||
.ToList();
|
||||
|
||||
_connectedTools = tools;
|
||||
|
||||
ConnectedLLMToolData[] toolsArray = _connectedTools
|
||||
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name))
|
||||
.ToArray();
|
||||
SaveConnectedToolsWhenChanged(
|
||||
toolsArray,
|
||||
McpEditorSettings.GetConnectedLLMTools,
|
||||
McpEditorSettings.SetConnectedLLMTools);
|
||||
|
||||
OnConnectedToolsChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore the persisted connected tools snapshot for reconnecting UI only.
|
||||
/// This path must not be used for normal startup because it can reintroduce stale entries.
|
||||
/// </summary>
|
||||
private static void RestoreConnectedToolsFromSettings()
|
||||
{
|
||||
ConnectedLLMToolData[] savedTools = McpEditorSettings.GetConnectedLLMTools();
|
||||
if (savedTools == null || savedTools.Length == 0)
|
||||
{
|
||||
_connectedTools = new List<ConnectedLLMToolData>();
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreConnectedToolsImmediately(savedTools);
|
||||
}
|
||||
|
||||
private static void SyncConnectedToolsToSettings()
|
||||
{
|
||||
ConnectedLLMToolData[] toolsArray = _connectedTools
|
||||
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name) && tool.Name != McpConstants.UNKNOWN_CLIENT_NAME)
|
||||
.ToArray();
|
||||
SaveConnectedToolsWhenChanged(
|
||||
toolsArray,
|
||||
McpEditorSettings.GetConnectedLLMTools,
|
||||
McpEditorSettings.SetConnectedLLMTools);
|
||||
}
|
||||
|
||||
internal static bool SaveConnectedToolsWhenChanged(
|
||||
ConnectedLLMToolData[] toolsArray,
|
||||
Func<ConnectedLLMToolData[]> loadPersistedTools,
|
||||
Action<ConnectedLLMToolData[]> savePersistedTools)
|
||||
{
|
||||
Debug.Assert(toolsArray != null, "toolsArray must not be null");
|
||||
Debug.Assert(loadPersistedTools != null, "loadPersistedTools must not be null");
|
||||
Debug.Assert(savePersistedTools != null, "savePersistedTools must not be null");
|
||||
|
||||
ConnectedLLMToolData[] persistedTools = loadPersistedTools() ?? Array.Empty<ConnectedLLMToolData>();
|
||||
if (AreConnectedToolSnapshotsEquivalent(toolsArray, persistedTools))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
savePersistedTools(toolsArray);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool AreConnectedToolSnapshotsEquivalent(
|
||||
IReadOnlyList<ConnectedLLMToolData> first,
|
||||
IReadOnlyList<ConnectedLLMToolData> second)
|
||||
{
|
||||
if (first == null || second == null)
|
||||
{
|
||||
return first == second;
|
||||
}
|
||||
|
||||
if (first.Count != second.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < first.Count; i++)
|
||||
{
|
||||
ConnectedLLMToolData firstTool = first[i];
|
||||
ConnectedLLMToolData secondTool = second[i];
|
||||
if (!AreConnectedToolsEquivalent(firstTool, secondTool))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool AreConnectedToolsEquivalent(
|
||||
ConnectedLLMToolData first,
|
||||
ConnectedLLMToolData second)
|
||||
{
|
||||
if (first == null || second == null)
|
||||
{
|
||||
return first == second;
|
||||
}
|
||||
|
||||
return string.Equals(first.Name, second.Name, StringComparison.Ordinal)
|
||||
&& string.Equals(first.Endpoint, second.Endpoint, StringComparison.Ordinal)
|
||||
&& first.Port == second.Port;
|
||||
}
|
||||
|
||||
internal static void ReplaceConnectedToolsForTests(IReadOnlyCollection<ConnectedClient> connectedClients)
|
||||
{
|
||||
ReplaceConnectedTools(connectedClients);
|
||||
}
|
||||
|
||||
internal static void RestorePersistedConnectedToolsForTests()
|
||||
{
|
||||
RestoreConnectedToolsFromSettings();
|
||||
}
|
||||
|
||||
internal static void ResetStateForTests()
|
||||
{
|
||||
_connectedTools = new List<ConnectedLLMToolData>();
|
||||
_toolsBackup = null;
|
||||
_cleanupCancellationTokenSource?.Cancel();
|
||||
_cleanupCancellationTokenSource?.Dispose();
|
||||
_cleanupCancellationTokenSource = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c691b46b7229e43c5807d2614f83568a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for Domain Reload detection and state management
|
||||
/// Single responsibility: Domain Reload lifecycle management
|
||||
/// Related classes: McpSessionManager, McpServerController
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class DomainReloadDetectionService
|
||||
{
|
||||
private static bool IsBackgroundUnityProcess()
|
||||
{
|
||||
bool isBatchMode = Application.isBatchMode;
|
||||
bool isAssetImportWorker = AssetDatabase.IsAssetImportWorkerProcess();
|
||||
return isBatchMode || isAssetImportWorker;
|
||||
}
|
||||
|
||||
static DomainReloadDetectionService()
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("domain_reload_hook_skip", "Skipping domain reload hooks in background Unity process.");
|
||||
return;
|
||||
}
|
||||
|
||||
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
|
||||
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
|
||||
}
|
||||
|
||||
private static void OnBeforeAssemblyReload()
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CreateLockFile();
|
||||
}
|
||||
|
||||
private static void OnAfterAssemblyReload()
|
||||
{
|
||||
// Lock file is deleted by McpBridgeServer when server startup completes
|
||||
// to avoid a gap between domain reload end and server ready
|
||||
}
|
||||
|
||||
private const string LOCK_FILE_NAME = "domainreload.lock";
|
||||
|
||||
private static string LockFilePath => Path.Combine(UnityEngine.Application.dataPath, "..", "Temp", LOCK_FILE_NAME);
|
||||
|
||||
/// <summary>
|
||||
/// Execute Domain Reload start processing
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Tracking ID for related operations</param>
|
||||
/// <param name="serverIsRunning">Whether server is running</param>
|
||||
/// <param name="serverPort">Server port number</param>
|
||||
public static void StartDomainReload(string correlationId, bool serverIsRunning, int? serverPort)
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("domain_reload_start_ignored", "background_process", correlationId: correlationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create lock file for external process detection (e.g., CLI tools)
|
||||
CreateLockFile();
|
||||
|
||||
// Save session state if server is running
|
||||
if (serverIsRunning && serverPort.HasValue)
|
||||
{
|
||||
int port = serverPort.Value;
|
||||
McpEditorSettings.UpdateSettings(s => s with
|
||||
{
|
||||
isDomainReloadInProgress = true,
|
||||
isServerRunning = true,
|
||||
customPort = port,
|
||||
isAfterCompile = true,
|
||||
isReconnecting = true,
|
||||
showReconnectingUI = true,
|
||||
showPostCompileReconnectingUI = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
McpEditorSettings.SetIsDomainReloadInProgress(true);
|
||||
}
|
||||
|
||||
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(true);
|
||||
|
||||
// Log recording
|
||||
VibeLogger.LogInfo(
|
||||
"domain_reload_start",
|
||||
"Domain reload starting",
|
||||
new
|
||||
{
|
||||
server_running = serverIsRunning,
|
||||
server_port = serverPort
|
||||
},
|
||||
correlationId
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute Domain Reload completion processing
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Tracking ID for related operations</param>
|
||||
public static void CompleteDomainReload(string correlationId)
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("domain_reload_complete_ignored", "background_process", correlationId: correlationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock file is deleted by McpBridgeServer when server startup completes
|
||||
// to avoid a gap between domain reload completion and server ready
|
||||
|
||||
// Clear Domain Reload completion flag
|
||||
McpEditorSettings.ClearDomainReloadFlag();
|
||||
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(false);
|
||||
|
||||
// Log recording
|
||||
VibeLogger.LogInfo(
|
||||
"domain_reload_complete",
|
||||
"Domain reload completed - starting server recovery process",
|
||||
new { session_server_port = McpEditorSettings.GetCustomPort() },
|
||||
correlationId
|
||||
);
|
||||
}
|
||||
|
||||
internal static void RollbackDomainReloadStart(string correlationId)
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("domain_reload_rollback_ignored", "background_process", correlationId: correlationId);
|
||||
return;
|
||||
}
|
||||
|
||||
McpEditorSettings.UpdateSettings(s => s with
|
||||
{
|
||||
isDomainReloadInProgress = false,
|
||||
isAfterCompile = false,
|
||||
isReconnecting = false,
|
||||
showReconnectingUI = false,
|
||||
showPostCompileReconnectingUI = false
|
||||
});
|
||||
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(false);
|
||||
DeleteLockFile();
|
||||
|
||||
VibeLogger.LogWarning(
|
||||
"domain_reload_start_rollback",
|
||||
"Rolled back domain reload start state after pre-reload failure.",
|
||||
correlationId: correlationId
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if currently in Domain Reload
|
||||
/// </summary>
|
||||
/// <returns>True if Domain Reload is in progress</returns>
|
||||
public static bool IsDomainReloadInProgress()
|
||||
{
|
||||
return McpEditorSettings.GetIsDomainReloadInProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if reconnection UI display is required
|
||||
/// </summary>
|
||||
/// <returns>True if reconnection UI display is required</returns>
|
||||
public static bool ShouldShowReconnectingUI()
|
||||
{
|
||||
return McpEditorSettings.GetShowReconnectingUI();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if in after-compile state
|
||||
/// </summary>
|
||||
/// <returns>True if after compile</returns>
|
||||
public static bool IsAfterCompile()
|
||||
{
|
||||
return McpEditorSettings.GetIsAfterCompile();
|
||||
}
|
||||
|
||||
private static void CreateLockFile()
|
||||
{
|
||||
string lockPath = LockFilePath;
|
||||
string tempDir = Path.GetDirectoryName(lockPath);
|
||||
|
||||
if (!Directory.Exists(tempDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(lockPath, System.DateTime.UtcNow.ToString("o"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete lock file to signal Domain Reload completion.
|
||||
/// </summary>
|
||||
public static void DeleteLockFile()
|
||||
{
|
||||
string lockPath = LockFilePath;
|
||||
if (File.Exists(lockPath))
|
||||
{
|
||||
File.Delete(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if Domain Reload lock file exists.
|
||||
/// Used by external processes to detect Domain Reload state.
|
||||
/// </summary>
|
||||
/// <returns>True if lock file exists</returns>
|
||||
public static bool IsLockFilePresent()
|
||||
{
|
||||
return File.Exists(LockFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 551d705a408284a048b775c7bc655fbe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for initialization notifications and dialogs.
|
||||
/// Handles user confirmations, error notifications, and status updates.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - UnityEditor.EditorUtility: Provides dialog functionality
|
||||
/// - McpServerController: Uses this service for user interactions
|
||||
/// </summary>
|
||||
public class InitializationNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows an invalid port error dialog to the user.
|
||||
/// </summary>
|
||||
/// <param name="port">The invalid port number</param>
|
||||
/// <returns>Success indicator</returns>
|
||||
public ServiceResult<bool> ShowInvalidPortDialog(int port)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"Invalid Port",
|
||||
$"Port {port} is not valid for server startup.\n\nPort must be 1024 or higher and not a reserved system port.",
|
||||
"OK"
|
||||
);
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a configuration validation error dialog.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">The validation error message</param>
|
||||
/// <returns>Success indicator</returns>
|
||||
public ServiceResult<bool> ShowValidationErrorDialog(string errorMessage)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"Server Configuration Error",
|
||||
$"Server configuration validation failed:\n\n{errorMessage}",
|
||||
"OK"
|
||||
);
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd9cbc0fca6884497a0955425558a337
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Linq;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Log filtering service
|
||||
/// Single function: Filter and limit log entries
|
||||
/// Related classes: GetLogsTool, GetLogsUseCase, LogEntry
|
||||
/// </summary>
|
||||
public class LogFilteringService
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter log entries and apply limits
|
||||
/// </summary>
|
||||
/// <param name="entries">Log entry array</param>
|
||||
/// <param name="maxCount">Maximum retrieval count</param>
|
||||
/// <param name="includeStackTrace">Whether to include stack trace</param>
|
||||
/// <returns>Filtered log entry array</returns>
|
||||
public LogEntry[] FilterAndLimitLogs(LogEntryDto[] entries, int maxCount, bool includeStackTrace)
|
||||
{
|
||||
// Get the most recent logs, limited by maxCount
|
||||
LogEntryDto[] limitedEntries = entries.Length > maxCount
|
||||
? entries.Skip(entries.Length - maxCount).ToArray()
|
||||
: entries;
|
||||
|
||||
// Reverse to show newest first
|
||||
limitedEntries = limitedEntries.Reverse().ToArray();
|
||||
|
||||
// Convert from LogEntryDto to LogEntry
|
||||
return limitedEntries.Select(entry => new LogEntry(
|
||||
type: entry.LogType,
|
||||
message: entry.Message,
|
||||
stackTrace: includeStackTrace ? entry.StackTrace : null
|
||||
)).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5e96519978e44ecfa6ed2daf1f7fa73
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Log retrieval service
|
||||
/// Single function: Retrieve Unity Console logs
|
||||
/// Related classes: LogGetter, GetLogsTool, GetLogsUseCase
|
||||
/// </summary>
|
||||
public class LogRetrievalService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve logs of specified log type
|
||||
/// </summary>
|
||||
/// <param name="logType">Log type to retrieve</param>
|
||||
/// <returns>Log data</returns>
|
||||
public LogDisplayDto GetLogs(string logType)
|
||||
{
|
||||
if (string.Equals(logType, McpLogType.All, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LogGetter.GetAllConsoleLogs();
|
||||
}
|
||||
else
|
||||
{
|
||||
return LogGetter.GetConsoleLogsByType(logType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve logs with search conditions
|
||||
/// </summary>
|
||||
/// <param name="logType">Log type to retrieve</param>
|
||||
/// <param name="searchText">Search text</param>
|
||||
/// <param name="useRegex">Whether to use regular expressions</param>
|
||||
/// <param name="searchInStackTrace">Whether to search within stack trace</param>
|
||||
/// <returns>Log data</returns>
|
||||
public LogDisplayDto GetLogsWithSearch(string logType, string searchText, bool useRegex, bool searchInStackTrace)
|
||||
{
|
||||
return LogGetter.SearchConsoleLogs(logType, searchText, useRegex, searchInStackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4efed97f61be0421f99a7da3afe1522a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for MCP server configuration management.
|
||||
/// Handles configuration validation, port resolution, and configuration updating.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpServerController: Uses this service for server configuration
|
||||
/// - McpEditorSettings: Provides configuration storage
|
||||
/// - McpPortValidator: Validates port configurations
|
||||
/// </summary>
|
||||
public class McpServerConfigurationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates server configuration settings.
|
||||
/// </summary>
|
||||
/// <param name="port">Port number to validate</param>
|
||||
/// <returns>Validation result with details</returns>
|
||||
public ServiceResult<bool> ValidateConfiguration(int port)
|
||||
{
|
||||
// Validate port range
|
||||
if (port < 1 || port > 65535)
|
||||
{
|
||||
return ServiceResult<bool>.FailureResult($"Port {port} is outside valid range (1-65535)");
|
||||
}
|
||||
|
||||
// Check for reserved ports
|
||||
if (port < 1024)
|
||||
{
|
||||
return ServiceResult<bool>.FailureResult($"Port {port} is reserved and may require administrator privileges");
|
||||
}
|
||||
|
||||
// Additional validation could include:
|
||||
// - Port availability check
|
||||
// - Firewall restrictions
|
||||
// - OS-specific limitations
|
||||
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the actual port to use for server startup.
|
||||
/// </summary>
|
||||
/// <param name="requestedPort">Requested port number (-1 for default)</param>
|
||||
/// <returns>Resolved port number</returns>
|
||||
public ServiceResult<int> ResolvePort(int requestedPort)
|
||||
{
|
||||
try
|
||||
{
|
||||
int actualPort = requestedPort == -1 ? McpEditorSettings.GetCustomPort() : requestedPort;
|
||||
|
||||
// Validate the resolved port
|
||||
var validation = ValidateConfiguration(actualPort);
|
||||
if (!validation.Success)
|
||||
{
|
||||
return ServiceResult<int>.FailureResult($"Resolved port {actualPort} is invalid: {validation.ErrorMessage}");
|
||||
}
|
||||
|
||||
return ServiceResult<int>.SuccessResult(actualPort);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return ServiceResult<int>.FailureResult($"Failed to resolve port: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d53dac596ccc4288952e1eae3f53428
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for MCP server startup operations.
|
||||
/// Handles server instance creation, startup, and lifecycle management.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpBridgeServer: The actual server instance being managed
|
||||
/// - McpSessionManager: Manages server session state
|
||||
/// - McpServerController: Coordinates overall server management
|
||||
/// </summary>
|
||||
public class McpServerStartupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and starts a new MCP server instance.
|
||||
/// </summary>
|
||||
/// <param name="port">Port number to start the server on</param>
|
||||
/// <returns>The created server instance</returns>
|
||||
public ServiceResult<McpBridgeServer> StartServer(
|
||||
int port,
|
||||
bool clearServerStartingLockWhenReady = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
McpBridgeServer server = new();
|
||||
server.StartServer(port, clearServerStartingLockWhenReady);
|
||||
return ServiceResult<McpBridgeServer>.SuccessResult(server);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return ServiceResult<McpBridgeServer>.FailureResult($"Failed to start server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and disposes of the given server instance.
|
||||
/// </summary>
|
||||
/// <param name="server">Server instance to stop</param>
|
||||
/// <returns>Success indicator</returns>
|
||||
public ServiceResult<bool> StopServer(McpBridgeServer server)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (server != null)
|
||||
{
|
||||
server.Dispose();
|
||||
}
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return ServiceResult<bool>.FailureResult($"Failed to stop server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates session manager with server state.
|
||||
/// On shutdown (isRunning=false), only the running flag is cleared — customPort is preserved
|
||||
/// so recovery can rebind to the same port after domain reload or editor restart.
|
||||
/// </summary>
|
||||
/// <param name="isRunning">Whether the server is running</param>
|
||||
/// <param name="port">Server port number (only written when isRunning=true)</param>
|
||||
/// <param name="projectRootPath">Project root for fast project validation during startup</param>
|
||||
/// <returns>Success indicator</returns>
|
||||
public ServiceResult<bool> UpdateSessionState(
|
||||
bool isRunning,
|
||||
int port = -1,
|
||||
string projectRootPath = null)
|
||||
{
|
||||
if (!isRunning)
|
||||
{
|
||||
McpEditorSettings.ClearServerSession();
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
if (port > 0 && !string.IsNullOrWhiteSpace(projectRootPath))
|
||||
{
|
||||
string serverSessionId = System.Guid.NewGuid().ToString("N");
|
||||
McpEditorSettings.SetRunningServerSession(port, projectRootPath, serverSessionId);
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
McpEditorSettings.SetIsServerRunning(true);
|
||||
if (port > 0)
|
||||
{
|
||||
McpEditorSettings.SetCustomPort(port);
|
||||
}
|
||||
|
||||
return ServiceResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 397aac884f23b4c3d92c433bd4f8afb5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,100 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Play Mode compilation preparation service
|
||||
/// Single function: Determine preparation action based on Play Mode state and settings
|
||||
/// Related classes: CompileUseCase, CompilationStateValidationService
|
||||
/// </summary>
|
||||
public class PlayModeCompilationPreparationService
|
||||
{
|
||||
private const string EDITOR_PREFS_KEY = "ScriptCompilationDuringPlay";
|
||||
|
||||
/// <summary>
|
||||
/// Determine preparation action based on Play Mode state and Unity settings
|
||||
/// </summary>
|
||||
/// <returns>Preparation result indicating what action to take before compilation</returns>
|
||||
public PreparationResult DeterminePreparationAction()
|
||||
{
|
||||
bool isPlaying = EditorApplication.isPlaying;
|
||||
|
||||
if (!isPlaying)
|
||||
{
|
||||
return PreparationResult.Proceed();
|
||||
}
|
||||
|
||||
int settingValue = EditorPrefs.GetInt(EDITOR_PREFS_KEY, 0);
|
||||
ScriptChangesDuringPlayOptions setting = (ScriptChangesDuringPlayOptions)settingValue;
|
||||
|
||||
return setting switch
|
||||
{
|
||||
ScriptChangesDuringPlayOptions.RecompileAndContinuePlaying
|
||||
=> PreparationResult.Proceed(),
|
||||
ScriptChangesDuringPlayOptions.StopPlayingAndRecompile
|
||||
=> PreparationResult.NeedsStopPlayMode(),
|
||||
ScriptChangesDuringPlayOptions.RecompileAfterFinishedPlaying
|
||||
=> PreparationResult.CannotProceed(
|
||||
"Cannot compile while in Play Mode. Unity's 'Script Changes While Playing' is set to " +
|
||||
"'Recompile After Finished Playing'. Stop Play Mode manually or change the setting."),
|
||||
_ => PreparationResult.Proceed()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop Play Mode to allow compilation
|
||||
/// </summary>
|
||||
public void StopPlayMode()
|
||||
{
|
||||
if (EditorApplication.isPaused)
|
||||
{
|
||||
EditorApplication.isPaused = false;
|
||||
}
|
||||
EditorApplication.isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data model representing preparation result for compilation
|
||||
/// </summary>
|
||||
public class PreparationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether compilation can proceed
|
||||
/// </summary>
|
||||
public bool CanProceed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Play Mode needs to be stopped before compilation
|
||||
/// </summary>
|
||||
public bool NeedsPlayModeStop { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message when compilation cannot proceed
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; }
|
||||
|
||||
private PreparationResult(bool canProceed, bool needsStop, string error)
|
||||
{
|
||||
CanProceed = canProceed;
|
||||
NeedsPlayModeStop = needsStop;
|
||||
ErrorMessage = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create result indicating compilation can proceed immediately
|
||||
/// </summary>
|
||||
public static PreparationResult Proceed() => new(true, false, null);
|
||||
|
||||
/// <summary>
|
||||
/// Create result indicating Play Mode must be stopped first
|
||||
/// </summary>
|
||||
public static PreparationResult NeedsStopPlayMode() => new(true, true, null);
|
||||
|
||||
/// <summary>
|
||||
/// Create result indicating compilation cannot proceed
|
||||
/// </summary>
|
||||
/// <param name="reason">Reason why compilation cannot proceed</param>
|
||||
public static PreparationResult CannotProceed(string reason) => new(false, false, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c713840e82aca460a855e363370ecdb0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for port allocation and conflict resolution.
|
||||
/// Handles port finding, validation, and conflict management.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - NetworkUtility: Provides port availability checking
|
||||
/// - McpPortValidator: Validates port configurations
|
||||
/// - McpPortChangeUpdater: Updates configurations on port changes
|
||||
/// </summary>
|
||||
public class PortAllocationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an available port starting from the given port number.
|
||||
/// </summary>
|
||||
/// <param name="startPort">The starting port number to check</param>
|
||||
/// <returns>The first available port number</returns>
|
||||
public ServiceResult<int> FindAvailablePort(int startPort)
|
||||
{
|
||||
try
|
||||
{
|
||||
int availablePort = NetworkUtility.FindAvailablePort(startPort);
|
||||
if (availablePort <= 0 || availablePort > 65535)
|
||||
{
|
||||
return ServiceResult<int>.FailureResult($"Invalid port number returned: {availablePort}");
|
||||
}
|
||||
return ServiceResult<int>.SuccessResult(availablePort);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
return ServiceResult<int>.FailureResult($"Failed to find available port: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles port conflict resolution with user confirmation.
|
||||
/// </summary>
|
||||
/// <param name="requestedPort">Originally requested port</param>
|
||||
/// <param name="availablePort">Available alternative port</param>
|
||||
/// <returns>True if user accepted the alternative port</returns>
|
||||
public ServiceResult<bool> HandlePortConflict(int requestedPort, int availablePort)
|
||||
{
|
||||
bool userConfirmed = UnityEditor.EditorUtility.DisplayDialog(
|
||||
"uLoopMCP - Port Conflict",
|
||||
$"Port {requestedPort} is already in use.\n\nWould you like to use port {availablePort} instead?",
|
||||
"OK",
|
||||
"Cancel"
|
||||
);
|
||||
|
||||
if (userConfirmed)
|
||||
{
|
||||
McpPortChangeUpdater.UpdateAllConfigurationsForPortChange(availablePort, "Server port conflict resolution");
|
||||
}
|
||||
|
||||
return ServiceResult<bool>.SuccessResult(userConfirmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a46a2b756eb1449b8abbecea6b4fcb7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity EditorPrefs "ScriptCompilationDuringPlay" value options
|
||||
/// Maps to Unity Editor's Preferences > General > Script Changes While Playing setting
|
||||
/// </summary>
|
||||
public enum ScriptChangesDuringPlayOptions
|
||||
{
|
||||
RecompileAndContinuePlaying = 0,
|
||||
RecompileAfterFinishedPlaying = 1,
|
||||
StopPlayingAndRecompile = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c82b7d324ea643f49436a0ed6ccfcc1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for security validation before server operations.
|
||||
/// Handles editor state validation and security checks.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - UnityEditor.EditorApplication: Provides editor state information
|
||||
/// - McpPortValidator: Validates port security
|
||||
/// </summary>
|
||||
public class SecurityValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that the Unity Editor is in a safe state for server operations.
|
||||
/// </summary>
|
||||
/// <returns>Validation result with error details if invalid</returns>
|
||||
public ValidationResult ValidateEditorState()
|
||||
{
|
||||
if (EditorApplication.isCompiling)
|
||||
{
|
||||
return new ValidationResult(false,
|
||||
"Cannot start MCP server while Unity is compiling. Please wait for compilation to complete.");
|
||||
}
|
||||
|
||||
return new ValidationResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates port security settings.
|
||||
/// </summary>
|
||||
/// <param name="port">Port number to validate</param>
|
||||
/// <returns>Validation result with security details</returns>
|
||||
public ValidationResult ValidatePortSecurity(int port)
|
||||
{
|
||||
if (!McpPortValidator.ValidatePort(port, "for MCP server"))
|
||||
{
|
||||
return new ValidationResult(false,
|
||||
$"Port number must be between 1 and 65535. Received: {port}");
|
||||
}
|
||||
|
||||
return new ValidationResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40aa5c61b29b440d2af6fc600a6c4304
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for server starting lock file management.
|
||||
/// Single responsibility: Create/delete lock file during server startup for CLI detection.
|
||||
/// Related classes: CompilationLockService, DomainReloadDetectionService (similar patterns)
|
||||
/// </summary>
|
||||
public static class ServerStartingLockService
|
||||
{
|
||||
private const string LOCK_FILE_NAME = "serverstarting.lock";
|
||||
private const int FILE_OPERATION_RETRY_COUNT = 3;
|
||||
private const int FILE_OPERATION_RETRY_DELAY_MILLISECONDS = 50;
|
||||
|
||||
private static string LockFilePath => Path.Combine(UnityEngine.Application.dataPath, "..", "Temp", LOCK_FILE_NAME);
|
||||
|
||||
internal static Action<string> OnOwnedLockFileClaimedForDeletionForTests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create lock file to signal server is starting.
|
||||
/// </summary>
|
||||
public static string CreateLockFile()
|
||||
{
|
||||
string lockPath = LockFilePath;
|
||||
string tempDir = Path.GetDirectoryName(lockPath);
|
||||
|
||||
if (!Directory.Exists(tempDir))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string ownershipToken = System.Guid.NewGuid().ToString("N");
|
||||
for (int attempt = 0; attempt < FILE_OPERATION_RETRY_COUNT; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(lockPath, ownershipToken);
|
||||
return ownershipToken;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
|
||||
if (attempt < FILE_OPERATION_RETRY_COUNT - 1)
|
||||
{
|
||||
Thread.Sleep(FILE_OPERATION_RETRY_DELAY_MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
VibeLogger.LogWarning("server_starting_lock_create_failed", $"Failed to create lock file: {lockPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete lock file. Called when server startup completes or on crash recovery.
|
||||
/// </summary>
|
||||
public static void DeleteLockFile(string ownershipToken = null)
|
||||
{
|
||||
string lockPath = LockFilePath;
|
||||
if (File.Exists(lockPath))
|
||||
{
|
||||
if (string.IsNullOrEmpty(ownershipToken))
|
||||
{
|
||||
TryDeleteLockFile(lockPath);
|
||||
return;
|
||||
}
|
||||
|
||||
string claimedLockPath = TryClaimOwnedLockFileForDeletion(lockPath, ownershipToken);
|
||||
if (string.IsNullOrEmpty(claimedLockPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryDeleteLockFile(claimedLockPath);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DeleteOwnedLockFile(string ownershipToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ownershipToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeleteLockFile(ownershipToken);
|
||||
}
|
||||
|
||||
private static string TryClaimOwnedLockFileForDeletion(string lockPath, string ownershipToken)
|
||||
{
|
||||
for (int attempt = 0; attempt < FILE_OPERATION_RETRY_COUNT; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using FileStream stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.Delete);
|
||||
using StreamReader reader = new StreamReader(stream);
|
||||
string existingOwnershipToken = reader.ReadToEnd();
|
||||
if (!string.Equals(existingOwnershipToken, ownershipToken, System.StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string claimedLockPath = CreateClaimedLockFilePath(lockPath);
|
||||
File.Move(lockPath, claimedLockPath);
|
||||
// Why: tests need a deterministic point after the old generation has been
|
||||
// detached from the canonical lock path but before we remove the claimed file.
|
||||
// Why not observe this with FileSystemWatcher: the editor test runner missed the
|
||||
// rename often enough to make the race coverage flaky on Windows.
|
||||
OnOwnedLockFileClaimedForDeletionForTests?.Invoke(claimedLockPath);
|
||||
return claimedLockPath;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
|
||||
if (attempt < FILE_OPERATION_RETRY_COUNT - 1)
|
||||
{
|
||||
Thread.Sleep(FILE_OPERATION_RETRY_DELAY_MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
VibeLogger.LogWarning("server_starting_lock_claim_failed", $"Failed to claim owned lock file: {lockPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string CreateClaimedLockFilePath(string lockPath)
|
||||
{
|
||||
return $"{lockPath}.{Guid.NewGuid():N}.owneddelete";
|
||||
}
|
||||
|
||||
private static void TryDeleteLockFile(string lockPath)
|
||||
{
|
||||
for (int attempt = 0; attempt < FILE_OPERATION_RETRY_COUNT; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(lockPath);
|
||||
if (!File.Exists(lockPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
|
||||
if (attempt < FILE_OPERATION_RETRY_COUNT - 1)
|
||||
{
|
||||
Thread.Sleep(FILE_OPERATION_RETRY_DELAY_MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
VibeLogger.LogWarning("server_starting_lock_delete_failed", $"Failed to delete lock file: {lockPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 608437d7bfb014480a1167c3fa138ff2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Application service responsible for session recovery processing
|
||||
/// Single responsibility: Server session recovery and retry control
|
||||
/// Related classes: McpSessionManager, McpBridgeServer, NetworkUtility
|
||||
/// </summary>
|
||||
public static class SessionRecoveryService
|
||||
{
|
||||
private const int MAX_RETRIES = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Restore server state if needed
|
||||
/// </summary>
|
||||
/// <returns>Recovery process result</returns>
|
||||
public static ValidationResult RestoreServerStateIfNeeded()
|
||||
{
|
||||
bool wasRunning = McpEditorSettings.GetIsServerRunning();
|
||||
int savedPort = McpEditorSettings.GetCustomPort();
|
||||
bool isAfterCompile = McpEditorSettings.GetIsAfterCompile();
|
||||
|
||||
// If server is already running
|
||||
McpBridgeServer currentServer = McpServerController.CurrentServer;
|
||||
if (currentServer?.IsRunning == true)
|
||||
{
|
||||
// Server is running, clean up lock files
|
||||
CompilationLockService.DeleteLockFile();
|
||||
DomainReloadDetectionService.DeleteLockFile();
|
||||
// Why: only the startup generation that created serverstarting.lock knows whether
|
||||
// the canonical lock still belongs to it or has already been replaced by a newer
|
||||
// generation. Why not delete it here: a stale domain-reload recovery path can race
|
||||
// with an active startup/prewarm sequence and tear down another generation's lock.
|
||||
|
||||
if (isAfterCompile)
|
||||
{
|
||||
McpEditorSettings.ClearAfterCompileFlag();
|
||||
}
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
|
||||
// Clear after-compile flag
|
||||
if (isAfterCompile)
|
||||
{
|
||||
McpEditorSettings.ClearAfterCompileFlag();
|
||||
}
|
||||
|
||||
// If server was running and is currently stopped, delegate to centralized controller logic
|
||||
if (wasRunning && (currentServer == null || !currentServer.IsRunning))
|
||||
{
|
||||
_ = McpServerController.StartRecoveryIfNeededAsync(savedPort, isAfterCompile, CancellationToken.None).ContinueWith(task =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
VibeLogger.LogError("server_startup_restore_failed",
|
||||
$"Failed to restore server: {task.Exception?.GetBaseException().Message}");
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt server recovery with retry mechanism
|
||||
/// </summary>
|
||||
/// <param name="port">Port number to recover</param>
|
||||
/// <param name="retryCount">Current retry count</param>
|
||||
/// <returns>Recovery process result</returns>
|
||||
public static ValidationResult TryRestoreServerWithRetry(int port, int retryCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Stop existing server instance
|
||||
McpBridgeServer currentServer = McpServerController.CurrentServer;
|
||||
if (currentServer != null)
|
||||
{
|
||||
currentServer.Dispose();
|
||||
}
|
||||
|
||||
// Find available port
|
||||
int availablePort = NetworkUtility.FindAvailablePort(port);
|
||||
|
||||
// Start new server
|
||||
var newServer = new McpBridgeServer();
|
||||
newServer.StartServer(availablePort);
|
||||
|
||||
// Update session state
|
||||
McpEditorSettings.UpdateSettings(s => s with
|
||||
{
|
||||
customPort = availablePort,
|
||||
isReconnecting = false
|
||||
});
|
||||
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
if (retryCount < MAX_RETRIES)
|
||||
{
|
||||
// Execute retry
|
||||
_ = RetryServerRestoreAsync(port, retryCount).ContinueWith(task =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
VibeLogger.LogError("server_restore_retry_failed",
|
||||
$"Failed to retry server restore (attempt {retryCount}): {task.Exception?.GetBaseException().Message}");
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear session when maximum retry count is reached
|
||||
McpEditorSettings.ClearServerSession();
|
||||
return ValidationResult.Failure($"Failed to restore server after {MAX_RETRIES} retries: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry server recovery (asynchronous)
|
||||
/// </summary>
|
||||
/// <param name="port">Port number to recover</param>
|
||||
/// <param name="retryCount">Current retry count</param>
|
||||
private static async Task RetryServerRestoreAsync(int port, int retryCount)
|
||||
{
|
||||
await EditorDelay.DelayFrame(5);
|
||||
_ = McpServerController.StartRecoveryIfNeededAsync(port, false, CancellationToken.None).ContinueWith(task =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
VibeLogger.LogError("server_startup_restore_failed",
|
||||
$"Failed to restore server: {task.Exception?.GetBaseException().Message}");
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear reconnection flag
|
||||
/// </summary>
|
||||
public static void ClearReconnectingFlag()
|
||||
{
|
||||
bool wasReconnecting = McpEditorSettings.GetIsReconnecting();
|
||||
bool wasShowingUI = McpEditorSettings.GetShowReconnectingUI();
|
||||
|
||||
if (wasReconnecting || wasShowingUI)
|
||||
{
|
||||
McpEditorSettings.ClearReconnectingFlags();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start reconnection UI display timeout
|
||||
/// </summary>
|
||||
/// <returns>Task for timeout processing</returns>
|
||||
public static async Task StartReconnectionUITimeoutAsync()
|
||||
{
|
||||
int timeoutFrames = McpConstants.RECONNECTION_TIMEOUT_SECONDS * 60;
|
||||
await EditorDelay.DelayFrame(timeoutFrames);
|
||||
|
||||
bool isStillShowingUI = McpEditorSettings.GetShowReconnectingUI();
|
||||
if (isStillShowingUI)
|
||||
{
|
||||
McpEditorSettings.ClearReconnectingFlags();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29da543035f2246c6aa314cb5b85e5f1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Test execution service
|
||||
/// Single function: Execute tests using Unity Test Runner
|
||||
/// Related classes: PlayModeTestExecuter, RunTestsUseCase, RunTestsTool
|
||||
/// </summary>
|
||||
public class TestExecutionService
|
||||
{
|
||||
#if ULOOPMCP_HAS_TEST_FRAMEWORK
|
||||
/// <summary>
|
||||
/// Execute tests in PlayMode
|
||||
/// </summary>
|
||||
/// <param name="filter">Test execution filter</param>
|
||||
/// <returns>Test execution result</returns>
|
||||
public virtual async Task<SerializableTestResult> ExecutePlayModeTestAsync(TestExecutionFilter filter)
|
||||
{
|
||||
return await PlayModeTestExecuter.ExecutePlayModeTest(filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute tests in EditMode
|
||||
/// </summary>
|
||||
/// <param name="filter">Test execution filter</param>
|
||||
/// <returns>Test execution result</returns>
|
||||
public virtual async Task<SerializableTestResult> ExecuteEditModeTestAsync(TestExecutionFilter filter)
|
||||
{
|
||||
return await PlayModeTestExecuter.ExecuteEditModeTest(filter);
|
||||
}
|
||||
#else
|
||||
public virtual Task<SerializableTestResult> ExecutePlayModeTestAsync(TestExecutionFilter filter)
|
||||
{
|
||||
return Task.FromResult(SerializableTestResult.CreateTestFrameworkUnavailable());
|
||||
}
|
||||
|
||||
public virtual Task<SerializableTestResult> ExecuteEditModeTestAsync(TestExecutionFilter filter)
|
||||
{
|
||||
return Task.FromResult(SerializableTestResult.CreateTestFrameworkUnavailable());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c70b2e00167314720ae17ebabbef34d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public class TestExecutionStateValidationService
|
||||
{
|
||||
private const string UnsavedEditorChangesFailureMessage =
|
||||
"Tests cannot run while the editor has unsaved scene or prefab changes. Save or discard these changes before running tests.";
|
||||
private const string UnsavedEditorChangesSaveFailureMessage =
|
||||
"Tests cannot save unsaved scene or prefab changes before running tests.";
|
||||
|
||||
protected virtual bool IsPlaying => EditorApplication.isPlaying;
|
||||
protected virtual bool IsCompiling => EditorApplication.isCompiling;
|
||||
protected virtual bool IsDomainReloadInProgress => DomainReloadDetectionService.IsDomainReloadInProgress();
|
||||
protected virtual bool IsUpdating => EditorApplication.isUpdating;
|
||||
protected virtual string[] DetectUnsavedEditorChanges()
|
||||
{
|
||||
return DetectCurrentUnsavedEditorChanges();
|
||||
}
|
||||
protected virtual ValidationResult SaveUnsavedEditorChanges()
|
||||
{
|
||||
return SaveCurrentUnsavedEditorChanges();
|
||||
}
|
||||
|
||||
public virtual ValidationResult Validate(RunTestMode testMode, bool saveBeforeRun)
|
||||
{
|
||||
if (IsCompiling)
|
||||
{
|
||||
return ValidationResult.Failure("Tests cannot run while compilation is in progress");
|
||||
}
|
||||
|
||||
if (IsDomainReloadInProgress)
|
||||
{
|
||||
return ValidationResult.Failure("Tests cannot run while domain reload is in progress");
|
||||
}
|
||||
|
||||
if (IsUpdating)
|
||||
{
|
||||
return ValidationResult.Failure("Tests cannot run while the editor is updating");
|
||||
}
|
||||
|
||||
if (testMode == RunTestMode.EditMode && IsPlaying)
|
||||
{
|
||||
return ValidationResult.Failure("EditMode tests cannot run during play mode");
|
||||
}
|
||||
|
||||
if (saveBeforeRun)
|
||||
{
|
||||
ValidationResult saveResult = SaveUnsavedEditorChanges();
|
||||
if (!saveResult.IsValid)
|
||||
{
|
||||
return saveResult;
|
||||
}
|
||||
}
|
||||
|
||||
string[] unsavedEditorChanges = DetectUnsavedEditorChanges();
|
||||
Debug.Assert(unsavedEditorChanges != null, "Unsaved editor change detection must return an array");
|
||||
if (unsavedEditorChanges.Length > 0)
|
||||
{
|
||||
return ValidationResult.Failure(CreateUnsavedEditorChangesFailureMessage(unsavedEditorChanges));
|
||||
}
|
||||
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
|
||||
private static string[] DetectCurrentUnsavedEditorChanges()
|
||||
{
|
||||
List<string> unsavedEditorChanges = new List<string>();
|
||||
AddDirtyLoadedScenes(unsavedEditorChanges);
|
||||
AddDirtyPrefabStage(unsavedEditorChanges);
|
||||
return unsavedEditorChanges.ToArray();
|
||||
}
|
||||
|
||||
private static ValidationResult SaveCurrentUnsavedEditorChanges()
|
||||
{
|
||||
List<string> failedChanges = new List<string>();
|
||||
SaveDirtyLoadedScenes(failedChanges);
|
||||
SaveDirtyPrefabStage(failedChanges);
|
||||
if (failedChanges.Count > 0)
|
||||
{
|
||||
return ValidationResult.Failure(CreateUnsavedEditorChangesSaveFailureMessage(failedChanges.ToArray()));
|
||||
}
|
||||
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
|
||||
private static void AddDirtyLoadedScenes(List<string> unsavedEditorChanges)
|
||||
{
|
||||
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");
|
||||
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
unsavedEditorChanges.Add("Scene: " + GetSceneDisplayPath(scene));
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveDirtyLoadedScenes(List<string> failedChanges)
|
||||
{
|
||||
Debug.Assert(failedChanges != null, "failedChanges must not be null");
|
||||
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(scene.path) || !EditorSceneManager.SaveScene(scene))
|
||||
{
|
||||
failedChanges.Add("Scene: " + GetSceneDisplayPath(scene));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDirtyPrefabStage(List<string> unsavedEditorChanges)
|
||||
{
|
||||
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");
|
||||
|
||||
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage == null || !prefabStage.scene.IsValid() || !prefabStage.scene.isDirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
unsavedEditorChanges.Add("Prefab Stage: " + GetPrefabStageDisplayPath(prefabStage));
|
||||
}
|
||||
|
||||
private static void SaveDirtyPrefabStage(List<string> failedChanges)
|
||||
{
|
||||
Debug.Assert(failedChanges != null, "failedChanges must not be null");
|
||||
|
||||
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage == null || !prefabStage.scene.IsValid() || !prefabStage.scene.isDirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SavePrefabStage(prefabStage))
|
||||
{
|
||||
failedChanges.Add("Prefab Stage: " + GetPrefabStageDisplayPath(prefabStage));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SavePrefabStage(PrefabStage prefabStage)
|
||||
{
|
||||
Debug.Assert(prefabStage != null, "prefabStage must not be null");
|
||||
|
||||
if (string.IsNullOrEmpty(prefabStage.assetPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success;
|
||||
PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success);
|
||||
if (success)
|
||||
{
|
||||
prefabStage.ClearDirtiness();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static string GetSceneDisplayPath(Scene scene)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(scene.path))
|
||||
{
|
||||
return scene.path;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(scene.name))
|
||||
{
|
||||
return scene.name;
|
||||
}
|
||||
|
||||
return "Untitled scene";
|
||||
}
|
||||
|
||||
private static string GetPrefabStageDisplayPath(PrefabStage prefabStage)
|
||||
{
|
||||
Debug.Assert(prefabStage != null, "prefabStage must not be null");
|
||||
|
||||
if (!string.IsNullOrEmpty(prefabStage.assetPath))
|
||||
{
|
||||
return prefabStage.assetPath;
|
||||
}
|
||||
|
||||
return GetSceneDisplayPath(prefabStage.scene);
|
||||
}
|
||||
|
||||
private static string CreateUnsavedEditorChangesFailureMessage(string[] unsavedEditorChanges)
|
||||
{
|
||||
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");
|
||||
Debug.Assert(unsavedEditorChanges.Length > 0, "unsavedEditorChanges must not be empty");
|
||||
|
||||
return UnsavedEditorChangesFailureMessage + " Unsaved changes: " + string.Join(", ", unsavedEditorChanges);
|
||||
}
|
||||
|
||||
private static string CreateUnsavedEditorChangesSaveFailureMessage(string[] failedChanges)
|
||||
{
|
||||
Debug.Assert(failedChanges != null, "failedChanges must not be null");
|
||||
Debug.Assert(failedChanges.Length > 0, "failedChanges must not be empty");
|
||||
|
||||
return UnsavedEditorChangesSaveFailureMessage + " Unsaved changes that failed to save: " + string.Join(", ", failedChanges);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8dfdb70f9061248eeb326b4403df2c8a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Test filter creation service
|
||||
/// Single function: Create filters for test execution
|
||||
/// Related classes: RunTestsTool, RunTestsUseCase, TestExecutionFilter
|
||||
/// </summary>
|
||||
public class TestFilterCreationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create test execution filter
|
||||
/// </summary>
|
||||
/// <param name="filterType">Filter type</param>
|
||||
/// <param name="filterValue">Filter value</param>
|
||||
/// <returns>Test execution filter</returns>
|
||||
public TestExecutionFilter CreateFilter(TestFilterType filterType, string filterValue)
|
||||
{
|
||||
return filterType switch
|
||||
{
|
||||
TestFilterType.all => TestExecutionFilter.All(),
|
||||
TestFilterType.exact => TestExecutionFilter.ByTestName(filterValue),
|
||||
TestFilterType.regex => TestExecutionFilter.ByClassName(filterValue),
|
||||
TestFilterType.assembly => TestExecutionFilter.ByAssemblyName(filterValue),
|
||||
_ => throw new ArgumentException($"Unsupported filter type: {filterType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44dc30cabe96d4382898be0f153bc200
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Connected LLM Tool data structure for persistence
|
||||
/// Related classes:
|
||||
/// - ConnectedClient: Connected client information
|
||||
/// - McpEditorWindow: UI tool display control
|
||||
/// - McpServerController: Server lifecycle management
|
||||
/// - McpBridgeServer: Actual client connection management
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ConnectedLLMToolData
|
||||
{
|
||||
[SerializeField] public string Name;
|
||||
[SerializeField] public string Endpoint;
|
||||
[SerializeField] public int Port;
|
||||
[SerializeField] public string ConnectedAtString;
|
||||
|
||||
// Unity doesn't serialize DateTime directly, so we use a string representation
|
||||
public DateTime ConnectedAt
|
||||
{
|
||||
get => string.IsNullOrEmpty(ConnectedAtString) ? DateTime.Now :
|
||||
DateTime.TryParse(ConnectedAtString, out DateTime result) ? result : DateTime.Now;
|
||||
set => ConnectedAtString = value.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz");
|
||||
}
|
||||
|
||||
public ConnectedLLMToolData()
|
||||
{
|
||||
// Default constructor for Unity serialization
|
||||
}
|
||||
|
||||
public ConnectedLLMToolData(string name, string endpoint, int port, DateTime connectedAt)
|
||||
{
|
||||
Name = name;
|
||||
Endpoint = endpoint;
|
||||
Port = port;
|
||||
ConnectedAt = connectedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a4320ff5004aa40e9ae756b620cc25a1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5839e0dfd0a8a40999c35ae1ad13837b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b379fad4ee2e948afb07c137164a1067
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,294 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// A class that provides a general-purpose static API for retrieving console logs.
|
||||
/// Uses ConsoleLogRetriever to access Unity's console logs directly via reflection.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class LogGetter
|
||||
{
|
||||
private static readonly ConsoleLogRetriever LogRetriever;
|
||||
|
||||
// Unity classifies csc.rsp compiler diagnostics as LogType.Log internally,
|
||||
// so without message-based detection they would be invisible to Error/Warning filters.
|
||||
// Note: Debug.Log messages matching ": error CSxxxx" / ": warning CSxxxx" will also
|
||||
// be treated as compiler diagnostics. This is by design.
|
||||
private static readonly Regex CompilerErrorPattern = new Regex(@":\s*error CS\d+\b", RegexOptions.Compiled);
|
||||
private static readonly Regex CompilerWarningPattern = new Regex(@":\s*warning CS\d+\b", RegexOptions.Compiled);
|
||||
|
||||
static LogGetter()
|
||||
{
|
||||
LogRetriever = new ConsoleLogRetriever();
|
||||
}
|
||||
|
||||
private static string NormalizeMcpLogType(string mcpLogType)
|
||||
{
|
||||
if (string.Equals(mcpLogType, McpLogType.Error, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return McpLogType.Error;
|
||||
}
|
||||
|
||||
if (string.Equals(mcpLogType, McpLogType.Warning, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return McpLogType.Warning;
|
||||
}
|
||||
|
||||
if (string.Equals(mcpLogType, McpLogType.Log, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return McpLogType.Log;
|
||||
}
|
||||
|
||||
if (string.Equals(mcpLogType, McpLogType.All, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return McpLogType.All;
|
||||
}
|
||||
|
||||
return McpLogType.Log;
|
||||
}
|
||||
|
||||
private static bool IsAssertionLikeEntry(LogEntryDto entry)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.StackTrace))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.StackTrace.IndexOf("Debug:LogAssertion", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.StackTrace.IndexOf("Debug.LogAssertion", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.StackTrace.IndexOf("Debug:Assert", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return entry.StackTrace.IndexOf("Debug.Assert", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
private static bool IsCompilerDiagnosticMessage(LogEntryDto entry, Regex pattern)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.LogType, McpLogType.Log, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return pattern.IsMatch(entry.Message);
|
||||
}
|
||||
|
||||
private static bool IsErrorFamilyEntry(LogEntryDto entry)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(entry.LogType, McpLogType.Error, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsAssertionLikeEntry(entry))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsCompilerDiagnosticMessage(entry, CompilerErrorPattern);
|
||||
}
|
||||
|
||||
private static bool IsWarningFamilyEntry(LogEntryDto entry)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(entry.LogType, McpLogType.Warning, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsCompilerDiagnosticMessage(entry, CompilerWarningPattern);
|
||||
}
|
||||
|
||||
private static List<LogEntryDto> GetFamilyEntries(
|
||||
List<LogEntryDto> allEntries,
|
||||
Func<LogEntryDto, bool> familyPredicate,
|
||||
string targetLogType)
|
||||
{
|
||||
List<LogEntryDto> familyEntries = new(allEntries.Count);
|
||||
|
||||
foreach (LogEntryDto entry in allEntries)
|
||||
{
|
||||
if (!familyPredicate(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(entry.LogType, targetLogType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
familyEntries.Add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
LogEntryDto normalizedEntry = new(targetLogType, entry.Message, entry.StackTrace);
|
||||
familyEntries.Add(normalizedEntry);
|
||||
}
|
||||
|
||||
return familyEntries;
|
||||
}
|
||||
|
||||
private static List<LogEntryDto> GetLogsByMcpLogType(string logType)
|
||||
{
|
||||
string normalizedLogType = NormalizeMcpLogType(logType);
|
||||
|
||||
if (string.Equals(normalizedLogType, McpLogType.All, StringComparison.Ordinal))
|
||||
{
|
||||
return LogRetriever.GetAllLogs();
|
||||
}
|
||||
|
||||
if (string.Equals(normalizedLogType, McpLogType.Error, StringComparison.Ordinal))
|
||||
{
|
||||
List<LogEntryDto> allEntries = LogRetriever.GetAllLogs();
|
||||
return GetFamilyEntries(allEntries, IsErrorFamilyEntry, McpLogType.Error);
|
||||
}
|
||||
|
||||
if (string.Equals(normalizedLogType, McpLogType.Warning, StringComparison.Ordinal))
|
||||
{
|
||||
List<LogEntryDto> allEntries = LogRetriever.GetAllLogs();
|
||||
return GetFamilyEntries(allEntries, IsWarningFamilyEntry, McpLogType.Warning);
|
||||
}
|
||||
|
||||
UnityEngine.LogType unityLogType = ConvertMcpLogTypeToLogType(normalizedLogType);
|
||||
return LogRetriever.GetLogsByType(unityLogType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts McpLogType to Unity's LogType
|
||||
/// </summary>
|
||||
/// <param name="mcpLogType">MCP log type</param>
|
||||
/// <returns>Corresponding Unity LogType</returns>
|
||||
private static LogType ConvertMcpLogTypeToLogType(string mcpLogType)
|
||||
{
|
||||
string normalizedLogType = NormalizeMcpLogType(mcpLogType);
|
||||
|
||||
return normalizedLogType switch
|
||||
{
|
||||
McpLogType.Error => LogType.Error,
|
||||
McpLogType.Warning => LogType.Warning,
|
||||
McpLogType.Log => LogType.Log,
|
||||
_ => LogType.Log // Default for unknown types, None will be handled separately
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all console logs and returns them as a LogDisplayDto.
|
||||
/// </summary>
|
||||
/// <returns>The retrieved log data.</returns>
|
||||
public static LogDisplayDto GetAllConsoleLogs()
|
||||
{
|
||||
System.Collections.Generic.List<LogEntryDto> logEntries = LogRetriever.GetAllLogs();
|
||||
return new LogDisplayDto(logEntries.ToArray(), logEntries.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directly retrieves an array of console log entries.
|
||||
/// </summary>
|
||||
/// <returns>An array of log entries.</returns>
|
||||
public static LogEntryDto[] GetConsoleLogEntries()
|
||||
{
|
||||
return LogRetriever.GetAllLogs().ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters and retrieves console logs by log type.
|
||||
/// </summary>
|
||||
/// <param name="logType">The log type to filter by (if "All", all types are retrieved).</param>
|
||||
/// <returns>The filtered log data.</returns>
|
||||
public static LogDisplayDto GetConsoleLogsByType(string logType)
|
||||
{
|
||||
List<LogEntryDto> allEntries = GetLogsByMcpLogType(logType);
|
||||
return new LogDisplayDto(allEntries.ToArray(), allEntries.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters and retrieves console logs by log type and searches within message content.
|
||||
/// </summary>
|
||||
/// <param name="logType">The log type to filter by (if "All", all types are included).</param>
|
||||
/// <param name="searchText">The text to search for within messages (if null or empty, no search is performed).</param>
|
||||
/// <param name="useRegex">Whether to use regular expression for search.</param>
|
||||
/// <param name="searchInStackTrace">Whether to search within stack trace as well.</param>
|
||||
/// <returns>The filtered log data.</returns>
|
||||
public static LogDisplayDto SearchConsoleLogs(string logType, string searchText, bool useRegex, bool searchInStackTrace)
|
||||
{
|
||||
// Get logs based on type
|
||||
List<LogEntryDto> allEntries = GetLogsByMcpLogType(logType);
|
||||
|
||||
// Filter by search text if provided
|
||||
if (!string.IsNullOrEmpty(searchText))
|
||||
{
|
||||
if (useRegex)
|
||||
{
|
||||
Regex regex = new Regex(searchText);
|
||||
allEntries = allEntries.FindAll(entry =>
|
||||
{
|
||||
bool messageMatch = regex.IsMatch(entry.Message);
|
||||
bool stackTraceMatch = searchInStackTrace && !string.IsNullOrEmpty(entry.StackTrace) && regex.IsMatch(entry.StackTrace);
|
||||
return messageMatch || stackTraceMatch;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
allEntries = allEntries.FindAll(entry =>
|
||||
{
|
||||
bool messageMatch = entry.Message.IndexOf(searchText, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
bool stackTraceMatch = searchInStackTrace && !string.IsNullOrEmpty(entry.StackTrace) &&
|
||||
entry.StackTrace.IndexOf(searchText, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
return messageMatch || stackTraceMatch;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new LogDisplayDto(allEntries.ToArray(), allEntries.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of console logs.
|
||||
/// </summary>
|
||||
/// <returns>The total number of logs.</returns>
|
||||
public static int GetConsoleLogCount()
|
||||
{
|
||||
return LogRetriever.GetLogCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the logs of the custom log manager.
|
||||
/// </summary>
|
||||
public static void ClearCustomLogs()
|
||||
{
|
||||
// This method is no longer needed since we're using ConsoleLogRetriever
|
||||
// Console logs are managed by Unity itself
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee011c0c8ecfb45ad97c3fcb83616734
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
[Serializable]
|
||||
public record LogEntryDto
|
||||
{
|
||||
public readonly string Message;
|
||||
public readonly string LogType;
|
||||
public readonly string StackTrace;
|
||||
|
||||
public LogEntryDto(string logType, string message, string stackTrace)
|
||||
{
|
||||
Message = message;
|
||||
LogType = logType;
|
||||
StackTrace = stackTrace;
|
||||
}
|
||||
}
|
||||
|
||||
public record LogDisplayDto
|
||||
{
|
||||
public readonly LogEntryDto[] LogEntries;
|
||||
public readonly int TotalCount;
|
||||
|
||||
public LogDisplayDto(LogEntryDto[] logEntries, int totalCount)
|
||||
{
|
||||
LogEntries = logEntries;
|
||||
TotalCount = totalCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35b4f6a13137f4470be3a63dc705bf1f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f6dcaf4dfa124399873d244ef893c1c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,492 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementation of console log retrieval using reflection to access Unity's internal APIs
|
||||
/// </summary>
|
||||
public class ConsoleLogRetriever
|
||||
{
|
||||
private const string GetFilteringTextMethodName = "GetFilteringText";
|
||||
private const string SetFilteringTextMethodName = "SetFilteringText";
|
||||
|
||||
private readonly Type _logEntriesType;
|
||||
private readonly MethodInfo _getFilteringTextMethod;
|
||||
private readonly MethodInfo _setFilteringTextMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the retriever with necessary reflection types
|
||||
/// </summary>
|
||||
public ConsoleLogRetriever()
|
||||
{
|
||||
Assembly editorAssembly = Assembly.GetAssembly(typeof(EditorWindow));
|
||||
_logEntriesType = editorAssembly.GetType("UnityEditor.LogEntries");
|
||||
editorAssembly.GetType("UnityEditor.ConsoleWindow");
|
||||
|
||||
if (_logEntriesType == null)
|
||||
{
|
||||
throw new InvalidOperationException("LogEntries type not found. Unity version compatibility issue.");
|
||||
}
|
||||
|
||||
_getFilteringTextMethod = _logEntriesType.GetMethod(GetFilteringTextMethodName, BindingFlags.Public | BindingFlags.Static);
|
||||
_setFilteringTextMethod = _logEntriesType.GetMethod(SetFilteringTextMethodName, BindingFlags.Public | BindingFlags.Static);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all console logs regardless of current mask settings
|
||||
/// </summary>
|
||||
public List<LogEntryDto> GetAllLogs()
|
||||
{
|
||||
// Save original mask to restore later
|
||||
int originalMask = GetCurrentMask();
|
||||
|
||||
using (ConsoleFilteringTextScope filteringTextScope = CreateFilteringTextScope())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Temporarily set mask to show all log types (0x387 = Error + Warning + Log + Base)
|
||||
SetMask(7); // This will convert to Unity's 0x387
|
||||
|
||||
List<LogEntryDto> logs = new();
|
||||
int logCount = GetLogCount();
|
||||
|
||||
for (int i = 0; i < logCount; i++)
|
||||
{
|
||||
LogEntryDto entry = GetLogEntryAt(i);
|
||||
if (entry != null)
|
||||
{
|
||||
logs.Add(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"GetAllLogs: Failed to get log at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always restore original mask, even if an exception occurred
|
||||
RestoreOriginalMask(originalMask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the original mask by converting Unity mask back to simple mask format
|
||||
/// </summary>
|
||||
private void RestoreOriginalMask(int originalUnityMask)
|
||||
{
|
||||
// Use consoleFlags property to restore the exact Unity mask
|
||||
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
|
||||
if (consoleFlagsProperty != null)
|
||||
{
|
||||
consoleFlagsProperty.SetValue(null, originalUnityMask);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Unity LogType to McpLogType
|
||||
/// </summary>
|
||||
private string ConvertLogTypeToMcpLogType(LogType logType)
|
||||
{
|
||||
return logType switch
|
||||
{
|
||||
LogType.Error => McpLogType.Error,
|
||||
LogType.Assert => McpLogType.Error,
|
||||
LogType.Exception => McpLogType.Error,
|
||||
LogType.Warning => McpLogType.Warning,
|
||||
LogType.Log => McpLogType.Log,
|
||||
_ => McpLogType.Log // Default unknown types to Log
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs of a specific type, temporarily adjusting mask if necessary
|
||||
/// </summary>
|
||||
public List<LogEntryDto> GetLogsByType(LogType logType)
|
||||
{
|
||||
// Save original mask to restore later
|
||||
int originalMask = GetCurrentMask();
|
||||
|
||||
using (ConsoleFilteringTextScope filteringTextScope = CreateFilteringTextScope())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Temporarily enable the specific log type we want
|
||||
int targetMask = GetMaskForLogType(logType);
|
||||
if (targetMask > 0)
|
||||
{
|
||||
SetMask(targetMask);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If unknown type, show all types
|
||||
SetMask(7);
|
||||
}
|
||||
|
||||
List<LogEntryDto> logs = new();
|
||||
int logCount = GetLogCount();
|
||||
string targetMcpLogType = ConvertLogTypeToMcpLogType(logType);
|
||||
|
||||
for (int i = 0; i < logCount; i++)
|
||||
{
|
||||
LogEntryDto entry = GetLogEntryAt(i);
|
||||
if (entry != null && entry.LogType == targetMcpLogType)
|
||||
{
|
||||
logs.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always restore original mask
|
||||
RestoreOriginalMask(originalMask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that temporarily disables Console text filtering during log retrieval.
|
||||
/// </summary>
|
||||
private ConsoleFilteringTextScope CreateFilteringTextScope()
|
||||
{
|
||||
if (_getFilteringTextMethod == null || _setFilteringTextMethod == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ConsoleFilteringTextScope(GetFilteringText, SetFilteringText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current Unity Console text filter through Unity's internal API.
|
||||
/// </summary>
|
||||
private string GetFilteringText()
|
||||
{
|
||||
Debug.Assert(_getFilteringTextMethod != null, "Filtering text getter must be available before use.");
|
||||
|
||||
object result = _getFilteringTextMethod.Invoke(null, null);
|
||||
return result?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current Unity Console text filter through Unity's internal API.
|
||||
/// </summary>
|
||||
private void SetFilteringText(string filteringText)
|
||||
{
|
||||
Debug.Assert(_setFilteringTextMethod != null, "Filtering text setter must be available before use.");
|
||||
Debug.Assert(filteringText != null, "Filtering text must not be null.");
|
||||
|
||||
_setFilteringTextMethod.Invoke(null, new object[] { filteringText });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current console mask settings using reflection
|
||||
/// </summary>
|
||||
public int GetCurrentMask()
|
||||
{
|
||||
// Use the consoleFlags property discovered in the investigation
|
||||
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
|
||||
if (consoleFlagsProperty != null)
|
||||
{
|
||||
object result = consoleFlagsProperty.GetValue(null);
|
||||
return result != null ? (int)result : 0;
|
||||
}
|
||||
|
||||
// Fallback: try to get from ConsoleWindow if available
|
||||
return GetMaskFromConsoleWindow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the console mask settings using reflection
|
||||
/// </summary>
|
||||
public void SetMask(int mask)
|
||||
{
|
||||
// Based on observed Unity Console mask patterns:
|
||||
// Base: 0x7, Log: +0x80, Warning: +0x100, Error: +0x200
|
||||
int unityMask = ConvertToUnityMask(mask);
|
||||
|
||||
// Use the consoleFlags property
|
||||
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
|
||||
if (consoleFlagsProperty != null)
|
||||
{
|
||||
consoleFlagsProperty.SetValue(null, unityMask);
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.LogWarning("Could not find consoleFlags property");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts simple mask (1=Error, 2=Warning, 4=Log) to Unity's internal mask format
|
||||
/// Preserves all existing console settings (Clear options, Collapse, Error Pause, etc.)
|
||||
/// </summary>
|
||||
private int ConvertToUnityMask(int simpleMask)
|
||||
{
|
||||
// Get current mask to preserve existing console settings
|
||||
int currentMask = GetCurrentMask();
|
||||
|
||||
// Preserve all console settings except log type flags
|
||||
// Clear log type bits (0x80=Log, 0x100=Warning, 0x200=Error) but keep everything else
|
||||
int preservedSettings = currentMask & ~(0x80 | 0x100 | 0x200);
|
||||
|
||||
// Start with preserved settings (includes Clear options, Collapse, Error Pause, etc.)
|
||||
int unityMask = preservedSettings;
|
||||
|
||||
if ((simpleMask & 1) != 0) // Error
|
||||
unityMask |= 0x200;
|
||||
|
||||
if ((simpleMask & 2) != 0) // Warning
|
||||
unityMask |= 0x100;
|
||||
|
||||
if ((simpleMask & 4) != 0) // Log
|
||||
unityMask |= 0x80;
|
||||
|
||||
return unityMask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count of console entries using reflection
|
||||
/// </summary>
|
||||
public int GetLogCount()
|
||||
{
|
||||
MethodInfo getCount = _logEntriesType.GetMethod("GetCount", BindingFlags.Public | BindingFlags.Static);
|
||||
if (getCount != null)
|
||||
{
|
||||
object result = getCount.Invoke(null, null);
|
||||
return result != null ? (int)result : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets a log entry at the specified index using reflection
|
||||
/// </summary>
|
||||
private LogEntryDto GetLogEntryAt(int index)
|
||||
{
|
||||
// Get LogEntry type from Unity's internal assembly
|
||||
Assembly editorAssembly = Assembly.GetAssembly(typeof(EditorWindow));
|
||||
Type logEntryType = editorAssembly.GetType("UnityEditor.LogEntry");
|
||||
if (logEntryType == null)
|
||||
{
|
||||
Debug.LogError("LogEntry type not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create LogEntry instance
|
||||
object logEntryInstance = Activator.CreateInstance(logEntryType);
|
||||
|
||||
// Use GetEntryInternal method discovered in investigation
|
||||
MethodInfo getEntryInternal = _logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Public | BindingFlags.Static);
|
||||
if (getEntryInternal == null)
|
||||
{
|
||||
Debug.LogError("GetEntryInternal method not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Call GetEntryInternal(int row, LogEntry outputEntry)
|
||||
object[] parameters = new object[] { index, logEntryInstance };
|
||||
bool success = (bool)getEntryInternal.Invoke(null, parameters);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
Debug.LogWarning($"GetEntryInternal failed for index {index}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract data from LogEntry instance using reflection
|
||||
string fullMessage = GetFieldValue(logEntryInstance, "message")?.ToString() ?? "";
|
||||
int mode = (int)(GetFieldValue(logEntryInstance, "mode") ?? 0);
|
||||
int callstackTextStart = (int)(GetFieldValue(logEntryInstance, "callstackTextStartUTF8") ?? 0);
|
||||
|
||||
LogType logType = GetLogTypeFromMode(mode);
|
||||
|
||||
// Separate message and stack trace using Unity's internal boundary
|
||||
(string message, string stackTrace) = SeparateMessageAndStackTrace(fullMessage, callstackTextStart);
|
||||
|
||||
string mcpLogType = ConvertLogTypeToMcpLogType(logType);
|
||||
return new LogEntryDto(mcpLogType, message, stackTrace);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get field value from object using reflection
|
||||
/// </summary>
|
||||
private object GetFieldValue(object obj, string fieldName)
|
||||
{
|
||||
Type type = obj.GetType();
|
||||
FieldInfo field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
return field?.GetValue(obj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a UTF-8 byte position to a character position in a string.
|
||||
/// Unity's LogEntry.callstackTextStartUTF8 provides byte offset, but
|
||||
/// string.Substring() requires character offset.
|
||||
/// </summary>
|
||||
private int ConvertUtf8BytePositionToCharPosition(string text, int utf8BytePosition)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || utf8BytePosition <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
byte[] utf8Bytes = Encoding.UTF8.GetBytes(text);
|
||||
|
||||
if (utf8BytePosition >= utf8Bytes.Length)
|
||||
{
|
||||
return text.Length;
|
||||
}
|
||||
|
||||
string decodedPortion = Encoding.UTF8.GetString(utf8Bytes, 0, utf8BytePosition);
|
||||
return decodedPortion.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Separates message and stack trace using Unity's internal callstack boundary
|
||||
/// </summary>
|
||||
private (string message, string stackTrace) SeparateMessageAndStackTrace(string fullMessage, int callstackTextStartUtf8)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullMessage)) return ("", "");
|
||||
|
||||
int charPosition = ConvertUtf8BytePositionToCharPosition(fullMessage, callstackTextStartUtf8);
|
||||
|
||||
if (charPosition <= 0 || charPosition >= fullMessage.Length)
|
||||
{
|
||||
return (fullMessage.Trim(), "");
|
||||
}
|
||||
|
||||
string message = fullMessage.Substring(0, charPosition).Trim();
|
||||
string stackTrace = fullMessage.Substring(charPosition).Trim();
|
||||
|
||||
return (message, stackTrace);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Unity's internal log mode to a LogType enum
|
||||
/// </summary>
|
||||
private LogType GetLogTypeFromMode(int mode)
|
||||
{
|
||||
// Analyze the observed patterns:
|
||||
// 0x804400 = Log
|
||||
// 0x804200 = Warning
|
||||
// 0x804100 = Error
|
||||
|
||||
// Extract bits 8-11 (shift right 8, mask 4 bits)
|
||||
int logType = (mode >> 8) & 0xF;
|
||||
|
||||
LogType result = logType switch
|
||||
{
|
||||
0x01 => LogType.Error, // 0x804100 -> (0x4100 >> 8) & 0xF = 0x41 & 0xF = 0x1
|
||||
0x02 => LogType.Warning, // 0x804200 -> (0x4200 >> 8) & 0xF = 0x42 & 0xF = 0x2
|
||||
0x04 => LogType.Log, // 0x804400 -> (0x4400 >> 8) & 0xF = 0x44 & 0xF = 0x4
|
||||
_ => DetermineLogTypeFromModeAnalysis(mode)
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes mode values to determine log type when standard mapping fails
|
||||
/// </summary>
|
||||
private LogType DetermineLogTypeFromModeAnalysis(int mode)
|
||||
{
|
||||
// Analyze the mode values we've seen:
|
||||
// 8406016 (0x802000) - appears to be Log type
|
||||
// 8405504 (0x801E00) - appears to be Warning type
|
||||
// 8405248 (0x801D00) - appears to be Error type
|
||||
|
||||
// Let's try different bit extraction strategies
|
||||
// int lowerBits = mode & 0x7; // Lower 3 bits
|
||||
// int midBits = (mode >> 8) & 0x7; // Bits 8-10
|
||||
// int higherBits = (mode >> 16) & 0x7; // Bits 16-18
|
||||
|
||||
// Based on observed patterns, try to determine type
|
||||
if (mode == 8406016) return LogType.Log; // This is a test log message
|
||||
if (mode == 8405504) return LogType.Warning; // This is a test warning message
|
||||
if (mode == 8405248) return LogType.Error; // This is a test error message
|
||||
|
||||
return LogType.Log; // Default to Log for unknown types
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appropriate mask value for a specific log type
|
||||
/// </summary>
|
||||
private int GetMaskForLogType(LogType logType)
|
||||
{
|
||||
return logType switch
|
||||
{
|
||||
LogType.Error => 1,
|
||||
LogType.Warning => 2,
|
||||
LogType.Log => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback method to get mask from ConsoleWindow
|
||||
/// </summary>
|
||||
private int GetMaskFromConsoleWindow()
|
||||
{
|
||||
// Implementation would access ConsoleWindow's filter state
|
||||
return 7; // Default to show all
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporarily clears Unity Console text filtering and restores the original filter on dispose.
|
||||
/// </summary>
|
||||
internal sealed class ConsoleFilteringTextScope : IDisposable
|
||||
{
|
||||
private readonly Action<string> _setFilteringText;
|
||||
private readonly string _originalFilteringText;
|
||||
private readonly bool _shouldRestoreFilteringText;
|
||||
private bool _disposed;
|
||||
|
||||
public ConsoleFilteringTextScope(Func<string> getFilteringText, Action<string> setFilteringText)
|
||||
{
|
||||
Debug.Assert(getFilteringText != null, "Filtering text getter must not be null.");
|
||||
Debug.Assert(setFilteringText != null, "Filtering text setter must not be null.");
|
||||
|
||||
_setFilteringText = setFilteringText;
|
||||
_originalFilteringText = getFilteringText() ?? string.Empty;
|
||||
_shouldRestoreFilteringText = !string.IsNullOrEmpty(_originalFilteringText);
|
||||
|
||||
if (!_shouldRestoreFilteringText)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Unity applies this text filter to LogEntries.GetCount/GetEntryInternal, so clear it for raw retrieval.
|
||||
_setFilteringText(string.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (!_shouldRestoreFilteringText)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_setFilteringText(_originalFilteringText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 935eee5e6013c4b46a7a71cd09908e4b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,66 @@
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Universal Console Utility wrapper
|
||||
/// Uses Unity 6+ standard API when available, fallback to custom implementation for older versions
|
||||
/// </summary>
|
||||
public static class ConsoleUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets console log counts by type (Universal API)
|
||||
/// </summary>
|
||||
/// <param name="errorCount">Number of error logs</param>
|
||||
/// <param name="warningCount">Number of warning logs</param>
|
||||
/// <param name="logCount">Number of info logs</param>
|
||||
public static void GetConsoleLogCounts(out int errorCount, out int warningCount, out int logCount)
|
||||
{
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
ConsoleWindowUtility.GetConsoleLogCounts(out errorCount, out warningCount, out logCount);
|
||||
#else
|
||||
GenericConsoleWindowUtility.GetConsoleLogCounts(out errorCount, out warningCount, out logCount);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the Unity Editor Console (Universal API)
|
||||
/// Unity does not provide a public API for clearing the Editor Console Window,
|
||||
/// so we use reflection-based implementation for all Unity versions
|
||||
/// Note: Debug.ClearDeveloperConsole() is for runtime developer console, not Editor Console
|
||||
/// </summary>
|
||||
public static void ClearConsole()
|
||||
{
|
||||
GenericConsoleWindowUtility.ClearConsole();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Unity version info for debugging
|
||||
/// </summary>
|
||||
/// <returns>Unity version and API info</returns>
|
||||
public static string GetVersionInfo()
|
||||
{
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
return $"Unity {Application.unityVersion} - Using Unity 6+ Standard ConsoleWindowUtility API";
|
||||
#else
|
||||
return $"Unity {Application.unityVersion} - Using Custom LogByReflection ConsoleWindowUtility API";
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Unity 6+ standard API is being used
|
||||
/// </summary>
|
||||
/// <returns>True if using Unity 6+ standard API</returns>
|
||||
public static bool IsUsingStandardAPI()
|
||||
{
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 184730250e2b448bfb9ad96a5e88515f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity 6 ConsoleWindowUtility API recreation for older Unity versions
|
||||
/// Provides event-driven console log monitoring and simple count retrieval
|
||||
/// </summary>
|
||||
public static class GenericConsoleWindowUtility
|
||||
{
|
||||
private static ConsoleLogRetriever logRetriever;
|
||||
|
||||
static GenericConsoleWindowUtility()
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the utility and start monitoring
|
||||
/// </summary>
|
||||
private static void Initialize()
|
||||
{
|
||||
logRetriever = new ConsoleLogRetriever();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets console log counts by type (Unity 6 compatible)
|
||||
/// </summary>
|
||||
/// <param name="errorCount">Number of error logs</param>
|
||||
/// <param name="warningCount">Number of warning logs</param>
|
||||
/// <param name="logCount">Number of info logs</param>
|
||||
public static void GetConsoleLogCounts(out int errorCount, out int warningCount, out int logCount)
|
||||
{
|
||||
errorCount = 0;
|
||||
warningCount = 0;
|
||||
logCount = 0;
|
||||
|
||||
if (logRetriever == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Save current mask state
|
||||
int originalMask = logRetriever.GetCurrentMask();
|
||||
|
||||
try
|
||||
{
|
||||
// Temporarily set mask to show all log types to get accurate counts
|
||||
logRetriever.SetMask(7); // Show all: Error(1) + Warning(2) + Log(4) = 7
|
||||
|
||||
// Use Unity's internal GetCount method which respects Console clear state
|
||||
int totalCount = logRetriever.GetLogCount();
|
||||
|
||||
if (totalCount == 0)
|
||||
{
|
||||
// Console is empty, all counts are 0
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all logs and count by type
|
||||
List<LogEntryDto> allLogs = logRetriever.GetAllLogs();
|
||||
|
||||
foreach (LogEntryDto log in allLogs)
|
||||
{
|
||||
switch (log.LogType)
|
||||
{
|
||||
case McpLogType.Error:
|
||||
errorCount++;
|
||||
break;
|
||||
case McpLogType.Warning:
|
||||
warningCount++;
|
||||
break;
|
||||
case McpLogType.Log:
|
||||
logCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always restore original mask
|
||||
logRetriever.SetMask(GetSimpleMaskFromUnityMask(originalMask));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Console log count retrieval failed: {ex.Message}");
|
||||
// Don't suppress this exception - caller needs to know count retrieval failed
|
||||
throw new InvalidOperationException(
|
||||
"Failed to retrieve console log counts. Console monitoring may be compromised.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the Unity Console (Unity 6 compatible)
|
||||
/// </summary>
|
||||
public static void ClearConsole()
|
||||
{
|
||||
try
|
||||
{
|
||||
Type _logEntriesType = Assembly.GetAssembly(typeof(EditorWindow))
|
||||
.GetType("UnityEditor.LogEntries");
|
||||
|
||||
MethodInfo clearMethod = _logEntriesType.GetMethod("Clear",
|
||||
BindingFlags.Public | BindingFlags.Static);
|
||||
|
||||
if (clearMethod != null)
|
||||
{
|
||||
clearMethod.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Console clear operation failed: {ex.Message}");
|
||||
// Don't suppress this exception - caller needs to know clear failed
|
||||
throw new InvalidOperationException("Failed to clear Unity console. Console operations may be compromised.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Unity internal mask back to simple mask format
|
||||
/// </summary>
|
||||
/// <param name="unityMask">Unity's internal mask value</param>
|
||||
/// <returns>Simple mask (1=Error, 2=Warning, 4=Log)</returns>
|
||||
private static int GetSimpleMaskFromUnityMask(int unityMask)
|
||||
{
|
||||
int simpleMask = 0;
|
||||
|
||||
if ((unityMask & 0x200) != 0) // Error bit
|
||||
simpleMask |= 1;
|
||||
|
||||
if ((unityMask & 0x100) != 0) // Warning bit
|
||||
simpleMask |= 2;
|
||||
|
||||
if ((unityMask & 0x80) != 0) // Log bit
|
||||
simpleMask |= 4;
|
||||
|
||||
return simpleMask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66a31c5e325b544b99c3bffebe644d20
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c10a25db7b2d4dfb89453370d51afe2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes component properties to ComponentPropertyInfo format
|
||||
/// Related classes:
|
||||
/// - ComponentSerializer: Uses this to serialize properties
|
||||
/// </summary>
|
||||
public class ComponentPropertySerializer
|
||||
{
|
||||
|
||||
public ComponentPropertyInfo[] SerializeProperties(Component component)
|
||||
{
|
||||
if (component == null)
|
||||
return new ComponentPropertyInfo[0];
|
||||
|
||||
List<ComponentPropertyInfo> propertyInfos = new List<ComponentPropertyInfo>();
|
||||
|
||||
// Use SerializedObject to get only Inspector-visible properties
|
||||
SerializedObject serializedObject = new SerializedObject(component);
|
||||
SerializedProperty iterator = serializedObject.GetIterator();
|
||||
|
||||
if (iterator.NextVisible(true))
|
||||
{
|
||||
do
|
||||
{
|
||||
// m_Script is an internal reference, not a user-facing property
|
||||
if (iterator.name == "m_Script")
|
||||
continue;
|
||||
|
||||
object value = GetSerializedPropertyValue(iterator);
|
||||
if (value != null)
|
||||
{
|
||||
ComponentPropertyInfo info = new ComponentPropertyInfo
|
||||
{
|
||||
name = iterator.displayName,
|
||||
type = iterator.propertyType.ToString(),
|
||||
value = SerializeValue(value)
|
||||
};
|
||||
|
||||
propertyInfos.Add(info);
|
||||
}
|
||||
} while (iterator.NextVisible(false));
|
||||
}
|
||||
|
||||
return propertyInfos.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract value from SerializedProperty based on its type
|
||||
/// </summary>
|
||||
private object GetSerializedPropertyValue(SerializedProperty property)
|
||||
{
|
||||
switch (property.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Integer:
|
||||
return property.intValue;
|
||||
case SerializedPropertyType.Boolean:
|
||||
return property.boolValue;
|
||||
case SerializedPropertyType.Float:
|
||||
return property.floatValue;
|
||||
case SerializedPropertyType.String:
|
||||
return property.stringValue;
|
||||
case SerializedPropertyType.Color:
|
||||
return property.colorValue;
|
||||
case SerializedPropertyType.Vector2:
|
||||
return property.vector2Value;
|
||||
case SerializedPropertyType.Vector3:
|
||||
return property.vector3Value;
|
||||
case SerializedPropertyType.Vector4:
|
||||
return property.vector4Value;
|
||||
case SerializedPropertyType.Rect:
|
||||
return property.rectValue;
|
||||
case SerializedPropertyType.Bounds:
|
||||
return property.boundsValue;
|
||||
case SerializedPropertyType.Quaternion:
|
||||
return property.quaternionValue;
|
||||
case SerializedPropertyType.Enum:
|
||||
return property.enumNames[property.enumValueIndex];
|
||||
case SerializedPropertyType.LayerMask:
|
||||
return property.intValue;
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
return GetObjectReferenceValue(property);
|
||||
default:
|
||||
return null; // Unsupported property types
|
||||
}
|
||||
}
|
||||
|
||||
private object GetObjectReferenceValue(SerializedProperty property)
|
||||
{
|
||||
UnityEngine.Debug.Assert(property != null, "SerializedProperty must exist before reading object references.");
|
||||
UnityEngine.Debug.Assert(property.propertyType == SerializedPropertyType.ObjectReference, "Object reference serialization requires an ObjectReference property.");
|
||||
|
||||
UnityEngine.Object obj = property.objectReferenceValue;
|
||||
if (obj == null)
|
||||
{
|
||||
if (HasStoredObjectReferenceId(property))
|
||||
{
|
||||
return new { name = "Missing", type = "Missing", entityId = GetStoredObjectReferenceId(property) };
|
||||
}
|
||||
|
||||
return new { name = "None", type = "None", entityId = "0" };
|
||||
}
|
||||
|
||||
return new { name = obj.name, type = obj.GetType().Name, entityId = GetObjectId(obj) };
|
||||
}
|
||||
|
||||
private static bool HasStoredObjectReferenceId(SerializedProperty property)
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
return property.objectReferenceEntityIdValue != UnityEngine.EntityId.None;
|
||||
#else
|
||||
return property.objectReferenceInstanceIDValue != 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
private static string GetStoredObjectReferenceId(SerializedProperty property)
|
||||
{
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
ulong entityId = UnityEngine.EntityId.ToULong(property.objectReferenceEntityIdValue);
|
||||
return entityId.ToString(CultureInfo.InvariantCulture);
|
||||
#else
|
||||
int instanceId = property.objectReferenceInstanceIDValue;
|
||||
return instanceId.ToString(CultureInfo.InvariantCulture);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static string GetObjectId(UnityEngine.Object obj)
|
||||
{
|
||||
UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier.");
|
||||
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
ulong entityId = UnityEngine.EntityId.ToULong(obj.GetEntityId());
|
||||
return entityId.ToString(CultureInfo.InvariantCulture);
|
||||
#else
|
||||
int instanceId = obj.GetInstanceID();
|
||||
return instanceId.ToString(CultureInfo.InvariantCulture);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
private object SerializeValue(object value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
Type valueType = value.GetType();
|
||||
|
||||
// Unity types need special serialization
|
||||
if (valueType == typeof(Vector2))
|
||||
{
|
||||
Vector2 v = (Vector2)value;
|
||||
return new { x = v.x, y = v.y };
|
||||
}
|
||||
else if (valueType == typeof(Vector3))
|
||||
{
|
||||
Vector3 v = (Vector3)value;
|
||||
return new { x = v.x, y = v.y, z = v.z };
|
||||
}
|
||||
else if (valueType == typeof(Vector4))
|
||||
{
|
||||
Vector4 v = (Vector4)value;
|
||||
return new { x = v.x, y = v.y, z = v.z, w = v.w };
|
||||
}
|
||||
else if (valueType == typeof(Quaternion))
|
||||
{
|
||||
Quaternion q = (Quaternion)value;
|
||||
return new
|
||||
{
|
||||
x = q.x,
|
||||
y = q.y,
|
||||
z = q.z,
|
||||
w = q.w,
|
||||
eulerAngles = new { x = q.eulerAngles.x, y = q.eulerAngles.y, z = q.eulerAngles.z }
|
||||
};
|
||||
}
|
||||
else if (valueType == typeof(Color))
|
||||
{
|
||||
Color c = (Color)value;
|
||||
return new { r = c.r, g = c.g, b = c.b, a = c.a };
|
||||
}
|
||||
else if (valueType == typeof(Rect))
|
||||
{
|
||||
Rect r = (Rect)value;
|
||||
return new { x = r.x, y = r.y, width = r.width, height = r.height };
|
||||
}
|
||||
else if (valueType == typeof(Bounds))
|
||||
{
|
||||
Bounds b = (Bounds)value;
|
||||
return new
|
||||
{
|
||||
center = new { x = b.center.x, y = b.center.y, z = b.center.z },
|
||||
size = new { x = b.size.x, y = b.size.y, z = b.size.z }
|
||||
};
|
||||
}
|
||||
else if (valueType.IsEnum)
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be3bd04b30c4d45ba8d8b1b7072ba4cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes Unity component information to ComponentInfo format
|
||||
/// Related classes:
|
||||
/// - GameObjectFinderService: Uses this to serialize components
|
||||
/// - ComponentPropertySerializer: Serializes individual properties (TODO)
|
||||
/// </summary>
|
||||
public class ComponentSerializer
|
||||
{
|
||||
private readonly ComponentPropertySerializer _propertySerializer;
|
||||
|
||||
public ComponentSerializer()
|
||||
{
|
||||
_propertySerializer = new ComponentPropertySerializer();
|
||||
}
|
||||
|
||||
public ComponentInfo[] SerializeComponents(GameObject gameObject)
|
||||
{
|
||||
if (gameObject == null)
|
||||
return new ComponentInfo[0];
|
||||
|
||||
Component[] components = gameObject.GetComponents<Component>();
|
||||
List<ComponentInfo> componentInfos = new List<ComponentInfo>();
|
||||
|
||||
foreach (Component component in components)
|
||||
{
|
||||
if (component == null)
|
||||
continue;
|
||||
|
||||
string componentTypeName = component.GetType().Name;
|
||||
ComponentPropertyInfo[] properties = _propertySerializer.SerializeProperties(component);
|
||||
|
||||
ComponentInfo info = new ComponentInfo
|
||||
{
|
||||
type = componentTypeName,
|
||||
fullTypeName = component.GetType().FullName,
|
||||
properties = properties
|
||||
};
|
||||
|
||||
componentInfos.Add(info);
|
||||
}
|
||||
|
||||
return componentInfos.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c85218799e2c4dddb513d8634c3f4fd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Value object containing GameObject search results
|
||||
/// </summary>
|
||||
public class GameObjectDetails
|
||||
{
|
||||
public bool Found { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
public GameObject GameObject { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Path { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8cc42570cd82f4d0fb30ebfcac03cc3d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Core service for finding GameObjects and extracting component information
|
||||
/// Related classes:
|
||||
/// - ComponentSerializer: Serializes component properties to ComponentInfo
|
||||
/// - FindGameObjectsCommand: API command for advanced search
|
||||
/// - GameObjectSearchFilters: Filtering logic for search operations
|
||||
/// </summary>
|
||||
public class GameObjectFinderService
|
||||
{
|
||||
|
||||
private string GetFullPath(GameObject gameObject)
|
||||
{
|
||||
if (gameObject.transform.parent == null)
|
||||
{
|
||||
return gameObject.name;
|
||||
}
|
||||
|
||||
return GetFullPath(gameObject.transform.parent.gameObject) + "/" + gameObject.name;
|
||||
}
|
||||
|
||||
public GameObjectDetails[] FindGameObjectsAdvanced(GameObjectSearchOptions options)
|
||||
{
|
||||
List<GameObjectDetails> results = new List<GameObjectDetails>();
|
||||
|
||||
// Handle hierarchy path search separately
|
||||
if (options.SearchMode == SearchMode.Path && !string.IsNullOrEmpty(options.NamePattern))
|
||||
{
|
||||
GameObject found = FindGameObjectByPath(options.NamePattern);
|
||||
if (found != null)
|
||||
{
|
||||
GameObjectDetails details = new GameObjectDetails
|
||||
{
|
||||
Found = true,
|
||||
GameObject = found,
|
||||
Name = found.name,
|
||||
Path = GetFullPath(found),
|
||||
IsActive = found.activeSelf
|
||||
};
|
||||
results.Add(details);
|
||||
}
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
// Get all root GameObjects from all loaded scenes
|
||||
List<GameObject> allGameObjects = GetAllGameObjects(options.IncludeInactive);
|
||||
|
||||
foreach (GameObject gameObject in allGameObjects)
|
||||
{
|
||||
if (MatchesAllCriteria(gameObject, options))
|
||||
{
|
||||
GameObjectDetails details = new GameObjectDetails
|
||||
{
|
||||
Found = true,
|
||||
GameObject = gameObject,
|
||||
Name = gameObject.name,
|
||||
Path = GetFullPath(gameObject),
|
||||
IsActive = gameObject.activeSelf
|
||||
};
|
||||
|
||||
results.Add(details);
|
||||
|
||||
if (results.Count >= options.MaxResults)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
private GameObject FindGameObjectByPath(string path)
|
||||
{
|
||||
// GameObject.Find() does not work in Prefab Stage; Transform.Find() intentionally
|
||||
// returns inactive children too — active/inactive filtering is handled by the caller.
|
||||
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage == null)
|
||||
{
|
||||
return GameObject.Find(path);
|
||||
}
|
||||
|
||||
GameObject prefabRoot = prefabStage.prefabContentsRoot;
|
||||
string trimmedPath = path.TrimStart('/');
|
||||
|
||||
if (trimmedPath == prefabRoot.name)
|
||||
{
|
||||
return prefabRoot;
|
||||
}
|
||||
|
||||
if (trimmedPath.StartsWith(prefabRoot.name + "/"))
|
||||
{
|
||||
string relativePath = trimmedPath.Substring(prefabRoot.name.Length + 1);
|
||||
Transform found = prefabRoot.transform.Find(relativePath);
|
||||
return found != null ? found.gameObject : null;
|
||||
}
|
||||
|
||||
Transform directFind = prefabRoot.transform.Find(trimmedPath);
|
||||
return directFind != null ? directFind.gameObject : null;
|
||||
}
|
||||
|
||||
private List<GameObject> GetAllGameObjects(bool includeInactive)
|
||||
{
|
||||
List<GameObject> allGameObjects = new List<GameObject>();
|
||||
|
||||
// Prefab Stage has its own scene not visible via SceneManager
|
||||
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
{
|
||||
GameObject prefabRoot = prefabStage.prefabContentsRoot;
|
||||
AddGameObjectAndChildren(prefabRoot, allGameObjects, includeInactive);
|
||||
return allGameObjects;
|
||||
}
|
||||
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (scene.isLoaded)
|
||||
{
|
||||
GameObject[] rootObjects = scene.GetRootGameObjects();
|
||||
foreach (GameObject root in rootObjects)
|
||||
{
|
||||
AddGameObjectAndChildren(root, allGameObjects, includeInactive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allGameObjects;
|
||||
}
|
||||
|
||||
private void AddGameObjectAndChildren(GameObject gameObject, List<GameObject> list, bool includeInactive)
|
||||
{
|
||||
if (!includeInactive && !gameObject.activeInHierarchy)
|
||||
return;
|
||||
|
||||
list.Add(gameObject);
|
||||
|
||||
foreach (Transform child in gameObject.transform)
|
||||
{
|
||||
AddGameObjectAndChildren(child.gameObject, list, includeInactive);
|
||||
}
|
||||
}
|
||||
|
||||
private bool MatchesAllCriteria(GameObject gameObject, GameObjectSearchOptions options)
|
||||
{
|
||||
// Check name pattern
|
||||
if (!GameObjectSearchFilters.MatchesNamePattern(gameObject, options.NamePattern, options.SearchMode))
|
||||
return false;
|
||||
|
||||
// Check required components
|
||||
if (!GameObjectSearchFilters.HasRequiredComponents(gameObject, options.RequiredComponents))
|
||||
return false;
|
||||
|
||||
// Check tag
|
||||
if (!GameObjectSearchFilters.MatchesTag(gameObject, options.Tag))
|
||||
return false;
|
||||
|
||||
// Check layer
|
||||
if (!GameObjectSearchFilters.MatchesLayer(gameObject, options.Layer))
|
||||
return false;
|
||||
|
||||
// Check active state
|
||||
if (!GameObjectSearchFilters.MatchesActiveState(gameObject, options.IncludeInactive))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get currently selected GameObjects in Unity Editor
|
||||
/// </summary>
|
||||
/// <param name="includeInactive">Whether to include inactive GameObjects in results</param>
|
||||
/// <returns>Array of GameObjectDetails for selected objects</returns>
|
||||
public GameObjectDetails[] FindSelectedGameObjects(bool includeInactive)
|
||||
{
|
||||
GameObject[] selectedGameObjects = Selection.gameObjects;
|
||||
|
||||
if (selectedGameObjects == null || selectedGameObjects.Length == 0)
|
||||
{
|
||||
return new GameObjectDetails[0];
|
||||
}
|
||||
|
||||
List<GameObjectDetails> results = new List<GameObjectDetails>();
|
||||
|
||||
foreach (GameObject gameObject in selectedGameObjects)
|
||||
{
|
||||
if (gameObject == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!includeInactive && !gameObject.activeInHierarchy)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GameObjectDetails details = new GameObjectDetails
|
||||
{
|
||||
Found = true,
|
||||
GameObject = gameObject,
|
||||
Name = gameObject.name,
|
||||
Path = GetFullPath(gameObject),
|
||||
IsActive = gameObject.activeSelf
|
||||
};
|
||||
|
||||
results.Add(details);
|
||||
}
|
||||
|
||||
return results.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59f87961d45464bad9bd399eec9f75ce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters for GameObject search operations
|
||||
/// </summary>
|
||||
public static class GameObjectSearchFilters
|
||||
{
|
||||
public static bool MatchesNamePattern(GameObject gameObject, string pattern, SearchMode searchMode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
return true;
|
||||
|
||||
switch (searchMode)
|
||||
{
|
||||
case SearchMode.Exact:
|
||||
return gameObject.name == pattern;
|
||||
|
||||
case SearchMode.Path:
|
||||
// Path search is handled separately in GameObjectFinderService
|
||||
return false;
|
||||
|
||||
case SearchMode.Regex:
|
||||
return Regex.IsMatch(gameObject.name, pattern);
|
||||
|
||||
case SearchMode.Contains:
|
||||
return gameObject.name.Contains(pattern);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasRequiredComponents(GameObject gameObject, string[] componentTypes)
|
||||
{
|
||||
if (componentTypes == null || componentTypes.Length == 0)
|
||||
return true;
|
||||
|
||||
foreach (string componentType in componentTypes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(componentType))
|
||||
continue;
|
||||
|
||||
Component component = gameObject.GetComponent(componentType);
|
||||
if (component == null)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool MatchesTag(GameObject gameObject, string tag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag))
|
||||
return true;
|
||||
|
||||
// Handle untagged objects
|
||||
if (gameObject.tag == "Untagged" && tag != "Untagged")
|
||||
return false;
|
||||
|
||||
return gameObject.CompareTag(tag);
|
||||
}
|
||||
|
||||
public static bool MatchesLayer(GameObject gameObject, int? layer)
|
||||
{
|
||||
if (!layer.HasValue)
|
||||
return true;
|
||||
|
||||
return gameObject.layer == layer.Value;
|
||||
}
|
||||
|
||||
public static bool MatchesActiveState(GameObject gameObject, bool includeInactive)
|
||||
{
|
||||
if (includeInactive)
|
||||
return true;
|
||||
|
||||
return gameObject.activeInHierarchy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb368ff1e6aa348498cc6806488b21ad
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for advanced GameObject search
|
||||
/// </summary>
|
||||
public class GameObjectSearchOptions
|
||||
{
|
||||
public string NamePattern { get; set; } = "";
|
||||
public SearchMode SearchMode { get; set; } = SearchMode.Exact;
|
||||
public string[] RequiredComponents { get; set; } = new string[0];
|
||||
public string Tag { get; set; } = "";
|
||||
public int? Layer { get; set; } = null;
|
||||
public bool IncludeInactive { get; set; } = false;
|
||||
public int MaxResults { get; set; } = 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e23fcae79cb2040e5881282169e08b48
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 73707137a029147528c295df48ab8a0c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Context information about the hierarchy
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class HierarchyContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of scene context
|
||||
/// </summary>
|
||||
public readonly string sceneType;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the current scene or prefab
|
||||
/// </summary>
|
||||
public readonly string sceneName;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of nodes in the hierarchy
|
||||
/// </summary>
|
||||
public readonly int nodeCount;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth found in the hierarchy
|
||||
/// </summary>
|
||||
public readonly int maxDepth;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for HierarchyContext
|
||||
/// </summary>
|
||||
public HierarchyContext(string sceneType, string sceneName, int nodeCount, int maxDepth)
|
||||
{
|
||||
this.sceneType = sceneType ?? "unknown";
|
||||
this.sceneName = sceneName ?? string.Empty;
|
||||
this.nodeCount = nodeCount;
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ed6a6e0a0fd14971b724f3ad56f8b72
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single node in the Unity Hierarchy
|
||||
/// Immutable data structure for AI-friendly JSON serialization
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class HierarchyNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Session-unique Unity object identifier
|
||||
/// </summary>
|
||||
public readonly string id;
|
||||
|
||||
/// <summary>
|
||||
/// GameObject name
|
||||
/// </summary>
|
||||
public readonly string name;
|
||||
|
||||
/// <summary>
|
||||
/// Parent node's session-unique Unity object identifier (null for root objects)
|
||||
/// </summary>
|
||||
public readonly string parent;
|
||||
|
||||
/// <summary>
|
||||
/// Depth level in hierarchy (0 for root)
|
||||
/// </summary>
|
||||
public readonly int depth;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the GameObject is active
|
||||
/// </summary>
|
||||
public readonly bool isActive;
|
||||
|
||||
/// <summary>
|
||||
/// List of component type names attached to this GameObject
|
||||
/// </summary>
|
||||
public readonly string[] components;
|
||||
|
||||
// Additional optional metadata for grouping/ordering
|
||||
public readonly int? siblingIndex;
|
||||
public readonly string tag;
|
||||
public readonly int? layer;
|
||||
|
||||
/// <summary>
|
||||
/// Scene name this GameObject belongs to
|
||||
/// </summary>
|
||||
public readonly string sceneName;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for HierarchyNode
|
||||
/// </summary>
|
||||
public HierarchyNode(string id, string name, string parent, int depth, bool isActive, string[] components, string sceneName = "", int? siblingIndex = null, string tag = null, int? layer = null)
|
||||
{
|
||||
this.id = id ?? string.Empty;
|
||||
this.name = name ?? string.Empty;
|
||||
this.parent = parent;
|
||||
this.depth = depth;
|
||||
this.isActive = isActive;
|
||||
this.components = components ?? Array.Empty<string>();
|
||||
this.sceneName = sceneName ?? string.Empty;
|
||||
this.siblingIndex = siblingIndex;
|
||||
this.tag = tag;
|
||||
this.layer = layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 655eb05c029cd4caeb1d9d467f9b4ce6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single node in the Unity Hierarchy with nested structure
|
||||
/// Immutable data structure for AI-friendly JSON serialization with direct parent-child relationships
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class HierarchyNodeNested
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject name
|
||||
/// </summary>
|
||||
public readonly string name;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the GameObject is active
|
||||
/// </summary>
|
||||
public readonly bool isActive;
|
||||
|
||||
/// <summary>
|
||||
/// List of component type names attached to this GameObject
|
||||
/// </summary>
|
||||
public string[] components;
|
||||
public int[] componentsIdx;
|
||||
|
||||
/// <summary>
|
||||
/// Child nodes directly nested under this node
|
||||
/// </summary>
|
||||
[JsonProperty(Order = 1000)]
|
||||
public readonly List<HierarchyNodeNested> children;
|
||||
|
||||
// Optional compact metadata
|
||||
public readonly int? siblingIndex;
|
||||
public readonly string tag;
|
||||
public readonly int? layer;
|
||||
public string path;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for HierarchyNodeNested
|
||||
/// </summary>
|
||||
public HierarchyNodeNested(string name, bool isActive, string[] components, List<HierarchyNodeNested> children = null, int? siblingIndex = null, string tag = null, int? layer = null, string path = null, int[] componentsIdx = null)
|
||||
{
|
||||
this.name = name ?? string.Empty;
|
||||
this.isActive = isActive;
|
||||
this.components = components ?? Array.Empty<string>();
|
||||
this.componentsIdx = componentsIdx;
|
||||
this.children = children ?? new List<HierarchyNodeNested>();
|
||||
this.siblingIndex = siblingIndex;
|
||||
this.tag = tag;
|
||||
this.layer = layer;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae969ca0e3c5d4b7e8e5af3e7c1f4380
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for hierarchy traversal
|
||||
/// </summary>
|
||||
public class HierarchyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Include inactive GameObjects in the result
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth to traverse (-1 for unlimited)
|
||||
/// </summary>
|
||||
public int MaxDepth { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Root path to start traversal from (null for all root objects)
|
||||
/// </summary>
|
||||
public string RootPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Include component information in the result
|
||||
/// </summary>
|
||||
public bool IncludeComponents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Use selected GameObject(s) as root(s) for hierarchy traversal.
|
||||
/// When true, RootPath is ignored.
|
||||
/// </summary>
|
||||
public bool UseSelection { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 714d16d365bf34784af2c3f6105c0ae2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializer for converting flat hierarchy nodes to scene-grouped nested structures
|
||||
/// Related classes:
|
||||
/// - HierarchyNode: Flat hierarchy structure (collected by service)
|
||||
/// - HierarchyNodeNested: Nested hierarchy structure (export model)
|
||||
/// - SceneHierarchyGroup: Scene-grouped container with stats and LUTs (export model)
|
||||
/// </summary>
|
||||
public class HierarchySerializer
|
||||
{
|
||||
public HierarchySerializationResult BuildGroups(List<HierarchyNode> nodes, HierarchyContext context, HierarchySerializationOptions options)
|
||||
{
|
||||
if (nodes == null)
|
||||
{
|
||||
nodes = new List<HierarchyNode>();
|
||||
}
|
||||
|
||||
if (options == null)
|
||||
{
|
||||
options = new HierarchySerializationOptions();
|
||||
}
|
||||
|
||||
int nodeCount = nodes.Count;
|
||||
int maxDepth = nodes.Any() ? nodes.Max(n => n.depth) : 0;
|
||||
|
||||
HierarchyContext updatedContext = new HierarchyContext(
|
||||
context.sceneType,
|
||||
context.sceneName,
|
||||
nodeCount,
|
||||
maxDepth
|
||||
);
|
||||
|
||||
// Group by scene
|
||||
List<SceneHierarchyGroup> groups = nodes
|
||||
.GroupBy(n => n.sceneName)
|
||||
.Select(g => BuildGroupForScene(g.Key ?? string.Empty, g.ToList(), options))
|
||||
.ToList();
|
||||
|
||||
return new HierarchySerializationResult(groups, updatedContext);
|
||||
}
|
||||
|
||||
private SceneHierarchyGroup BuildGroupForScene(string sceneName, List<HierarchyNode> sceneNodes, HierarchySerializationOptions options)
|
||||
{
|
||||
// Build nested structure per scene
|
||||
Dictionary<string, HierarchyNodeNested> nodeDict = new Dictionary<string, HierarchyNodeNested>();
|
||||
|
||||
foreach (HierarchyNode flat in sceneNodes)
|
||||
{
|
||||
HierarchyNodeNested nested = new HierarchyNodeNested(
|
||||
name: flat.name,
|
||||
isActive: flat.isActive,
|
||||
components: flat.components,
|
||||
children: null,
|
||||
siblingIndex: flat.siblingIndex,
|
||||
tag: flat.tag,
|
||||
layer: flat.layer
|
||||
);
|
||||
nodeDict[flat.id] = nested;
|
||||
}
|
||||
|
||||
List<HierarchyNodeNested> roots = new List<HierarchyNodeNested>();
|
||||
foreach (HierarchyNode flat in sceneNodes)
|
||||
{
|
||||
HierarchyNodeNested nested = nodeDict[flat.id];
|
||||
if (flat.parent == null)
|
||||
{
|
||||
roots.Add(nested);
|
||||
}
|
||||
else if (nodeDict.TryGetValue(flat.parent, out HierarchyNodeNested parentNested))
|
||||
{
|
||||
parentNested.children.Add(nested);
|
||||
}
|
||||
else
|
||||
{
|
||||
roots.Add(nested);
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
int rootCount = roots.Count;
|
||||
int groupNodeCount = sceneNodes.Count;
|
||||
int groupMaxDepth = sceneNodes.Any() ? sceneNodes.Max(n => n.depth) : 0;
|
||||
SceneHierarchyStats stats = new SceneHierarchyStats(rootCount, groupNodeCount, groupMaxDepth);
|
||||
|
||||
// Optional LUTs
|
||||
List<string> componentsLut = null;
|
||||
if (ShouldUseComponentsLut(options, sceneNodes))
|
||||
{
|
||||
componentsLut = BuildComponentsLutAndApply(sceneNodes, nodeDict);
|
||||
}
|
||||
|
||||
if (options.IncludePaths)
|
||||
{
|
||||
// Assign direct string paths per node; no LUT
|
||||
AssignStringPaths(roots);
|
||||
}
|
||||
|
||||
return new SceneHierarchyGroup(sceneName, stats, roots, componentsLut);
|
||||
}
|
||||
|
||||
private static bool ShouldUseComponentsLut(HierarchySerializationOptions options, List<HierarchyNode> sceneNodes)
|
||||
{
|
||||
if (options == null) return false;
|
||||
if (options.UseComponentsLut == "true") return true;
|
||||
if (options.UseComponentsLut == "false") return false;
|
||||
// auto heuristic: high duplication of component names
|
||||
List<string> all = new List<string>();
|
||||
foreach (var n in sceneNodes)
|
||||
{
|
||||
if (n.components != null && n.components.Length > 0)
|
||||
{
|
||||
all.AddRange(n.components);
|
||||
}
|
||||
}
|
||||
if (all.Count < 50) return false;
|
||||
int unique = all.Distinct().Count();
|
||||
return unique * 2 < all.Count; // more than 50% duplicates
|
||||
}
|
||||
|
||||
private static List<string> BuildComponentsLutAndApply(List<HierarchyNode> sceneNodes, Dictionary<string, HierarchyNodeNested> nodeDict)
|
||||
{
|
||||
Dictionary<string, int> lutIndex = new Dictionary<string, int>();
|
||||
List<string> lut = new List<string>();
|
||||
|
||||
foreach (HierarchyNode flat in sceneNodes)
|
||||
{
|
||||
if (flat.components == null) continue;
|
||||
int[] idx = new int[flat.components.Length];
|
||||
for (int i = 0; i < flat.components.Length; i++)
|
||||
{
|
||||
string comp = flat.components[i];
|
||||
if (!lutIndex.TryGetValue(comp, out int index))
|
||||
{
|
||||
index = lut.Count;
|
||||
lutIndex[comp] = index;
|
||||
lut.Add(comp);
|
||||
}
|
||||
idx[i] = index;
|
||||
}
|
||||
|
||||
HierarchyNodeNested nested = nodeDict[flat.id];
|
||||
nested.componentsIdx = idx;
|
||||
nested.components = null;
|
||||
}
|
||||
|
||||
return lut;
|
||||
}
|
||||
|
||||
private static void AssignStringPaths(List<HierarchyNodeNested> roots)
|
||||
{
|
||||
void Traverse(HierarchyNodeNested node, string parentPath)
|
||||
{
|
||||
string path = string.IsNullOrEmpty(parentPath) ? node.name : parentPath + "/" + node.name;
|
||||
node.path = path;
|
||||
|
||||
foreach (HierarchyNodeNested child in node.children)
|
||||
{
|
||||
Traverse(child, path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (HierarchyNodeNested root in roots)
|
||||
{
|
||||
Traverse(root, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class HierarchySerializationOptions
|
||||
{
|
||||
public bool IncludePaths { get; set; }
|
||||
public string UseComponentsLut { get; set; } = "auto"; // auto|true|false
|
||||
}
|
||||
|
||||
public class HierarchySerializationResult
|
||||
{
|
||||
public readonly List<SceneHierarchyGroup> Groups;
|
||||
public readonly HierarchyContext Context;
|
||||
|
||||
public HierarchySerializationResult(List<SceneHierarchyGroup> groups, HierarchyContext context)
|
||||
{
|
||||
this.Groups = groups ?? new List<SceneHierarchyGroup>();
|
||||
this.Context = context;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c91e560ecb964cf1b0f7df1451175c4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,395 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for retrieving Unity Hierarchy information
|
||||
/// Reusable logic separated from command implementation
|
||||
/// </summary>
|
||||
public class HierarchyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all hierarchy nodes based on options
|
||||
/// </summary>
|
||||
public List<HierarchyNode> GetHierarchyNodes(HierarchyOptions options)
|
||||
{
|
||||
List<HierarchyNode> nodes = new List<HierarchyNode>();
|
||||
GameObject[] rootObjects = options.UseSelection
|
||||
? GetSelectedRootGameObjects()
|
||||
: GetRootGameObjects(options.RootPath);
|
||||
|
||||
foreach (GameObject root in rootObjects)
|
||||
{
|
||||
if (!options.IncludeInactive && !root.activeInHierarchy)
|
||||
continue;
|
||||
|
||||
TraverseHierarchy(root, null, 0, options, nodes);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get selected GameObjects as roots, filtering out descendants
|
||||
/// to avoid duplicate traversal when parent and child are both selected.
|
||||
/// </summary>
|
||||
private GameObject[] GetSelectedRootGameObjects()
|
||||
{
|
||||
GameObject[] selected = Selection.gameObjects;
|
||||
if (selected == null || selected.Length == 0)
|
||||
{
|
||||
return System.Array.Empty<GameObject>();
|
||||
}
|
||||
|
||||
List<GameObject> roots = new List<GameObject>();
|
||||
foreach (GameObject obj in selected)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isDescendantOfAnotherSelected = false;
|
||||
foreach (GameObject other in selected)
|
||||
{
|
||||
if (other == null || other == obj)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj.transform.IsChildOf(other.transform))
|
||||
{
|
||||
isDescendantOfAnotherSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDescendantOfAnotherSelected)
|
||||
{
|
||||
roots.Add(obj);
|
||||
}
|
||||
}
|
||||
|
||||
return roots.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current scene context information
|
||||
/// </summary>
|
||||
public HierarchyContext GetCurrentContext()
|
||||
{
|
||||
string sceneType = "editor";
|
||||
string sceneName = "Unknown";
|
||||
|
||||
// Check if in Prefab Edit Mode
|
||||
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
{
|
||||
sceneType = "prefab";
|
||||
sceneName = prefabStage.assetPath;
|
||||
}
|
||||
else if (Application.isPlaying)
|
||||
{
|
||||
sceneType = "runtime";
|
||||
sceneName = BuildSceneNameSummary();
|
||||
}
|
||||
else
|
||||
{
|
||||
sceneType = "editor";
|
||||
sceneName = BuildSceneNameSummary();
|
||||
}
|
||||
|
||||
return new HierarchyContext(sceneType, sceneName, 0, 0);
|
||||
}
|
||||
|
||||
private string BuildSceneNameSummary()
|
||||
{
|
||||
int count = SceneManager.sceneCount;
|
||||
if (count <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
System.Collections.Generic.List<string> names = new System.Collections.Generic.List<string>();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (scene.isLoaded)
|
||||
{
|
||||
names.Add(scene.name);
|
||||
}
|
||||
}
|
||||
|
||||
GameObject[] ddolRoots = GetDontDestroyOnLoadRootObjects();
|
||||
if (ddolRoots.Length > 0 && !names.Contains("DontDestroyOnLoad"))
|
||||
{
|
||||
names.Add("DontDestroyOnLoad");
|
||||
}
|
||||
|
||||
if (names.Count <= 1)
|
||||
{
|
||||
return names.Count == 1 ? names[0] : string.Empty;
|
||||
}
|
||||
|
||||
string joined = string.Join(", ", names.ToArray());
|
||||
return $"Multiple({names.Count}): {joined}";
|
||||
}
|
||||
|
||||
private GameObject[] GetRootGameObjects(string rootPath)
|
||||
{
|
||||
// Check if in Prefab Edit Mode
|
||||
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
{
|
||||
GameObject prefabRoot = prefabStage.prefabContentsRoot;
|
||||
if (!string.IsNullOrEmpty(rootPath))
|
||||
{
|
||||
if (prefabRoot.name == rootPath)
|
||||
{
|
||||
return new[] { prefabRoot };
|
||||
}
|
||||
|
||||
string localPath = NormalizeRootRelativePath(rootPath, prefabRoot.name);
|
||||
Transform found = string.IsNullOrEmpty(localPath)
|
||||
? prefabRoot.transform
|
||||
: prefabRoot.transform.Find(localPath);
|
||||
if (found != null)
|
||||
{
|
||||
return new[] { found.gameObject };
|
||||
}
|
||||
|
||||
return System.Array.Empty<GameObject>();
|
||||
}
|
||||
return new[] { prefabRoot };
|
||||
}
|
||||
|
||||
// Normal scene mode: iterate all loaded scenes (additive included)
|
||||
List<GameObject> results = new List<GameObject>();
|
||||
GameObject[] ddolRoots = GetDontDestroyOnLoadRootObjects();
|
||||
|
||||
int sceneCount = SceneManager.sceneCount;
|
||||
if (!string.IsNullOrEmpty(rootPath))
|
||||
{
|
||||
for (int i = 0; i < sceneCount; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (!scene.isLoaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GameObject[] roots = scene.GetRootGameObjects();
|
||||
AppendMatchesForSceneRoot(results, roots, rootPath);
|
||||
}
|
||||
|
||||
if (ddolRoots.Length > 0)
|
||||
{
|
||||
AppendMatchesForSceneRoot(results, ddolRoots, rootPath);
|
||||
}
|
||||
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
for (int i = 0; i < sceneCount; i++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(i);
|
||||
if (!scene.isLoaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GameObject[] roots = scene.GetRootGameObjects();
|
||||
results.AddRange(roots);
|
||||
}
|
||||
|
||||
if (ddolRoots.Length > 0)
|
||||
{
|
||||
results.AddRange(ddolRoots);
|
||||
}
|
||||
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeRootRelativePath(string rootPath, string rootName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rootPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string trimmed = rootPath.TrimStart('/');
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith(rootName + "/"))
|
||||
{
|
||||
return trimmed.Substring(rootName.Length + 1);
|
||||
}
|
||||
|
||||
if (trimmed == rootName)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static void AppendMatchesForSceneRoot(System.Collections.Generic.List<GameObject> results, GameObject[] roots, string rootPath)
|
||||
{
|
||||
if (roots == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < roots.Length; i++)
|
||||
{
|
||||
GameObject root = roots[i];
|
||||
if (root == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.name == rootPath)
|
||||
{
|
||||
results.Add(root);
|
||||
continue;
|
||||
}
|
||||
|
||||
string localPath = NormalizeRootRelativePath(rootPath, root.name);
|
||||
if (string.IsNullOrEmpty(localPath))
|
||||
{
|
||||
results.Add(root);
|
||||
continue;
|
||||
}
|
||||
|
||||
Transform found = root.transform.Find(localPath);
|
||||
if (found != null)
|
||||
{
|
||||
results.Add(found.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static GameObject[] GetDontDestroyOnLoadRootObjects()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
return System.Array.Empty<GameObject>();
|
||||
}
|
||||
|
||||
GameObject probe = null;
|
||||
try
|
||||
{
|
||||
probe = new GameObject("__mcp_ddol_probe__");
|
||||
UnityEngine.Object.DontDestroyOnLoad(probe);
|
||||
|
||||
Scene ddolScene = probe.scene;
|
||||
if (!ddolScene.IsValid())
|
||||
{
|
||||
return System.Array.Empty<GameObject>();
|
||||
}
|
||||
|
||||
GameObject[] roots = ddolScene.GetRootGameObjects();
|
||||
if (roots == null || roots.Length == 0)
|
||||
{
|
||||
return System.Array.Empty<GameObject>();
|
||||
}
|
||||
|
||||
System.Collections.Generic.List<GameObject> filtered = new System.Collections.Generic.List<GameObject>();
|
||||
for (int i = 0; i < roots.Length; i++)
|
||||
{
|
||||
GameObject root = roots[i];
|
||||
if (root == null || root == probe)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.Add(root);
|
||||
}
|
||||
|
||||
return filtered.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (probe != null)
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
UnityEngine.Object.Destroy(probe);
|
||||
}
|
||||
else
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(probe);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private void TraverseHierarchy(GameObject obj, string parentId, int depth, HierarchyOptions options, List<HierarchyNode> nodes)
|
||||
{
|
||||
// Check depth limit
|
||||
if (options.MaxDepth >= 0 && depth > options.MaxDepth)
|
||||
return;
|
||||
|
||||
// Get components
|
||||
string[] componentNames = new string[0];
|
||||
if (options.IncludeComponents)
|
||||
{
|
||||
Component[] components = obj.GetComponents<Component>();
|
||||
componentNames = components
|
||||
.Where(c => c != null)
|
||||
.Select(c => c.GetType().Name)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// Create node
|
||||
string currentId = GetObjectId(obj);
|
||||
HierarchyNode node = new HierarchyNode(
|
||||
id: currentId,
|
||||
name: obj.name,
|
||||
parent: parentId,
|
||||
depth: depth,
|
||||
isActive: obj.activeSelf,
|
||||
components: componentNames,
|
||||
sceneName: obj.scene.name,
|
||||
siblingIndex: obj.transform.GetSiblingIndex(),
|
||||
tag: obj.tag,
|
||||
layer: obj.layer
|
||||
);
|
||||
|
||||
nodes.Add(node);
|
||||
|
||||
// Traverse children
|
||||
foreach (Transform child in obj.transform)
|
||||
{
|
||||
if (!options.IncludeInactive && !child.gameObject.activeInHierarchy)
|
||||
continue;
|
||||
|
||||
TraverseHierarchy(child.gameObject, currentId, depth + 1, options, nodes);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetObjectId(UnityEngine.Object obj)
|
||||
{
|
||||
UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier.");
|
||||
|
||||
#if UNITY_6000_4_OR_NEWER
|
||||
ulong entityId = UnityEngine.EntityId.ToULong(obj.GetEntityId());
|
||||
return entityId.ToString(CultureInfo.InvariantCulture);
|
||||
#else
|
||||
int instanceId = obj.GetInstanceID();
|
||||
return instanceId.ToString(CultureInfo.InvariantCulture);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9217527cf4dd54605b9f1269a129523a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene-level grouping of hierarchy nodes for AI-friendly JSON export
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SceneHierarchyGroup
|
||||
{
|
||||
public readonly string sceneName;
|
||||
|
||||
public readonly SceneHierarchyStats stats;
|
||||
|
||||
// Optional: lookup table for component names to reduce redundancy
|
||||
public readonly List<string> componentsLut;
|
||||
|
||||
public readonly List<HierarchyNodeNested> roots;
|
||||
|
||||
public SceneHierarchyGroup(
|
||||
string sceneName,
|
||||
SceneHierarchyStats stats,
|
||||
List<HierarchyNodeNested> roots,
|
||||
List<string> componentsLut = null)
|
||||
{
|
||||
this.sceneName = sceneName ?? string.Empty;
|
||||
this.stats = stats ?? new SceneHierarchyStats(0, 0, 0);
|
||||
this.roots = roots ?? new List<HierarchyNodeNested>();
|
||||
this.componentsLut = componentsLut; // may be null when not used
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SceneHierarchyStats
|
||||
{
|
||||
public readonly int rootCount;
|
||||
public readonly int nodeCount;
|
||||
public readonly int maxDepth;
|
||||
|
||||
public SceneHierarchyStats(int rootCount, int nodeCount, int maxDepth)
|
||||
{
|
||||
this.rootCount = rootCount;
|
||||
this.nodeCount = nodeCount;
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76f1fbd4e38ca460eb431f6379ead8bc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 275b65ca4254f4f68a23f32857120920
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,271 @@
|
||||
#if ULOOPMCP_HAS_TEST_FRAMEWORK
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using UnityEditor;
|
||||
using UnityEditor.TestTools.TestRunner.Api;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Class to export test results as NUnit XML.
|
||||
/// </summary>
|
||||
public static class NUnitXmlResultExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs the test result as XML.
|
||||
/// </summary>
|
||||
public static void LogTestResultAsXml(ITestResultAdaptor testResult)
|
||||
{
|
||||
GenerateNUnitXml(testResult);
|
||||
// Test Result XML generated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the test result as an XML file.
|
||||
/// </summary>
|
||||
public static string SaveTestResultAsXml(ITestResultAdaptor testResult)
|
||||
{
|
||||
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
string fileName = $"{timestamp}.xml";
|
||||
|
||||
// Save to .uloop/outputs/TestResults folder at project root (same level as Assets)
|
||||
DirectoryInfo parentDir = Directory.GetParent(Application.dataPath);
|
||||
if (parentDir == null)
|
||||
{
|
||||
throw new System.InvalidOperationException("Unable to determine project root directory");
|
||||
}
|
||||
string projectRoot = parentDir.FullName;
|
||||
string testResultsDir = Path.Combine(projectRoot, McpConstants.OUTPUT_ROOT_DIR, McpConstants.TEST_RESULTS_DIR);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!Directory.Exists(testResultsDir))
|
||||
{
|
||||
Directory.CreateDirectory(testResultsDir);
|
||||
}
|
||||
|
||||
string filePath = Path.Combine(testResultsDir, fileName);
|
||||
string xmlContent = GenerateNUnitXml(testResult);
|
||||
File.WriteAllText(filePath, xmlContent, Encoding.UTF8);
|
||||
|
||||
// Refresh Assets folder.
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
// Test result XML saved
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates NUnit format XML.
|
||||
/// </summary>
|
||||
private static string GenerateNUnitXml(ITestResultAdaptor testResult)
|
||||
{
|
||||
XmlDocument doc = new XmlDocument();
|
||||
|
||||
// Add XML declaration.
|
||||
XmlDeclaration declaration = doc.CreateXmlDeclaration("1.0", "UTF-8", null);
|
||||
doc.AppendChild(declaration);
|
||||
|
||||
// Create root element.
|
||||
XmlElement testRun = doc.CreateElement("test-run");
|
||||
testRun.SetAttribute("id", "2");
|
||||
testRun.SetAttribute("testcasecount", CountTestCases(testResult).ToString());
|
||||
testRun.SetAttribute("result", GetOverallResult(testResult));
|
||||
testRun.SetAttribute("total", CountTestCases(testResult).ToString());
|
||||
testRun.SetAttribute("passed", CountPassed(testResult).ToString());
|
||||
testRun.SetAttribute("failed", CountFailed(testResult).ToString());
|
||||
testRun.SetAttribute("skipped", CountSkipped(testResult).ToString());
|
||||
testRun.SetAttribute("start-time", testResult.StartTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
testRun.SetAttribute("end-time", testResult.EndTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
testRun.SetAttribute("duration", testResult.Duration.ToString("F3"));
|
||||
doc.AppendChild(testRun);
|
||||
|
||||
// Add test suite.
|
||||
XmlElement testSuite = CreateTestSuiteElement(doc, testResult);
|
||||
testRun.AppendChild(testSuite);
|
||||
|
||||
// Return formatted XML.
|
||||
using (StringWriter stringWriter = new StringWriter())
|
||||
{
|
||||
XmlWriterSettings settings = new XmlWriterSettings
|
||||
{
|
||||
Indent = true,
|
||||
IndentChars = " ",
|
||||
NewLineChars = "\n",
|
||||
NewLineHandling = NewLineHandling.Replace
|
||||
};
|
||||
|
||||
using (XmlWriter xmlWriter = XmlWriter.Create(stringWriter, settings))
|
||||
{
|
||||
doc.Save(xmlWriter);
|
||||
}
|
||||
|
||||
return stringWriter.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test suite element (recursive).
|
||||
/// </summary>
|
||||
private static XmlElement CreateTestSuiteElement(XmlDocument doc, ITestResultAdaptor result)
|
||||
{
|
||||
if (result.Test.IsSuite)
|
||||
{
|
||||
XmlElement suite = doc.CreateElement("test-suite");
|
||||
suite.SetAttribute("type", result.Test.TypeInfo?.FullName ?? "TestSuite");
|
||||
suite.SetAttribute("name", result.Test.Name);
|
||||
suite.SetAttribute("fullname", result.Test.FullName);
|
||||
suite.SetAttribute("result", result.TestStatus.ToString());
|
||||
suite.SetAttribute("start-time", result.StartTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
suite.SetAttribute("end-time", result.EndTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
suite.SetAttribute("duration", result.Duration.ToString("F3"));
|
||||
suite.SetAttribute("total", CountTestCases(result).ToString());
|
||||
suite.SetAttribute("passed", CountPassed(result).ToString());
|
||||
suite.SetAttribute("failed", CountFailed(result).ToString());
|
||||
suite.SetAttribute("skipped", CountSkipped(result).ToString());
|
||||
|
||||
// Add child elements.
|
||||
if (result.Children != null)
|
||||
{
|
||||
foreach (ITestResultAdaptor child in result.Children)
|
||||
{
|
||||
XmlElement childElement = CreateTestSuiteElement(doc, child);
|
||||
suite.AppendChild(childElement);
|
||||
}
|
||||
}
|
||||
|
||||
return suite;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Test case.
|
||||
XmlElement testCase = doc.CreateElement("test-case");
|
||||
testCase.SetAttribute("name", result.Test.Name);
|
||||
testCase.SetAttribute("fullname", result.Test.FullName);
|
||||
testCase.SetAttribute("result", result.TestStatus.ToString());
|
||||
testCase.SetAttribute("start-time", result.StartTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
testCase.SetAttribute("end-time", result.EndTime.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
testCase.SetAttribute("duration", result.Duration.ToString("F3"));
|
||||
|
||||
// If failed, add message and stack trace.
|
||||
if (result.TestStatus == TestStatus.Failed)
|
||||
{
|
||||
XmlElement failure = doc.CreateElement("failure");
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Message))
|
||||
{
|
||||
XmlElement message = doc.CreateElement("message");
|
||||
message.InnerText = result.Message;
|
||||
failure.AppendChild(message);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.StackTrace))
|
||||
{
|
||||
XmlElement stackTrace = doc.CreateElement("stack-trace");
|
||||
stackTrace.InnerText = result.StackTrace;
|
||||
failure.AppendChild(stackTrace);
|
||||
}
|
||||
|
||||
testCase.AppendChild(failure);
|
||||
}
|
||||
|
||||
return testCase;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the overall result.
|
||||
/// </summary>
|
||||
private static string GetOverallResult(ITestResultAdaptor result)
|
||||
{
|
||||
if (CountFailed(result) > 0)
|
||||
return "Failed";
|
||||
if (CountSkipped(result) > 0 && CountPassed(result) == 0)
|
||||
return "Skipped";
|
||||
return "Passed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of test cases.
|
||||
/// </summary>
|
||||
private static int CountTestCases(ITestResultAdaptor result)
|
||||
{
|
||||
if (!result.Test.IsSuite)
|
||||
return 1;
|
||||
|
||||
int count = 0;
|
||||
if (result.Children != null)
|
||||
{
|
||||
foreach (ITestResultAdaptor child in result.Children)
|
||||
{
|
||||
count += CountTestCases(child);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of passed tests.
|
||||
/// </summary>
|
||||
private static int CountPassed(ITestResultAdaptor result)
|
||||
{
|
||||
if (!result.Test.IsSuite)
|
||||
return result.TestStatus == TestStatus.Passed ? 1 : 0;
|
||||
|
||||
int count = 0;
|
||||
if (result.Children != null)
|
||||
{
|
||||
foreach (ITestResultAdaptor child in result.Children)
|
||||
{
|
||||
count += CountPassed(child);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of failed tests.
|
||||
/// </summary>
|
||||
private static int CountFailed(ITestResultAdaptor result)
|
||||
{
|
||||
if (!result.Test.IsSuite)
|
||||
return result.TestStatus == TestStatus.Failed ? 1 : 0;
|
||||
|
||||
int count = 0;
|
||||
if (result.Children != null)
|
||||
{
|
||||
foreach (ITestResultAdaptor child in result.Children)
|
||||
{
|
||||
count += CountFailed(child);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of skipped tests.
|
||||
/// </summary>
|
||||
private static int CountSkipped(ITestResultAdaptor result)
|
||||
{
|
||||
if (!result.Test.IsSuite)
|
||||
return result.TestStatus == TestStatus.Skipped ? 1 : 0;
|
||||
|
||||
int count = 0;
|
||||
if (result.Children != null)
|
||||
{
|
||||
foreach (ITestResultAdaptor child in result.Children)
|
||||
{
|
||||
count += CountSkipped(child);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0805925b4a65547c6b8f44ab274cd917
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,145 @@
|
||||
#if ULOOPMCP_HAS_TEST_FRAMEWORK
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor.TestTools.TestRunner.Api;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Unified test executor with domain reload control and filtering support
|
||||
/// Supports both EditMode and PlayMode tests with advanced filtering capabilities
|
||||
/// </summary>
|
||||
public static class PlayModeTestExecuter
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute PlayMode tests asynchronously with domain reload control
|
||||
/// </summary>
|
||||
/// <param name="filter">Test execution filter (null for all tests)</param>
|
||||
/// <returns>Test execution result</returns>
|
||||
public static async Task<SerializableTestResult> ExecutePlayModeTest(
|
||||
TestExecutionFilter filter = null)
|
||||
{
|
||||
using DomainReloadDisableScope scope = new();
|
||||
return await ExecuteTestWithEventNotification(TestMode.PlayMode, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute EditMode tests asynchronously
|
||||
/// </summary>
|
||||
/// <param name="filter">Test execution filter (null for all tests)</param>
|
||||
/// <returns>Test execution result</returns>
|
||||
public static async Task<SerializableTestResult> ExecuteEditModeTest(
|
||||
TestExecutionFilter filter = null)
|
||||
{
|
||||
return await ExecuteTestWithEventNotification(TestMode.EditMode, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute test with event-based result notification
|
||||
/// </summary>
|
||||
private static async Task<SerializableTestResult> ExecuteTestWithEventNotification(
|
||||
TestMode testMode,
|
||||
TestExecutionFilter filter)
|
||||
{
|
||||
TaskCompletionSource<SerializableTestResult> tcs = new();
|
||||
|
||||
using UnifiedTestCallback callback = new();
|
||||
callback.OnTestCompleted += (result, rawResult) =>
|
||||
{
|
||||
if (!tcs.Task.IsCompleted)
|
||||
{
|
||||
// Auto-save XML when tests fail for detailed failure diagnosis
|
||||
if (result.failedCount > 0 && rawResult != null)
|
||||
{
|
||||
result.xmlPath = NUnitXmlResultExporter.SaveTestResultAsXml(rawResult);
|
||||
}
|
||||
tcs.SetResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
StartTestExecution(testMode, filter, callback);
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start test execution with callback handler
|
||||
/// </summary>
|
||||
private static void StartTestExecution(TestMode testMode, TestExecutionFilter filter, UnifiedTestCallback callback)
|
||||
{
|
||||
TestRunnerApi testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();
|
||||
callback.SetTestRunnerApi(testRunnerApi);
|
||||
testRunnerApi.RegisterCallbacks(callback);
|
||||
|
||||
Filter unityFilter = CreateUnityFilter(testMode, filter);
|
||||
testRunnerApi.Execute(new ExecutionSettings(unityFilter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create Unity Filter from TestExecutionFilter
|
||||
/// </summary>
|
||||
private static Filter CreateUnityFilter(TestMode testMode, TestExecutionFilter filter)
|
||||
{
|
||||
Filter unityFilter = new Filter
|
||||
{
|
||||
testMode = testMode
|
||||
};
|
||||
|
||||
if (filter != null && filter.FilterType != TestExecutionFilterType.All)
|
||||
{
|
||||
switch (filter.FilterType)
|
||||
{
|
||||
case TestExecutionFilterType.Exact:
|
||||
// Use testNames for exact matching (individual test methods)
|
||||
unityFilter.testNames = new string[] { filter.FilterValue };
|
||||
break;
|
||||
case TestExecutionFilterType.Regex:
|
||||
// Use groupNames with regex for pattern matching (classes, namespaces)
|
||||
unityFilter.groupNames = new string[] { filter.FilterValue };
|
||||
break;
|
||||
case TestExecutionFilterType.AssemblyName:
|
||||
unityFilter.assemblyNames = new string[] { filter.FilterValue };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return unityFilter;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified callback handler for both EditMode and PlayMode test execution
|
||||
/// </summary>
|
||||
internal class UnifiedTestCallback : ICallbacks, IDisposable
|
||||
{
|
||||
public event Action<SerializableTestResult, ITestResultAdaptor> OnTestCompleted;
|
||||
|
||||
private TestRunnerApi _testRunnerApi;
|
||||
|
||||
public void SetTestRunnerApi(TestRunnerApi testRunnerApi)
|
||||
{
|
||||
_testRunnerApi = testRunnerApi;
|
||||
}
|
||||
|
||||
public void RunStarted(ITestAdaptor tests)
|
||||
{
|
||||
}
|
||||
|
||||
public void RunFinished(ITestResultAdaptor result)
|
||||
{
|
||||
SerializableTestResult serializableResult = SerializableTestResult.FromTestResult(result);
|
||||
OnTestCompleted?.Invoke(serializableResult, result);
|
||||
}
|
||||
|
||||
public void TestStarted(ITestAdaptor test) { }
|
||||
|
||||
public void TestFinished(ITestResultAdaptor result) { }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
OnTestCompleted = null;
|
||||
_testRunnerApi?.UnregisterCallbacks(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d2e59a7065014da0a2a747d461a264a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
#if ULOOPMCP_HAS_TEST_FRAMEWORK
|
||||
using UnityEditor.TestTools.TestRunner.Api;
|
||||
#endif
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializable test result for domain reload survival
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SerializableTestResult
|
||||
{
|
||||
[SerializeField] public bool success;
|
||||
[SerializeField] public string message;
|
||||
[SerializeField] public string completedAt;
|
||||
[SerializeField] public int testCount;
|
||||
[SerializeField] public int passedCount;
|
||||
[SerializeField] public int failedCount;
|
||||
[SerializeField] public int skippedCount;
|
||||
[SerializeField] public string xmlPath;
|
||||
|
||||
public static SerializableTestResult CreateTestFrameworkUnavailable()
|
||||
{
|
||||
return new SerializableTestResult
|
||||
{
|
||||
success = false,
|
||||
message = RunTestsResponse.TestFrameworkUnavailableMessage,
|
||||
completedAt = DateTime.UtcNow.ToString("o"),
|
||||
testCount = 0,
|
||||
passedCount = 0,
|
||||
failedCount = 0,
|
||||
skippedCount = 0,
|
||||
xmlPath = null
|
||||
};
|
||||
}
|
||||
|
||||
#if ULOOPMCP_HAS_TEST_FRAMEWORK
|
||||
/// <summary>
|
||||
/// Convert from ITestResultAdaptor to SerializableTestResult
|
||||
/// </summary>
|
||||
public static SerializableTestResult FromTestResult(ITestResultAdaptor result)
|
||||
{
|
||||
if (result == null)
|
||||
{
|
||||
return new SerializableTestResult
|
||||
{
|
||||
success = true,
|
||||
message = "PlayMode test execution completed (detailed results not available)",
|
||||
completedAt = DateTime.UtcNow.ToString("o"),
|
||||
testCount = 1,
|
||||
passedCount = 1,
|
||||
failedCount = 0,
|
||||
skippedCount = 0,
|
||||
xmlPath = null
|
||||
};
|
||||
}
|
||||
|
||||
int totalTests = CountTotalTests(result);
|
||||
int passedTests = CountPassedTests(result);
|
||||
int failedTests = CountFailedTests(result);
|
||||
int skippedTests = CountSkippedTests(result);
|
||||
|
||||
string message;
|
||||
bool success;
|
||||
|
||||
if (totalTests == 0)
|
||||
{
|
||||
message = "No tests found matching the specified filter criteria";
|
||||
success = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
message = $"Test execution completed with status: {result.TestStatus}";
|
||||
success = result.TestStatus == TestStatus.Passed;
|
||||
}
|
||||
|
||||
return new SerializableTestResult
|
||||
{
|
||||
success = success,
|
||||
message = message,
|
||||
completedAt = DateTime.UtcNow.ToString("o"),
|
||||
testCount = totalTests,
|
||||
passedCount = passedTests,
|
||||
failedCount = failedTests,
|
||||
skippedCount = skippedTests,
|
||||
xmlPath = null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count total tests
|
||||
/// </summary>
|
||||
private static int CountTotalTests(ITestResultAdaptor result)
|
||||
{
|
||||
int count = 0;
|
||||
CountTestsByStatus(result, ref count, null);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count passed tests
|
||||
/// </summary>
|
||||
private static int CountPassedTests(ITestResultAdaptor result)
|
||||
{
|
||||
int count = 0;
|
||||
CountTestsByStatus(result, ref count, TestStatus.Passed);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count failed tests
|
||||
/// </summary>
|
||||
private static int CountFailedTests(ITestResultAdaptor result)
|
||||
{
|
||||
int count = 0;
|
||||
CountTestsByStatus(result, ref count, TestStatus.Failed);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count skipped tests
|
||||
/// </summary>
|
||||
private static int CountSkippedTests(ITestResultAdaptor result)
|
||||
{
|
||||
int count = 0;
|
||||
CountTestsByStatus(result, ref count, TestStatus.Skipped);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively count tests by status
|
||||
/// </summary>
|
||||
private static void CountTestsByStatus(ITestResultAdaptor result, ref int count, TestStatus? targetStatus)
|
||||
{
|
||||
if (!result.Test.IsSuite)
|
||||
{
|
||||
if (targetStatus == null || result.TestStatus == targetStatus)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Children != null)
|
||||
{
|
||||
foreach (ITestResultAdaptor child in result.Children)
|
||||
{
|
||||
CountTestsByStatus(child, ref count, targetStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4249d7720b7654e0da01cd8a32969143
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of test execution filter.
|
||||
/// </summary>
|
||||
public enum TestExecutionFilterType
|
||||
{
|
||||
/// <summary>
|
||||
/// Run all tests without any filter.
|
||||
/// </summary>
|
||||
All,
|
||||
|
||||
/// <summary>
|
||||
/// Filter by exact test method name (complete match).
|
||||
/// </summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>
|
||||
/// Filter by regex pattern (class name or namespace).
|
||||
/// </summary>
|
||||
Regex,
|
||||
|
||||
/// <summary>
|
||||
/// Filter by assembly name.
|
||||
/// </summary>
|
||||
AssemblyName
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class that holds test execution filter information.
|
||||
/// </summary>
|
||||
public class TestExecutionFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of filter to apply (TestName, ClassName, Namespace, or AssemblyName).
|
||||
/// This determines how the FilterValue will be interpreted.
|
||||
/// </summary>
|
||||
public TestExecutionFilterType FilterType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The specific value to filter tests by, based on the FilterType.
|
||||
/// Examples:
|
||||
/// • When FilterType is Exact: "io.github.hatayama.uLoopMCP.ConsoleLogRetrieverTests.GetAllLogs_WithMaskAllOff_StillReturnsAllLogs"
|
||||
/// • When FilterType is Regex: "io.github.hatayama.uLoopMCP.ConsoleLogRetrieverTests" or "io.github.hatayama.uLoopMCP"
|
||||
/// • When FilterType is AssemblyName: "uLoopMCP.Tests.Editor"
|
||||
/// </summary>
|
||||
public string FilterValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test execution filter.
|
||||
/// </summary>
|
||||
public TestExecutionFilter(TestExecutionFilterType filterType, string filterValue)
|
||||
{
|
||||
FilterType = filterType;
|
||||
FilterValue = filterValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter by exact test method name.
|
||||
/// </summary>
|
||||
public static TestExecutionFilter ByTestName(string testName)
|
||||
{
|
||||
return new TestExecutionFilter(TestExecutionFilterType.Exact, testName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter by regex pattern (class name or namespace).
|
||||
/// </summary>
|
||||
public static TestExecutionFilter ByClassName(string className)
|
||||
{
|
||||
return new TestExecutionFilter(TestExecutionFilterType.Regex, className);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter by assembly name.
|
||||
/// </summary>
|
||||
public static TestExecutionFilter ByAssemblyName(string assemblyName)
|
||||
{
|
||||
return new TestExecutionFilter(TestExecutionFilterType.AssemblyName, assemblyName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter to run all tests without any filter.
|
||||
/// </summary>
|
||||
public static TestExecutionFilter All()
|
||||
{
|
||||
return new TestExecutionFilter(TestExecutionFilterType.All, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05a14757d8184488aa9b57cca4ba1e30
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bedde66928084edabc2d846f2a9af778
|
||||
timeCreated: 1751015418
|
||||
@@ -0,0 +1,405 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.Compilation;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// A class that asynchronously executes Unity's compilation process and monitors the results.
|
||||
/// It handles starting compilation, monitoring its progress, and retrieving the results.
|
||||
/// </summary>
|
||||
public class CompileController : IDisposable
|
||||
{
|
||||
private bool _isCompiling = false;
|
||||
private List<CompilerMessage> _compileMessages = new();
|
||||
private TaskCompletionSource<CompileResult> _currentCompileTask;
|
||||
private bool _isForceCompile = false;
|
||||
|
||||
/// <summary>
|
||||
/// Event that occurs when compilation is complete.
|
||||
/// </summary>
|
||||
public event Action<CompileResult> OnCompileCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Event that occurs when compilation starts.
|
||||
/// </summary>
|
||||
public event Action<string> OnCompileStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Event that occurs when assembly compilation is complete.
|
||||
/// </summary>
|
||||
public event Action<string, CompilerMessage[]> OnAssemblyCompiled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a compilation is currently in progress.
|
||||
/// </summary>
|
||||
public bool IsCompiling => _isCompiling;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current list of compiler messages.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompilerMessage> CompileMessages => _compileMessages.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Executes compilation asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="forceRecompile">Whether to force a recompile.</param>
|
||||
/// <returns>The compilation result.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the task is not found during compilation.</exception>
|
||||
public async Task<CompileResult> TryCompileAsync(bool forceRecompile = false)
|
||||
{
|
||||
return await TryCompileAsync(forceRecompile, CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task<CompileResult> TryCompileAsync(bool forceRecompile, CancellationToken ct)
|
||||
{
|
||||
if (_isCompiling)
|
||||
{
|
||||
// If compilation is already in progress, wait for the current task.
|
||||
if (_currentCompileTask != null)
|
||||
{
|
||||
return await _currentCompileTask.Task;
|
||||
}
|
||||
throw new InvalidOperationException("Compilation is in progress, but the task could not be found.");
|
||||
}
|
||||
|
||||
CompilationStateValidationService validationService = new();
|
||||
ValidationResult validation = validationService.ValidateCompilationState();
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return new CompileResult(
|
||||
success: false,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
completedAt: DateTime.Now,
|
||||
messages: new CompilerMessage[0],
|
||||
errors: new CompilerMessage[0],
|
||||
warnings: new CompilerMessage[0],
|
||||
isIndeterminate: true,
|
||||
message: validation.ErrorMessage
|
||||
);
|
||||
}
|
||||
|
||||
_isCompiling = true;
|
||||
_compileMessages.Clear();
|
||||
_currentCompileTask = new TaskCompletionSource<CompileResult>();
|
||||
_isForceCompile = forceRecompile;
|
||||
|
||||
// Execute asset refresh.
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
// Register events.
|
||||
CompilationPipeline.compilationFinished += HandleCompileFinished;
|
||||
CompilationPipeline.assemblyCompilationFinished += HandleAssemblyFinished;
|
||||
|
||||
string startMessage = forceRecompile ? "Forced recompile started after asset refresh..." : "Compilation started after asset refresh...";
|
||||
OnCompileStarted?.Invoke(startMessage);
|
||||
|
||||
if (forceRecompile)
|
||||
{
|
||||
CompilationPipeline.RequestScriptCompilation(RequestScriptCompilationOptions.CleanBuildCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
CompilationPipeline.RequestScriptCompilation();
|
||||
}
|
||||
|
||||
_ = WatchCompileStartAsync(ct);
|
||||
return await _currentCompileTask.Task;
|
||||
}
|
||||
|
||||
private async Task WatchCompileStartAsync(CancellationToken ct)
|
||||
{
|
||||
int waitedMs = 0;
|
||||
|
||||
while (waitedMs < McpConstants.COMPILE_START_TIMEOUT_MS)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
AbortCompile("Compilation request was cancelled before it started.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (EditorApplication.isCompiling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentCompileTask == null || _currentCompileTask.Task.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not pass CancellationToken here: this method is fire-and-forget and must not throw.
|
||||
// Cancellation is handled by explicit checks above to avoid leaving _currentCompileTask pending.
|
||||
await TimerDelay.Wait(McpConstants.COMPILE_START_POLL_INTERVAL_MS);
|
||||
waitedMs += McpConstants.COMPILE_START_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
AssemblyDefinitionDuplicationValidationService asmdefValidationService = new();
|
||||
ValidationResult asmdefValidation = asmdefValidationService.ValidateNoDuplicateAsmdefNames();
|
||||
if (!asmdefValidation.IsValid)
|
||||
{
|
||||
AbortCompile(asmdefValidation.ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
AbortCompile(
|
||||
"Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes."
|
||||
);
|
||||
}
|
||||
|
||||
private void AbortCompile(string reason)
|
||||
{
|
||||
if (_currentCompileTask == null || _currentCompileTask.Task.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Unregister events.
|
||||
CompilationPipeline.compilationFinished -= HandleCompileFinished;
|
||||
CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyFinished;
|
||||
|
||||
_isCompiling = false;
|
||||
|
||||
CompileResult result = new CompileResult(
|
||||
success: false,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
completedAt: DateTime.Now,
|
||||
messages: new CompilerMessage[0],
|
||||
errors: new CompilerMessage[0],
|
||||
warnings: new CompilerMessage[0],
|
||||
isIndeterminate: true,
|
||||
message: reason
|
||||
);
|
||||
|
||||
OnCompileCompleted?.Invoke(result);
|
||||
|
||||
TaskCompletionSource<CompileResult> task = _currentCompileTask;
|
||||
_currentCompileTask = null;
|
||||
task.SetResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the compiler messages.
|
||||
/// </summary>
|
||||
public void ClearMessages()
|
||||
{
|
||||
_compileMessages.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for when compilation is complete.
|
||||
/// </summary>
|
||||
/// <param name="context">The compilation context.</param>
|
||||
private void HandleCompileFinished(object context)
|
||||
{
|
||||
// Unregister events.
|
||||
CompilationPipeline.compilationFinished -= HandleCompileFinished;
|
||||
CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyFinished;
|
||||
|
||||
_isCompiling = false;
|
||||
|
||||
CompileResult result = CreateCompileResult();
|
||||
OnCompileCompleted?.Invoke(result);
|
||||
|
||||
// Set the result on the TaskCompletionSource.
|
||||
TaskCompletionSource<CompileResult> task = _currentCompileTask;
|
||||
_currentCompileTask = null;
|
||||
task?.SetResult(result);
|
||||
|
||||
// Reset force compile flag for future compilations
|
||||
_isForceCompile = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for when assembly compilation is complete.
|
||||
/// </summary>
|
||||
/// <param name="asmPath">The assembly path.</param>
|
||||
/// <param name="messages">The compiler messages.</param>
|
||||
private void HandleAssemblyFinished(string asmPath, CompilerMessage[] messages)
|
||||
{
|
||||
string assemblyName = System.IO.Path.GetFileName(asmPath);
|
||||
|
||||
foreach (CompilerMessage message in messages)
|
||||
{
|
||||
_compileMessages.Add(message);
|
||||
}
|
||||
|
||||
OnAssemblyCompiled?.Invoke(assemblyName, messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the compilation result.
|
||||
/// </summary>
|
||||
/// <returns>The compilation result.</returns>
|
||||
private CompileResult CreateCompileResult()
|
||||
{
|
||||
int errorCount = _compileMessages.Count(m => m.type == CompilerMessageType.Error);
|
||||
int warningCount = _compileMessages.Count(m => m.type == CompilerMessageType.Warning);
|
||||
|
||||
// For force compile, don't include messages in response
|
||||
// User should use get-logs tool after domain reload completes
|
||||
if (_isForceCompile)
|
||||
{
|
||||
return new CompileResult(
|
||||
success: null, // Success status is indeterminate during force compile
|
||||
errorCount: errorCount,
|
||||
warningCount: warningCount,
|
||||
completedAt: DateTime.Now,
|
||||
messages: new CompilerMessage[0],
|
||||
errors: new CompilerMessage[0],
|
||||
warnings: new CompilerMessage[0],
|
||||
isIndeterminate: true,
|
||||
message: "Force compilation executed. Use get-logs tool to retrieve compilation messages."
|
||||
);
|
||||
}
|
||||
|
||||
CompilerMessage[] errors = _compileMessages.Where(m => m.type == CompilerMessageType.Error).ToArray();
|
||||
CompilerMessage[] warnings = _compileMessages.Where(m => m.type == CompilerMessageType.Warning).ToArray();
|
||||
|
||||
return new CompileResult(
|
||||
success: errorCount == 0,
|
||||
errorCount: errorCount,
|
||||
warningCount: warningCount,
|
||||
completedAt: DateTime.Now,
|
||||
messages: _compileMessages.ToArray(),
|
||||
errors: errors,
|
||||
warnings: warnings
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up resources.
|
||||
/// </summary>
|
||||
public void Cleanup()
|
||||
{
|
||||
// Unregister events just in case.
|
||||
CompilationPipeline.compilationFinished -= HandleCompileFinished;
|
||||
CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyFinished;
|
||||
|
||||
// If there is an incomplete task, cancel it.
|
||||
if (_currentCompileTask != null && !_currentCompileTask.Task.IsCompleted)
|
||||
{
|
||||
_currentCompileTask.SetCanceled();
|
||||
_currentCompileTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Cleanup();
|
||||
_compileMessages?.Clear();
|
||||
_compileMessages = null;
|
||||
|
||||
// Clear all events.
|
||||
OnCompileCompleted = null;
|
||||
OnCompileStarted = null;
|
||||
OnAssemblyCompiled = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A class that represents the result of a compilation.
|
||||
/// Includes information on errors, warnings, and the completion time.
|
||||
/// </summary>
|
||||
public class CompileResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the compilation was successful. Null indicates indeterminate status.
|
||||
/// </summary>
|
||||
public bool? Success { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of errors.
|
||||
/// </summary>
|
||||
public int ErrorCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of warnings.
|
||||
/// </summary>
|
||||
public int WarningCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The time of compilation completion.
|
||||
/// </summary>
|
||||
public DateTime CompletedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// All compiler messages.
|
||||
/// </summary>
|
||||
public CompilerMessage[] Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Error messages only.
|
||||
/// </summary>
|
||||
public CompilerMessage[] Errors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Warning messages only.
|
||||
/// </summary>
|
||||
public CompilerMessage[] Warnings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the compilation result is indeterminate (cannot be determined).
|
||||
/// </summary>
|
||||
public bool IsIndeterminate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional message for additional information
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Alias for error messages (for backward compatibility).
|
||||
/// </summary>
|
||||
public CompilerMessage[] error => Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for warning messages (for backward compatibility).
|
||||
/// </summary>
|
||||
public CompilerMessage[] warning => Warnings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the compilation result.
|
||||
/// </summary>
|
||||
/// <param name="success">The compilation success flag. Null indicates indeterminate status.</param>
|
||||
/// <param name="errorCount">The number of errors.</param>
|
||||
/// <param name="warningCount">The number of warnings.</param>
|
||||
/// <param name="completedAt">The completion time.</param>
|
||||
/// <param name="messages">All messages.</param>
|
||||
/// <param name="errors">The error messages.</param>
|
||||
/// <param name="warnings">The warning messages.</param>
|
||||
/// <param name="isIndeterminate">Whether the result is indeterminate.</param>
|
||||
public CompileResult(
|
||||
bool? success,
|
||||
int errorCount,
|
||||
int warningCount,
|
||||
DateTime completedAt,
|
||||
CompilerMessage[] messages,
|
||||
CompilerMessage[] errors,
|
||||
CompilerMessage[] warnings,
|
||||
bool isIndeterminate = false,
|
||||
string message = null
|
||||
)
|
||||
{
|
||||
Success = success;
|
||||
ErrorCount = errorCount;
|
||||
WarningCount = warningCount;
|
||||
CompletedAt = completedAt;
|
||||
Messages = messages;
|
||||
Errors = errors;
|
||||
Warnings = warnings;
|
||||
IsIndeterminate = isIndeterminate;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99ed5b71d644a42f2a4c2db2c0b33271
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user