@atelierai/uco 1.0.7 → 1.0.8

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 (87) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +1 -1
  3. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetConfig.cs +132 -132
  4. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDependencyResolver.cs +237 -237
  5. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDownloader.cs +82 -82
  6. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/MenuItems.cs +247 -247
  7. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/ToolGroups.cs +153 -153
  8. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/SystemTool/Skills.Create.SkillBody.cs +155 -155
  9. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Console.GetLogs.cs +95 -95
  10. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Docs.HttpFetcher.cs +139 -139
  11. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Instance.ListAll.cs +72 -72
  12. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/PathReadHelper.cs +106 -106
  13. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Script.Validate.cs +252 -252
  14. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests/TestResultCollector.cs +537 -537
  15. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests.cs +400 -400
  16. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Branding/ProductInfo.cs +62 -62
  17. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +1034 -1034
  18. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Skills/Skill_InitialSetup.cs +197 -197
  19. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +201 -201
  20. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.cs +67 -67
  21. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotListWindowBase.cs +393 -393
  22. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotWindowBase.cs +113 -113
  23. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +3 -3
  24. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotFeatures.cs +195 -195
  25. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +20 -20
  26. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +354 -354
  27. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +78 -78
  28. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowInitializer.cs +47 -47
  29. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/SerializationCheckWindow.cs +190 -190
  30. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Build.cs +91 -91
  31. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +298 -298
  32. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +296 -296
  33. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.cs +69 -69
  34. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PackageUtils.cs +168 -168
  35. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PrefsKeyMigration.cs +119 -119
  36. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/SafeDefaultsGuard.cs +180 -180
  37. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ScriptUtils.cs +229 -229
  38. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolsManifestGenerator.cs +200 -200
  39. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringAdapters.cs +1467 -1467
  40. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringSafetyReloadGuard.cs +65 -65
  41. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityCopilotUpdateProjectSettings.cs +66 -66
  42. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
  43. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
  44. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
  45. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Converter/Json/Structures/EntityIdConverter.cs +120 -120
  46. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.cs +110 -110
  47. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.pre-Unity.6.5.cs +110 -110
  48. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +547 -547
  49. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Build.cs +243 -243
  50. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +3 -3
  51. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +295 -295
  52. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginBuilder.cs +105 -105
  53. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.Static.cs +155 -155
  54. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.cs +59 -59
  55. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +11 -11
  56. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/AtomicApiTestFixtures.cs +99 -99
  57. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/TryModifyAtTests.cs +227 -227
  58. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AuthoringSafetyPilotTests.cs +1050 -1050
  59. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +304 -304
  60. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +1 -1
  61. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/BlacklistTypeTests.cs +416 -416
  62. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.cs +172 -172
  63. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.pre-Unity.6.5.cs +169 -169
  64. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.cs +182 -182
  65. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.pre-Unity.6.5.cs +179 -179
  66. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.cs +175 -175
  67. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.pre-Unity.6.5.cs +174 -174
  68. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.cs +171 -171
  69. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.pre-Unity.6.5.cs +170 -170
  70. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsCreateFolderTests.cs +243 -243
  71. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsMaterialTests.cs +101 -101
  72. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.cs +348 -348
  73. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.pre-Unity.6.5.cs +348 -348
  74. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsShaderTests.cs +194 -194
  75. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.cs +573 -573
  76. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.pre-Unity.6.5.cs +513 -513
  77. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.cs +154 -154
  78. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.pre-Unity.6.5.cs +154 -154
  79. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotGameViewTests.cs +164 -164
  80. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotSceneViewTests.cs +155 -155
  81. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolParameterTests.cs +56 -56
  82. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.cs +651 -651
  83. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.pre-Unity.6.5.cs +651 -651
  84. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ToolBatchExecutionIntegrationTests.cs +407 -407
  85. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs → UcoPluginTests.cs} +105 -105
  86. package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
  87. /package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs.meta → UcoPluginTests.cs.meta} +0 -0
@@ -1,1034 +1,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.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
+ #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 Uco 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 Uco 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 Uco 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
+ }