/* +------------------------------------------------------------------+ | 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 System.IO; using System.Linq; using UnityEngine; namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver { /// /// Restores NuGet packages on domain reload. /// Compares the required packages (from NuGetConfig) against the on-disk manifest at /// Assets/Plugins/NuGet/.nuget-installed.json. Downloads and installs any missing /// packages. Also removes packages that Unity now provides natively. /// /// Since #733 the on-disk layout is flat — see and /// . The restorer also runs the one-shot /// migration from the legacy per-package layout via /// at the start of every Restore() pass; the migration is idempotent and is a /// no-op once the legacy folders are gone. /// static class NuGetPackageRestorer { const string Tag = NuGetConfig.LogTag; /// /// Performs a full package restore. Returns true if any packages were installed /// or removed (meaning a domain reload is needed). /// public static bool Restore() { var anyChanged = false; try { NuGetPackageInstaller.ResetSession(); // Ensure install directory exists if (!Directory.Exists(NuGetConfig.InstallPath)) Directory.CreateDirectory(NuGetConfig.InstallPath); // Best-effort: continue past AbortedFileLock so extraction can proceed // on what the migration freed up; the rest finishes on the next reload. var migration = NuGetLegacyMigration.Run(NuGetConfig.InstallPath); if (migration.Outcome != NuGetLegacyMigration.Outcome.NoLegacyState) anyChanged = true; // Manifest disaster recovery: rebuild from on-disk versioned // filenames when .nuget-installed.json is missing. The // {stem}.{packageVersion}.dll pattern carries enough metadata // for TryRebuildFromDisk to reconstruct a manifest matching the // steady state, so Install()'s alreadyOnDisk check then short- // circuits and no re-extraction churn happens. Persist the // rebuilt manifest immediately so subsequent passes see it. if (!File.Exists(NuGetInstallManifest.GetPath(NuGetConfig.InstallPath))) { var rebuilt = NuGetInstallManifest.TryRebuildFromDisk(NuGetConfig.InstallPath); if (rebuilt.Packages.Count > 0) { Debug.Log($"{Tag} Manifest missing — rebuilt {rebuilt.Packages.Count} entries from on-disk filenames."); NuGetInstallManifest.Save(NuGetConfig.InstallPath, rebuilt); } } // Install configured packages. Install() populates InstalledThisSession with the // full resolved closure (direct + transitive) by always reading the dep graph, // including for packages already on disk from a previous session. foreach (var package in NuGetConfig.Packages) anyChanged |= NuGetPackageInstaller.Install(package); // Invalidate assembly cache only after packages may have changed, // so RemoveUnnecessaryPackages sees the current state. if (anyChanged) UnityAssemblyResolver.InvalidateCache(); // Remove stale-version manifest entries for anything in the closure // and packages whose DLLs are now all provided by Unity. var anyRemoved = NuGetPackageInstaller.RemoveUnnecessaryPackages( NuGetPackageInstaller.InstalledThisSession); anyChanged |= anyRemoved; if (anyChanged) Debug.Log($"{Tag} Package restore complete. Changes applied (installed and/or removed packages)."); else Debug.Log($"{Tag} All packages up to date."); } catch (Exception ex) { Debug.LogError($"{Tag} Package restore failed: {ex.Message}\n{ex.StackTrace}"); } return anyChanged; } /// /// Quick check: are all configured packages already installed at their configured version, /// with no stale-version entries of configured packages present in the manifest, AND is the /// full transitive closure present? /// Used to skip the full restore when everything is up to date. Returning false here forces /// the full Restore() path, which runs migration + reconciliation. /// public static bool AllPackagesInstalled() { if (!Directory.Exists(NuGetConfig.InstallPath)) return false; // Legacy per-package directories on disk → migration is pending → force a full restore. // Cheap enough on the steady-state path because there are no legacy directories left // after the first migration run. foreach (var dir in Directory.GetDirectories(NuGetConfig.InstallPath)) { var dirName = Path.GetFileName(dir); if (NuGetPackageInstaller.ExtractPackageIdFromDirName(dirName) != null) return false; } // Versioned-filename flat DLL on disk → migration is pending → force restore. foreach (var dllPath in Directory.GetFiles(NuGetConfig.InstallPath, "*.dll", SearchOption.TopDirectoryOnly)) { if (NuGetInstallManifest.TryParseInstalledDllName(Path.GetFileName(dllPath), out _, out _)) return false; } // Manifest is the source of truth for the flat layout. If it's missing we need a full // restore — Restore() will rebuild it from on-disk filenames as the first step, then // the closure-completeness check below runs against the rebuilt entries. if (!File.Exists(NuGetInstallManifest.GetPath(NuGetConfig.InstallPath))) return false; var manifest = NuGetInstallManifest.Load(NuGetConfig.InstallPath); var skipSet = new HashSet(NuGetConfig.SkipPackages, StringComparer.OrdinalIgnoreCase); // No skip-listed package may sit in the manifest. foreach (var packageId in manifest.Packages.Keys) { if (skipSet.Contains(packageId)) return false; } // Every configured package must be installed at the configured version, UNLESS the // package is a development-only dependency (analyzers, source generators, build // tooling). The installer intentionally leaves an empty-Dlls manifest entry for those. foreach (var package in NuGetConfig.Packages) { if (IsCachedDevelopmentDependency(package)) { // Dev-deps are still tracked in the manifest with an empty DLL list — that // marker tells AllPackagesInstalled() to skip the on-disk DLL check for them. if (!manifest.Packages.TryGetValue(package.Id, out var devEntry)) return false; if (!string.Equals(devEntry.Version, package.Version, StringComparison.OrdinalIgnoreCase)) return false; continue; } if (!manifest.Packages.TryGetValue(package.Id, out var entry)) return false; if (!string.Equals(entry.Version, package.Version, StringComparison.OrdinalIgnoreCase)) return false; // Versioned-filename manifest entry → pre-unversioned resolver → force restore. foreach (var dll in entry.Dlls) { if (!File.Exists(Path.Combine(NuGetConfig.InstallPath, dll))) return false; if (NuGetInstallManifest.TryParseInstalledDllName(dll, out _, out _)) return false; } } // Walk the transitive closure from each configured top-level package via cached // .nuspec files and verify every declared dep has a manifest entry. Catches the // case where a transitive-dep DLL was deleted externally or a prior restore // failed mid-install; without this we'd incorrectly return true and skip the // fix-up pass in Restore(). var installedPackageIds = new HashSet(manifest.Packages.Keys, StringComparer.OrdinalIgnoreCase); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var package in NuGetConfig.Packages) { if (!HasTransitiveClosure(package, visited, installedPackageIds, skipSet)) return false; } return true; } /// /// Recursively verifies that and every package reachable /// through its cached .nuspec dependency list has a manifest entry (any version — /// the resolved version may differ from the declared one due to highest-version-wins, /// but the ID must be present). /// static bool HasTransitiveClosure( NuGetPackage package, HashSet visited, HashSet installedPackageIds, HashSet skipSet) { if (!visited.Add(package.Id)) return true; // Skip-listed packages are expected to be absent. if (skipSet.Contains(package.Id)) return true; var cachedPath = NuGetCache.IsCached(package) ? NuGetCache.GetCachedPath(package) : null; // Development-only dependencies (analyzers, source generators, build tooling) // legitimately have an empty DLL list in the manifest. if (cachedPath != null && NuGetExtractor.IsDevelopmentDependency(cachedPath)) return true; if (!installedPackageIds.Contains(package.Id)) return false; // If this specific version's .nupkg isn't cached, the package may have been // superseded by a higher version from another chain (which caused Install() to // early-return without downloading this version). The manifest check above // already confirmed the ID is present, so stop recursing here. if (cachedPath == null) return true; List deps; try { deps = NuGetExtractor.GetDependencies(cachedPath); } catch { // Corrupted cache — let Restore() re-download and re-validate. return false; } foreach (var dep in deps) { if (!HasTransitiveClosure(dep, visited, installedPackageIds, skipSet)) return false; } return true; } /// /// Returns true when 's .nupkg is on disk in the NuGet cache /// and its .nuspec declares <developmentDependency>true</developmentDependency>. /// static bool IsCachedDevelopmentDependency(NuGetPackage package) { if (!NuGetCache.IsCached(package)) return false; return NuGetExtractor.IsDevelopmentDependency(NuGetCache.GetCachedPath(package)); } } }