/* +------------------------------------------------------------------+ | Author: Ivan Murzak (https://github.com/IvanMurzak) | | Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) | | Copyright (c) 2025 Ivan Murzak | | Licensed under the Apache License, Version 2.0. | | See the LICENSE file in the project root for more information. | +------------------------------------------------------------------+ */ #nullable enable using System; using System.Collections.Generic; using UnityEditor; using UnityEditor.PackageManager; using UnityEngine; using PackageManagerEvents = UnityEditor.PackageManager.Events; namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver { /// /// Entry point for NuGet dependency management. Runs on every domain reload via [InitializeOnLoad] /// AND in response to UPM package upgrades via PackageManager.Events.registeredPackages. /// /// This assembly has ZERO external dependencies — it always compiles, even when the main plugin /// fails due to missing or conflicting DLLs. It downloads NuGet packages directly from nuget.org, /// extracts DLLs, skips assemblies Unity already provides, and sets the UNITY_MCP_READY define /// so the main plugin assemblies can compile. /// /// Why both triggers are needed: /// [InitializeOnLoad] fires after a *successful* project-wide recompile. When a user upgrades /// the Unity package and the new code references newer NuGet APIs whose DLL is still on disk /// at the old version, the post-import recompile fails, Unity blocks the domain reload, and /// the new resolver's static constructor never runs. The previous AppDomain is still alive /// though — and so are its event subscriptions. registeredPackages fires from the still-alive /// AppDomain after UPM writes new package files but before the failed recompile, giving us a /// chance to clean up stale DLLs and unblock the next compile attempt. See Unity-MCP#707. /// /// Flow on domain reload: /// 1. [InitializeOnLoad] fires /// 2. Deferred via EditorApplication.update (runs without editor focus, unlike delayCall) /// 3. NuGetPackageRestorer checks if all packages are installed /// 4. Downloads and installs any missing packages, removes stale-version siblings /// 5. Sets UNITY_MCP_READY scripting define /// 6. If packages were installed: triggers AssetDatabase.Refresh() → domain reload /// 7. On next reload: everything is in place, main plugin compiles /// /// Flow on UPM package change (no domain reload required to invoke): /// 1. registeredPackages fires from the previous AppDomain /// 2. NuGetPackageRestorer.Restore() runs the same install + cleanup logic /// 3. AssetDatabase.Refresh() prompts Unity to retry compilation with the corrected DLL set /// 4. On the resulting successful reload, [InitializeOnLoad] resumes the normal path /// [InitializeOnLoad] static class NuGetDependencyResolver { const string Tag = "[Unity-MCP DependencyResolver]"; static NuGetDependencyResolver() { // In CI, skip runtime resolution — DLLs are committed to git // and UNITY_MCP_READY should already be in ProjectSettings. // Setting defines at runtime in batch mode causes "Error building Player // because scripts are compiling" when the test runner races the recompilation. if (IsCi()) { RecompileGate.EnsureReadyDefine(); return; } EditorApplication.update += ResolveOnce; // Subscribe to UPM package-change events so that when the user upgrades a package // whose new code references a newer NuGet DLL than the one currently on disk, we // can run the cleanup + restore from the still-alive AppDomain. The compile that // would otherwise fail (and block [InitializeOnLoad] in the next domain) gets // retried by Unity once AssetDatabase.Refresh() picks up the corrected DLL set. // -= before += guards against accidental double-subscription if a hot-reload path // ever re-runs this constructor without a full domain swap. PackageManagerEvents.registeredPackages -= OnRegisteredPackages; PackageManagerEvents.registeredPackages += OnRegisteredPackages; } /// /// Tracks whether a UPM-event-triggered restore is currently in flight, so a second /// event burst (UPM can fire multiple registeredPackages events for a single user /// action) doesn't kick off a parallel restore. /// static bool isRestoringFromPackageEvent; static void OnRegisteredPackages(PackageRegistrationEventArgs args) { if (isRestoringFromPackageEvent) return; // Cheap pre-filter: if no packages were added, removed, or upgraded, there is no // possibility of a NuGet-dep version change. Skip the heavier AllPackagesInstalled() // probe to avoid touching disk on every UPM ping. if ((args.added == null || args.added.Count == 0) && (args.removed == null || args.removed.Count == 0) && (args.changedFrom == null || args.changedFrom.Count == 0) && (args.changedTo == null || args.changedTo.Count == 0)) { return; } isRestoringFromPackageEvent = true; try { Debug.Log($"{Tag} UPM package change detected — running NuGet restore."); RunRestoreAndRefresh(); } catch (Exception ex) { // Same rationale as ResolveOnce: do NOT set UNITY_MCP_READY on a failed restore. Debug.LogError($"{Tag} Restore from UPM event failed: {ex}"); } finally { isRestoringFromPackageEvent = false; } } /// /// Shared core used by both ResolveOnce (post-domain-reload) and OnRegisteredPackages /// (no-domain-reload UPM event path). AllPackagesInstalled() short-circuits the common /// no-op case without touching the network. /// static void RunRestoreAndRefresh() { if (NuGetPackageRestorer.AllPackagesInstalled()) { NuGetPluginConfigurator.ConfigureAll(); RecompileGate.EnsureReadyDefine(); return; } Debug.Log($"{Tag} Restoring NuGet packages..."); var changed = NuGetPackageRestorer.Restore(); NuGetPluginConfigurator.ConfigureAll(); RecompileGate.EnsureReadyDefine(); if (changed) { Debug.Log($"{Tag} Packages restored. Refreshing AssetDatabase..."); AssetDatabase.Refresh(); } } static void ResolveOnce() { EditorApplication.update -= ResolveOnce; try { RunRestoreAndRefresh(); } catch (Exception ex) { // Do NOT set UNITY_MCP_READY here: if restore/configuration failed, the DLL // layout is unknown/inconsistent, and letting main-plugin assemblies compile // against a partial/mismatched set (via defineConstraints) produces hard-to- // diagnose MissingMethodException / TypeLoadException at runtime. Surface the // failure loud and clear instead, so the user fixes the underlying problem // and retries (the next domain reload will run Restore again). Debug.LogError($"{Tag} Failed: {ex}"); } } /// /// Checks if the current environment is a CI environment. /// Mirrors EnvironmentUtils.IsCi() but without external dependencies, /// since this assembly must compile standalone. /// Checks both command-line arguments and environment variables for /// CI, GITHUB_ACTIONS, and TF_BUILD (Azure Pipelines). /// static bool IsCi() { var args = ParseCommandLineArguments(); var ci = GetArgOrEnv(args, "CI"); var gha = GetArgOrEnv(args, "GITHUB_ACTIONS"); var az = GetArgOrEnv(args, "TF_BUILD"); return IsTrue(ci) || IsTrue(gha) || IsTrue(az); static string? GetArgOrEnv(Dictionary args, string key) => args.TryGetValue(key, out var v) ? v : Environment.GetEnvironmentVariable(key); static bool IsTrue(string? value) => string.Equals(value?.Trim()?.Trim('"'), "true", StringComparison.OrdinalIgnoreCase); } /// /// Parses Unity command-line arguments into a dictionary. /// Handles both "-key value" and "-key=value" forms, plus bare flags like "-batchmode". /// Keys are stored WITHOUT the leading dash. /// static Dictionary ParseCommandLineArguments() { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var rawArgs = Environment.GetCommandLineArgs(); for (var i = 0; i < rawArgs.Length; i++) { var arg = rawArgs[i]; if (!arg.StartsWith("-")) continue; var key = arg.TrimStart('-'); // Handle -key=value form var eqIndex = key.IndexOf('='); if (eqIndex >= 0) { result[key.Substring(0, eqIndex)] = key.Substring(eqIndex + 1); continue; } // Handle -key value form (next arg is value if it doesn't start with -) if (i + 1 < rawArgs.Length && !rawArgs[i + 1].StartsWith("-")) { result[key] = rawArgs[++i]; } else { // Bare flag like -batchmode result[key] = null; } } return result; } } }