@atelierai/uco 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +29 -8
  2. package/package.json +2 -2
  3. package/vendor/plugin/com.atelierai.unity.copilot/CHANGELOG.md +4 -4
  4. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetConfig.cs +132 -132
  5. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDependencyResolver.cs +237 -237
  6. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDownloader.cs +82 -82
  7. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/MenuItems.cs +247 -247
  8. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/ToolGroups.cs +153 -153
  9. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/SystemTool/Skills.Create.SkillBody.cs +155 -155
  10. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Console.GetLogs.cs +95 -95
  11. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Docs.HttpFetcher.cs +139 -139
  12. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Instance.ListAll.cs +72 -72
  13. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/PathReadHelper.cs +106 -106
  14. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Script.Validate.cs +252 -252
  15. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests/TestResultCollector.cs +537 -537
  16. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests.cs +400 -400
  17. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/ToolGroups.SetEnabled.cs +92 -92
  18. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Branding/ProductInfo.cs +62 -62
  19. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +887 -1034
  20. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Skills/Skill_InitialSetup.cs +197 -197
  21. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +201 -201
  22. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.cs +67 -67
  23. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotListWindowBase.cs +393 -393
  24. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotWindowBase.cs +113 -113
  25. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +3 -3
  26. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotFeatures.cs +195 -195
  27. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +22 -22
  28. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +354 -354
  29. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +78 -78
  30. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowInitializer.cs +47 -47
  31. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/SerializationCheckWindow.cs +190 -190
  32. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Build.cs +91 -91
  33. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +298 -298
  34. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +296 -296
  35. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.cs +69 -69
  36. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PackageUtils.cs +168 -168
  37. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PrefsKeyMigration.cs +119 -119
  38. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/SafeDefaultsGuard.cs +180 -180
  39. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ScriptUtils.cs +229 -229
  40. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolGroupRegistry.cs +461 -460
  41. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolsManifestGenerator.cs +200 -200
  42. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringAdapters.cs +1467 -1467
  43. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringSafetyReloadGuard.cs +65 -65
  44. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityCopilotUpdateProjectSettings.cs +66 -66
  45. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotPromptsWindow.uss +11 -11
  46. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotResourcesWindow.uss +16 -16
  47. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotToolsWindow.uss +36 -36
  48. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/MainWindow.uss +183 -183
  49. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/{_mcp-list-window.uss → _copilot-list-window.uss} +18 -18
  50. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/_list-view.uss +77 -77
  51. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotPromptsWindow.uxml +18 -18
  52. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotResourcesWindow.uxml +18 -18
  53. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotToolsWindow.uxml +18 -18
  54. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/MainWindow.uxml +115 -115
  55. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
  56. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
  57. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
  58. package/vendor/plugin/com.atelierai.unity.copilot/README.md +88 -88
  59. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Converter/Json/Structures/EntityIdConverter.cs +120 -120
  60. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.cs +110 -110
  61. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.pre-Unity.6.5.cs +110 -110
  62. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +547 -547
  63. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Build.cs +243 -243
  64. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +3 -3
  65. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +295 -295
  66. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginBuilder.cs +105 -105
  67. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.Static.cs +155 -155
  68. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.cs +59 -59
  69. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +11 -11
  70. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/AtomicApiTestFixtures.cs +99 -99
  71. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/TryModifyAtTests.cs +227 -227
  72. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AuthoringSafetyPilotTests.cs +1050 -1050
  73. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetExtractorDevelopmentDependencyTests.cs +148 -148
  74. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetExtractorFlatLayoutTests.cs +166 -166
  75. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetInstallManifestTests.cs +436 -436
  76. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +304 -304
  77. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetPackageInstallerStaleVersionCleanupTests.cs +200 -200
  78. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +2 -2
  79. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/BlacklistTypeTests.cs +416 -416
  80. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.cs +172 -172
  81. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.pre-Unity.6.5.cs +169 -169
  82. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.cs +182 -182
  83. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.pre-Unity.6.5.cs +179 -179
  84. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.cs +175 -175
  85. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.pre-Unity.6.5.cs +174 -174
  86. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.cs +171 -171
  87. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.pre-Unity.6.5.cs +170 -170
  88. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsCreateFolderTests.cs +243 -243
  89. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsMaterialTests.cs +101 -101
  90. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.cs +348 -348
  91. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.pre-Unity.6.5.cs +348 -348
  92. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsShaderTests.cs +194 -194
  93. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/GameObject/TestJsonSchema.cs +199 -199
  94. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.cs +573 -573
  95. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.pre-Unity.6.5.cs +513 -513
  96. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.cs +154 -154
  97. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.pre-Unity.6.5.cs +154 -154
  98. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotGameViewTests.cs +164 -164
  99. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotSceneViewTests.cs +155 -155
  100. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolParameterTests.cs +56 -56
  101. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.cs +651 -651
  102. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.pre-Unity.6.5.cs +651 -651
  103. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ToolBatchExecutionIntegrationTests.cs +407 -407
  104. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs → UcoPluginTests.cs} +105 -105
  105. package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
  106. /package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/{_mcp-list-window.uss.meta → _copilot-list-window.uss.meta} +0 -0
  107. /package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs.meta → UcoPluginTests.cs.meta} +0 -0
