/* +------------------------------------------------------------------+ | 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 UnityEngine; namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver { /// /// One-shot migration from the legacy per-package NuGet install layout /// ({Id}.{Version}/{Dll}.dll) to the flat versioned-filename layout /// ({stem}.{packageVersion}.dll). /// /// Runs at the start of every /// pass. After the first successful migration the install directory has no /// legacy {Id}.{Version}/ directories left, so subsequent runs /// short-circuit at the detection probe. /// /// Failure mode (Windows file lock — see issue #733): if a legacy DLL is /// loaded into the editor AppDomain, File.Delete throws /// . The migration is best-effort per file — /// the locked file's is disabled so the next /// domain reload unloads the assembly and unlocks the file, then the next /// migration pass deletes it. The caller proceeds with the rest of the /// restore regardless: even if a legacy folder still survives, the /// downstream stale-flat sweep + extraction can still make progress on /// whatever the migration freed up. /// /// /// Asset GUIDs in legacy .meta files are intentionally NOT preserved /// onto the new flat-layout meta files: these DLLs are referenced via /// asmdef precompiledReferences (filename-based) rather than via /// GUID, so the link survives the rename without the migration carrying /// per-meta GUID state forward. Unity regenerates fresh meta files on the /// next asset import. /// /// /// /// Plugin-exclusive directory contract: the install path /// (, by default /// Assets/Plugins/NuGet/) MUST be reserved exclusively for this /// resolver. Any directory directly underneath whose name parses as /// {Id}.{System.Version} is treated as a legacy NuGet artifact and /// removed with Directory.Delete(recursive: true). A user-authored /// folder dropped here whose name happens to match that pattern (e.g. /// MyTools.1.0/) would be silently destroyed. Do not co-locate /// non-NuGet content with the install path. /// /// static class NuGetLegacyMigration { const string Tag = NuGetConfig.LogTag; /// /// Result code returned by . /// public enum Outcome { /// No legacy directories were found; nothing to do. NoLegacyState, /// One or more legacy directories were detected and removed. Migrated, /// /// At least one legacy directory could not be fully removed /// because of a file lock or other I/O error. The migration has /// already done what it could (deleted any unlocked siblings, /// disabled the PluginImporter on locked files); the caller may /// proceed and the next migration pass will pick up the rest. /// AbortedFileLock, } /// /// Outcome of a call. /// is the first directory or file the migration could not fully remove /// (others get logged); lists everything /// that did get cleaned up. /// public readonly struct Result { public Outcome Outcome { get; } public IReadOnlyList RemovedItems { get; } public string? FirstFailedItem { get; } public string? FirstFailureMessage { get; } public Result(Outcome outcome, IReadOnlyList removedItems, string? firstFailedItem = null, string? firstFailureMessage = null) { Outcome = outcome; RemovedItems = removedItems; FirstFailedItem = firstFailedItem; FirstFailureMessage = firstFailureMessage; } } /// /// Migrates the install path off pre-#733 layouts to the canonical /// unversioned flat layout: removes legacy {Id}.{Version}/ /// directories AND flat {stem}.{numericVersion}.dll files. /// Best-effort — see the class doc for the locked-file recovery /// contract. /// public static Result Run(string installPath) { var removed = new List(); if (!Directory.Exists(installPath)) return new Result(Outcome.NoLegacyState, removed); var legacyDirs = DetectLegacyDirectories(installPath); var versionedFiles = DetectVersionedFilenameDlls(installPath); if (legacyDirs.Count == 0 && versionedFiles.Count == 0) return new Result(Outcome.NoLegacyState, removed); Debug.Log($"{Tag} Legacy install detected: {legacyDirs.Count} directories, {versionedFiles.Count} versioned-filename DLLs to migrate to the unversioned flat layout."); string? firstFailedItem = null; string? firstFailureMessage = null; foreach (var dir in legacyDirs) { Debug.Log($"{Tag} Removing legacy directory: {dir}"); if (TryRemoveDirectoryRecursive(dir, out var failureMessage)) { NuGetPluginConfigurator.TryDeleteFile(dir + ".meta"); removed.Add(dir); continue; } firstFailedItem ??= dir; firstFailureMessage ??= failureMessage; Debug.LogWarning(BuildLockMessage(dir, failureMessage ?? "(unknown)")); } foreach (var file in versionedFiles) { Debug.Log($"{Tag} Removing versioned-filename DLL: {Path.GetFileName(file)} (version moved into the manifest)."); if (TryDeleteVersionedFile(file, out var failureMessage)) { removed.Add(file); continue; } firstFailedItem ??= file; firstFailureMessage ??= failureMessage; Debug.LogWarning(BuildLockMessage(file, failureMessage ?? "(unknown)")); } if (firstFailedItem != null) return new Result(Outcome.AbortedFileLock, removed, firstFailedItem, firstFailureMessage); Debug.Log($"{Tag} Migration complete: {removed.Count} legacy items removed; unversioned flat layout active."); return new Result(Outcome.Migrated, removed); } static List DetectLegacyDirectories(string installPath) { var result = new List(); foreach (var dir in Directory.GetDirectories(installPath)) { var dirName = Path.GetFileName(dir); if (NuGetPackageInstaller.ExtractPackageIdFromDirName(dirName) != null) result.Add(dir); } return result; } /// /// Returns every {stem}.{numericVersion}.dll at the install /// root — these are flat-layout files written by an earlier resolver /// that embedded the package version in the filename. With the /// version now living in the manifest, every such file is by /// definition stale and the canonical replacement is {stem}.dll. /// static List DetectVersionedFilenameDlls(string installPath) { var result = new List(); foreach (var dllPath in Directory.GetFiles(installPath, "*.dll", SearchOption.TopDirectoryOnly)) { var fileName = Path.GetFileName(dllPath); if (NuGetInstallManifest.TryParseInstalledDllName(fileName, out _, out _)) result.Add(dllPath); } return result; } static bool TryDeleteVersionedFile(string filePath, out string? failureMessage) { failureMessage = null; try { File.Delete(filePath); NuGetPluginConfigurator.TryDeleteFile(filePath + ".meta"); return true; } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { failureMessage = ex.Message; NuGetPluginConfigurator.DisableImporter(filePath); return false; } } /// /// Best-effort recursive delete; returns true only when the directory /// is gone after the call. Locked file → disable importer so the next /// reload unloads it and the next migration pass finishes. /// static bool TryRemoveDirectoryRecursive(string dir, out string? failureMessage) { failureMessage = null; foreach (var subDir in Directory.GetDirectories(dir)) { if (!TryRemoveDirectoryRecursive(subDir, out var subFailure)) failureMessage ??= subFailure; } foreach (var file in Directory.GetFiles(dir)) { try { File.Delete(file); } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { failureMessage ??= ex.Message; NuGetPluginConfigurator.DisableImporter(file); } } try { Directory.Delete(dir); return true; } catch (Exception ex) { failureMessage ??= ex.Message; return false; } } static string BuildLockMessage(string directory, string reason) { return $"{Tag} Could not fully remove legacy install directory:\n" + $" {directory}\n\n" + $" Reason: {reason}\n\n" + "On Windows this typically means a DLL inside the legacy folder is loaded " + "into the editor AppDomain. The PluginImporter for the locked DLL has been " + "disabled so the next domain reload will unload it; the next migration pass " + "will retry the deletion. Restart Unity if the warning persists."; } } }