升级XR插件版本
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for managing PID files and handshake state for the local HTTP server.
|
||||
/// Handles persistence of server process information across Unity domain reloads.
|
||||
/// </summary>
|
||||
public interface IPidFileManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the directory where PID files are stored.
|
||||
/// </summary>
|
||||
/// <returns>Path to the PID file directory</returns>
|
||||
string GetPidDirectory();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the PID file for a specific port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number</param>
|
||||
/// <returns>Full path to the PID file</returns>
|
||||
string GetPidFilePath(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read the PID from a PID file.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="pid">Output: the process ID if found</param>
|
||||
/// <returns>True if a valid PID was read</returns>
|
||||
bool TryReadPid(string pidFilePath, out int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to extract the port number from a PID file path.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="port">Output: the port number</param>
|
||||
/// <returns>True if the port was extracted successfully</returns>
|
||||
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a PID file.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file to delete</param>
|
||||
void DeletePidFile(string pidFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="instanceToken">Unique instance token for the server</param>
|
||||
void StoreHandshake(string pidFilePath, string instanceToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve stored handshake information from EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Output: stored PID file path</param>
|
||||
/// <param name="instanceToken">Output: stored instance token</param>
|
||||
/// <returns>True if valid handshake information was found</returns>
|
||||
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stores PID tracking information in EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID</param>
|
||||
/// <param name="port">The port number</param>
|
||||
/// <param name="argsHash">Optional hash of the command arguments</param>
|
||||
void StoreTracking(int pid, int port, string argsHash = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a stored PID for the expected port.
|
||||
/// Validates that the stored information is still valid (within 6-hour window).
|
||||
/// </summary>
|
||||
/// <param name="expectedPort">The expected port number</param>
|
||||
/// <param name="pid">Output: the stored process ID</param>
|
||||
/// <returns>True if a valid stored PID was found</returns>
|
||||
bool TryGetStoredPid(int expectedPort, out int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stored args hash for the tracked server.
|
||||
/// </summary>
|
||||
/// <returns>The stored args hash, or empty string if not found</returns>
|
||||
string GetStoredArgsHash();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all PID tracking information from EditorPrefs.
|
||||
/// </summary>
|
||||
void ClearTracking();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a short hash of the input string for fingerprinting.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>A short hash string (16 hex characters)</returns>
|
||||
string ComputeShortHash(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4a4c5d093da74ce79fb29a0670a58a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for platform-specific process inspection operations.
|
||||
/// Provides methods to detect MCP server processes, query process command lines,
|
||||
/// and find processes listening on specific ports.
|
||||
/// </summary>
|
||||
public interface IProcessDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if a process looks like an MCP server process based on its command line.
|
||||
/// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to check</param>
|
||||
/// <returns>True if the process appears to be an MCP server</returns>
|
||||
bool LooksLikeMcpServerProcess(int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the command line arguments for a Unix process.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID</param>
|
||||
/// <param name="argsLower">Output: normalized (lowercase, whitespace removed) command line args</param>
|
||||
/// <returns>True if the command line was retrieved successfully</returns>
|
||||
bool TryGetProcessCommandLine(int pid, out string argsLower);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process IDs of all processes listening on a specific TCP port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to check</param>
|
||||
/// <returns>List of process IDs listening on the port</returns>
|
||||
List<int> GetListeningProcessIdsForPort(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current Unity Editor process ID safely.
|
||||
/// </summary>
|
||||
/// <returns>The current process ID, or -1 if it cannot be determined</returns>
|
||||
int GetCurrentProcessId();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a process exists on Unix systems.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to check</param>
|
||||
/// <returns>True if the process exists</returns>
|
||||
bool ProcessExists(int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by removing whitespace and converting to lowercase.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>Normalized string for matching</returns>
|
||||
string NormalizeForMatch(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25f32875fb87541b69ead19c08520836
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for platform-specific process termination.
|
||||
/// Provides methods to terminate processes gracefully or forcefully.
|
||||
/// </summary>
|
||||
public interface IProcessTerminator
|
||||
{
|
||||
/// <summary>
|
||||
/// Terminates a process using platform-appropriate methods.
|
||||
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
|
||||
/// On Windows: Tries taskkill, then taskkill /F.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to terminate</param>
|
||||
/// <returns>True if the process was terminated successfully</returns>
|
||||
bool Terminate(int pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a55c18e08b534afa85654410da8a463
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for building uvx/server command strings.
|
||||
/// Handles platform-specific command construction for starting the MCP HTTP server.
|
||||
/// </summary>
|
||||
public interface IServerCommandBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to build the command parts for starting the local HTTP server.
|
||||
/// </summary>
|
||||
/// <param name="fileName">Output: the executable file name (e.g., uvx path)</param>
|
||||
/// <param name="arguments">Output: the command arguments</param>
|
||||
/// <param name="displayCommand">Output: the full command string for display</param>
|
||||
/// <param name="error">Output: error message if the command cannot be built</param>
|
||||
/// <returns>True if the command was built successfully</returns>
|
||||
bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the uv path from the uvx path by replacing uvx with uv.
|
||||
/// </summary>
|
||||
/// <param name="uvxPath">Path to uvx executable</param>
|
||||
/// <returns>Path to uv executable</returns>
|
||||
string BuildUvPathFromUvx(string uvxPath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the platform-specific PATH prepend string for finding uv/uvx.
|
||||
/// </summary>
|
||||
/// <returns>Paths to prepend to PATH environment variable</returns>
|
||||
string GetPlatformSpecificPathPrepend();
|
||||
|
||||
/// <summary>
|
||||
/// Quotes a string if it contains spaces.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>The string, wrapped in quotes if it contains spaces</returns>
|
||||
string QuoteIfNeeded(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12e80005e3f5b45239c48db981675ccf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for launching commands in platform-specific terminal windows.
|
||||
/// Supports macOS Terminal, Windows cmd, and Linux terminal emulators.
|
||||
/// </summary>
|
||||
public interface ITerminalLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a ProcessStartInfo for opening a terminal window with the given command.
|
||||
/// Works cross-platform: macOS, Windows, and Linux.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to execute in the terminal</param>
|
||||
/// <returns>A configured ProcessStartInfo for launching the terminal</returns>
|
||||
ProcessStartInfo CreateTerminalProcessStartInfo(string command);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the project root path for storing terminal scripts.
|
||||
/// </summary>
|
||||
/// <returns>Path to the project root directory</returns>
|
||||
string GetProjectRootPath();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5990e868c0cd4999858ce1c1a2defed
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
275
Packages/MCPForUnity/Editor/Services/Server/PidFileManager.cs
Normal file
275
Packages/MCPForUnity/Editor/Services/Server/PidFileManager.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages PID files and handshake state for the local HTTP server.
|
||||
/// Handles persistence of server process information across Unity domain reloads.
|
||||
/// </summary>
|
||||
public class PidFileManager : IPidFileManager
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string GetPidDirectory()
|
||||
{
|
||||
return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "RunState");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string GetPidFilePath(int port)
|
||||
{
|
||||
string dir = GetPidDirectory();
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, $"mcp_http_{port}.pid");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryReadPid(string pidFilePath, out int pid)
|
||||
{
|
||||
pid = 0;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(pidFilePath) || !File.Exists(pidFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string text = File.ReadAllText(pidFilePath).Trim();
|
||||
if (int.TryParse(text, out pid))
|
||||
{
|
||||
return pid > 0;
|
||||
}
|
||||
|
||||
// Best-effort: tolerate accidental extra whitespace/newlines.
|
||||
var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
if (int.TryParse(firstLine, out pid))
|
||||
{
|
||||
return pid > 0;
|
||||
}
|
||||
|
||||
pid = 0;
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pid = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryGetPortFromPidFilePath(string pidFilePath, out int port)
|
||||
{
|
||||
port = 0;
|
||||
if (string.IsNullOrEmpty(pidFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string fileName = Path.GetFileNameWithoutExtension(pidFilePath);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const string prefix = "mcp_http_";
|
||||
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string portText = fileName.Substring(prefix.Length);
|
||||
return int.TryParse(portText, out port) && port > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
port = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DeletePidFile(string pidFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath))
|
||||
{
|
||||
File.Delete(pidFilePath);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StoreHandshake(string pidFilePath, string instanceToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(pidFilePath))
|
||||
{
|
||||
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(instanceToken))
|
||||
{
|
||||
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryGetHandshake(out string pidFilePath, out string instanceToken)
|
||||
{
|
||||
pidFilePath = null;
|
||||
instanceToken = null;
|
||||
try
|
||||
{
|
||||
pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty);
|
||||
instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty);
|
||||
if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken))
|
||||
{
|
||||
pidFilePath = null;
|
||||
instanceToken = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pidFilePath = null;
|
||||
instanceToken = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StoreTracking(int pid, int port, string argsHash = null)
|
||||
{
|
||||
try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { }
|
||||
try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { }
|
||||
try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { }
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(argsHash))
|
||||
{
|
||||
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryGetStoredPid(int expectedPort, out int pid)
|
||||
{
|
||||
pid = 0;
|
||||
try
|
||||
{
|
||||
int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0);
|
||||
int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0);
|
||||
string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty);
|
||||
|
||||
if (storedPid <= 0 || storedPort != expectedPort)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only trust the stored PID for a short window to avoid PID reuse issues.
|
||||
// (We still verify the PID is listening on the expected port before killing.)
|
||||
if (!string.IsNullOrEmpty(storedUtc)
|
||||
&& DateTime.TryParse(storedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var startedAt))
|
||||
{
|
||||
if ((DateTime.UtcNow - startedAt) > TimeSpan.FromHours(6))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
pid = storedPid;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string GetStoredArgsHash()
|
||||
{
|
||||
try
|
||||
{
|
||||
return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ClearTracking()
|
||||
{
|
||||
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { }
|
||||
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { }
|
||||
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { }
|
||||
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { }
|
||||
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { }
|
||||
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ComputeShortHash(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return string.Empty;
|
||||
try
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(input);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
// 8 bytes => 16 hex chars is plenty as a stable fingerprint for our purposes.
|
||||
var sb = new StringBuilder(16);
|
||||
for (int i = 0; i < 8 && i < hash.Length; i++)
|
||||
{
|
||||
sb.Append(hash[i].ToString("x2"));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetProjectRootPath()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Application.dataPath is ".../<Project>/Assets"
|
||||
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Application.dataPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57875f281fda94a4ea17cb74d4b13378
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
268
Packages/MCPForUnity/Editor/Services/Server/ProcessDetector.cs
Normal file
268
Packages/MCPForUnity/Editor/Services/Server/ProcessDetector.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Platform-specific process inspection for detecting MCP server processes.
|
||||
/// </summary>
|
||||
public class ProcessDetector : IProcessDetector
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string NormalizeForMatch(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return string.Empty;
|
||||
var sb = new StringBuilder(input.Length);
|
||||
foreach (char c in input)
|
||||
{
|
||||
if (char.IsWhiteSpace(c)) continue;
|
||||
sb.Append(char.ToLowerInvariant(c));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetCurrentProcessId()
|
||||
{
|
||||
try { return System.Diagnostics.Process.GetCurrentProcess().Id; }
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ProcessExists(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
// On Windows, use tasklist to check if process exists
|
||||
bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000);
|
||||
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
|
||||
return ok && combined.Contains(pid.ToString());
|
||||
}
|
||||
|
||||
// Unix: ps exits non-zero when PID is not found.
|
||||
string psPath = "/bin/ps";
|
||||
if (!File.Exists(psPath)) psPath = "ps";
|
||||
ExecPath.TryRun(psPath, $"-p {pid} -o pid=", Application.dataPath, out var psStdout, out var psStderr, 2000);
|
||||
string combined2 = ((psStdout ?? string.Empty) + "\n" + (psStderr ?? string.Empty)).Trim();
|
||||
return !string.IsNullOrEmpty(combined2) && combined2.Any(char.IsDigit);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true; // Assume it exists if we cannot verify.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryGetProcessCommandLine(int pid, out string argsLower)
|
||||
{
|
||||
argsLower = string.Empty;
|
||||
try
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
// Windows: use wmic to get command line
|
||||
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
|
||||
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty));
|
||||
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.ToLowerInvariant().Contains("commandline="))
|
||||
{
|
||||
argsLower = NormalizeForMatch(wmicOut ?? string.Empty);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unix: ps -p pid -ww -o args=
|
||||
string psPath = "/bin/ps";
|
||||
if (!File.Exists(psPath)) psPath = "ps";
|
||||
|
||||
bool ok = ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000);
|
||||
if (!ok && string.IsNullOrWhiteSpace(stdout))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim();
|
||||
if (string.IsNullOrEmpty(combined)) return false;
|
||||
// Normalize for matching to tolerate ps wrapping/newlines.
|
||||
argsLower = NormalizeForMatch(combined);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public List<int> GetListeningProcessIdsForPort(int port)
|
||||
{
|
||||
var results = new List<int>();
|
||||
try
|
||||
{
|
||||
string stdout, stderr;
|
||||
bool success;
|
||||
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
// Run netstat -ano directly (without findstr) and filter in C#.
|
||||
// Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found,
|
||||
// which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success.
|
||||
success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr);
|
||||
|
||||
// Process stdout regardless of success flag - netstat might still produce valid output
|
||||
if (!string.IsNullOrEmpty(stdout))
|
||||
{
|
||||
string portSuffix = $":{port}";
|
||||
var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
// Windows netstat format: Proto Local Address Foreign Address State PID
|
||||
// Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345
|
||||
if (line.Contains("LISTENING") && line.Contains(portSuffix))
|
||||
{
|
||||
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
// Verify the local address column actually ends with :{port}
|
||||
// parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID
|
||||
if (parts.Length >= 5)
|
||||
{
|
||||
string localAddr = parts[1];
|
||||
if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int parsedPid))
|
||||
{
|
||||
results.Add(parsedPid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// lsof: only return LISTENers (avoids capturing random clients)
|
||||
// Use /usr/sbin/lsof directly as it might not be in PATH for Unity
|
||||
string lsofPath = "/usr/sbin/lsof";
|
||||
if (!File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback
|
||||
|
||||
// -nP: avoid DNS/service name lookups; faster and less error-prone
|
||||
success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr);
|
||||
if (success && !string.IsNullOrWhiteSpace(stdout))
|
||||
{
|
||||
var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var pidString in pidStrings)
|
||||
{
|
||||
if (int.TryParse(pidString.Trim(), out int parsedPid))
|
||||
{
|
||||
results.Add(parsedPid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Error checking port {port}: {ex.Message}");
|
||||
}
|
||||
return results.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool LooksLikeMcpServerProcess(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Windows best-effort: First check process name with tasklist, then try to get command line with wmic
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
// Step 1: Check if process name matches known server executables
|
||||
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000);
|
||||
string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant();
|
||||
|
||||
// Check for common process names
|
||||
bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe");
|
||||
if (!isPythonOrUv)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Try to get command line with wmic for better validation
|
||||
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
|
||||
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant();
|
||||
string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty);
|
||||
|
||||
// If we can see the command line, validate it's our server
|
||||
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline="))
|
||||
{
|
||||
bool mentionsMcp = wmicCompact.Contains("mcp-for-unity")
|
||||
|| wmicCompact.Contains("mcp_for_unity")
|
||||
|| wmicCompact.Contains("mcpforunity")
|
||||
|| wmicCompact.Contains("mcpforunityserver");
|
||||
bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http"));
|
||||
bool mentionsUvicorn = wmicCombined.Contains("uvicorn");
|
||||
|
||||
if (mentionsMcp || mentionsTransport || mentionsUvicorn)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to just checking for python/uv processes if wmic didn't give us details
|
||||
// This is less precise but necessary for cases where wmic access is restricted
|
||||
return isPythonOrUv;
|
||||
}
|
||||
|
||||
// macOS/Linux: ps -p pid -ww -o comm= -o args=
|
||||
// Use -ww to avoid truncating long command lines (important for reliably spotting 'mcp-for-unity').
|
||||
// Use an absolute ps path to avoid relying on PATH inside the Unity Editor process.
|
||||
string psPath = "/bin/ps";
|
||||
if (!File.Exists(psPath)) psPath = "ps";
|
||||
// Important: ExecPath.TryRun returns false when exit code != 0, but ps output can still be useful.
|
||||
// Always parse stdout/stderr regardless of exit code to avoid false negatives.
|
||||
ExecPath.TryRun(psPath, $"-p {pid} -ww -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000);
|
||||
string raw = ((psOut ?? string.Empty) + "\n" + (psErr ?? string.Empty)).Trim();
|
||||
string s = raw.ToLowerInvariant();
|
||||
string sCompact = NormalizeForMatch(raw);
|
||||
if (!string.IsNullOrEmpty(s))
|
||||
{
|
||||
bool mentionsMcp = sCompact.Contains("mcp-for-unity")
|
||||
|| sCompact.Contains("mcp_for_unity")
|
||||
|| sCompact.Contains("mcpforunity");
|
||||
|
||||
// If it explicitly mentions the server package/entrypoint, that is sufficient.
|
||||
// Note: Check before Unity exclusion since "mcp-for-unity" contains "unity".
|
||||
if (mentionsMcp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Explicitly never kill Unity / Unity Hub processes
|
||||
// Note: explicit !mentionsMcp is defensive; we already return early for mentionsMcp above.
|
||||
if (s.Contains("unityhub") || s.Contains("unity hub") || (s.Contains("unity") && !mentionsMcp))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Positive indicators
|
||||
bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx ");
|
||||
bool mentionsUv = s.Contains("uv ") || s.Contains("/uv");
|
||||
bool mentionsPython = s.Contains("python");
|
||||
bool mentionsUvicorn = s.Contains("uvicorn");
|
||||
bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http"));
|
||||
|
||||
// Accept if it looks like uv/uvx/python launching our server package/entrypoint
|
||||
if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4df6fa24a35d74d1cb9b67e40e50b45d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Platform-specific process termination for stopping MCP server processes.
|
||||
/// </summary>
|
||||
public class ProcessTerminator : IProcessTerminator
|
||||
{
|
||||
private readonly IProcessDetector _processDetector;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ProcessTerminator with the specified process detector.
|
||||
/// </summary>
|
||||
/// <param name="processDetector">Process detector for checking process existence</param>
|
||||
public ProcessTerminator(IProcessDetector processDetector)
|
||||
{
|
||||
_processDetector = processDetector ?? throw new ArgumentNullException(nameof(processDetector));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Terminate(int pid)
|
||||
{
|
||||
// CRITICAL: Validate PID before any kill operation.
|
||||
// On Unix, kill(-1) kills ALL processes the user can signal!
|
||||
// On Unix, kill(0) signals all processes in the process group.
|
||||
// PID 1 is init/launchd and must never be killed.
|
||||
// Only positive PIDs > 1 are valid for targeted termination.
|
||||
if (pid <= 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never kill the current Unity process
|
||||
int currentPid = _processDetector.GetCurrentProcessId();
|
||||
if (currentPid > 0 && pid == currentPid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string stdout, stderr;
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
// taskkill without /F first; fall back to /F if needed.
|
||||
bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr);
|
||||
if (!ok)
|
||||
{
|
||||
ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try a graceful termination first, then escalate if the process is still alive.
|
||||
// Note: `kill -15` can succeed (exit 0) even if the process takes time to exit,
|
||||
// so we verify and only escalate when needed.
|
||||
string killPath = "/bin/kill";
|
||||
if (!File.Exists(killPath)) killPath = "kill";
|
||||
ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr);
|
||||
|
||||
// Wait briefly for graceful shutdown.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (!_processDetector.ProcessExists(pid))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
|
||||
// Escalate.
|
||||
ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr);
|
||||
return !_processDetector.ProcessExists(pid);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Error killing process {pid}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 900df88b4d0844704af9cb47633d44a9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds uvx/server command strings for starting the MCP HTTP server.
|
||||
/// Handles platform-specific command construction.
|
||||
/// </summary>
|
||||
public class ServerCommandBuilder : IServerCommandBuilder
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
|
||||
{
|
||||
fileName = null;
|
||||
arguments = null;
|
||||
displayCommand = null;
|
||||
error = null;
|
||||
|
||||
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
|
||||
if (!useHttpTransport)
|
||||
{
|
||||
error = "HTTP transport is disabled. Enable it in the MCP For Unity window first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
|
||||
if (!IsLocalUrl(httpUrl))
|
||||
{
|
||||
error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
|
||||
if (string.IsNullOrEmpty(uvxPath))
|
||||
{
|
||||
error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
||||
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
|
||||
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
|
||||
bool projectScopedTools = EditorPrefs.GetBool(
|
||||
EditorPrefKeys.ProjectScopedToolsLocalHttp,
|
||||
true
|
||||
);
|
||||
string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty;
|
||||
|
||||
// Use centralized helper for beta server / prerelease args
|
||||
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
|
||||
|
||||
string args = string.IsNullOrEmpty(fromArgs)
|
||||
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
|
||||
: $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
|
||||
|
||||
fileName = uvxPath;
|
||||
arguments = args;
|
||||
displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}";
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string BuildUvPathFromUvx(string uvxPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uvxPath))
|
||||
{
|
||||
return uvxPath;
|
||||
}
|
||||
|
||||
string directory = Path.GetDirectoryName(uvxPath);
|
||||
string extension = Path.GetExtension(uvxPath);
|
||||
string uvFileName = "uv" + extension;
|
||||
|
||||
return string.IsNullOrEmpty(directory)
|
||||
? uvFileName
|
||||
: Path.Combine(directory, uvFileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string GetPlatformSpecificPathPrepend()
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.OSXEditor)
|
||||
{
|
||||
return string.Join(Path.PathSeparator.ToString(), new[]
|
||||
{
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin"
|
||||
});
|
||||
}
|
||||
|
||||
if (Application.platform == RuntimePlatform.LinuxEditor)
|
||||
{
|
||||
return string.Join(Path.PathSeparator.ToString(), new[]
|
||||
{
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin"
|
||||
});
|
||||
}
|
||||
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
|
||||
return string.Join(Path.PathSeparator.ToString(), new[]
|
||||
{
|
||||
!string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null,
|
||||
!string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null
|
||||
}.Where(p => !string.IsNullOrEmpty(p)).ToArray());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string QuoteIfNeeded(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1)
|
||||
/// </summary>
|
||||
private static bool IsLocalUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
string host = uri.Host.ToLower();
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db917800a5c2948088ede8a5d230b56e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
143
Packages/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs
Normal file
143
Packages/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Launches commands in platform-specific terminal windows.
|
||||
/// Supports macOS Terminal, Windows cmd, and Linux terminal emulators.
|
||||
/// </summary>
|
||||
public class TerminalLauncher : ITerminalLauncher
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string GetProjectRootPath()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Application.dataPath is ".../<Project>/Assets"
|
||||
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Application.dataPath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
throw new ArgumentException("Command cannot be empty", nameof(command));
|
||||
|
||||
command = command.Replace("\r", "").Replace("\n", "");
|
||||
|
||||
#if UNITY_EDITOR_OSX
|
||||
// macOS: Avoid AppleScript (automation permission prompts). Use a .command script and open it.
|
||||
string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts");
|
||||
Directory.CreateDirectory(scriptsDir);
|
||||
string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.command");
|
||||
File.WriteAllText(
|
||||
scriptPath,
|
||||
"#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"clear\n" +
|
||||
$"{command}\n");
|
||||
ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000);
|
||||
return new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "/usr/bin/open",
|
||||
Arguments = $"-a Terminal \"{scriptPath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
#elif UNITY_EDITOR_WIN
|
||||
// Windows: Avoid brittle nested-quote escaping by writing a .cmd script and starting it in a new window.
|
||||
string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts");
|
||||
Directory.CreateDirectory(scriptsDir);
|
||||
string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.cmd");
|
||||
File.WriteAllText(
|
||||
scriptPath,
|
||||
"@echo off\r\n" +
|
||||
"cls\r\n" +
|
||||
command + "\r\n");
|
||||
return new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{scriptPath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
#else
|
||||
// Linux: Try common terminal emulators
|
||||
// We use bash -c to execute the command, so we must properly quote/escape for bash
|
||||
// Escape single quotes for the inner bash string
|
||||
string escapedCommandLinux = command.Replace("'", "'\\''");
|
||||
// Wrap the command in single quotes for bash -c
|
||||
string script = $"'{escapedCommandLinux}; exec bash'";
|
||||
// Escape double quotes for the outer Process argument string
|
||||
string escapedScriptForArg = script.Replace("\"", "\\\"");
|
||||
string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\"";
|
||||
|
||||
string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" };
|
||||
string terminalCmd = null;
|
||||
|
||||
foreach (var term in terminals)
|
||||
{
|
||||
try
|
||||
{
|
||||
var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "which",
|
||||
Arguments = term,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous
|
||||
if (which.ExitCode == 0)
|
||||
{
|
||||
terminalCmd = term;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (terminalCmd == null)
|
||||
{
|
||||
terminalCmd = "xterm"; // Fallback
|
||||
}
|
||||
|
||||
// Different terminals have different argument formats
|
||||
string args;
|
||||
if (terminalCmd == "gnome-terminal")
|
||||
{
|
||||
args = $"-- {bashCmdArgs}";
|
||||
}
|
||||
else if (terminalCmd == "konsole")
|
||||
{
|
||||
args = $"-e {bashCmdArgs}";
|
||||
}
|
||||
else if (terminalCmd == "xfce4-terminal")
|
||||
{
|
||||
// xfce4-terminal expects -e "command string" or -e command arg
|
||||
args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
else // xterm and others
|
||||
{
|
||||
args = $"-hold -e {bashCmdArgs}";
|
||||
}
|
||||
|
||||
return new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = terminalCmd,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9693a18d706548b3aae28ea87f1ed08
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user