@@ -1,1034 +1,887 @@
1
- /*
2
- ┌──────────────────────────────────────────────────────────────────┐
3
- │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
- │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
- │ Copyright (c) 2025 Ivan Murzak │
6
- │ Licensed under the Apache License, Version 2.0. │
7
- │ See the LICENSE file in the project root for more information. │
8
- └──────────────────────────────────────────────────────────────────┘
9
- */
10
-
11
- #nullable enable
12
- using System;
13
- using System.Collections.Generic;
14
- using System.Diagnostics;
15
- using System.IO;
16
- using System.Runtime.InteropServices;
17
- using System.Text.Json.Nodes;
18
- using System.Threading.Tasks;
19
- using com.IvanMurzak.ReflectorNet.Utils;
20
- using com.AtelierAI.Unity.Copilot.Editor.Utils;
21
- using com.AtelierAI.Unity.Copilot.Runtime.Utils;
22
- using com.AtelierAI.Unity.Copilot.Utils;
23
- using Microsoft.Extensions.Logging;
24
- using R3;
25
- using UnityEditor;
26
-
27
- namespace com.AtelierAI.Unity.Copilot.Editor
28
- {
29
- using static com.AtelierAI.Uco.Framework.Common.Consts.MCP.Server;
30
- using Consts = com.AtelierAI.Uco.Framework.Common.Consts;
31
- using ILogger = Microsoft.Extensions.Logging.ILogger;
32
-
33
- public enum CopilotServerStatus
34
- {
35
- Stopped,
36
- Starting,
37
- Running,
38
- Stopping,
39
- External
40
- }
41
-
42
- /// <summary>
43
- /// Manages the Node.js MCP server (cocli) process lifecycle independently from UI.
44
- /// The server entry script is discovered from the plugin config (nodeServerPath),
45
- /// the Unity project's node_modules, or the npm global installation — the plugin no
46
- /// longer stages or downloads a server binary into Library/.
47
- /// Provides cross-platform support for Windows, macOS, and Linux.
48
- /// </summary>
49
- [InitializeOnLoad]
50
- public static class CopilotServerManager
51
- {
52
- const string ProcessIdKey = "CopilotServerManager_ProcessId";
53
-
54
- // The Node.js MCP server runs as a "node" process.
55
- const string NodeProcessName = "node";
56
-
57
- static readonly ILogger _logger = UnityLoggerFactory.LoggerFactory.CreateLogger(typeof(CopilotServerManager));
58
- static readonly ReactiveProperty<CopilotServerStatus> _serverStatus = new(CopilotServerStatus.Stopped);
59
- static readonly object _processMutex = new();
60
-
61
- static Process? _serverProcess;
62
-
63
- public static ReadOnlyReactiveProperty<CopilotServerStatus> ServerStatus => _serverStatus;
64
-
65
- public static bool IsRunning => _serverStatus.CurrentValue == CopilotServerStatus.Running;
66
- public static bool IsStarting => _serverStatus.CurrentValue == CopilotServerStatus.Starting;
67
-
68
- static CopilotServerManager()
69
- {
70
- if (!UnityProcessGuard.ShouldInitialize(AssetDatabase.IsAssetImportWorkerProcess()))
71
- return;
72
-
73
- // Register for editor quit to clean up the server process
74
- EditorApplication.quitting += OnEditorQuitting;
75
-
76
- // Check if server process is still running (e.g., after domain reload)
77
- EditorApplication.update += CheckExistingProcess;
78
-
79
- if (EnvironmentUtils.IsCi())
80
- return; // Skip auto-start in CI environment
81
-
82
- EditorApplication.update += StartServerIfNeeded;
83
- }
84
-
85
- #region Node Server Discovery
86
-
87
- /// <summary>
88
- /// npm package directories that ship the Node.js server, newest first: the
89
- /// published scoped package (@atelierai/uco — the registry rejected the bare
90
- /// short name), the pre-scope local layout, and the deprecated cocli install
91
- /// from the private era.
92
- /// </summary>
93
- public static readonly string[] NodeServerPackageNames = { "@atelierai/uco", "uco", "cocli" };
94
-
95
- /// <summary>
96
- /// Path of the server entry script inside a <see cref="NodeServerPackageNames"/> package.
97
- /// </summary>
98
- public const string NodeServerEntryRelativePath = "bin/server.mjs";
99
-
100
- /// <summary>
101
- /// Resolves the Node.js server entry script (uco's bin/server.mjs) to launch.
102
- /// Lookup order:
103
- /// 1. Explicit <c>nodeServerPath</c> from the plugin config (absolute, or relative
104
- /// to the Unity project root) — when set, it must exist or launching is refused.
105
- /// 2. uco installed in the Unity project's node_modules.
106
- /// 3. uco installed globally via npm.
107
- /// Returns null when nothing is found — the caller must not launch in that case,
108
- /// but the plugin can still connect to an already-running server.
109
- /// </summary>
110
- public static string? ResolveNodeServerEntry()
111
- {
112
- // 1) Explicit path from the config
113
- var configured = UnityCopilotPluginEditor.NodeServerPath;
114
- if (!string.IsNullOrWhiteSpace(configured))
115
- {
116
- var path = Path.GetFullPath(Path.IsPathRooted(configured)
117
- ? configured
118
- : Path.Combine(UnityCopilotPluginEditor.ProjectRootPath, configured));
119
-
120
- if (File.Exists(path))
121
- return path;
122
-
123
- _logger.LogError(
124
- "nodeServerPath is set in the plugin config but does not exist: {path}. " +
125
- "Fix the path or clear it to let the plugin auto-discover the server.",
126
- path);
127
- return null;
128
- }
129
-
130
- // 2) Installed in the Unity project (@atelierai/uco first, legacy names after)
131
- foreach (var packageName in NodeServerPackageNames)
132
- {
133
- var projectLocal = Path.GetFullPath(Path.Combine(
134
- UnityCopilotPluginEditor.ProjectRootPath,
135
- "node_modules",
136
- packageName,
137
- NodeServerEntryRelativePath));
138
- if (File.Exists(projectLocal))
139
- return projectLocal;
140
- }
141
-
142
- // 3) Installed globally via npm (@atelierai/uco first, legacy names after)
143
- foreach (var globalRoot in GetNpmGlobalRoots())
144
- {
145
- foreach (var packageName in NodeServerPackageNames)
146
- {
147
- try
148
- {
149
- var globalPath = Path.Combine(globalRoot, packageName, NodeServerEntryRelativePath);
150
- if (File.Exists(globalPath))
151
- return Path.GetFullPath(globalPath);
152
- }
153
- catch
154
- {
155
- // Inaccessible candidate — skip it.
156
- }
157
- }
158
- }
159
-
160
- return null;
161
- }
162
-
163
- /// <summary>
164
- /// Standard npm global installation roots (the <c>npm root -g</c> equivalents).
165
- /// </summary>
166
- static IEnumerable<string> GetNpmGlobalRoots()
167
- {
168
- // Windows: %AppData%\npm\node_modules
169
- var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
170
- if (!string.IsNullOrEmpty(appData))
171
- yield return Path.Combine(appData, "npm", "node_modules");
172
-
173
- // Unix: default prefixes for system-wide npm installs
174
- yield return "/usr/local/lib/node_modules";
175
- yield return "/usr/lib/node_modules";
176
- }
177
-
178
- /// <summary>
179
- /// Resolves the Node.js runtime executable. Probes PATH explicitly because
180
- /// Process.Start with UseShellExecute=false does not reliably search PATH for
181
- /// the application name on every runtime Unity ships with. Falls back to the
182
- /// bare name so runtimes that do search PATH still work.
183
- /// </summary>
184
- public static string ResolveNodeExecutable()
185
- {
186
- var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "node.exe" : "node";
187
-
188
- var candidates = new List<string>();
189
- var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
190
- var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':';
191
- foreach (var directory in pathVariable.Split(separator, StringSplitOptions.RemoveEmptyEntries))
192
- {
193
- try { candidates.Add(Path.Combine(directory.Trim().Trim('"'), fileName)); }
194
- catch { /* Malformed PATH entry — skip it. */ }
195
- }
196
-
197
- // Common install locations, in case node is not on the editor's PATH
198
- // (e.g. Unity launched from a GUI session with a stale environment).
199
- var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
200
- if (!string.IsNullOrEmpty(programFiles))
201
- candidates.Add(Path.Combine(programFiles, "nodejs", fileName));
202
- var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
203
- if (!string.IsNullOrEmpty(programFilesX86))
204
- candidates.Add(Path.Combine(programFilesX86, "nodejs", fileName));
205
-
206
- foreach (var candidate in candidates)
207
- {
208
- try
209
- {
210
- if (File.Exists(candidate))
211
- return candidate;
212
- }
213
- catch
214
- {
215
- // Inaccessible candidate — skip it.
216
- }
217
- }
218
-
219
- // Fall back to the bare name and let the OS resolve it.
220
- return fileName;
221
- }
222
-
223
- #endregion // Node Server Discovery
224
-
225
- #region Client Configuration
226
-
227
- /// <summary>
228
- /// Generates a JSON configuration for stdio transport.
229
- /// <code>
230
- /// {
231
- /// "mcpServers": {
232
- /// "Unity ProjectName": {
233
- /// "type": "...", // optional, only if provided
234
- /// "command": "node",
235
- /// "args": ["path/to/bin/server.mjs", "--port", "...", "--plugin-timeout-ms", "...", "--authorization", "..." /*, "--token", "..." if auth required */]
236
- /// }
237
- /// }
238
- /// }
239
- /// </code>
240
- /// </summary>
241
- public static JsonNode RawJsonConfigurationStdio(
242
- int port,
243
- string bodyPath = "mcpServers",
244
- int timeoutMs = Consts.Hub.DefaultTimeoutMs,
245
- string? type = null)
246
- {
247
- var pathSegments = BodyPathSegments(bodyPath);
248
-
249
- // Build innermost content first
250
- var serverConfig = new JsonObject();
251
-
252
- if (type != null)
253
- serverConfig["type"] = type;
254
-
255
- // The AI agent launches the Node.js MCP server via the node runtime.
256
- // When the entry script cannot be resolved right now, fall back to the
257
- // documented project-local install location so the generated config is
258
- // still a valid, copy-pasteable starting point.
259
- var serverEntry = ResolveNodeServerEntry()
260
- ?? Path.Combine("node_modules", NodeServerPackageNames[0], NodeServerEntryRelativePath);
261
-
262
- serverConfig["command"] = ResolveNodeExecutable().Replace('\\', '/');
263
-
264
- var args = new JsonArray
265
- {
266
- serverEntry.Replace('\\', '/'),
267
- "--port",
268
- port.ToString(),
269
- "--plugin-timeout-ms",
270
- timeoutMs.ToString(),
271
- "--authorization",
272
- UnityCopilotPluginEditor.AuthOption.ToString()
273
- };
274
-
275
- var authRequired = UnityCopilotPluginEditor.AuthOption == AuthOption.required;
276
- if (authRequired && !string.IsNullOrEmpty(UnityCopilotPluginEditor.Token))
277
- {
278
- args.Add("--token");
279
- args.Add(UnityCopilotPluginEditor.Token);
280
- }
281
-
282
- serverConfig["args"] = args;
283
-
284
- var innerContent = new JsonObject
285
- {
286
- ["ai-game-developer"] = serverConfig
287
- };
288
-
289
- // Build nested structure from innermost to outermost
290
- var result = innerContent;
291
- for (int i = pathSegments.Length - 1; i >= 0; i--)
292
- {
293
- result = new JsonObject { [pathSegments[i]] = result };
294
- }
295
-
296
- return result;
297
- }
298
-
299
- /// <summary>
300
- /// Generates a JSON configuration for HTTP transport.
301
- /// <code>
302
- /// {
303
- /// "mcpServers": {
304
- /// "Unity ProjectName": {
305
- /// "type": "...", // optional, only if provided
306
- /// "url": "http://localhost:port",
307
- /// "headers": { // only if token is provided
308
- /// "Authorization": "Bearer token"
309
- /// }
310
- /// }
311
- /// }
312
- /// }
313
- /// </code>
314
- /// </summary>
315
- public static JsonNode RawJsonConfigurationHttp(
316
- string url,
317
- string bodyPath = "mcpServers",
318
- string? type = null)
319
- {
320
- // ── Safe-defaults advisory ────────────────────────────────────────────────
321
- // If the URL targets a remote host over plain HTTP and the user has not opted in
322
- // to insecure remote HTTP, log a single warning. We deliberately do NOT throw or
323
- // rewrite the URL here — the configurators are expected to produce *some* output
324
- // even when the configuration is sub-optimal, so the user can see what was
325
- // generated and decide. The dialog/UI gate handles refusal at start time.
326
- if (SafeDefaultsGuard.IsInsecureRemoteHttpUrl(url) &&
327
- !UnityCopilotPluginEditor.AllowInsecureRemoteHttp)
328
- {
329
- _logger.LogWarning(
330
- "Generating AI agent client config for plain-HTTP remote URL '{url}' without " +
331
- "AllowInsecureRemoteHttp. The token (if any) and tool payloads will be " +
332
- "sent unencrypted. Either switch to https:// or enable 'Allow Insecure " +
333
- "Remote HTTP' in the Game Developer window to silence this warning.",
334
- url);
335
- }
336
-
337
- var pathSegments = BodyPathSegments(bodyPath);
338
-
339
- // Build innermost content first
340
- var serverConfig = new JsonObject();
341
-
342
- if (type != null)
343
- serverConfig["type"] = type;
344
-
345
- serverConfig["url"] = url;
346
-
347
- var authRequired = UnityCopilotPluginEditor.AuthOption == AuthOption.required;
348
- if (authRequired && !string.IsNullOrEmpty(UnityCopilotPluginEditor.Token))
349
- {
350
- serverConfig["headers"] = new JsonObject
351
- {
352
- ["Authorization"] = $"Bearer {UnityCopilotPluginEditor.Token}"
353
- };
354
- }
355
-
356
- var innerContent = new JsonObject
357
- {
358
- ["ai-game-developer"] = serverConfig
359
- };
360
-
361
- // Build nested structure from innermost to outermost
362
- var result = innerContent;
363
- for (int i = pathSegments.Length - 1; i >= 0; i--)
364
- {
365
- result = new JsonObject { [pathSegments[i]] = result };
366
- }
367
-
368
- return result;
369
- }
370
-
371
- #endregion // Client Configuration
372
-
373
- #region Process Lifecycle
374
-
375
- static void CheckExistingProcess()
376
- {
377
- EditorApplication.update -= CheckExistingProcess;
378
- // Try to find an existing server process by checking if our tracked PID is still running
379
- // This helps maintain state across domain reloads
380
- var savedPid = EditorPrefs.GetInt(ProcessIdKey, -1);
381
- if (savedPid > 0)
382
- {
383
- try
384
- {
385
- var process = Process.GetProcessById(savedPid);
386
- if (process != null && !process.HasExited)
387
- {
388
- var processName = process.ProcessName.ToLowerInvariant();
389
- // The server runs as a "node" process, and PIDs get reused, so also
390
- // verify the process still owns this project's port before
391
- // re-attaching otherwise we might adopt an unrelated node process
392
- // (dev server, ...) that recycled our PID.
393
- if (processName.Contains(NodeProcessName) &&
394
- GetPidListeningOnPort(UnityCopilotPluginEditor.Port) == savedPid)
395
- {
396
- _serverProcess = process;
397
- _serverStatus.Value = CopilotServerStatus.Running;
398
- _logger.LogInformation("Reconnected to existing server process (PID: {pid})", savedPid);
399
-
400
- // Re-attach exit handler
401
- process.EnableRaisingEvents = true;
402
- process.Exited += OnProcessExited;
403
-
404
- // Schedule verification check to detect if process crashes shortly after reconnection
405
- ScheduleStartupVerification(savedPid);
406
- return;
407
- }
408
- }
409
- }
410
- catch (Exception ex)
411
- {
412
- _logger.LogDebug("Could not reconnect to previous process: {message}", ex.Message);
413
- }
414
-
415
- // Clear stale PID
416
- EditorPrefs.DeleteKey(ProcessIdKey);
417
- }
418
- }
419
-
420
- static void OnEditorQuitting()
421
- {
422
- StopServer(force: true);
423
- }
424
-
425
- public static bool StartServer()
426
- {
427
- lock (_processMutex)
428
- {
429
- if (_serverStatus.CurrentValue == CopilotServerStatus.Running ||
430
- _serverStatus.CurrentValue == CopilotServerStatus.Starting ||
431
- _serverStatus.CurrentValue == CopilotServerStatus.Stopping)
432
- {
433
- _logger.LogWarning("server is already {status}", _serverStatus.CurrentValue);
434
- return false;
435
- }
436
-
437
- // ── Safe-defaults (fail-closed) gate ──────────────────────────────────────
438
- // Refuse to start the local server when the current configuration would
439
- // expose it to the network without explicit opt-in. The gate is consulted
440
- // BEFORE we mutate _serverStatus / spawn the process so a reject leaves the
441
- // manager in a fully clean state. See SafeDefaultsGuard for the full policy.
442
- if (SafeDefaultsGuard.ShouldRejectStart(out var rejectReason))
443
- {
444
- _logger.LogError("Refusing to start server: {reason}", rejectReason);
445
- var openSettings = EditorUtility.DisplayDialog(
446
- title: "Server: unsafe configuration",
447
- message: rejectReason,
448
- ok: "Open Settings",
449
- cancel: "Cancel");
450
- if (openSettings)
451
- {
452
- try { com.AtelierAI.Unity.Copilot.Editor.UI.MainWindowEditor.ShowWindowVoid(); }
453
- catch (Exception openEx)
454
- {
455
- _logger.LogDebug("Failed to open Game Developer window: {message}", openEx.Message);
456
- }
457
- }
458
- return false;
459
- }
460
-
461
- // If something already listens on this project's port, treat it as an
462
- // already-running server (e.g. started externally by the user or a
463
- // previous editor session). Do not launch a second instance — just let
464
- // the plugin's connect flow attach to it.
465
- var ownPid = -1;
466
- try { ownPid = _serverProcess?.Id ?? -1; } catch { /* process gone */ }
467
- var listeningPid = GetPidListeningOnPort(UnityCopilotPluginEditor.Port);
468
- if (listeningPid > 0 && listeningPid != ownPid)
469
- {
470
- _logger.LogInformation(
471
- "A server is already listening on port {port} (PID: {pid}) — skipping local launch, the plugin will connect to it",
472
- UnityCopilotPluginEditor.Port, listeningPid);
473
- return true;
474
- }
475
-
476
- var serverEntry = ResolveNodeServerEntry();
477
- if (serverEntry == null)
478
- {
479
- _logger.LogError(
480
- "Node.js MCP server entry not found. Install cocli (npm i -g cocli), or install it into the " +
481
- "Unity project (npm i cocli), or set 'nodeServerPath' in '{config}' to the absolute path of " +
482
- "cocli's bin/server.mjs. The plugin will still connect to an already-running server.",
483
- UnityCopilotPluginEditor.AssetsFilePath);
484
- return false;
485
- }
486
-
487
- _serverStatus.Value = CopilotServerStatus.Starting;
488
-
489
- try
490
- {
491
- var nodeExecutable = ResolveNodeExecutable();
492
- var argumentList = BuildArgumentList(serverEntry);
493
-
494
- _logger.LogInformation("Starting server: {path} {args}", nodeExecutable, string.Join(" ", argumentList));
495
-
496
- var startInfo = new ProcessStartInfo
497
- {
498
- FileName = nodeExecutable,
499
- UseShellExecute = false,
500
- CreateNoWindow = true,
501
- RedirectStandardOutput = true,
502
- RedirectStandardError = true,
503
- WorkingDirectory = UnityCopilotPluginEditor.ProjectRootPath
504
- };
505
-
506
- // Use ArgumentList (Collection<string>) rather than the legacy single
507
- // Arguments string so that any argument containing spaces or quote
508
- // characters is escaped correctly by the runtime. Required because the
509
- // server entry path may live under a project path that contains spaces.
510
- foreach (var arg in argumentList)
511
- startInfo.ArgumentList.Add(arg);
512
-
513
- _serverProcess = new Process
514
- {
515
- StartInfo = startInfo,
516
- EnableRaisingEvents = true
517
- };
518
- _serverProcess.Exited += OnProcessExited;
519
- _serverProcess.OutputDataReceived += OnOutputDataReceived;
520
- _serverProcess.ErrorDataReceived += OnErrorDataReceived;
521
-
522
- if (!_serverProcess.Start())
523
- {
524
- _logger.LogError("Failed to start server process");
525
- CleanupProcess();
526
- return false;
527
- }
528
-
529
- _serverProcess.BeginOutputReadLine();
530
- _serverProcess.BeginErrorReadLine();
531
-
532
- // Save PID for reconnection after domain reload
533
- EditorPrefs.SetInt(ProcessIdKey, _serverProcess.Id);
534
-
535
- // Keep status as Starting - it will be set to Running after verification
536
- _logger.LogInformation("server process started (PID: {pid}), awaiting verification...", _serverProcess.Id);
537
-
538
- // Schedule a delayed check to verify the process is still running
539
- // This catches early crashes that might not trigger the Exited event reliably
540
- // Status will be set to Running only after successful verification
541
- ScheduleStartupVerification(_serverProcess.Id);
542
-
543
- return true;
544
- }
545
- catch (Exception ex)
546
- {
547
- _logger.LogError("Failed to start server: {message}", ex.Message);
548
- CleanupProcess();
549
- return false;
550
- }
551
- }
552
- }
553
-
554
- /// <summary>
555
- /// Stops the server process.
556
- /// By default, this method is non-blocking: it sends the kill/terminate signal
557
- /// and lets the Exited event handler perform cleanup asynchronously.
558
- /// When force is true (e.g., editor quitting), it blocks until the process exits.
559
- /// </summary>
560
- public static bool StopServer(bool force = false)
561
- {
562
- lock (_processMutex)
563
- {
564
- if (_serverStatus.CurrentValue == CopilotServerStatus.Stopped ||
565
- _serverStatus.CurrentValue == CopilotServerStatus.Stopping)
566
- {
567
- _logger.LogDebug("server is already stopped or stopping");
568
- return true;
569
- }
570
-
571
- if (_serverProcess == null)
572
- {
573
- _serverStatus.Value = CopilotServerStatus.Stopped;
574
- EditorPrefs.DeleteKey(ProcessIdKey);
575
- return true;
576
- }
577
-
578
- _serverStatus.Value = CopilotServerStatus.Stopping;
579
-
580
- try
581
- {
582
- _logger.LogInformation("Stopping server (PID: {pid})", _serverProcess.Id);
583
-
584
- if (!_serverProcess.HasExited)
585
- {
586
- SendTerminateSignal();
587
- }
588
-
589
- if (force)
590
- {
591
- // Synchronous path: block until exit (used during editor quitting)
592
- WaitForExitAndForceKillIfNeeded();
593
- CleanupProcess();
594
- }
595
- else
596
- {
597
- if (_serverProcess.HasExited)
598
- {
599
- CleanupProcess();
600
- }
601
- else
602
- {
603
- // Non-blocking path: schedule background wait + force kill safety net.
604
- // CleanupProcess will be called by OnProcessExited or the background task.
605
- ScheduleForceKillIfNeeded();
606
- }
607
- }
608
-
609
- _logger.LogInformation("server stop initiated");
610
- return true;
611
- }
612
- catch (Exception ex)
613
- {
614
- _logger.LogError("Error stopping server: {message}", ex.Message);
615
- CleanupProcess();
616
- return false;
617
- }
618
- }
619
- }
620
-
621
- /// <summary>
622
- /// Sends the platform-appropriate terminate signal without waiting for exit.
623
- /// </summary>
624
- static void SendTerminateSignal()
625
- {
626
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
627
- {
628
- _serverProcess!.Kill();
629
- }
630
- else
631
- {
632
- // On Unix-like systems, send SIGTERM for graceful shutdown
633
- try
634
- {
635
- using var killProcess = Process.Start(new ProcessStartInfo
636
- {
637
- FileName = "kill",
638
- Arguments = $"-TERM {_serverProcess!.Id}",
639
- UseShellExecute = false,
640
- CreateNoWindow = true
641
- });
642
- killProcess?.WaitForExit(1000);
643
- }
644
- catch (Exception ex)
645
- {
646
- _logger.LogDebug("SIGTERM failed, falling back to Kill(): {message}", ex.Message);
647
- _serverProcess!.Kill();
648
- }
649
- }
650
- }
651
-
652
- /// <summary>
653
- /// Blocking wait for process exit, with force-kill fallback.
654
- /// Used only during editor quitting to prevent orphaned processes.
655
- /// </summary>
656
- static void WaitForExitAndForceKillIfNeeded()
657
- {
658
- if (_serverProcess == null || _serverProcess.HasExited)
659
- return;
660
-
661
- if (!_serverProcess.WaitForExit(5000))
662
- {
663
- _logger.LogWarning("server did not exit gracefully, forcing termination");
664
- try
665
- {
666
- _serverProcess.Kill();
667
- _serverProcess.WaitForExit(2000);
668
- }
669
- catch (Exception ex)
670
- {
671
- _logger.LogDebug("Force kill failed: {message}", ex.Message);
672
- }
673
- }
674
- }
675
-
676
- /// <summary>
677
- /// Background safety net: waits for the process to exit and force-kills after timeout.
678
- /// Calls CleanupProcess on the main thread when done.
679
- /// </summary>
680
- static void ScheduleForceKillIfNeeded()
681
- {
682
- var process = _serverProcess;
683
- if (process == null)
684
- return;
685
-
686
- Task.Run(() =>
687
- {
688
- try
689
- {
690
- if (!process.HasExited && !process.WaitForExit(5000))
691
- {
692
- _logger.LogWarning("server did not exit gracefully, forcing termination");
693
- try
694
- {
695
- process.Kill();
696
- process.WaitForExit(2000);
697
- }
698
- catch (Exception ex)
699
- {
700
- _logger.LogDebug("Force kill error: {message}", ex.Message);
701
- }
702
- }
703
- }
704
- catch (InvalidOperationException ex)
705
- {
706
- _logger.LogDebug("Process already exited or disposed while waiting for exit: {message}", ex.Message);
707
- }
708
-
709
- // Ensure cleanup on the main thread.
710
- // Safe to call even if OnProcessExited already triggered cleanup.
711
- MainThread.Instance.Run(CleanupProcess);
712
- });
713
- }
714
-
715
- /// <summary>
716
- /// Returns the PID of the process listening on the specified TCP port,
717
- /// or -1 if no process is found or the lookup fails.
718
- /// </summary>
719
- static int GetPidListeningOnPort(int port)
720
- {
721
- try
722
- {
723
- var startInfo = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
724
- ? new ProcessStartInfo
725
- {
726
- FileName = "netstat",
727
- Arguments = "-ano -p tcp",
728
- UseShellExecute = false,
729
- CreateNoWindow = true,
730
- RedirectStandardOutput = true
731
- }
732
- : new ProcessStartInfo
733
- {
734
- FileName = "lsof",
735
- Arguments = $"-ti tcp:{port} -sTCP:LISTEN",
736
- UseShellExecute = false,
737
- CreateNoWindow = true,
738
- RedirectStandardOutput = true,
739
- RedirectStandardError = true
740
- };
741
-
742
- using var process = Process.Start(startInfo);
743
- if (process == null) return -1;
744
-
745
- var output = process.StandardOutput.ReadToEnd();
746
- process.WaitForExit(5000);
747
-
748
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
749
- {
750
- var portSuffix = $":{port}";
751
- foreach (var line in output.Split('\n'))
752
- {
753
- var trimmed = line.Trim();
754
- if (!trimmed.Contains("LISTENING"))
755
- continue;
756
-
757
- var parts = trimmed.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
758
- if (parts.Length < 5)
759
- continue;
760
-
761
- var localAddress = parts[1];
762
- if (localAddress.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out var pid))
763
- return pid;
764
- }
765
- }
766
- else
767
- {
768
- var trimmed = output.Trim();
769
- if (string.IsNullOrEmpty(trimmed))
770
- return -1;
771
-
772
- var firstLine = trimmed.Split('\n')[0].Trim();
773
- if (int.TryParse(firstLine, out var pid))
774
- return pid;
775
- }
776
- }
777
- catch (Exception ex)
778
- {
779
- _logger.LogDebug("Failed to determine PID listening on port {port}: {message}", port, ex.Message);
780
- }
781
-
782
- return -1;
783
- }
784
-
785
- /// <summary>
786
- /// Builds the argument list for the Node.js MCP server as a structured collection
787
- /// so each entry can be passed to <see cref="ProcessStartInfo.ArgumentList"/>
788
- /// verbatim. The runtime escapes each entry individually — safe for values that
789
- /// contain spaces, quotes, or other characters the shell would otherwise interpret.
790
- /// </summary>
791
- static IReadOnlyList<string> BuildArgumentList(string serverEntry)
792
- {
793
- var port = UnityCopilotPluginEditor.Port;
794
- var timeout = UnityCopilotPluginEditor.TimeoutMs;
795
- var token = UnityCopilotPluginEditor.Token;
796
- var authOption = UnityCopilotPluginEditor.AuthOption;
797
-
798
- // The plugin always talks to the server over REST + WebSocket
799
- // (streamable HTTP transport), so no client-transport flag is needed.
800
- var args = new List<string>(9)
801
- {
802
- serverEntry,
803
- "--port", port.ToString(),
804
- "--plugin-timeout-ms", timeout.ToString(),
805
- "--authorization", authOption.ToString()
806
- };
807
-
808
- if (authOption == AuthOption.required && !string.IsNullOrEmpty(token))
809
- {
810
- args.Add("--token");
811
- args.Add(token!);
812
- }
813
-
814
- return args;
815
- }
816
-
817
- /// <summary>
818
- /// Schedules a verification check 5 seconds after startup to detect early crashes.
819
- /// If the process is still running after verification, the status is set to Running.
820
- /// If the process has exited and no longer exists, the status is set to Stopped.
821
- /// </summary>
822
- static void ScheduleStartupVerification(int processId)
823
- {
824
- var startTime = DateTime.UtcNow;
825
- const double verificationDelaySeconds = 5.0;
826
-
827
- void CheckProcess()
828
- {
829
- // If status is no longer Starting (e.g., OnProcessExited already cleaned up), unsubscribe
830
- if (_serverStatus.CurrentValue != CopilotServerStatus.Starting)
831
- {
832
- EditorApplication.update -= CheckProcess;
833
- return;
834
- }
835
-
836
- var elapsed = DateTime.UtcNow - startTime;
837
-
838
- // If we haven't reached verification delay yet, wait for next frame
839
- if (elapsed.TotalSeconds < verificationDelaySeconds)
840
- return;
841
-
842
- // Detect early process exit before the verification delay
843
- // This catches crashes that happen within the first few seconds (e.g., port already in use)
844
- if (!IsProcessRunning(processId))
845
- {
846
- _logger.LogError("server process (PID: {pid}) exited early within {seconds:F1} seconds after launch",
847
- processId, elapsed.TotalSeconds);
848
-
849
- EditorApplication.update -= CheckProcess;
850
- if (_serverStatus.CurrentValue == CopilotServerStatus.Starting)
851
- CleanupProcess();
852
- return;
853
- }
854
-
855
- // Process is still running after the verification delay - mark as Running
856
- _logger.LogDebug("server process (PID: {pid}) is still running after {seconds:F1}s verification",
857
- processId, elapsed.TotalSeconds);
858
-
859
- EditorApplication.update -= CheckProcess;
860
- if (_serverStatus.CurrentValue == CopilotServerStatus.Starting)
861
- {
862
- _serverStatus.Value = CopilotServerStatus.Running;
863
- _logger.LogInformation("server verified and running (PID: {pid})", processId);
864
- }
865
- }
866
-
867
- EditorApplication.update += CheckProcess;
868
- }
869
-
870
- /// <summary>
871
- /// Checks if a process with the given ID is still running and is the Node server.
872
- /// </summary>
873
- static bool IsProcessRunning(int processId)
874
- {
875
- try
876
- {
877
- var process = Process.GetProcessById(processId);
878
- if (process == null || process.HasExited)
879
- return false;
880
-
881
- var processName = process.ProcessName.ToLowerInvariant();
882
- return processName.Contains(NodeProcessName);
883
- }
884
- catch (ArgumentException)
885
- {
886
- // Process with this ID does not exist
887
- return false;
888
- }
889
- catch (InvalidOperationException)
890
- {
891
- // Process has exited
892
- return false;
893
- }
894
- catch (Exception ex)
895
- {
896
- _logger.LogDebug("Error checking process status: {message}", ex.Message);
897
- return false;
898
- }
899
- }
900
-
901
- static void OnProcessExited(object? sender, EventArgs e)
902
- {
903
- _logger.LogInformation("server process exited");
904
- // Marshal to main thread since this event is raised from a thread pool thread
905
- // and CleanupProcess modifies reactive properties that may be observed on the main thread
906
- MainThread.Instance.Run(CleanupProcess);
907
- }
908
-
909
- static void OnOutputDataReceived(object sender, DataReceivedEventArgs e)
910
- {
911
- if (!string.IsNullOrEmpty(e.Data))
912
- {
913
- _logger.LogDebug("[Server] {output}", e.Data);
914
- }
915
- }
916
-
917
- static void OnErrorDataReceived(object sender, DataReceivedEventArgs e)
918
- {
919
- if (!string.IsNullOrEmpty(e.Data))
920
- {
921
- _logger.LogWarning("[Server Error] {error}", e.Data);
922
- }
923
- }
924
-
925
- static void CleanupProcess()
926
- {
927
- _logger.LogDebug("Cleaning up server process resources");
928
- lock (_processMutex)
929
- {
930
- var processToDispose = _serverProcess;
931
- _serverProcess = null;
932
-
933
- if (processToDispose != null)
934
- {
935
- processToDispose.Exited -= OnProcessExited;
936
- processToDispose.OutputDataReceived -= OnOutputDataReceived;
937
- processToDispose.ErrorDataReceived -= OnErrorDataReceived;
938
-
939
- // Dispose on a background thread to prevent deadlock.
940
- // Process.Dispose() can hang on the main thread when redirected
941
- // stdout/stderr streams are active, even after CancelOutputRead/CancelErrorRead.
942
- Task.Run(() =>
943
- {
944
- try
945
- {
946
- try { processToDispose.CancelOutputRead(); } catch { }
947
- try { processToDispose.CancelErrorRead(); } catch { }
948
- processToDispose.Dispose();
949
- }
950
- catch (Exception ex)
951
- {
952
- _logger.LogDebug("Error disposing server process: {message}", ex.Message);
953
- }
954
- });
955
- }
956
-
957
- EditorPrefs.DeleteKey(ProcessIdKey);
958
- _serverStatus.Value = CopilotServerStatus.Stopped;
959
- }
960
- }
961
-
962
- /// <summary>
963
- /// Starts the server if KeepServerRunning is enabled and no external server is detected.
964
- /// This method is called during Unity Editor startup to auto-start the server based on user preference.
965
- /// The external server check is performed asynchronously to avoid blocking the main thread.
966
- /// </summary>
967
- public static void StartServerIfNeeded()
968
- {
969
- EditorApplication.update -= StartServerIfNeeded;
970
-
971
- // Check if user wants the server to keep running
972
- if (!UnityCopilotPluginEditor.KeepServerRunning)
973
- {
974
- _logger.LogDebug("StartServerIfNeeded: KeepServerRunning is false, skipping auto-start");
975
- return;
976
- }
977
-
978
- // Check if server is already running (either local or detected from previous session)
979
- if (_serverStatus.CurrentValue == CopilotServerStatus.Running ||
980
- _serverStatus.CurrentValue == CopilotServerStatus.Starting)
981
- {
982
- _logger.LogDebug("StartServerIfNeeded: Server is already running or starting");
983
- return;
984
- }
985
-
986
- // Check if an external server is available on the port (non-blocking)
987
- var port = UnityCopilotPluginEditor.Port;
988
- CheckExternalServerAsync(port, externalAvailable =>
989
- {
990
- if (externalAvailable)
991
- {
992
- _logger.LogInformation("StartServerIfNeeded: External server detected on port {port}, skipping local server start", port);
993
- return;
994
- }
995
-
996
- // Start the local server
997
- _logger.LogInformation("StartServerIfNeeded: Starting local server (KeepServerRunning=true)");
998
- StartServer();
999
- });
1000
- }
1001
-
1002
- /// <summary>
1003
- /// Checks if an external server is listening on the given port on a background thread,
1004
- /// then invokes the callback on the main thread with the result.
1005
- /// </summary>
1006
- static void CheckExternalServerAsync(int port, Action<bool> onResult)
1007
- {
1008
- Task.Run(() =>
1009
- {
1010
- var result = false;
1011
- try
1012
- {
1013
- using var client = new System.Net.Sockets.TcpClient();
1014
- var connectTask = client.ConnectAsync("localhost", port);
1015
- var completed = connectTask.Wait(500); // 500ms timeout
1016
-
1017
- if (completed && client.Connected)
1018
- {
1019
- _logger.LogDebug("CheckExternalServerAsync: Port {port} is in use by another process", port);
1020
- result = true;
1021
- }
1022
- }
1023
- catch (Exception ex)
1024
- {
1025
- _logger.LogDebug("CheckExternalServerAsync: No server detected on port {port} ({message})", port, ex.Message);
1026
- }
1027
- return result;
1028
- })
1029
- .ContinueWith(task => onResult(task.Result), TaskScheduler.FromCurrentSynchronizationContext());
1030
- }
1031
-
1032
- #endregion // Process Lifecycle
1033
- }
1034
- }
1
+ /*
2
+ ┌──────────────────────────────────────────────────────────────────┐
3
+ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
+ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
+ │ Copyright (c) 2025 Ivan Murzak │
6
+ │ Licensed under the Apache License, Version 2.0. │
7
+ │ See the LICENSE file in the project root for more information. │
8
+ └──────────────────────────────────────────────────────────────────┘
9
+ */
10
+
11
+ #nullable enable
12
+ using System;
13
+ using System.Collections.Generic;
14
+ using System.Diagnostics;
15
+ using System.IO;
16
+ using System.Runtime.InteropServices;
17
+ using System.Text.Json.Nodes;
18
+ using System.Threading.Tasks;
19
+ using com.IvanMurzak.ReflectorNet.Utils;
20
+ using com.AtelierAI.Unity.Copilot.Editor.Utils;
21
+ using com.AtelierAI.Unity.Copilot.Runtime.Utils;
22
+ using com.AtelierAI.Unity.Copilot.Utils;
23
+ using Microsoft.Extensions.Logging;
24
+ using R3;
25
+ using UnityEditor;
26
+
27
+ namespace com.AtelierAI.Unity.Copilot.Editor
28
+ {
29
+ using static com.AtelierAI.Uco.Framework.Common.Consts.Uco.Server;
30
+ using Consts = com.AtelierAI.Uco.Framework.Common.Consts;
31
+ using ILogger = Microsoft.Extensions.Logging.ILogger;
32
+
33
+ public enum CopilotServerStatus
34
+ {
35
+ Stopped,
36
+ Starting,
37
+ Running,
38
+ Stopping,
39
+ External
40
+ }
41
+
42
+ /// <summary>
43
+ /// Manages the Node.js Uco server (cocli) process lifecycle independently from UI.
44
+ /// The server entry script is discovered from the plugin config (nodeServerPath),
45
+ /// the Unity project's node_modules, or the npm global installation — the plugin no
46
+ /// longer stages or downloads a server binary into Library/.
47
+ /// Provides cross-platform support for Windows, macOS, and Linux.
48
+ /// </summary>
49
+ [InitializeOnLoad]
50
+ public static class CopilotServerManager
51
+ {
52
+ const string ProcessIdKey = "CopilotServerManager_ProcessId";
53
+
54
+ // The Node.js Uco server runs as a "node" process.
55
+ const string NodeProcessName = "node";
56
+
57
+ static readonly ILogger _logger = UnityLoggerFactory.LoggerFactory.CreateLogger(typeof(CopilotServerManager));
58
+ static readonly ReactiveProperty<CopilotServerStatus> _serverStatus = new(CopilotServerStatus.Stopped);
59
+ static readonly object _processMutex = new();
60
+
61
+ static Process? _serverProcess;
62
+
63
+ public static ReadOnlyReactiveProperty<CopilotServerStatus> ServerStatus => _serverStatus;
64
+
65
+ public static bool IsRunning => _serverStatus.CurrentValue == CopilotServerStatus.Running;
66
+ public static bool IsStarting => _serverStatus.CurrentValue == CopilotServerStatus.Starting;
67
+
68
+ static CopilotServerManager()
69
+ {
70
+ if (!UnityProcessGuard.ShouldInitialize(AssetDatabase.IsAssetImportWorkerProcess()))
71
+ return;
72
+
73
+ // Register for editor quit to clean up the server process
74
+ EditorApplication.quitting += OnEditorQuitting;
75
+
76
+ // Check if server process is still running (e.g., after domain reload)
77
+ EditorApplication.update += CheckExistingProcess;
78
+
79
+ if (EnvironmentUtils.IsCi())
80
+ return; // Skip auto-start in CI environment
81
+
82
+ EditorApplication.update += StartServerIfNeeded;
83
+ }
84
+
85
+ #region Node Server Discovery
86
+
87
+ /// <summary>
88
+ /// npm package directories that ship the Node.js server, newest first: the
89
+ /// published scoped package (@atelierai/uco — the registry rejected the bare
90
+ /// short name), the pre-scope local layout, and the deprecated cocli install
91
+ /// from the private era.
92
+ /// </summary>
93
+ public static readonly string[] NodeServerPackageNames = { "@atelierai/uco", "uco", "cocli" };
94
+
95
+ /// <summary>
96
+ /// Path of the server entry script inside a <see cref="NodeServerPackageNames"/> package.
97
+ /// </summary>
98
+ public const string NodeServerEntryRelativePath = "bin/server.mjs";
99
+
100
+ /// <summary>
101
+ /// Resolves the Node.js server entry script (uco's bin/server.mjs) to launch.
102
+ /// Lookup order:
103
+ /// 1. Explicit <c>nodeServerPath</c> from the plugin config (absolute, or relative
104
+ /// to the Unity project root) — when set, it must exist or launching is refused.
105
+ /// 2. uco installed in the Unity project's node_modules.
106
+ /// 3. uco installed globally via npm.
107
+ /// Returns null when nothing is found — the caller must not launch in that case,
108
+ /// but the plugin can still connect to an already-running server.
109
+ /// </summary>
110
+ public static string? ResolveNodeServerEntry()
111
+ {
112
+ // 1) Explicit path from the config
113
+ var configured = UnityCopilotPluginEditor.NodeServerPath;
114
+ if (!string.IsNullOrWhiteSpace(configured))
115
+ {
116
+ var path = Path.GetFullPath(Path.IsPathRooted(configured)
117
+ ? configured
118
+ : Path.Combine(UnityCopilotPluginEditor.ProjectRootPath, configured));
119
+
120
+ if (File.Exists(path))
121
+ return path;
122
+
123
+ _logger.LogError(
124
+ "nodeServerPath is set in the plugin config but does not exist: {path}. " +
125
+ "Fix the path or clear it to let the plugin auto-discover the server.",
126
+ path);
127
+ return null;
128
+ }
129
+
130
+ // 2) Installed in the Unity project (@atelierai/uco first, legacy names after)
131
+ foreach (var packageName in NodeServerPackageNames)
132
+ {
133
+ var projectLocal = Path.GetFullPath(Path.Combine(
134
+ UnityCopilotPluginEditor.ProjectRootPath,
135
+ "node_modules",
136
+ packageName,
137
+ NodeServerEntryRelativePath));
138
+ if (File.Exists(projectLocal))
139
+ return projectLocal;
140
+ }
141
+
142
+ // 3) Installed globally via npm (@atelierai/uco first, legacy names after)
143
+ foreach (var globalRoot in GetNpmGlobalRoots())
144
+ {
145
+ foreach (var packageName in NodeServerPackageNames)
146
+ {
147
+ try
148
+ {
149
+ var globalPath = Path.Combine(globalRoot, packageName, NodeServerEntryRelativePath);
150
+ if (File.Exists(globalPath))
151
+ return Path.GetFullPath(globalPath);
152
+ }
153
+ catch
154
+ {
155
+ // Inaccessible candidate — skip it.
156
+ }
157
+ }
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ /// <summary>
164
+ /// Standard npm global installation roots (the <c>npm root -g</c> equivalents).
165
+ /// </summary>
166
+ static IEnumerable<string> GetNpmGlobalRoots()
167
+ {
168
+ // Windows: %AppData%\npm\node_modules
169
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
170
+ if (!string.IsNullOrEmpty(appData))
171
+ yield return Path.Combine(appData, "npm", "node_modules");
172
+
173
+ // Unix: default prefixes for system-wide npm installs
174
+ yield return "/usr/local/lib/node_modules";
175
+ yield return "/usr/lib/node_modules";
176
+ }
177
+
178
+ /// <summary>
179
+ /// Resolves the Node.js runtime executable. Probes PATH explicitly because
180
+ /// Process.Start with UseShellExecute=false does not reliably search PATH for
181
+ /// the application name on every runtime Unity ships with. Falls back to the
182
+ /// bare name so runtimes that do search PATH still work.
183
+ /// </summary>
184
+ public static string ResolveNodeExecutable()
185
+ {
186
+ var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "node.exe" : "node";
187
+
188
+ var candidates = new List<string>();
189
+ var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
190
+ var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':';
191
+ foreach (var directory in pathVariable.Split(separator, StringSplitOptions.RemoveEmptyEntries))
192
+ {
193
+ try { candidates.Add(Path.Combine(directory.Trim().Trim('"'), fileName)); }
194
+ catch { /* Malformed PATH entry — skip it. */ }
195
+ }
196
+
197
+ // Common install locations, in case node is not on the editor's PATH
198
+ // (e.g. Unity launched from a GUI session with a stale environment).
199
+ var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
200
+ if (!string.IsNullOrEmpty(programFiles))
201
+ candidates.Add(Path.Combine(programFiles, "nodejs", fileName));
202
+ var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
203
+ if (!string.IsNullOrEmpty(programFilesX86))
204
+ candidates.Add(Path.Combine(programFilesX86, "nodejs", fileName));
205
+
206
+ foreach (var candidate in candidates)
207
+ {
208
+ try
209
+ {
210
+ if (File.Exists(candidate))
211
+ return candidate;
212
+ }
213
+ catch
214
+ {
215
+ // Inaccessible candidate — skip it.
216
+ }
217
+ }
218
+
219
+ // Fall back to the bare name and let the OS resolve it.
220
+ return fileName;
221
+ }
222
+
223
+ #endregion // Node Server Discovery
224
+
225
+
226
+ #region Process Lifecycle
227
+
228
+ static void CheckExistingProcess()
229
+ {
230
+ EditorApplication.update -= CheckExistingProcess;
231
+ // Try to find an existing server process by checking if our tracked PID is still running
232
+ // This helps maintain state across domain reloads
233
+ var savedPid = EditorPrefs.GetInt(ProcessIdKey, -1);
234
+ if (savedPid > 0)
235
+ {
236
+ try
237
+ {
238
+ var process = Process.GetProcessById(savedPid);
239
+ if (process != null && !process.HasExited)
240
+ {
241
+ var processName = process.ProcessName.ToLowerInvariant();
242
+ // The server runs as a "node" process, and PIDs get reused, so also
243
+ // verify the process still owns this project's port before
244
+ // re-attaching otherwise we might adopt an unrelated node process
245
+ // (dev server, ...) that recycled our PID.
246
+ if (processName.Contains(NodeProcessName) &&
247
+ GetPidListeningOnPort(UnityCopilotPluginEditor.Port) == savedPid)
248
+ {
249
+ _serverProcess = process;
250
+ _serverStatus.Value = CopilotServerStatus.Running;
251
+ _logger.LogInformation("Reconnected to existing server process (PID: {pid})", savedPid);
252
+
253
+ // Re-attach exit handler
254
+ process.EnableRaisingEvents = true;
255
+ process.Exited += OnProcessExited;
256
+
257
+ // Schedule verification check to detect if process crashes shortly after reconnection
258
+ ScheduleStartupVerification(savedPid);
259
+ return;
260
+ }
261
+ }
262
+ }
263
+ catch (Exception ex)
264
+ {
265
+ _logger.LogDebug("Could not reconnect to previous process: {message}", ex.Message);
266
+ }
267
+
268
+ // Clear stale PID
269
+ EditorPrefs.DeleteKey(ProcessIdKey);
270
+ }
271
+ }
272
+
273
+ static void OnEditorQuitting()
274
+ {
275
+ StopServer(force: true);
276
+ }
277
+
278
+ public static bool StartServer()
279
+ {
280
+ lock (_processMutex)
281
+ {
282
+ if (_serverStatus.CurrentValue == CopilotServerStatus.Running ||
283
+ _serverStatus.CurrentValue == CopilotServerStatus.Starting ||
284
+ _serverStatus.CurrentValue == CopilotServerStatus.Stopping)
285
+ {
286
+ _logger.LogWarning("server is already {status}", _serverStatus.CurrentValue);
287
+ return false;
288
+ }
289
+
290
+ // ── Safe-defaults (fail-closed) gate ──────────────────────────────────────
291
+ // Refuse to start the local server when the current configuration would
292
+ // expose it to the network without explicit opt-in. The gate is consulted
293
+ // BEFORE we mutate _serverStatus / spawn the process so a reject leaves the
294
+ // manager in a fully clean state. See SafeDefaultsGuard for the full policy.
295
+ if (SafeDefaultsGuard.ShouldRejectStart(out var rejectReason))
296
+ {
297
+ _logger.LogError("Refusing to start server: {reason}", rejectReason);
298
+ var openSettings = EditorUtility.DisplayDialog(
299
+ title: "Server: unsafe configuration",
300
+ message: rejectReason,
301
+ ok: "Open Settings",
302
+ cancel: "Cancel");
303
+ if (openSettings)
304
+ {
305
+ try { com.AtelierAI.Unity.Copilot.Editor.UI.MainWindowEditor.ShowWindowVoid(); }
306
+ catch (Exception openEx)
307
+ {
308
+ _logger.LogDebug("Failed to open Game Developer window: {message}", openEx.Message);
309
+ }
310
+ }
311
+ return false;
312
+ }
313
+
314
+ // If something already listens on this project's port, treat it as an
315
+ // already-running server (e.g. started externally by the user or a
316
+ // previous editor session). Do not launch a second instance — just let
317
+ // the plugin's connect flow attach to it.
318
+ var ownPid = -1;
319
+ try { ownPid = _serverProcess?.Id ?? -1; } catch { /* process gone */ }
320
+ var listeningPid = GetPidListeningOnPort(UnityCopilotPluginEditor.Port);
321
+ if (listeningPid > 0 && listeningPid != ownPid)
322
+ {
323
+ _logger.LogInformation(
324
+ "A server is already listening on port {port} (PID: {pid}) — skipping local launch, the plugin will connect to it",
325
+ UnityCopilotPluginEditor.Port, listeningPid);
326
+ return true;
327
+ }
328
+
329
+ var serverEntry = ResolveNodeServerEntry();
330
+ if (serverEntry == null)
331
+ {
332
+ _logger.LogError(
333
+ "Node.js Uco server entry not found. Install cocli (npm i -g cocli), or install it into the " +
334
+ "Unity project (npm i cocli), or set 'nodeServerPath' in '{config}' to the absolute path of " +
335
+ "cocli's bin/server.mjs. The plugin will still connect to an already-running server.",
336
+ UnityCopilotPluginEditor.AssetsFilePath);
337
+ return false;
338
+ }
339
+
340
+ _serverStatus.Value = CopilotServerStatus.Starting;
341
+
342
+ try
343
+ {
344
+ var nodeExecutable = ResolveNodeExecutable();
345
+ var argumentList = BuildArgumentList(serverEntry);
346
+
347
+ _logger.LogInformation("Starting server: {path} {args}", nodeExecutable, string.Join(" ", argumentList));
348
+
349
+ var startInfo = new ProcessStartInfo
350
+ {
351
+ FileName = nodeExecutable,
352
+ UseShellExecute = false,
353
+ CreateNoWindow = true,
354
+ RedirectStandardOutput = true,
355
+ RedirectStandardError = true,
356
+ WorkingDirectory = UnityCopilotPluginEditor.ProjectRootPath
357
+ };
358
+
359
+ // Use ArgumentList (Collection<string>) rather than the legacy single
360
+ // Arguments string so that any argument containing spaces or quote
361
+ // characters is escaped correctly by the runtime. Required because the
362
+ // server entry path may live under a project path that contains spaces.
363
+ foreach (var arg in argumentList)
364
+ startInfo.ArgumentList.Add(arg);
365
+
366
+ _serverProcess = new Process
367
+ {
368
+ StartInfo = startInfo,
369
+ EnableRaisingEvents = true
370
+ };
371
+ _serverProcess.Exited += OnProcessExited;
372
+ _serverProcess.OutputDataReceived += OnOutputDataReceived;
373
+ _serverProcess.ErrorDataReceived += OnErrorDataReceived;
374
+
375
+ if (!_serverProcess.Start())
376
+ {
377
+ _logger.LogError("Failed to start server process");
378
+ CleanupProcess();
379
+ return false;
380
+ }
381
+
382
+ _serverProcess.BeginOutputReadLine();
383
+ _serverProcess.BeginErrorReadLine();
384
+
385
+ // Save PID for reconnection after domain reload
386
+ EditorPrefs.SetInt(ProcessIdKey, _serverProcess.Id);
387
+
388
+ // Keep status as Starting - it will be set to Running after verification
389
+ _logger.LogInformation("server process started (PID: {pid}), awaiting verification...", _serverProcess.Id);
390
+
391
+ // Schedule a delayed check to verify the process is still running
392
+ // This catches early crashes that might not trigger the Exited event reliably
393
+ // Status will be set to Running only after successful verification
394
+ ScheduleStartupVerification(_serverProcess.Id);
395
+
396
+ return true;
397
+ }
398
+ catch (Exception ex)
399
+ {
400
+ _logger.LogError("Failed to start server: {message}", ex.Message);
401
+ CleanupProcess();
402
+ return false;
403
+ }
404
+ }
405
+ }
406
+
407
+ /// <summary>
408
+ /// Stops the server process.
409
+ /// By default, this method is non-blocking: it sends the kill/terminate signal
410
+ /// and lets the Exited event handler perform cleanup asynchronously.
411
+ /// When force is true (e.g., editor quitting), it blocks until the process exits.
412
+ /// </summary>
413
+ public static bool StopServer(bool force = false)
414
+ {
415
+ lock (_processMutex)
416
+ {
417
+ if (_serverStatus.CurrentValue == CopilotServerStatus.Stopped ||
418
+ _serverStatus.CurrentValue == CopilotServerStatus.Stopping)
419
+ {
420
+ _logger.LogDebug("server is already stopped or stopping");
421
+ return true;
422
+ }
423
+
424
+ if (_serverProcess == null)
425
+ {
426
+ _serverStatus.Value = CopilotServerStatus.Stopped;
427
+ EditorPrefs.DeleteKey(ProcessIdKey);
428
+ return true;
429
+ }
430
+
431
+ _serverStatus.Value = CopilotServerStatus.Stopping;
432
+
433
+ try
434
+ {
435
+ _logger.LogInformation("Stopping server (PID: {pid})", _serverProcess.Id);
436
+
437
+ if (!_serverProcess.HasExited)
438
+ {
439
+ SendTerminateSignal();
440
+ }
441
+
442
+ if (force)
443
+ {
444
+ // Synchronous path: block until exit (used during editor quitting)
445
+ WaitForExitAndForceKillIfNeeded();
446
+ CleanupProcess();
447
+ }
448
+ else
449
+ {
450
+ if (_serverProcess.HasExited)
451
+ {
452
+ CleanupProcess();
453
+ }
454
+ else
455
+ {
456
+ // Non-blocking path: schedule background wait + force kill safety net.
457
+ // CleanupProcess will be called by OnProcessExited or the background task.
458
+ ScheduleForceKillIfNeeded();
459
+ }
460
+ }
461
+
462
+ _logger.LogInformation("server stop initiated");
463
+ return true;
464
+ }
465
+ catch (Exception ex)
466
+ {
467
+ _logger.LogError("Error stopping server: {message}", ex.Message);
468
+ CleanupProcess();
469
+ return false;
470
+ }
471
+ }
472
+ }
473
+
474
+ /// <summary>
475
+ /// Sends the platform-appropriate terminate signal without waiting for exit.
476
+ /// </summary>
477
+ static void SendTerminateSignal()
478
+ {
479
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
480
+ {
481
+ _serverProcess!.Kill();
482
+ }
483
+ else
484
+ {
485
+ // On Unix-like systems, send SIGTERM for graceful shutdown
486
+ try
487
+ {
488
+ using var killProcess = Process.Start(new ProcessStartInfo
489
+ {
490
+ FileName = "kill",
491
+ Arguments = $"-TERM {_serverProcess!.Id}",
492
+ UseShellExecute = false,
493
+ CreateNoWindow = true
494
+ });
495
+ killProcess?.WaitForExit(1000);
496
+ }
497
+ catch (Exception ex)
498
+ {
499
+ _logger.LogDebug("SIGTERM failed, falling back to Kill(): {message}", ex.Message);
500
+ _serverProcess!.Kill();
501
+ }
502
+ }
503
+ }
504
+
505
+ /// <summary>
506
+ /// Blocking wait for process exit, with force-kill fallback.
507
+ /// Used only during editor quitting to prevent orphaned processes.
508
+ /// </summary>
509
+ static void WaitForExitAndForceKillIfNeeded()
510
+ {
511
+ if (_serverProcess == null || _serverProcess.HasExited)
512
+ return;
513
+
514
+ if (!_serverProcess.WaitForExit(5000))
515
+ {
516
+ _logger.LogWarning("server did not exit gracefully, forcing termination");
517
+ try
518
+ {
519
+ _serverProcess.Kill();
520
+ _serverProcess.WaitForExit(2000);
521
+ }
522
+ catch (Exception ex)
523
+ {
524
+ _logger.LogDebug("Force kill failed: {message}", ex.Message);
525
+ }
526
+ }
527
+ }
528
+
529
+ /// <summary>
530
+ /// Background safety net: waits for the process to exit and force-kills after timeout.
531
+ /// Calls CleanupProcess on the main thread when done.
532
+ /// </summary>
533
+ static void ScheduleForceKillIfNeeded()
534
+ {
535
+ var process = _serverProcess;
536
+ if (process == null)
537
+ return;
538
+
539
+ Task.Run(() =>
540
+ {
541
+ try
542
+ {
543
+ if (!process.HasExited && !process.WaitForExit(5000))
544
+ {
545
+ _logger.LogWarning("server did not exit gracefully, forcing termination");
546
+ try
547
+ {
548
+ process.Kill();
549
+ process.WaitForExit(2000);
550
+ }
551
+ catch (Exception ex)
552
+ {
553
+ _logger.LogDebug("Force kill error: {message}", ex.Message);
554
+ }
555
+ }
556
+ }
557
+ catch (InvalidOperationException ex)
558
+ {
559
+ _logger.LogDebug("Process already exited or disposed while waiting for exit: {message}", ex.Message);
560
+ }
561
+
562
+ // Ensure cleanup on the main thread.
563
+ // Safe to call even if OnProcessExited already triggered cleanup.
564
+ MainThread.Instance.Run(CleanupProcess);
565
+ });
566
+ }
567
+
568
+ /// <summary>
569
+ /// Returns the PID of the process listening on the specified TCP port,
570
+ /// or -1 if no process is found or the lookup fails.
571
+ /// </summary>
572
+ static int GetPidListeningOnPort(int port)
573
+ {
574
+ try
575
+ {
576
+ var startInfo = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
577
+ ? new ProcessStartInfo
578
+ {
579
+ FileName = "netstat",
580
+ Arguments = "-ano -p tcp",
581
+ UseShellExecute = false,
582
+ CreateNoWindow = true,
583
+ RedirectStandardOutput = true
584
+ }
585
+ : new ProcessStartInfo
586
+ {
587
+ FileName = "lsof",
588
+ Arguments = $"-ti tcp:{port} -sTCP:LISTEN",
589
+ UseShellExecute = false,
590
+ CreateNoWindow = true,
591
+ RedirectStandardOutput = true,
592
+ RedirectStandardError = true
593
+ };
594
+
595
+ using var process = Process.Start(startInfo);
596
+ if (process == null) return -1;
597
+
598
+ var output = process.StandardOutput.ReadToEnd();
599
+ process.WaitForExit(5000);
600
+
601
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
602
+ {
603
+ var portSuffix = $":{port}";
604
+ foreach (var line in output.Split('\n'))
605
+ {
606
+ var trimmed = line.Trim();
607
+ if (!trimmed.Contains("LISTENING"))
608
+ continue;
609
+
610
+ var parts = trimmed.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
611
+ if (parts.Length < 5)
612
+ continue;
613
+
614
+ var localAddress = parts[1];
615
+ if (localAddress.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out var pid))
616
+ return pid;
617
+ }
618
+ }
619
+ else
620
+ {
621
+ var trimmed = output.Trim();
622
+ if (string.IsNullOrEmpty(trimmed))
623
+ return -1;
624
+
625
+ var firstLine = trimmed.Split('\n')[0].Trim();
626
+ if (int.TryParse(firstLine, out var pid))
627
+ return pid;
628
+ }
629
+ }
630
+ catch (Exception ex)
631
+ {
632
+ _logger.LogDebug("Failed to determine PID listening on port {port}: {message}", port, ex.Message);
633
+ }
634
+
635
+ return -1;
636
+ }
637
+
638
+ /// <summary>
639
+ /// Builds the argument list for the Node.js Uco server as a structured collection
640
+ /// so each entry can be passed to <see cref="ProcessStartInfo.ArgumentList"/>
641
+ /// verbatim. The runtime escapes each entry individually — safe for values that
642
+ /// contain spaces, quotes, or other characters the shell would otherwise interpret.
643
+ /// </summary>
644
+ static IReadOnlyList<string> BuildArgumentList(string serverEntry)
645
+ {
646
+ var port = UnityCopilotPluginEditor.Port;
647
+ var timeout = UnityCopilotPluginEditor.TimeoutMs;
648
+ var token = UnityCopilotPluginEditor.Token;
649
+ var authOption = UnityCopilotPluginEditor.AuthOption;
650
+
651
+ // The plugin always talks to the server over REST + WebSocket
652
+ // (streamable HTTP transport), so no client-transport flag is needed.
653
+ var args = new List<string>(9)
654
+ {
655
+ serverEntry,
656
+ "--port", port.ToString(),
657
+ "--plugin-timeout-ms", timeout.ToString(),
658
+ "--authorization", authOption.ToString()
659
+ };
660
+
661
+ if (authOption == AuthOption.required && !string.IsNullOrEmpty(token))
662
+ {
663
+ args.Add("--token");
664
+ args.Add(token!);
665
+ }
666
+
667
+ return args;
668
+ }
669
+
670
+ /// <summary>
671
+ /// Schedules a verification check 5 seconds after startup to detect early crashes.
672
+ /// If the process is still running after verification, the status is set to Running.
673
+ /// If the process has exited and no longer exists, the status is set to Stopped.
674
+ /// </summary>
675
+ static void ScheduleStartupVerification(int processId)
676
+ {
677
+ var startTime = DateTime.UtcNow;
678
+ const double verificationDelaySeconds = 5.0;
679
+
680
+ void CheckProcess()
681
+ {
682
+ // If status is no longer Starting (e.g., OnProcessExited already cleaned up), unsubscribe
683
+ if (_serverStatus.CurrentValue != CopilotServerStatus.Starting)
684
+ {
685
+ EditorApplication.update -= CheckProcess;
686
+ return;
687
+ }
688
+
689
+ var elapsed = DateTime.UtcNow - startTime;
690
+
691
+ // If we haven't reached verification delay yet, wait for next frame
692
+ if (elapsed.TotalSeconds < verificationDelaySeconds)
693
+ return;
694
+
695
+ // Detect early process exit before the verification delay
696
+ // This catches crashes that happen within the first few seconds (e.g., port already in use)
697
+ if (!IsProcessRunning(processId))
698
+ {
699
+ _logger.LogError("server process (PID: {pid}) exited early within {seconds:F1} seconds after launch",
700
+ processId, elapsed.TotalSeconds);
701
+
702
+ EditorApplication.update -= CheckProcess;
703
+ if (_serverStatus.CurrentValue == CopilotServerStatus.Starting)
704
+ CleanupProcess();
705
+ return;
706
+ }
707
+
708
+ // Process is still running after the verification delay - mark as Running
709
+ _logger.LogDebug("server process (PID: {pid}) is still running after {seconds:F1}s verification",
710
+ processId, elapsed.TotalSeconds);
711
+
712
+ EditorApplication.update -= CheckProcess;
713
+ if (_serverStatus.CurrentValue == CopilotServerStatus.Starting)
714
+ {
715
+ _serverStatus.Value = CopilotServerStatus.Running;
716
+ _logger.LogInformation("server verified and running (PID: {pid})", processId);
717
+ }
718
+ }
719
+
720
+ EditorApplication.update += CheckProcess;
721
+ }
722
+
723
+ /// <summary>
724
+ /// Checks if a process with the given ID is still running and is the Node server.
725
+ /// </summary>
726
+ static bool IsProcessRunning(int processId)
727
+ {
728
+ try
729
+ {
730
+ var process = Process.GetProcessById(processId);
731
+ if (process == null || process.HasExited)
732
+ return false;
733
+
734
+ var processName = process.ProcessName.ToLowerInvariant();
735
+ return processName.Contains(NodeProcessName);
736
+ }
737
+ catch (ArgumentException)
738
+ {
739
+ // Process with this ID does not exist
740
+ return false;
741
+ }
742
+ catch (InvalidOperationException)
743
+ {
744
+ // Process has exited
745
+ return false;
746
+ }
747
+ catch (Exception ex)
748
+ {
749
+ _logger.LogDebug("Error checking process status: {message}", ex.Message);
750
+ return false;
751
+ }
752
+ }
753
+
754
+ static void OnProcessExited(object? sender, EventArgs e)
755
+ {
756
+ _logger.LogInformation("server process exited");
757
+ // Marshal to main thread since this event is raised from a thread pool thread
758
+ // and CleanupProcess modifies reactive properties that may be observed on the main thread
759
+ MainThread.Instance.Run(CleanupProcess);
760
+ }
761
+
762
+ static void OnOutputDataReceived(object sender, DataReceivedEventArgs e)
763
+ {
764
+ if (!string.IsNullOrEmpty(e.Data))
765
+ {
766
+ _logger.LogDebug("[Server] {output}", e.Data);
767
+ }
768
+ }
769
+
770
+ static void OnErrorDataReceived(object sender, DataReceivedEventArgs e)
771
+ {
772
+ if (!string.IsNullOrEmpty(e.Data))
773
+ {
774
+ _logger.LogWarning("[Server Error] {error}", e.Data);
775
+ }
776
+ }
777
+
778
+ static void CleanupProcess()
779
+ {
780
+ _logger.LogDebug("Cleaning up server process resources");
781
+ lock (_processMutex)
782
+ {
783
+ var processToDispose = _serverProcess;
784
+ _serverProcess = null;
785
+
786
+ if (processToDispose != null)
787
+ {
788
+ processToDispose.Exited -= OnProcessExited;
789
+ processToDispose.OutputDataReceived -= OnOutputDataReceived;
790
+ processToDispose.ErrorDataReceived -= OnErrorDataReceived;
791
+
792
+ // Dispose on a background thread to prevent deadlock.
793
+ // Process.Dispose() can hang on the main thread when redirected
794
+ // stdout/stderr streams are active, even after CancelOutputRead/CancelErrorRead.
795
+ Task.Run(() =>
796
+ {
797
+ try
798
+ {
799
+ try { processToDispose.CancelOutputRead(); } catch { }
800
+ try { processToDispose.CancelErrorRead(); } catch { }
801
+ processToDispose.Dispose();
802
+ }
803
+ catch (Exception ex)
804
+ {
805
+ _logger.LogDebug("Error disposing server process: {message}", ex.Message);
806
+ }
807
+ });
808
+ }
809
+
810
+ EditorPrefs.DeleteKey(ProcessIdKey);
811
+ _serverStatus.Value = CopilotServerStatus.Stopped;
812
+ }
813
+ }
814
+
815
+ /// <summary>
816
+ /// Starts the server if KeepServerRunning is enabled and no external server is detected.
817
+ /// This method is called during Unity Editor startup to auto-start the server based on user preference.
818
+ /// The external server check is performed asynchronously to avoid blocking the main thread.
819
+ /// </summary>
820
+ public static void StartServerIfNeeded()
821
+ {
822
+ EditorApplication.update -= StartServerIfNeeded;
823
+
824
+ // Check if user wants the server to keep running
825
+ if (!UnityCopilotPluginEditor.KeepServerRunning)
826
+ {
827
+ _logger.LogDebug("StartServerIfNeeded: KeepServerRunning is false, skipping auto-start");
828
+ return;
829
+ }
830
+
831
+ // Check if server is already running (either local or detected from previous session)
832
+ if (_serverStatus.CurrentValue == CopilotServerStatus.Running ||
833
+ _serverStatus.CurrentValue == CopilotServerStatus.Starting)
834
+ {
835
+ _logger.LogDebug("StartServerIfNeeded: Server is already running or starting");
836
+ return;
837
+ }
838
+
839
+ // Check if an external server is available on the port (non-blocking)
840
+ var port = UnityCopilotPluginEditor.Port;
841
+ CheckExternalServerAsync(port, externalAvailable =>
842
+ {
843
+ if (externalAvailable)
844
+ {
845
+ _logger.LogInformation("StartServerIfNeeded: External server detected on port {port}, skipping local server start", port);
846
+ return;
847
+ }
848
+
849
+ // Start the local server
850
+ _logger.LogInformation("StartServerIfNeeded: Starting local server (KeepServerRunning=true)");
851
+ StartServer();
852
+ });
853
+ }
854
+
855
+ /// <summary>
856
+ /// Checks if an external server is listening on the given port on a background thread,
857
+ /// then invokes the callback on the main thread with the result.
858
+ /// </summary>
859
+ static void CheckExternalServerAsync(int port, Action<bool> onResult)
860
+ {
861
+ Task.Run(() =>
862
+ {
863
+ var result = false;
864
+ try
865
+ {
866
+ using var client = new System.Net.Sockets.TcpClient();
867
+ var connectTask = client.ConnectAsync("localhost", port);
868
+ var completed = connectTask.Wait(500); // 500ms timeout
869
+
870
+ if (completed && client.Connected)
871
+ {
872
+ _logger.LogDebug("CheckExternalServerAsync: Port {port} is in use by another process", port);
873
+ result = true;
874
+ }
875
+ }
876
+ catch (Exception ex)
877
+ {
878
+ _logger.LogDebug("CheckExternalServerAsync: No server detected on port {port} ({message})", port, ex.Message);
879
+ }
880
+ return result;
881
+ })
882
+ .ContinueWith(task => onResult(task.Result), TaskScheduler.FromCurrentSynchronizationContext());
883
+ }
884
+
885
+ #endregion // Process Lifecycle
886
+ }
887
+ }