/*
+------------------------------------------------------------------+
| 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
{
///
/// Installs NuGet packages: downloads .nupkg, extracts DLLs, resolves transitive dependencies.
/// Skips packages that Unity already provides (detected by UnityAssemblyResolver).
/// Uses highest-version-wins strategy for dependency conflicts.
///
/// Since #733 the on-disk layout is flat — every DLL sits directly under
/// with the versioned filename
/// {stem}.{packageVersion}.dll. The package → DLL mapping is
/// recorded in at the root of the
/// install path; the installer reads/writes that manifest as the
/// authoritative source of truth.
///
static class NuGetPackageInstaller
{
const string Tag = NuGetConfig.LogTag;
///
/// Resolved (id → version) closure for the current session, including both top-level
/// configured packages and their transitive dependencies. Populated by Install() for
/// every package it processes, whether newly extracted or already on disk. Used by
/// RemoveUnnecessaryPackages() as the authoritative "keep list".
///
static readonly Dictionary installedThisSession = new(StringComparer.OrdinalIgnoreCase);
public static IReadOnlyDictionary InstalledThisSession => installedThisSession;
///
/// Installs a package and its transitive dependencies.
/// Returns true if any new DLLs were extracted, OR if any stale-version DLLs of this
/// package's Id were removed from the install path (both require a domain reload).
/// Always reads the dependency graph (from the cached .nupkg) and records the resolved
/// (id, version) in , even when the package is already
/// present on disk — otherwise transitive deps of already-installed packages would be
/// missing from the closure, and stale-version DLLs of those transitives would be kept
/// by RemoveUnnecessaryPackages.
///
public static bool Install(NuGetPackage package, HashSet? visitedIds = null)
{
return Install(package, NuGetConfig.InstallPath, visitedIds, manifest: null);
}
///
/// Test seam — Install() with the install path and the in-memory manifest
/// injected. Production callers use the no-args overload above. Tests
/// drive a temp directory and an in-memory manifest so they don't touch
/// the real Assets/Plugins/NuGet path.
///
internal static bool Install(NuGetPackage package, string installPath, HashSet? visitedIds, InstallManifest? manifest)
{
visitedIds ??= new HashSet(StringComparer.OrdinalIgnoreCase);
manifest ??= NuGetInstallManifest.Load(installPath);
// Prevent circular dependencies while still allowing the same package Id to be
// re-entered at a *different* version (so higher-version-wins can replace an
// already-installed lower version encountered earlier in the graph).
if (!visitedIds.Add($"{package.Id}:{package.Version}"))
return false;
// Skip packages explicitly excluded from the dependency closure.
// We do NOT recurse into their transitive deps — anything only they need is also
// unwanted. Packages legitimately needed by other chains will still resolve via
// those chains.
if (IsSkipped(package.Id))
return false;
var anyInstalled = false;
try
{
// Higher-version-wins when a second chain requests the same Id at a lower version.
if (installedThisSession.TryGetValue(package.Id, out var existingVersion)
&& CompareVersions(existingVersion, package.Version) >= 0)
{
return false;
}
// Download (cached in Library/NuGetCache across sessions). Needed even when
// the package is already on disk, so GetDependencies() can build the full closure.
var nupkgPath = NuGetDownloader.Download(package);
var dependencies = NuGetExtractor.GetDependencies(nupkgPath);
foreach (var dep in dependencies)
anyInstalled |= Install(dep, installPath, visitedIds, manifest);
// Remove any stale-version DLLs for this package Id BEFORE extracting the new
// payload. Mirrors the legacy stale-version cleanup, just driven by the manifest
// instead of by directory naming. Still flips anyInstalled because deletion is a
// material Asset change that requires a domain reload.
if (RemoveStaleSiblingVersions(installPath, package.Id, package.Version, manifest))
anyInstalled = true;
// Development-only dependencies (Roslyn analyzers, source generators, build
// tooling) ship their payload under analyzers/, build/, tools/ etc. — never
// under lib// — so there are no runtime DLLs for the resolver to extract.
// Record them in the closure but do not extract anything; the manifest carries
// an entry with an empty DLL list so AllPackagesInstalled() short-circuits cleanly
// on the next pass.
if (NuGetExtractor.IsDevelopmentDependency(nupkgPath))
{
installedThisSession[package.Id] = package.Version;
UpsertManifestEntry(manifest, package.Id, package.Version, dlls: Array.Empty());
NuGetInstallManifest.Save(installPath, manifest);
return anyInstalled;
}
// Plan DLL paths up front so synthetic-owner reconciliation (below) can
// operate on the same filenames the collision check would compare against.
var planned = NuGetExtractor.PlanDllPaths(nupkgPath, installPath);
// Disaster-recovery reconciliation: TryRebuildFromDisk has no way to recover
// multi-DLL package IDs from filenames alone (e.g., Microsoft.Bcl.Memory ships
// System.Memory.dll / System.Buffers.dll / System.Runtime.CompilerServices.Unsafe.dll),
// so it keys those DLLs under their own stems as synthetic package IDs. When the
// real owner re-installs at the same version, those synthetic entries would trip
// the collision check below; remove them and let the real owner take over.
// Persist immediately on a successful migration so a downstream halt (collision
// check, long-path overflow, extraction failure) cannot leave the manifest
// desynced from disk.
if (MigrateSyntheticOwnerEntries(manifest, package.Id, package.Version, planned))
NuGetInstallManifest.Save(installPath, manifest);
// alreadyOnDisk requires a full match against the planned set, not just
// "every recorded DLL exists". After MigrateSyntheticOwnerEntries pulls
// synthetic stem-keyed entries into the real-owner entry, that entry's
// Dlls list only covers whichever DLLs the disaster-recovery rebuild
// actually observed on disk. If a strict subset of a multi-DLL package's
// files survived (manifest deleted + one DLL lost to AV / partial cleanup),
// a recorded-DLLs-only check would short-circuit extraction and leave the
// missing files unrecovered. Requiring planned ⊆ manifestEntry.Dlls forces
// re-extraction in that case.
var alreadyOnDisk = false;
if (manifest.Packages.TryGetValue(package.Id, out var manifestEntry)
&& string.Equals(manifestEntry.Version, package.Version, StringComparison.OrdinalIgnoreCase)
&& manifestEntry.Dlls.Count > 0
&& planned.All(p => manifestEntry.Dlls.Contains(p.FileName, StringComparer.OrdinalIgnoreCase))
&& manifestEntry.Dlls.All(d => File.Exists(Path.Combine(installPath, d))))
{
alreadyOnDisk = true;
}
// Extract DLLs only when not already present at this exact version.
if (!alreadyOnDisk)
{
// Defense-in-depth: collision detection. With versioned filenames a real
// collision requires two distinct packages shipping the same DLL stem at
// the same package version — essentially impossible in practice. Keep the
// detection branch anyway so the failure mode is loud rather than silent.
foreach (var planEntry in planned)
{
if (TryFindDllOwner(manifest, planEntry.FileName, package.Id, out var owner))
{
Debug.LogError(
$"{Tag} Refusing to install {package.Id} {package.Version}: " +
$"DLL '{planEntry.FileName}' is already owned by package '{owner}' " +
$"in the manifest. Loud failure beats silent overwrite — investigate the conflict.");
return anyInstalled;
}
}
// Long-path pre-flight (Windows MAX_PATH). Throws on overflow; the catch
// below logs and continues — the rest of the closure can still install,
// and the user gets a single actionable error in the Console.
foreach (var planEntry in planned)
NuGetLongPathPreflight.Check(planEntry.TargetPath, package.Id);
var extractedDlls = NuGetExtractor.ExtractDlls(nupkgPath, installPath);
if (extractedDlls.Count > 0)
{
Debug.Log($"{Tag} Installed {package.Id} {package.Version} ({extractedDlls.Count} DLL(s))");
UpsertManifestEntry(manifest, package.Id, package.Version, extractedDlls);
NuGetInstallManifest.Save(installPath, manifest);
anyInstalled = true;
}
else
{
Debug.LogWarning($"{Tag} No DLLs extracted for {package.Id} {package.Version}");
}
}
else
{
// Already on disk at the right version — just keep the manifest entry
// around (it was the source of truth that decided alreadyOnDisk).
UpsertManifestEntry(manifest, package.Id, package.Version, manifestEntry!.Dlls);
NuGetInstallManifest.Save(installPath, manifest);
}
installedThisSession[package.Id] = package.Version;
}
catch (InstallPathTooLongException ex)
{
// Pre-flight overflow — the message is fully self-contained.
Debug.LogError(ex.Message);
}
catch (Exception ex)
{
Debug.LogError($"{Tag} Failed to install {package}: {ex.Message}\n{ex.StackTrace}");
}
return anyInstalled;
}
///
/// Removes manifest entries (and their on-disk DLLs / .meta files) that
/// should no longer exist:
/// 1. Stale-version entries for any package in the resolved session closure
/// (e.g., a leftover "Microsoft.AspNetCore.SignalR.Common" at 10.0.3 when the
/// current closure resolves that transitive to 8.0.15). Keeping both produces
/// duplicate-assembly conflicts.
/// 2. Entries whose DLLs are now all provided by Unity (e.g., after a Unity upgrade
/// that bundled the BCL assembly) and whose package ID is not in the closure.
/// 3. Skip-listed packages — always removed regardless of closure membership.
///
/// must contain the full resolved closure
/// (pass from the restorer after Install() calls).
/// Returns true if anything was removed.
///
public static bool RemoveUnnecessaryPackages(IReadOnlyDictionary requiredVersionByPackageId)
{
return RemoveUnnecessaryPackages(requiredVersionByPackageId, NuGetConfig.InstallPath, manifest: null);
}
internal static bool RemoveUnnecessaryPackages(
IReadOnlyDictionary requiredVersionByPackageId,
string installPath,
InstallManifest? manifest)
{
if (!Directory.Exists(installPath))
return false;
manifest ??= NuGetInstallManifest.Load(installPath);
var anyRemoved = false;
// Iterate a snapshot so we can mutate the manifest mid-loop.
foreach (var packageId in manifest.Packages.Keys.ToList())
{
var entry = manifest.Packages[packageId];
// Case 0: explicitly skipped package — always remove from disk.
if (IsSkipped(packageId))
{
Debug.Log($"{Tag} Removing {packageId} {entry.Version} — package is in SkipPackages exclusion list.");
DeleteEntryFiles(installPath, entry);
manifest.Packages.Remove(packageId);
anyRemoved = true;
continue;
}
// Case 1: configured package with a stale version — delete the stale one.
if (requiredVersionByPackageId.TryGetValue(packageId, out var requiredVersion))
{
if (string.Equals(entry.Version, requiredVersion, StringComparison.OrdinalIgnoreCase))
continue;
Debug.Log($"{Tag} Removing {packageId} {entry.Version} — stale version; closure requires {packageId} {requiredVersion}.");
DeleteEntryFiles(installPath, entry);
manifest.Packages.Remove(packageId);
anyRemoved = true;
continue;
}
// Case 2: unrequired package — remove only when Unity provides every DLL it ships.
// Using the NuGet package ID directly is unreliable because a package often ships
// DLLs with names that differ from the package ID (e.g., Microsoft.Bcl.Memory
// ships System.Memory / System.Buffers / System.Runtime.CompilerServices.Unsafe).
if (entry.Dlls.Count == 0)
{
// Empty manifest entry (development dependency that's no longer in the closure).
Debug.Log($"{Tag} Removing {packageId} {entry.Version} — package no longer required.");
manifest.Packages.Remove(packageId);
anyRemoved = true;
continue;
}
var allProvidedByUnity = entry.Dlls.All(dll =>
{
// Strip the version tail so the lookup matches the assembly's
// manifest name, not the on-disk versioned filename.
if (!NuGetInstallManifest.TryParseInstalledDllName(dll, out var stem, out _) || stem == null)
stem = Path.GetFileNameWithoutExtension(dll);
return UnityAssemblyResolver.IsAlreadyImported(stem!);
});
if (!allProvidedByUnity)
continue;
Debug.Log($"{Tag} Removing {packageId} {entry.Version} — Unity now provides all of its assemblies.");
DeleteEntryFiles(installPath, entry);
manifest.Packages.Remove(packageId);
anyRemoved = true;
}
if (anyRemoved)
NuGetInstallManifest.Save(installPath, manifest);
return anyRemoved;
}
///
/// Manifest-driven version of the legacy directory-based stale-sibling
/// scan. Removes any DLLs the manifest records under
/// at a version OTHER than
/// , deletes their .meta sidecars,
/// and removes the manifest entry — the caller re-adds it after
/// extracting the new DLLs at .
///
/// Returns true when at least one DLL was removed.
///
internal static bool RemoveStaleSiblingVersions(string installPath, string packageId, string keepVersion)
{
var manifest = NuGetInstallManifest.Load(installPath);
var removed = RemoveStaleSiblingVersions(installPath, packageId, keepVersion, manifest);
if (removed)
NuGetInstallManifest.Save(installPath, manifest);
return removed;
}
internal static bool RemoveStaleSiblingVersions(string installPath, string packageId, string keepVersion, InstallManifest manifest)
{
if (!Directory.Exists(installPath))
return false;
if (!manifest.Packages.TryGetValue(packageId, out var entry))
return false;
// Same exact version — keep it. Downstream extraction owns this entry.
if (string.Equals(entry.Version, keepVersion, StringComparison.OrdinalIgnoreCase))
return false;
Debug.Log($"{Tag} Removing stale {packageId} {entry.Version} — superseded by {packageId} {keepVersion}.");
DeleteEntryFiles(installPath, entry);
manifest.Packages.Remove(packageId);
return true;
}
static void DeleteEntryFiles(string installPath, InstalledPackage entry)
{
foreach (var dll in entry.Dlls)
{
var dllPath = Path.Combine(installPath, dll);
NuGetPluginConfigurator.TryDeleteFile(dllPath);
NuGetPluginConfigurator.TryDeleteFile(dllPath + ".meta");
}
}
static bool TryFindDllOwner(InstallManifest manifest, string dllFileName, string excludePackageId, out string? owner)
{
foreach (var (id, entry) in manifest.Packages)
{
if (string.Equals(id, excludePackageId, StringComparison.OrdinalIgnoreCase))
continue;
if (entry.Dlls.Any(d => string.Equals(d, dllFileName, StringComparison.OrdinalIgnoreCase)))
{
owner = id;
return true;
}
}
owner = null;
return false;
}
///
/// Reconciles synthetic manifest entries left behind by
/// with the real
/// owning package. The rebuild keys multi-DLL packages under each DLL's
/// stem (because filenames carry no package-id metadata); when the real
/// owner re-installs at the same version, those stem-keyed entries are
/// the same physical DLLs we are about to extract — drop them so the
/// downstream collision check does not flag the package as a duplicate.
///
/// Match criteria for "synthetic owner":
/// * the manifest entry's id is NOT this package's id;
/// * the entry version matches the package version exactly;
/// * every DLL the entry lists is also one of this package's planned
/// filenames (so we never strip a real, distinct package that
/// happens to share a version number).
///
/// On a hit we also migrate any DLLs that already exist on disk into a
/// real entry under , which lets the
/// caller's alreadyOnDisk check short-circuit re-extraction.
///
/// Returns true when the manifest was mutated (caller MUST persist
/// via ); false on a no-op.
///
///
/// Same-version cross-package DLL collision: this migrator is the only
/// place where a hypothetical legitimate distinct package shipping a
/// same-named DLL at the same package version is silently absorbed into
/// the real owner — the downstream collision check (in
/// ) cannot fire for entries this method already
/// migrated. The collision is essentially impossible in practice
/// (versioned filenames + the same-version constraint), but the silent
/// absorption is intentional for the disaster-recovery scenario.
///
///
internal static bool MigrateSyntheticOwnerEntries(
InstallManifest manifest,
string realPackageId,
string version,
IReadOnlyList planned)
{
if (planned.Count == 0)
return false;
var plannedFileNames = new HashSet(
planned.Select(p => p.FileName),
StringComparer.OrdinalIgnoreCase);
var migratedDlls = new List();
foreach (var id in manifest.Packages.Keys.ToList())
{
if (string.Equals(id, realPackageId, StringComparison.OrdinalIgnoreCase))
continue;
var entry = manifest.Packages[id];
if (!string.Equals(entry.Version, version, StringComparison.OrdinalIgnoreCase))
continue;
if (entry.Dlls.Count == 0 || !entry.Dlls.All(d => plannedFileNames.Contains(d)))
continue;
// Synthetic entry confirmed — surface the reconciliation so a stuck
// user can correlate it with the rebuild log.
Debug.Log(
$"{Tag} Reconciling disaster-recovery manifest entry '{id}' {version} into real owner '{realPackageId}'.");
migratedDlls.AddRange(entry.Dlls);
manifest.Packages.Remove(id);
}
if (migratedDlls.Count == 0)
return false;
// Merge into any pre-existing real entry rather than overwriting it.
if (!manifest.Packages.TryGetValue(realPackageId, out var realEntry)
|| !string.Equals(realEntry.Version, version, StringComparison.OrdinalIgnoreCase))
{
realEntry = new InstalledPackage(version);
manifest.Packages[realPackageId] = realEntry;
}
foreach (var dll in migratedDlls)
{
if (!realEntry.Dlls.Contains(dll, StringComparer.OrdinalIgnoreCase))
realEntry.Dlls.Add(dll);
}
return true;
}
static void UpsertManifestEntry(InstallManifest manifest, string packageId, string version, IReadOnlyList dlls)
{
var newEntry = new InstalledPackage(version);
foreach (var dll in dlls)
newEntry.Dlls.Add(dll);
manifest.Packages[packageId] = newEntry;
}
///
/// Extracts the package ID from a directory name like "System.Text.Json.8.0.5"
/// or "Microsoft.AspNetCore.SignalR.Protocols.Json.8.0.15".
/// Scans left-to-right for the FIRST (leftmost) segment that starts with a digit AND
/// where all segments from there to the end parse as a System.Version. This greedily
/// consumes the entire version tail (e.g., "10.0.3") rather than a shorter suffix
/// (e.g., "0.3") that would also satisfy System.Version.TryParse.
///
/// Retained post-#733 because needs it
/// to detect legacy {Id}.{Version}/ directories during the one-time
/// migration. Production install paths never use this layout anymore.
///
internal static string? ExtractPackageIdFromDirName(string dirName)
{
var parts = dirName.Split('.');
for (var i = 1; i < parts.Length; i++)
{
if (parts[i].Length == 0 || !char.IsDigit(parts[i][0]))
continue;
var versionPart = string.Join(".", parts.Skip(i));
// System.Version.TryParse only accepts plain Major.Minor[.Build[.Revision]];
// it rejects SemVer prerelease / build-metadata suffixes like
// "1.0.0-preview" or "1.2.3+build.42". NuGet packages legitimately use
// those (e.g. Microsoft.AspNetCore.SignalR.Client.8.0.15-preview), so
// fall through to a SemVer-shape regex when System.Version refuses,
// otherwise the migration silently leaves those legacy folders on disk
// and the user ends up with duplicate-DLL compile errors.
if (System.Version.TryParse(versionPart, out _) || SemVerShape.IsMatch(versionPart))
return string.Join(".", parts.Take(i));
}
return null;
}
// Matches a SemVer 2.0 version tail: at least Major.Minor (numeric, dot-separated),
// optionally followed by a `-prerelease` or `+build-metadata` segment whose body
// can include word chars, dots, dashes, and pluses. Used as a fallback when
// System.Version.TryParse refuses an otherwise-valid NuGet folder version.
static readonly System.Text.RegularExpressions.Regex SemVerShape = new System.Text.RegularExpressions.Regex(
@"^\d+(\.\d+)+([-+][\w.+-]+)?$",
System.Text.RegularExpressions.RegexOptions.CultureInvariant);
///
/// Compares two version strings. Returns -1, 0, or 1.
///
static int CompareVersions(string a, string b)
{
if (System.Version.TryParse(a, out var va) && System.Version.TryParse(b, out var vb))
return va.CompareTo(vb);
return string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
}
///
/// Resets the session tracking. Call at the start of a new restore cycle.
///
public static void ResetSession()
{
installedThisSession.Clear();
}
static bool IsSkipped(string packageId)
{
foreach (var skip in NuGetConfig.SkipPackages)
{
if (string.Equals(packageId, skip, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
}