diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 9e91b993..a38621f8 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -7,8 +7,8 @@ using Newtonsoft.Json; using NUnit.Framework; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; using StardewModdingAPI.Toolkit.Serialisation.Models; namespace StardewModdingAPI.Tests.Core @@ -31,7 +31,7 @@ namespace StardewModdingAPI.Tests.Core Directory.CreateDirectory(rootFolder); // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray(); + IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray(); // assert Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); @@ -46,7 +46,7 @@ namespace StardewModdingAPI.Tests.Core Directory.CreateDirectory(modFolder); // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray(); + IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray(); IModMetadata mod = mods.FirstOrDefault(); // assert @@ -85,7 +85,7 @@ namespace StardewModdingAPI.Tests.Core File.WriteAllText(filename, JsonConvert.SerializeObject(original)); // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray(); + IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray(); IModMetadata mod = mods.FirstOrDefault(); // assert diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 174820a1..9ac95fd4 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Serialisation.Models; using StardewModdingAPI.Toolkit.Utilities; @@ -17,38 +18,15 @@ namespace StardewModdingAPI.Framework.ModLoading ** Public methods *********/ /// Get manifest metadata for each folder in the given root path. + /// The mod toolkit. /// The root path to search for mods. - /// The JSON helper with which to read manifests. /// Handles access to SMAPI's internal mod metadata list. /// Returns the manifests by relative folder. - public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, ModDatabase modDatabase) + public IEnumerable ReadManifests(ModToolkit toolkit, string rootPath, ModDatabase modDatabase) { - foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) + foreach (ModFolder folder in toolkit.GetModFolders(rootPath)) { - // read file - Manifest manifest = null; - string error = null; - { - string path = Path.Combine(modDir.FullName, "manifest.json"); - try - { - manifest = jsonHelper.ReadJsonFile(path); - if (manifest == null) - { - error = File.Exists(path) - ? "its manifest is invalid." - : "it doesn't have a manifest."; - } - } - catch (SParseException ex) - { - error = $"parsing its manifest failed: {ex.Message}"; - } - catch (Exception ex) - { - error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; - } - } + Manifest manifest = folder.Manifest; // parse internal data record (if any) ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest); @@ -58,7 +36,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (string.IsNullOrWhiteSpace(displayName)) displayName = dataRecord?.DisplayName; if (string.IsNullOrWhiteSpace(displayName)) - displayName = PathUtilities.GetRelativePath(rootPath, modDir.FullName); + displayName = PathUtilities.GetRelativePath(rootPath, folder.ActualDirectory?.FullName ?? folder.SearchDirectory.FullName); // apply defaults if (manifest != null && dataRecord != null) @@ -68,10 +46,10 @@ namespace StardewModdingAPI.Framework.ModLoading } // build metadata - ModMetadataStatus status = error == null + ModMetadataStatus status = folder.ManifestParseError == null ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - yield return new ModMetadata(displayName, modDir.FullName, manifest, dataRecord).SetStatus(status, error); + yield return new ModMetadata(displayName, folder.ActualDirectory?.FullName, manifest, dataRecord).SetStatus(status, folder.ManifestParseError); } } diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index a88db105..c9266c69 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -104,8 +104,8 @@ namespace StardewModdingAPI new Regex(@"^DebugOutput: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) }; - /// Encapsulates SMAPI's JSON file parsing. - private readonly JsonHelper JsonHelper = new JsonHelper(); + /// The mod toolkit used for generic mod interactions. + private readonly ModToolkit Toolkit = new ModToolkit(); /********* @@ -205,7 +205,7 @@ namespace StardewModdingAPI new RectangleConverter() }; foreach (JsonConverter converter in converters) - this.JsonHelper.JsonSettings.Converters.Add(converter); + this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); // add error handlers #if SMAPI_FOR_WINDOWS @@ -423,14 +423,14 @@ namespace StardewModdingAPI ModResolver resolver = new ModResolver(); // load manifests - IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, this.JsonHelper, modDatabase).ToArray(); + IModMetadata[] mods = resolver.ReadManifests(toolkit, Constants.ModPath, modDatabase).ToArray(); resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); // process dependencies mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); // load mods - this.LoadMods(mods, this.JsonHelper, this.ContentCore, modDatabase); + this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); // write metadata file if (this.Settings.DumpMetadata) @@ -443,7 +443,7 @@ namespace StardewModdingAPI ModFolderPath = Constants.ModPath, Mods = mods }; - this.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.metadata-dump.json"), export); + this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.metadata-dump.json"), export); } // check for updates @@ -875,7 +875,7 @@ namespace StardewModdingAPI { IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.JsonHelper); + return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); } modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager); @@ -1117,7 +1117,7 @@ namespace StardewModdingAPI /// The mods for which to reload translations. private void ReloadTranslations(IEnumerable mods) { - JsonHelper jsonHelper = this.JsonHelper; + JsonHelper jsonHelper = this.Toolkit.JsonHelper; foreach (IModMetadata metadata in mods) { if (metadata.IsContentPack) diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs new file mode 100644 index 00000000..9b6853b4 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StardewModdingAPI.Toolkit.Serialisation.Models; + +namespace StardewModdingAPI.Toolkit.Framework.ModScanning +{ + /// The info about a mod read from its folder. + public class ModFolder + { + /********* + ** Accessors + *********/ + /// The Mods subfolder containing this mod. + public DirectoryInfo SearchDirectory { get; } + + /// The folder containing manifest.json. + public DirectoryInfo ActualDirectory { get; } + + /// The mod manifest. + public Manifest Manifest { get; } + + /// The error which occurred parsing the manifest, if any. + public string ManifestParseError { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance when a mod wasn't found in a folder. + /// The directory that was searched. + public ModFolder(DirectoryInfo searchDirectory) + { + this.SearchDirectory = searchDirectory; + } + + /// Construct an instance. + /// The Mods subfolder containing this mod. + /// The folder containing manifest.json. + /// The mod manifest. + /// The error which occurred parsing the manifest, if any. + public ModFolder(DirectoryInfo searchDirectory, DirectoryInfo actualDirectory, Manifest manifest, string manifestParseError = null) + { + this.SearchDirectory = searchDirectory; + this.ActualDirectory = actualDirectory; + this.Manifest = manifest; + this.ManifestParseError = manifestParseError; + } + + /// Get the update keys for a mod. + /// The mod manifest. + public IEnumerable GetUpdateKeys(Manifest manifest) + { + return + (manifest.UpdateKeys ?? new string[0]) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .ToArray(); + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs new file mode 100644 index 00000000..d3662c9c --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialisation.Models; + +namespace StardewModdingAPI.Toolkit.Framework.ModScanning +{ + /// Scans folders for mod data. + public class ModScanner + { + /********* + ** Properties + *********/ + /// The JSON helper with which to read manifests. + private readonly JsonHelper JsonHelper; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The JSON helper with which to read manifests. + public ModScanner(JsonHelper jsonHelper) + { + this.JsonHelper = jsonHelper; + } + + /// Extract information about all mods in the given folder. + /// The root folder containing mods. + public IEnumerable GetModFolders(string rootPath) + { + foreach (DirectoryInfo folder in new DirectoryInfo(rootPath).EnumerateDirectories()) + yield return this.ReadFolder(rootPath, folder); + } + + /// Extract information from a mod folder. + /// The root folder containing mods. + /// The folder to search for a mod. + public ModFolder ReadFolder(string rootPath, DirectoryInfo searchFolder) + { + // find manifest.json + FileInfo manifestFile = this.FindManifest(searchFolder); + if (manifestFile == null) + return new ModFolder(searchFolder); + + // read mod info + Manifest manifest = null; + string manifestError = null; + { + try + { + manifest = this.JsonHelper.ReadJsonFile(manifestFile.FullName); + if (manifest == null) + { + manifestError = File.Exists(manifestFile.FullName) + ? "its manifest is invalid." + : "it doesn't have a manifest."; + } + } + catch (SParseException ex) + { + manifestError = $"parsing its manifest failed: {ex.Message}"; + } + catch (Exception ex) + { + manifestError = $"parsing its manifest failed:\n{ex}"; + } + } + + return new ModFolder(searchFolder, manifestFile.Directory, manifest, manifestError); + } + + + /********* + ** Private methods + *********/ + /// Find the manifest for a mod folder. + /// The folder to search. + private FileInfo FindManifest(DirectoryInfo folder) + { + while (true) + { + // check for manifest in current folder + FileInfo file = new FileInfo(Path.Combine(folder.FullName, "manifest.json")); + if (file.Exists) + return file; + + // check for single subfolder + FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray(); + if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder) + { + folder = subfolder; + continue; + } + + // not found + return null; + } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/ModToolkit.cs b/src/StardewModdingAPI.Toolkit/ModToolkit.cs index 18fe1ff3..8c78b2f3 100644 --- a/src/StardewModdingAPI.Toolkit/ModToolkit.cs +++ b/src/StardewModdingAPI.Toolkit/ModToolkit.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.ModScanning; +using StardewModdingAPI.Toolkit.Serialisation; namespace StardewModdingAPI.Toolkit { @@ -27,6 +29,13 @@ namespace StardewModdingAPI.Toolkit }; + /********* + ** Accessors + *********/ + /// Encapsulates SMAPI's JSON parsing. + public JsonHelper JsonHelper { get; } = new JsonHelper(); + + /********* ** Public methods *********/ @@ -53,6 +62,13 @@ namespace StardewModdingAPI.Toolkit return new ModDatabase(records, this.GetUpdateUrl); } + /// Extract information about all mods in the given folder. + /// The root folder containing mods. + public IEnumerable GetModFolders(string rootPath) + { + return new ModScanner(this.JsonHelper).GetModFolders(rootPath); + } + /// Get an update URL for an update key (if valid). /// The update key. public string GetUpdateUrl(string updateKey)