using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization.Models; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { /// <summary>Scans folders for mod data.</summary> public class ModScanner { /********* ** Fields *********/ /// <summary>The JSON helper with which to read manifests.</summary> private readonly JsonHelper JsonHelper; /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary> private readonly HashSet<Regex> IgnoreFilesystemNames = new HashSet<Regex> { new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // Windows }; /// <summary>A list of file extensions to ignore when searching for mod files.</summary> private readonly HashSet<string> IgnoreFileExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { // text ".doc", ".docx", ".md", ".rtf", ".txt", // images ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".psd", ".tif", // archives ".rar", ".zip", // backup files ".backup", ".bak", ".old", // Windows shortcut files ".url", ".lnk" }; /// <summary>The extensions for files which an XNB mod may contain. If a mod doesn't have a <c>manifest.json</c> and contains *only* these file extensions, it should be considered an XNB mod.</summary> private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { // XNB files ".xgs", ".xnb", ".xsb", ".xwb", // unpacking artifacts ".json", ".yaml" }; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="jsonHelper">The JSON helper with which to read manifests.</param> public ModScanner(JsonHelper jsonHelper) { this.JsonHelper = jsonHelper; } /// <summary>Extract information about all mods in the given folder.</summary> /// <param name="rootPath">The root folder containing mods.</param> public IEnumerable<ModFolder> GetModFolders(string rootPath) { DirectoryInfo root = new DirectoryInfo(rootPath); return this.GetModFolders(root, root); } /// <summary>Extract information about all mods in the given folder.</summary> /// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param> /// <param name="modPath">The mod path to search.</param> // /// <param name="tryConsolidateMod">If the folder contains multiple XNB mods, treat them as subfolders of a single mod. This is useful when reading a single mod archive, as opposed to a mods folder.</param> public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath) { return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath)); } /// <summary>Extract information from a mod folder.</summary> /// <param name="root">The root folder containing mods.</param> /// <param name="searchFolder">The folder to search for a mod.</param> public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder) { // find manifest.json FileInfo manifestFile = this.FindManifest(searchFolder); // set appropriate invalid-mod error if (manifestFile == null) { FileInfo[] files = this.RecursivelyGetRelevantFiles(searchFolder).ToArray(); if (!files.Any()) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); if (files.All(this.IsPotentialXnbFile)) return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json."); } // read mod info Manifest manifest = null; ModParseError error = ModParseError.None; string errorText = null; { try { if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null) { error = ModParseError.ManifestInvalid; errorText = "its manifest is invalid."; } } catch (SParseException ex) { error = ModParseError.ManifestInvalid; errorText = $"parsing its manifest failed: {ex.Message}"; } catch (Exception ex) { error = ModParseError.ManifestInvalid; errorText = $"parsing its manifest failed:\n{ex}"; } } // normalize display fields if (manifest != null) { manifest.Name = this.StripNewlines(manifest.Name); manifest.Description = this.StripNewlines(manifest.Description); manifest.Author = this.StripNewlines(manifest.Author); } // get mod type ModType type = ModType.Invalid; if (manifest != null) { type = !string.IsNullOrWhiteSpace(manifest.ContentPackFor?.UniqueID) ? ModType.ContentPack : ModType.Smapi; } // build result return new ModFolder(root, manifestFile.Directory, type, manifest, error, errorText); } /********* ** Private methods *********/ /// <summary>Recursively extract information about all mods in the given folder.</summary> /// <param name="root">The root mod folder.</param> /// <param name="folder">The folder to search for mods.</param> private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) { bool isRoot = folder.FullName == root.FullName; // skip if (!isRoot) { if (folder.Name.StartsWith(".")) { yield return new ModFolder(root, folder, ModType.Ignored, null, ModParseError.IgnoredFolder, "ignored folder because its name starts with a dot."); yield break; } if (!this.IsRelevant(folder)) yield break; } // find mods in subfolders if (this.IsModSearchFolder(root, folder)) { IEnumerable<ModFolder> subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub)); if (!isRoot) subfolders = this.TryConsolidate(root, folder, subfolders.ToArray()); foreach (ModFolder subfolder in subfolders) yield return subfolder; } // treat as mod folder else yield return this.ReadFolder(root, folder); } /// <summary>Consolidate adjacent folders into one mod folder, if possible.</summary> /// <param name="root">The folder containing both parent and subfolders.</param> /// <param name="parentFolder">The parent folder to consolidate, if possible.</param> /// <param name="subfolders">The subfolders to consolidate, if possible.</param> private IEnumerable<ModFolder> TryConsolidate(DirectoryInfo root, DirectoryInfo parentFolder, ModFolder[] subfolders) { if (subfolders.Length > 1) { // a collection of empty folders if (subfolders.All(p => p.ManifestParseError == ModParseError.EmptyFolder)) return new[] { new ModFolder(root, parentFolder, ModType.Invalid, null, ModParseError.EmptyFolder, subfolders[0].ManifestParseErrorText) }; // an XNB mod if (subfolders.All(p => p.Type == ModType.Xnb || p.ManifestParseError == ModParseError.EmptyFolder)) return new[] { new ModFolder(root, parentFolder, ModType.Xnb, null, ModParseError.XnbMod, subfolders[0].ManifestParseErrorText) }; } return subfolders; } /// <summary>Find the manifest for a mod folder.</summary> /// <param name="folder">The folder to search.</param> 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; } } /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary> /// <param name="root">The root mod folder.</param> /// <param name="folder">The folder to search for mods.</param> private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder) { if (root.FullName == folder.FullName) return true; DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray(); FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray(); return subfolders.Any() && !files.Any(); } /// <summary>Recursively get all relevant files in a folder based on the result of <see cref="IsRelevant"/>.</summary> /// <param name="folder">The root folder to search.</param> private IEnumerable<FileInfo> RecursivelyGetRelevantFiles(DirectoryInfo folder) { foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) { if (!this.IsRelevant(entry)) continue; if (entry is FileInfo file) yield return file; if (entry is DirectoryInfo subfolder) { foreach (FileInfo subfolderFile in this.RecursivelyGetRelevantFiles(subfolder)) yield return subfolderFile; } } } /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary> /// <param name="entry">The file or folder.</param> private bool IsRelevant(FileSystemInfo entry) { // ignored file extension if (entry is FileInfo file && this.IgnoreFileExtensions.Contains(file.Extension)) return false; // ignored entry name return !this.IgnoreFilesystemNames.Any(p => p.IsMatch(entry.Name)); } /// <summary>Get whether a file is potentially part of an XNB mod.</summary> /// <param name="entry">The file.</param> private bool IsPotentialXnbFile(FileInfo entry) { if (!this.IsRelevant(entry)) return true; return this.PotentialXnbModExtensions.Contains(entry.Extension); // use EndsWith to handle cases like image..png } /// <summary>Strip newlines from a string.</summary> /// <param name="input">The input to strip.</param> private string StripNewlines(string input) { return input?.Replace("\r", "").Replace("\n", ""); } } }