diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-05-13 18:20:09 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-05-13 18:20:09 -0400 |
commit | 63edebaef1019ce103f5a86d55e1d1c4eb8d371c (patch) | |
tree | a61b1ebd08324477b049699b63f405a5025555d6 /src/StardewModdingAPI | |
parent | 66d2b5746ab063b89ca42525a78e217e71d00858 (diff) | |
download | SMAPI-63edebaef1019ce103f5a86d55e1d1c4eb8d371c.tar.gz SMAPI-63edebaef1019ce103f5a86d55e1d1c4eb8d371c.tar.bz2 SMAPI-63edebaef1019ce103f5a86d55e1d1c4eb8d371c.zip |
decouple mod metadata resolution from main SMAPI logic (#285)
This makes the logic more self-contained for eventual unit testing, and makes failed mods available during dependency resolution so we can make errors more relevant.
Diffstat (limited to 'src/StardewModdingAPI')
-rw-r--r-- | src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs | 34 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs | 267 | ||||
-rw-r--r-- | src/StardewModdingAPI/Program.cs | 51 | ||||
-rw-r--r-- | src/StardewModdingAPI/SemanticVersion.cs | 17 |
4 files changed, 219 insertions, 150 deletions
diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 1ac167dc..72c4692b 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -20,6 +20,12 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> public ModCompatibility Compatibility { get; } + /// <summary>The metadata resolution status.</summary> + public ModMetadataStatus Status { get; set; } + + /// <summary>The reason the metadata is invalid, if any.</summary> + public string Error { get; set; } + /********* ** Public methods @@ -30,11 +36,39 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="manifest">The mod manifest.</param> /// <param name="compatibility">Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param> public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) + : this(displayName, directoryPath, manifest, compatibility, ModMetadataStatus.Found, null) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Compatibility = compatibility; + } + + /// <summary>Construct an instance.</summary> + /// <param name="displayName">The mod's display name.</param> + /// <param name="directoryPath">The mod's full directory path.</param> + /// <param name="manifest">The mod manifest.</param> + /// <param name="compatibility">Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param> + /// <param name="status">The metadata resolution status.</param> + /// <param name="error">The reason the metadata is invalid, if any.</param> + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility, ModMetadataStatus status, string error) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; this.Manifest = manifest; this.Compatibility = compatibility; + this.Status = status; + this.Error = error; } } + + /// <summary>Indicates the status of a mod's metadata resolution.</summary> + internal enum ModMetadataStatus + { + /// <summary>The mod has been found, but hasn't been processed yet.</summary> + Found, + + /// <summary>The mod cannot be loaded.</summary> + Failed + } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 450fe6bf..30c38aca 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -13,12 +13,6 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Properties *********/ - /// <summary>Encapsulates monitoring and logging.</summary> - private readonly IMonitor Monitor; - - /// <summary>Manages deprecation warnings.</summary> - private readonly DeprecationManager DeprecationManager; - /// <summary>Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> private readonly ModCompatibility[] CompatibilityRecords; @@ -26,78 +20,43 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Public methods *********/ - public ModResolver(IMonitor monitor, DeprecationManager deprecationManager, IEnumerable<ModCompatibility> compatibilityRecords) + /// <summary>Construct an instance.</summary> + /// <param name="compatibilityRecords">Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param> + public ModResolver(IEnumerable<ModCompatibility> compatibilityRecords) { - this.Monitor = monitor; - this.DeprecationManager = deprecationManager; this.CompatibilityRecords = compatibilityRecords.ToArray(); } + /// <summary>Read mod metadata from the given folder in dependency order.</summary> + /// <param name="rootPath">The root path to search for mods.</param> + /// <param name="jsonHelper">The JSON helper with which to read manifests.</param> + public IEnumerable<ModMetadata> GetMods(string rootPath, JsonHelper jsonHelper) + { + ModMetadata[] mods = this.GetDataFromFolder(rootPath, jsonHelper).ToArray(); + mods = this.ProcessDependencies(mods.ToArray()); + return mods; + } + + + /********* + ** Private methods + *********/ /// <summary>Find all mods in the given folder.</summary> /// <param name="rootPath">The root mod path to search.</param> /// <param name="jsonHelper">The JSON helper with which to read the manifest file.</param> - /// <param name="deprecationWarnings">A list to populate with any deprecation warnings.</param> - public ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList<Action> deprecationWarnings) + private IEnumerable<ModMetadata> GetDataFromFolder(string rootPath, JsonHelper jsonHelper) { - this.Monitor.Log("Finding mods..."); - void LogSkip(string displayName, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {displayName} because {reasonPhrase}", level); - // load mod metadata - List<ModMetadata> mods = new List<ModMetadata>(); - foreach (string modRootPath in Directory.GetDirectories(rootPath)) + foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) { - if (this.Monitor.IsExiting) - return new ModMetadata[0]; // exit in progress - - // init metadata - string displayName = modRootPath.Replace(rootPath, "").Trim('/', '\\'); - - // passthrough empty directories - DirectoryInfo directory = new DirectoryInfo(modRootPath); - while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) - directory = directory.GetDirectories().First(); - - // get manifest path - string manifestPath = Path.Combine(directory.FullName, "manifest.json"); - if (!File.Exists(manifestPath)) - { - LogSkip(displayName, "it doesn't have a manifest.", LogLevel.Warn); - continue; - } + string displayName = modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); // read manifest Manifest manifest; - try - { - // read manifest file - string json = File.ReadAllText(manifestPath); - if (string.IsNullOrEmpty(json)) - { - LogSkip(displayName, "its manifest is empty."); - continue; - } - - // parse manifest - manifest = jsonHelper.ReadJsonFile<Manifest>(Path.Combine(directory.FullName, "manifest.json")); - if (manifest == null) - { - LogSkip(displayName, "its manifest is invalid."); - continue; - } - - // validate manifest - if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - { - LogSkip(displayName, "its manifest doesn't set an entry DLL."); - continue; - } - if (string.IsNullOrWhiteSpace(manifest.UniqueID)) - deprecationWarnings.Add(() => this.Monitor.Log($"{manifest.Name} doesn't have a {nameof(IManifest.UniqueID)} in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); - } - catch (Exception ex) { - LogSkip(displayName, $"parsing its manifest failed:\n{ex.GetLogSummary()}"); - continue; + string manifestPath = Path.Combine(modDir.FullName, "manifest.json"); + if (!this.TryReadManifest(manifestPath, jsonHelper, out manifest, out string error)) + yield return new ModMetadata(displayName, modDir.FullName, null, null, ModMetadataStatus.Failed, error); } if (!string.IsNullOrWhiteSpace(manifest.Name)) displayName = manifest.Name; @@ -116,89 +75,35 @@ namespace StardewModdingAPI.Framework.ModLoading if (hasUnofficialUrl) error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; - LogSkip(displayName, error); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, error); } // validate SMAPI version if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) { - try - { - ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); - if (minVersion.IsNewerThan(Constants.ApiVersion)) - { - LogSkip(displayName, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); - continue; - } - } - catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) - { - LogSkip(displayName, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); - continue; - } - } - - // create per-save directory - if (manifest.PerSaveConfigs) - { - deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); - try - { - string psDir = Path.Combine(directory.FullName, "psconfigs"); - Directory.CreateDirectory(psDir); - if (!Directory.Exists(psDir)) - { - LogSkip(displayName, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); - continue; - } - } - catch (Exception ex) - { - LogSkip(displayName, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); - continue; - } + if (!SemanticVersion.TryParse(manifest.MinimumApiVersion, out ISemanticVersion minVersion)) + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + if (minVersion.IsNewerThan(Constants.ApiVersion)) + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); } // validate DLL path - string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); + string assemblyPath = Path.Combine(modDir.FullName, manifest.EntryDll); if (!File.Exists(assemblyPath)) { - LogSkip(displayName, $"its DLL '{manifest.EntryDll}' doesn't exist."); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"its DLL '{manifest.EntryDll}' doesn't exist."); continue; } // add mod metadata - mods.Add(new ModMetadata(displayName, directory.FullName, manifest, compatibility)); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility); } - - return this.HandleModDependencies(mods.ToArray()); - } - - - /********* - ** Private methods - *********/ - /// <summary>Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code.</summary> - /// <param name="manifest">The mod manifest.</param> - /// <returns>Returns the incompatibility record if applicable, else <c>null</c>.</returns> - private ModCompatibility GetCompatibilityRecord(IManifest manifest) - { - string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; - return ( - from mod in this.CompatibilityRecords - where - mod.ID == key - && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) - && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) - select mod - ).FirstOrDefault(); } /// <summary>Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies.</summary> /// <param name="mods">The mods to process.</param> - private ModMetadata[] HandleModDependencies(ModMetadata[] mods) + private ModMetadata[] ProcessDependencies(ModMetadata[] mods) { - this.Monitor.Log("Checking mod dependencies..."); var unsortedMods = mods.ToList(); var sortedMods = new Stack<ModMetadata>(); var visitedMods = new bool[unsortedMods.Count]; @@ -207,17 +112,16 @@ namespace StardewModdingAPI.Framework.ModLoading for (int index = 0; index < unsortedMods.Count; index++) { - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (unsortedMods[index].Status == ModMetadataStatus.Failed) + continue; + + success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } if (!success) - { - // Failed to sort list, return no mods. - this.Monitor.Log("No mods will be loaded.", LogLevel.Error); return new ModMetadata[0]; - } return sortedMods.Reverse().ToArray(); } @@ -229,14 +133,18 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="currentChain">The current change of mod dependencies.</param> /// <param name="unsortedMods">The mods remaining to sort.</param> /// <returns>Returns whether the mod can be loaded.</returns> - private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack<ModMetadata> sortedMods, List<ModMetadata> currentChain, List<ModMetadata> unsortedMods) + private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack<ModMetadata> sortedMods, List<ModMetadata> currentChain, List<ModMetadata> unsortedMods) { // visit mod if (visitedMods[modIndex]) return true; // already sorted - ModMetadata mod = unsortedMods[modIndex]; visitedMods[modIndex] = true; + // mod already failed + ModMetadata mod = unsortedMods[modIndex]; + if (mod.Status == ModMetadataStatus.Failed) + return false; + // process dependencies bool success = true; if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) @@ -251,7 +159,8 @@ namespace StardewModdingAPI.Framework.ModLoading } if (missingMods != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."; return false; } } @@ -269,14 +178,8 @@ namespace StardewModdingAPI.Framework.ModLoading ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); - string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; - for (int i = currentChain.Count - 1; i >= 0; i--) - { - chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; - if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; - } - this.Monitor.Log(chain, LogLevel.Error); + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."; return false; } currentChain.Add(mod); @@ -285,7 +188,7 @@ namespace StardewModdingAPI.Framework.ModLoading foreach (ModMetadata requiredMod in modsToLoadFirst) { int index = unsortedMods.IndexOf(requiredMod); - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } @@ -296,5 +199,81 @@ namespace StardewModdingAPI.Framework.ModLoading currentChain.Remove(mod); return success; } + + /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary> + /// <param name="rootPath">The root folder path to search.</param> + private IEnumerable<DirectoryInfo> GetModFolders(string rootPath) + { + foreach (string modRootPath in Directory.GetDirectories(rootPath)) + { + DirectoryInfo directory = new DirectoryInfo(modRootPath); + + // if a folder only contains another folder, check the inner folder instead + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); + + yield return directory; + } + } + + /// <summary>Read a manifest file if it's valid, else set a relevant error phrase.</summary> + /// <param name="path">The absolute path to the manifest file.</param> + /// <param name="jsonHelper">The JSON helper with which to read the manifest file.</param> + /// <param name="manifest">The loaded manifest, if reading succeeded.</param> + /// <param name="errorPhrase">The read error, if reading failed.</param> + /// <returns>Returns whether the manifest was read successfully.</returns> + private bool TryReadManifest(string path, JsonHelper jsonHelper, out Manifest manifest, out string errorPhrase) + { + try + { + // validate path + if (!File.Exists(path)) + { + manifest = null; + errorPhrase = "it doesn't have a manifest."; + return false; + } + + // parse manifest + manifest = jsonHelper.ReadJsonFile<Manifest>(path); + if (manifest == null) + { + errorPhrase = "its manifest is invalid."; + return false; + } + + // validate manifest + if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + { + errorPhrase = "its manifest doesn't set an entry DLL."; + return false; + } + + errorPhrase = null; + return true; + } + catch (Exception ex) + { + manifest = null; + errorPhrase = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; + return false; + } + } + + /// <summary>Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code.</summary> + /// <param name="manifest">The mod manifest.</param> + /// <returns>Returns the incompatibility record if applicable, else <c>null</c>.</returns> + private ModCompatibility GetCompatibilityRecord(IManifest manifest) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + return ( + from mod in this.CompatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } } } diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 7b421895..c8840538 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -313,14 +313,45 @@ namespace StardewModdingAPI // load mods int modsLoaded; { - // load mods + // get mod metadata (in dependency order) + this.Monitor.Log("Loading mod metadata..."); JsonHelper jsonHelper = new JsonHelper(); + ModMetadata[] mods = new ModResolver(this.Settings.ModCompatibility) + .GetMods(Constants.ModPath, new JsonHelper()) + .ToArray(); + + // check for deprecated metadata IList<Action> deprecationWarnings = new List<Action>(); - ModMetadata[] mods = new ModResolver(this.Monitor, this.DeprecationManager, this.Settings.ModCompatibility) - .FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings); - modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); + foreach (ModMetadata mod in mods) + { + // missing unique ID + if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) + deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} doesn't have specify a {nameof(IManifest.UniqueID)} field in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); - // log deprecation warnings together + // per-save directories + if ((mod.Manifest as Manifest)?.PerSaveConfigs == true) + { + deprecationWarnings.Add(() => this.DeprecationManager.Warn(mod.DisplayName, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); + try + { + string psDir = Path.Combine(mod.DirectoryPath, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) + { + mod.Status = ModMetadataStatus.Failed; + mod.Error = "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."; + } + } + catch (Exception ex) + { + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"; + } + } + } + + // load mods + modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); foreach (Action warning in deprecationWarnings) warning(); } @@ -474,9 +505,17 @@ namespace StardewModdingAPI AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); foreach (ModMetadata metadata in mods) { + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + LogSkip(metadata, metadata.Error); + continue; + } + + // get basic info IManifest manifest = metadata.Manifest; string assemblyPath = Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll); - + // preprocess & load mod assembly Assembly modAssembly; try diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs index db25dc11..a2adb657 100644 --- a/src/StardewModdingAPI/SemanticVersion.cs +++ b/src/StardewModdingAPI/SemanticVersion.cs @@ -182,6 +182,23 @@ namespace StardewModdingAPI return result; } + /// <summary>Parse a version string without throwing an exception if it fails.</summary> + /// <param name="version">The version string.</param> + /// <param name="parsed">The parsed representation.</param> + /// <returns>Returns whether parsing the version succeeded.</returns> + internal static bool TryParse(string version, out ISemanticVersion parsed) + { + try + { + parsed = new SemanticVersion(version); + return true; + } + catch + { + parsed = null; + return false; + } + } /********* ** Private methods |