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; using StardewModdingAPI.Toolkit.Utilities.PathLookups; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { /// Scans folders for mod data. public class ModScanner { /********* ** Fields *********/ /// The JSON helper with which to read manifests. private readonly JsonHelper JsonHelper; /// A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod. private readonly HashSet IgnoreFilesystemNames = new() { 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 }; /// A list of file extensions to ignore when searching for mod files. private readonly HashSet IgnoreFileExtensions = new(StringComparer.OrdinalIgnoreCase) { // text ".doc", ".docx", ".md", ".rtf", ".txt", // images ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".psd", ".tif", ".xcf", // gimp files // archives ".rar", ".zip", ".7z", ".tar", ".tar.gz" // backup files ".backup", ".bak", ".old", // Windows shortcut files ".url", ".lnk" }; /// The extensions for packed content files. private readonly HashSet StrictXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) { ".xgs", ".xnb", ".xsb", ".xwb" }; /// The extensions for files which an XNB mod may contain, in addition to . private readonly HashSet PotentialXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) { ".json", ".yaml" }; /// The name of the marker file added by Vortex to indicate it's managing the folder. private readonly string VortexMarkerFileName = "__folder_managed_by_vortex"; /// The name for a mod's configuration JSON file. private readonly string ConfigFileName = "config.json"; /********* ** 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. /// Whether to match file paths case-insensitively, even on Linux. public IEnumerable GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths) { DirectoryInfo root = new(rootPath); return this.GetModFolders(root, root, useCaseInsensitiveFilePaths); } /// Extract information about all mods in the given folder. /// The root folder containing mods. Only the will be searched, but this field allows it to be treated as a potential mod folder of its own. /// The mod path to search. /// Whether to match file paths case-insensitively, even on Linux. public IEnumerable GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths) { return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath), useCaseInsensitiveFilePaths: useCaseInsensitiveFilePaths); } /// Extract information from a mod folder. /// The root folder containing mods. /// The folder to search for a mod. /// Whether to match file paths case-insensitively, even on Linux. public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder, bool useCaseInsensitiveFilePaths) { // find manifest.json FileInfo? manifestFile = this.FindManifest(searchFolder, useCaseInsensitiveFilePaths); // set appropriate invalid-mod error if (manifestFile == null) { FileInfo[] files = this.RecursivelyGetFiles(searchFolder).ToArray(); FileInfo[] relevantFiles = files.Where(this.IsRelevant).ToArray(); // empty Vortex folder // (this filters relevant files internally so it can check for the normally-ignored Vortex marker file) if (this.IsEmptyVortexFolder(files)) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyVortexFolder, "it's an empty Vortex folder (is the mod disabled in Vortex?)."); // empty folder if (!relevantFiles.Any()) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); // XNB mod if (this.IsXnbMod(relevantFiles)) return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); // SMAPI installer if (relevantFiles.Any(p => p.Name is "install on Linux.sh" or "install on macOS.command" or "install on Windows.bat")) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file)."); // not a mod? 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(manifestFile.FullName, out manifest)) { 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}"; } } // get mod type ModType type; { bool isContentPack = !string.IsNullOrWhiteSpace(manifest?.ContentPackFor?.UniqueID); bool isSmapi = !string.IsNullOrWhiteSpace(manifest?.EntryDll); if (isContentPack == isSmapi) type = ModType.Invalid; else if (isContentPack) type = ModType.ContentPack; else type = ModType.Smapi; } // build result return new ModFolder(root, manifestFile.Directory!, type, manifest, error, errorText); } /********* ** Private methods *********/ /// Recursively extract information about all mods in the given folder. /// The root mod folder. /// The folder to search for mods. /// Whether to match file paths case-insensitively, even on Linux. private IEnumerable GetModFolders(DirectoryInfo root, DirectoryInfo folder, bool useCaseInsensitiveFilePaths) { 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 subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub, useCaseInsensitiveFilePaths)); 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, useCaseInsensitiveFilePaths); } /// Consolidate adjacent folders into one mod folder, if possible. /// The folder containing both parent and subfolders. /// The parent folder to consolidate, if possible. /// The subfolders to consolidate, if possible. private IEnumerable 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; } /// Find the manifest for a mod folder. /// The folder to search. /// Whether to match file paths case-insensitively, even on Linux. private FileInfo? FindManifest(DirectoryInfo folder, bool useCaseInsensitiveFilePaths) { // check for conventional manifest in current folder const string defaultName = "manifest.json"; FileInfo file = new(Path.Combine(folder.FullName, defaultName)); if (file.Exists) return file; // check for manifest with incorrect capitalization if (useCaseInsensitiveFilePaths) { CaseInsensitiveFileLookup fileLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily file = fileLookup.GetFile(defaultName); return file.Exists ? file : null; } // not found return null; } /// Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods). /// The root mod folder. /// The folder to search for mods. 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(); } /// Recursively get all files in a folder. /// The root folder to search. private IEnumerable RecursivelyGetFiles(DirectoryInfo folder) { foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) { if (entry is DirectoryInfo && !this.IsRelevant(entry)) continue; if (entry is FileInfo file) yield return file; if (entry is DirectoryInfo subfolder) { foreach (FileInfo subfolderFile in this.RecursivelyGetFiles(subfolder)) yield return subfolderFile; } } } /// Get whether a file or folder is relevant when deciding how to process a mod folder. /// The file or folder. private bool IsRelevant(FileSystemInfo entry) { // ignored file extensions and any files starting with "." if ((entry is FileInfo file) && (this.IgnoreFileExtensions.Contains(file.Extension) || file.Name.StartsWith("."))) return false; // ignored entry name return !this.IgnoreFilesystemNames.Any(p => p.IsMatch(entry.Name)); } /// Get whether a set of files looks like an XNB mod. /// The files in the mod. private bool IsXnbMod(IEnumerable files) { bool hasXnbFile = false; foreach (FileInfo file in files.Where(this.IsRelevant)) { if (this.StrictXnbModExtensions.Contains(file.Extension)) { hasXnbFile = true; continue; } if (!this.PotentialXnbModExtensions.Contains(file.Extension)) return false; } return hasXnbFile; } /// Get whether a set of files looks like an XNB mod. /// The files in the mod. private bool IsEmptyVortexFolder(IEnumerable files) { bool hasVortexMarker = false; foreach (FileInfo file in files) { if (file.Name == this.VortexMarkerFileName) { hasVortexMarker = true; continue; } if (this.IsRelevant(file) && file.Name != this.ConfigFileName) return false; } return hasVortexMarker; } } }