From ee23346ca2832facea1204ae06d6852b2b6dc130 Mon Sep 17 00:00:00 2001 From: David Sarno Date: Sat, 23 Aug 2025 22:13:47 -0700 Subject: [PATCH] feat: installer cleanup, auto-migration, logging normalization --- UnityMcpBridge/Editor/Helpers/McpLog.cs | 33 +++ UnityMcpBridge/Editor/Helpers/McpLog.cs.meta | 13 + .../Editor/Helpers/PackageDetector.cs | 96 +++++++ .../Editor/Helpers/PackageDetector.cs.meta | 2 + .../Editor/Helpers/ServerInstaller.cs | 252 ++++++++++++++++-- .../Editor/Windows/MCPForUnityEditorWindow.cs | 81 +++++- .../UnityMcpServer~/src/server_version.txt | 2 + UnityMcpBridge/package.json | 2 +- 8 files changed, 461 insertions(+), 20 deletions(-) create mode 100644 UnityMcpBridge/Editor/Helpers/McpLog.cs create mode 100644 UnityMcpBridge/Editor/Helpers/McpLog.cs.meta create mode 100644 UnityMcpBridge/Editor/Helpers/PackageDetector.cs create mode 100644 UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta create mode 100644 UnityMcpBridge/UnityMcpServer~/src/server_version.txt diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs b/UnityMcpBridge/Editor/Helpers/McpLog.cs new file mode 100644 index 0000000..7e46718 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs @@ -0,0 +1,33 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLog + { + private const string Prefix = "MCP-FOR-UNITY:"; + + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + Debug.Log($"{Prefix} {message}"); + } + + public static void Warn(string message) + { + Debug.LogWarning($"{Prefix} {message}"); + } + + public static void Error(string message) + { + Debug.LogError($"{Prefix} {message}"); + } + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta b/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta new file mode 100644 index 0000000..b9e0fc3 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs new file mode 100644 index 0000000..0a67200 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -0,0 +1,96 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Auto-runs legacy/older install detection on package load/update (log-only). + /// Runs once per embedded server version using an EditorPrefs version-scoped key. + /// + [InitializeOnLoad] + public static class PackageDetector + { + private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; + + static PackageDetector() + { + try + { + string pkgVer = ReadPackageVersionOrFallback(); + string key = DetectOnceFlagKeyPrefix + pkgVer; + + // Always force-run if legacy roots exist or canonical install is missing + bool legacyPresent = LegacyRootsExist(); + bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); + + if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) + { + EditorApplication.delayCall += () => + { + try + { + ServerInstaller.EnsureServerInstalled(); + } + catch (System.Exception ex) + { + Debug.LogWarning("MCP for Unity: Auto-detect on load failed: " + ex.Message); + } + finally + { + EditorPrefs.SetBool(key, true); + } + }; + } + } + catch { /* ignore */ } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); + if (System.IO.File.Exists(p)) + return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); + } + } + catch { } + return "unknown"; + } + + private static string ReadPackageVersionOrFallback() + { + try + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); + if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; + } + catch { } + // Fallback to embedded server version if package info unavailable + return ReadEmbeddedVersionOrFallback(); + } + + private static bool LegacyRootsExist() + { + try + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] roots = + { + System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), + System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") + }; + foreach (var r in roots) + { + try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } + } + } + catch { } + return false; + } + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta new file mode 100644 index 0000000..af30530 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b82eaef548d164ca095f17db64d15af8 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index e32be85..1a1780d 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Runtime.InteropServices; using System.Text; +using System.Collections.Generic; using UnityEditor; using UnityEngine; @@ -11,6 +12,7 @@ namespace MCPForUnity.Editor.Helpers { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; + private const string VersionFileName = "server_version.txt"; /// /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. @@ -24,22 +26,70 @@ namespace MCPForUnity.Editor.Helpers string destRoot = Path.Combine(saveLocation, ServerFolder); string destSrc = Path.Combine(destRoot, "src"); - if (File.Exists(Path.Combine(destSrc, "server.py"))) - { - return; // Already installed - } + // Detect legacy installs and version state (logs) + DetectAndLogLegacyInstallStates(destRoot); + // Resolve embedded source and versions if (!TryGetEmbeddedServerSource(out string embeddedSrc)) { throw new Exception("Could not find embedded UnityMcpServer/src in the package."); } + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; + string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); + + bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); + bool needOverwrite = !destHasServer + || string.IsNullOrEmpty(installedVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); // Ensure destination exists Directory.CreateDirectory(destRoot); - // Copy the entire UnityMcpServer folder (parent of src) - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer - CopyDirectoryRecursive(embeddedRoot, destRoot); + if (needOverwrite) + { + // Copy the entire UnityMcpServer folder (parent of src) + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer + CopyDirectoryRecursive(embeddedRoot, destRoot); + // Write/refresh version file + try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } + McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); + } + + // Cleanup legacy installs that are missing version or older than embedded + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + try + { + string legacySrc = Path.Combine(legacyRoot, "src"); + if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + bool legacyOlder = string.IsNullOrEmpty(legacyVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); + if (legacyOlder) + { + TryKillUvForPath(legacySrc); + try + { + Directory.Delete(legacyRoot, recursive: true); + McpLog.Info($"Removed legacy server at '{legacyRoot}'."); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}"); + } + } + } + catch { } + } + + // Clear overrides that might point at legacy locations + try + { + EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); + EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); + } + catch { } + return; } catch (Exception ex) { @@ -49,11 +99,11 @@ namespace MCPForUnity.Editor.Helpers if (hasInstalled || TryGetEmbeddedServerSource(out _)) { - Debug.LogWarning($"MCP for Unity: Using existing server; skipped install. Details: {ex.Message}"); + McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); return; } - Debug.LogError($"Failed to ensure server installation: {ex.Message}"); + McpLog.Error($"Failed to ensure server installation: {ex.Message}"); } } @@ -69,9 +119,10 @@ namespace MCPForUnity.Editor.Helpers { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + // Use per-user LocalApplicationData for canonical install location var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, "Programs", RootFolder); + return Path.Combine(localAppData, RootFolder); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { @@ -85,11 +136,15 @@ namespace MCPForUnity.Editor.Helpers } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - // Use Application Support for a stable, user-writable location - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - RootFolder - ); + // On macOS, use LocalApplicationData (~/Library/Application Support) + var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localAppSupport)) + { + // Fallback: construct from $HOME + var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + localAppSupport = Path.Combine(home, "Library", "Application Support"); + } + return Path.Combine(localAppSupport, RootFolder); } throw new Exception("Unsupported operating system."); } @@ -117,6 +172,173 @@ namespace MCPForUnity.Editor.Helpers && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); } + /// + /// Detects legacy installs or older versions and logs findings (no deletion yet). + /// + private static void DetectAndLogLegacyInstallStates(string canonicalRoot) + { + try + { + string canonicalSrc = Path.Combine(canonicalRoot, "src"); + // Normalize canonical root for comparisons + string normCanonicalRoot = NormalizePathSafe(canonicalRoot); + string embeddedSrc = null; + TryGetEmbeddedServerSource(out embeddedSrc); + + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); + string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); + + // Legacy paths (macOS/Linux .config; Windows roaming as example) + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + // Skip logging for the canonical root itself + if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) + continue; + string legacySrc = Path.Combine(legacyRoot, "src"); + bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + + if (hasServer) + { + // Case 1: No version file + if (string.IsNullOrEmpty(legacyVer)) + { + McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); + } + + // Case 2: Lives in legacy path + McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); + + // Case 3: Has version but appears older than embedded + if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) + { + McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); + } + } + } + + // Also log if canonical is missing version (treated as older) + if (Directory.Exists(canonicalRoot)) + { + if (string.IsNullOrEmpty(installedVer)) + { + McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); + } + else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) + { + McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); + } + } + } + catch (Exception ex) + { + McpLog.Warn("Detect legacy/version state failed: " + ex.Message); + } + } + + private static string NormalizePathSafe(string path) + { + try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } + catch { return path; } + } + + private static bool PathsEqualSafe(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + string na = NormalizePathSafe(a); + string nb = NormalizePathSafe(b); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch { return false; } + } + + private static IEnumerable GetLegacyRootsForDetection() + { + var roots = new System.Collections.Generic.List(); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + // macOS/Linux legacy + roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); + roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); + // Windows roaming example + try + { + string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + if (!string.IsNullOrEmpty(roaming)) + roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); + } + catch { } + return roots; + } + + private static void TryKillUvForPath(string serverSrcPath) + { + try + { + if (string.IsNullOrEmpty(serverSrcPath)) return; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/pgrep", + Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return; + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1500); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(line.Trim(), out int pid)) + { + try { System.Diagnostics.Process.GetProcessById(pid).Kill(); } catch { } + } + } + } + } + catch { } + } + + private static string ReadVersionFile(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + string v = File.ReadAllText(path).Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } + catch { return null; } + } + + private static int CompareSemverSafe(string a, string b) + { + try + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; + var ap = a.Split('.'); + var bp = b.Split('.'); + for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) + { + int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; + int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; + if (ai != bi) return ai.CompareTo(bi); + } + return 0; + } + catch { return 0; } + } + /// /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package /// or common development locations. diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 3091f37..cd2d5f3 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -58,6 +58,10 @@ namespace MCPForUnity.Editor.Windows isUnityBridgeRunning = MCPForUnityBridge.IsRunning; autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); @@ -242,10 +246,79 @@ namespace MCPForUnity.Editor.Windows { debugLogsEnabled = newDebug; EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } } EditorGUILayout.Space(15); } + private void LogDebugPrefsState() + { + try + { + string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); + string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); + string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); + bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); + + // Version-scoped detection key + string embeddedVer = ReadEmbeddedVersionOrFallback(); + string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; + bool detectLogged = SafeGetPrefBool(detectKey); + + // Project-scoped auto-register key + string projectPath = Application.dataPath ?? string.Empty; + string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; + bool autoRegistered = SafeGetPrefBool(autoKey); + + MCPForUnity.Editor.Helpers.McpLog.Info( + "MCP Debug Prefs:\n" + + $" DebugLogs: {debugLogsEnabled}\n" + + $" PythonDirOverride: '{pythonDirOverridePref}'\n" + + $" UvPath: '{uvPathPref}'\n" + + $" ServerSrc: '{serverSrcPref}'\n" + + $" UseEmbeddedServer: {useEmbedded}\n" + + $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + + $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", + always: false + ); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); + } + } + + private static string SafeGetPrefString(string key) + { + try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } + } + + private static bool SafeGetPrefBool(string key) + { + try { return EditorPrefs.GetBool(key, false); } catch { return false; } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = Path.Combine(embeddedSrc, "server_version.txt"); + if (File.Exists(p)) + { + var s = File.ReadAllText(p)?.Trim(); + if (!string.IsNullOrEmpty(s)) return s; + } + } + } + catch { } + return "unknown"; + } + private void DrawServerStatusSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); @@ -504,7 +577,7 @@ namespace MCPForUnity.Editor.Windows } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"Auto-setup client '{client.name}' failed: {ex.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); @@ -521,7 +594,7 @@ namespace MCPForUnity.Editor.Windows } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"Auto-setup StartAutoConnect failed: {ex.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); } } @@ -532,7 +605,7 @@ namespace MCPForUnity.Editor.Windows } catch (Exception e) { - UnityEngine.Debug.LogWarning($"MCP for Unity auto-setup skipped: {e.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); } } @@ -1000,7 +1073,7 @@ namespace MCPForUnity.Editor.Windows return true; } - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) + private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt new file mode 100644 index 0000000..d4c4b54 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -0,0 +1,2 @@ +3.0.1 + diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index a96a6f0..473ceda 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "3.0.0", + "version": "3.0.1", "displayName": "MCP for Unity", "description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3",