@atelierai/uco 1.0.6 → 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.
- package/CHANGELOG.md +32 -0
- package/README.md +7 -8
- package/bin/server.mjs +1 -1
- package/bin/stub-plugin.mjs +3 -3
- package/dist/codegen/coerce.js +1 -1
- package/dist/codegen/coerce.js.map +1 -1
- package/dist/codegen/emit.js +2 -2
- package/dist/codegen/emit.js.map +1 -1
- package/dist/codegen/refs.js +1 -1
- package/dist/codegen/refs.js.map +1 -1
- package/dist/codegen/types.js +1 -1
- package/dist/codegen/types.js.map +1 -1
- package/dist/commands/call.js +1 -1
- package/dist/commands/call.js.map +1 -1
- package/dist/commands/devops/configure.js +1 -1
- package/dist/commands/devops/configure.js.map +1 -1
- package/dist/commands/devops/index.js +0 -4
- package/dist/commands/devops/index.js.map +1 -1
- package/dist/commands/devops/install-plugin.js +1 -1
- package/dist/commands/devops/install-plugin.js.map +1 -1
- package/dist/commands/devops/open.js +7 -7
- package/dist/commands/devops/open.js.map +1 -1
- package/dist/commands/devops/status.js +3 -3
- package/dist/commands/devops/status.js.map +1 -1
- package/dist/commands/exec.js +1 -1
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/install.js +2 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/prompt.js +2 -2
- package/dist/commands/prompt.js.map +1 -1
- package/dist/commands/resource.js +2 -2
- package/dist/commands/resource.js.map +1 -1
- package/dist/commands/update.js +1 -26
- package/dist/commands/update.js.map +1 -1
- package/dist/config/port.js +2 -2
- package/dist/config/port.js.map +1 -1
- package/dist/config/resolve.d.ts +1 -2
- package/dist/config/resolve.js +0 -12
- package/dist/config/resolve.js.map +1 -1
- package/dist/devops/lib/configure.d.ts +1 -1
- package/dist/devops/lib/configure.js +2 -2
- package/dist/devops/lib/configure.js.map +1 -1
- package/dist/devops/lib/install-plugin.d.ts +1 -1
- package/dist/devops/lib/install-plugin.js +1 -1
- package/dist/devops/lib/install-plugin.js.map +1 -1
- package/dist/devops/lib/install.d.ts +2 -2
- package/dist/devops/lib/install.js +5 -5
- package/dist/devops/lib/install.js.map +1 -1
- package/dist/devops/lib/open.js +11 -32
- package/dist/devops/lib/open.js.map +1 -1
- package/dist/devops/lib/remove-plugin.d.ts +1 -1
- package/dist/devops/lib/remove-plugin.js +3 -3
- package/dist/devops/lib/remove-plugin.js.map +1 -1
- package/dist/devops/lib/run-tool.d.ts +2 -2
- package/dist/devops/lib/run-tool.js +2 -2
- package/dist/devops/lib/types.d.ts +17 -54
- package/dist/devops/lib/types.js +2 -2
- package/dist/devops/lib/types.js.map +1 -1
- package/dist/devops/lib/update.d.ts +0 -15
- package/dist/devops/lib/update.js +2 -91
- package/dist/devops/lib/update.js.map +1 -1
- package/dist/devops/utils/agents.d.ts +0 -28
- package/dist/devops/utils/agents.js +0 -462
- package/dist/devops/utils/agents.js.map +1 -1
- package/dist/devops/utils/config.d.ts +9 -18
- package/dist/devops/utils/config.js +7 -20
- package/dist/devops/utils/config.js.map +1 -1
- package/dist/devops/utils/editor-cache.js +1 -1
- package/dist/devops/utils/editor-cache.js.map +1 -1
- package/dist/devops/utils/launch-error-dismiss.d.ts +2 -2
- package/dist/devops/utils/launch-error-dismiss.js +5 -5
- package/dist/devops/utils/manifest.d.ts +2 -2
- package/dist/devops/utils/manifest.js +3 -3
- package/dist/devops/utils/manifest.js.map +1 -1
- package/dist/devops/utils/port.d.ts +1 -1
- package/dist/devops/utils/port.js +1 -1
- package/dist/devops/utils/probe.d.ts +1 -1
- package/dist/devops/utils/probe.js +1 -1
- package/dist/devops/utils/ui.js +2 -2
- package/dist/devops/utils/ui.js.map +1 -1
- package/dist/devops/utils/unity-editor.js +4 -4
- package/dist/devops/utils/unity-editor.js.map +1 -1
- package/dist/devops/utils/unity-hub.js +1 -1
- package/dist/devops/utils/unity-hub.js.map +1 -1
- package/dist/devops/utils/unity-shutdown.d.ts +1 -1
- package/dist/devops/utils/unity-shutdown.js +1 -1
- package/dist/devops/utils/update-check.js +3 -3
- package/dist/devops/utils/update-check.js.map +1 -1
- package/dist/devops/utils/vendor.d.ts +2 -2
- package/dist/devops/utils/vendor.js +4 -4
- package/dist/devops/utils/vendor.js.map +1 -1
- package/dist/generated/tools.js +1 -1
- package/dist/generated/tools.js.map +1 -1
- package/dist/program.js +3 -3
- package/dist/program.js.map +1 -1
- package/dist/server/app.d.ts +5 -5
- package/dist/server/app.js +3 -3
- package/dist/server/app.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/rest/auth.d.ts +1 -1
- package/dist/server/rest/auth.js +3 -3
- package/dist/server/rest/auth.js.map +1 -1
- package/dist/server/rest/context.d.ts +2 -2
- package/dist/server/rest/forward.d.ts +2 -2
- package/dist/server/session/store.d.ts +3 -3
- package/dist/server/session/store.js +4 -4
- package/dist/server/session/store.js.map +1 -1
- package/dist/server/stub-client/scenarios.js +8 -8
- package/dist/server/stub-client/scenarios.js.map +1 -1
- package/dist/server/types.d.ts +11 -11
- package/dist/server/types.js +9 -9
- package/dist/server/types.js.map +1 -1
- package/dist/server/ws/hub.d.ts +8 -8
- package/dist/server/ws/hub.js +13 -13
- package/dist/server/ws/hub.js.map +1 -1
- package/dist/server/ws/rpc.d.ts +3 -3
- package/dist/server/ws/rpc.js +4 -4
- package/dist/server/ws/rpc.js.map +1 -1
- package/dist/transport/index.d.ts +1 -1
- package/dist/transport/index.js +3 -3
- package/dist/transport/index.js.map +1 -1
- package/dist/transport/rest.js +1 -1
- package/dist/transport/rest.js.map +1 -1
- package/docs/INSTALL.zh-CN.md +2 -3
- package/package.json +1 -1
- package/skills/uco-setup/SKILL.md +1 -1
- package/skills/unity-editor/references/diagnostics.md +24 -24
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetConfig.cs +132 -132
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDependencyResolver.cs +237 -237
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDownloader.cs +82 -82
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/MenuItems.cs +247 -247
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/ToolGroups.cs +153 -153
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/SystemTool/Skills.Create.SkillBody.cs +155 -155
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Console.GetLogs.cs +95 -95
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Docs.HttpFetcher.cs +139 -139
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Instance.ListAll.cs +72 -72
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/PathReadHelper.cs +106 -106
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Script.Validate.cs +252 -252
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests/TestResultCollector.cs +537 -537
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests.cs +400 -400
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Branding/ProductInfo.cs +62 -62
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +1034 -1034
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Skills/Skill_InitialSetup.cs +197 -197
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +201 -201
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.cs +67 -67
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Components/ExtensionPanel.cs +379 -379
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/MenuItems.cs +72 -72
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/UnityCopilotProjectSettingsProvider.cs +64 -64
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotListWindowBase.cs +393 -393
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotWindowBase.cs +113 -113
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +3 -3
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotFeatures.cs +195 -195
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +372 -372
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +354 -354
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +78 -78
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowInitializer.cs +47 -47
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/SerializationCheckWindow.cs +190 -190
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Build.cs +91 -91
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +298 -298
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +296 -296
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.cs +69 -69
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PackageUtils.cs +168 -168
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PrefsKeyMigration.cs +119 -119
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/SafeDefaultsGuard.cs +180 -180
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ScriptUtils.cs +229 -229
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolsManifestGenerator.cs +200 -200
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringAdapters.cs +1467 -1467
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringSafetyReloadGuard.cs +65 -65
- package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityCopilotUpdateProjectSettings.cs +66 -66
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Converter/Json/Structures/EntityIdConverter.cs +120 -120
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.cs +110 -110
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.pre-Unity.6.5.cs +110 -110
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +547 -547
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Build.cs +243 -243
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +175 -175
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +295 -295
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginBuilder.cs +105 -105
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.Static.cs +155 -155
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.cs +59 -59
- package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +368 -368
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/AtomicApiTestFixtures.cs +99 -99
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/TryModifyAtTests.cs +227 -227
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AuthoringSafetyPilotTests.cs +1050 -1050
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +304 -304
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +4 -4
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/BlacklistTypeTests.cs +416 -416
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.cs +172 -172
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.pre-Unity.6.5.cs +169 -169
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.cs +182 -182
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.pre-Unity.6.5.cs +179 -179
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.cs +175 -175
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.pre-Unity.6.5.cs +174 -174
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.cs +171 -171
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.pre-Unity.6.5.cs +170 -170
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsCreateFolderTests.cs +243 -243
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsMaterialTests.cs +101 -101
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.cs +348 -348
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.pre-Unity.6.5.cs +348 -348
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsShaderTests.cs +194 -194
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.cs +573 -573
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.pre-Unity.6.5.cs +513 -513
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.cs +154 -154
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.pre-Unity.6.5.cs +154 -154
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotGameViewTests.cs +164 -164
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotSceneViewTests.cs +155 -155
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolParameterTests.cs +56 -56
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.cs +651 -651
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.pre-Unity.6.5.cs +651 -651
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ToolBatchExecutionIntegrationTests.cs +407 -407
- package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs → UcoPluginTests.cs} +105 -105
- package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
- package/dist/commands/devops/login.d.ts +0 -2
- package/dist/commands/devops/login.js +0 -29
- package/dist/commands/devops/login.js.map +0 -1
- package/dist/commands/devops/setup-mcp.d.ts +0 -2
- package/dist/commands/devops/setup-mcp.js +0 -41
- package/dist/commands/devops/setup-mcp.js.map +0 -1
- package/dist/devops/commands-upstream/bootstrap-local.d.ts +0 -17
- package/dist/devops/commands-upstream/bootstrap-local.js +0 -87
- package/dist/devops/commands-upstream/bootstrap-local.js.map +0 -1
- package/dist/devops/commands-upstream/close.d.ts +0 -57
- package/dist/devops/commands-upstream/close.js +0 -188
- package/dist/devops/commands-upstream/close.js.map +0 -1
- package/dist/devops/commands-upstream/configure.d.ts +0 -2
- package/dist/devops/commands-upstream/configure.js +0 -87
- package/dist/devops/commands-upstream/configure.js.map +0 -1
- package/dist/devops/commands-upstream/create-project.d.ts +0 -2
- package/dist/devops/commands-upstream/create-project.js +0 -43
- package/dist/devops/commands-upstream/create-project.js.map +0 -1
- package/dist/devops/commands-upstream/install-plugin.d.ts +0 -2
- package/dist/devops/commands-upstream/install-plugin.js +0 -64
- package/dist/devops/commands-upstream/install-plugin.js.map +0 -1
- package/dist/devops/commands-upstream/install-unity.d.ts +0 -2
- package/dist/devops/commands-upstream/install-unity.js +0 -72
- package/dist/devops/commands-upstream/install-unity.js.map +0 -1
- package/dist/devops/commands-upstream/login.d.ts +0 -2
- package/dist/devops/commands-upstream/login.js +0 -33
- package/dist/devops/commands-upstream/login.js.map +0 -1
- package/dist/devops/commands-upstream/open.d.ts +0 -32
- package/dist/devops/commands-upstream/open.js +0 -282
- package/dist/devops/commands-upstream/open.js.map +0 -1
- package/dist/devops/commands-upstream/remove-plugin.d.ts +0 -2
- package/dist/devops/commands-upstream/remove-plugin.js +0 -41
- package/dist/devops/commands-upstream/remove-plugin.js.map +0 -1
- package/dist/devops/commands-upstream/run-system-tool.d.ts +0 -1
- package/dist/devops/commands-upstream/run-system-tool.js +0 -13
- package/dist/devops/commands-upstream/run-system-tool.js.map +0 -1
- package/dist/devops/commands-upstream/run-tool-builder.d.ts +0 -25
- package/dist/devops/commands-upstream/run-tool-builder.js +0 -125
- package/dist/devops/commands-upstream/run-tool-builder.js.map +0 -1
- package/dist/devops/commands-upstream/run-tool.d.ts +0 -1
- package/dist/devops/commands-upstream/run-tool.js +0 -13
- package/dist/devops/commands-upstream/run-tool.js.map +0 -1
- package/dist/devops/commands-upstream/setup-mcp.d.ts +0 -2
- package/dist/devops/commands-upstream/setup-mcp.js +0 -68
- package/dist/devops/commands-upstream/setup-mcp.js.map +0 -1
- package/dist/devops/commands-upstream/setup-skills.d.ts +0 -2
- package/dist/devops/commands-upstream/setup-skills.js +0 -167
- package/dist/devops/commands-upstream/setup-skills.js.map +0 -1
- package/dist/devops/commands-upstream/status.d.ts +0 -2
- package/dist/devops/commands-upstream/status.js +0 -87
- package/dist/devops/commands-upstream/status.js.map +0 -1
- package/dist/devops/commands-upstream/update.d.ts +0 -2
- package/dist/devops/commands-upstream/update.js +0 -77
- package/dist/devops/commands-upstream/update.js.map +0 -1
- package/dist/devops/commands-upstream/wait-for-ready.d.ts +0 -2
- package/dist/devops/commands-upstream/wait-for-ready.js +0 -93
- package/dist/devops/commands-upstream/wait-for-ready.js.map +0 -1
- package/dist/devops/lib/setup-mcp.d.ts +0 -35
- package/dist/devops/lib/setup-mcp.js +0 -177
- package/dist/devops/lib/setup-mcp.js.map +0 -1
- package/dist/devops/utils/cloud-login.d.ts +0 -7
- package/dist/devops/utils/cloud-login.js +0 -56
- package/dist/devops/utils/cloud-login.js.map +0 -1
- /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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|