Files
SMAPI/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
T

371 lines
14 KiB
C#

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;
/// <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(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager
new(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // macOS
new(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase)
];
/// <summary>A list of file extensions to ignore when searching for mod files.</summary>
private readonly HashSet<string> 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"
};
/// <summary>The extensions for packed content files.</summary>
private readonly HashSet<string> StrictXnbModExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".xgs",
".xnb",
".xsb",
".xwb"
};
/// <summary>The extensions for files which an XNB mod may contain, in addition to <see cref="StrictXnbModExtensions"/>.</summary>
private readonly HashSet<string> PotentialXnbModExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".json",
".yaml"
};
/// <summary>The name of the marker file added by Vortex to indicate it's managing the folder.</summary>
private readonly string VortexMarkerFileName = "__folder_managed_by_vortex";
/// <summary>The name for a mod's configuration JSON file.</summary>
private readonly string ConfigFileName = "config.json";
/*********
** 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>
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, bool useCaseInsensitiveFilePaths)
{
DirectoryInfo root = new(rootPath);
return this.GetModFolders(root, root, useCaseInsensitiveFilePaths);
}
/// <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="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath, bool useCaseInsensitiveFilePaths)
{
return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath), useCaseInsensitiveFilePaths: useCaseInsensitiveFilePaths);
}
/// <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>
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
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)
{
List<FileInfo> files = [];
this.RecursivelyGetFiles(searchFolder, files);
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<Manifest>(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
*********/
/// <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>
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
private IEnumerable<ModFolder> 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<ModFolder> 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);
}
/// <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 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 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>
/// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
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;
}
/// <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 files in a folder.</summary>
/// <param name="folder">The root folder to search.</param>
/// <param name="files">The list of files to populate.</param>
private void RecursivelyGetFiles(DirectoryInfo folder, List<FileInfo> files)
{
foreach (FileSystemInfo entry in folder.GetFileSystemInfos())
{
if (entry is DirectoryInfo && !this.IsRelevant(entry))
continue;
if (entry is FileInfo file)
files.Add(file);
if (entry is DirectoryInfo subfolder)
this.RecursivelyGetFiles(subfolder, files);
}
}
/// <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 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));
}
/// <summary>Get whether a set of files looks like an XNB mod.</summary>
/// <param name="files">The files in the mod.</param>
private bool IsXnbMod(IEnumerable<FileInfo> files)
{
bool hasXnbFile = false;
foreach (FileInfo file in files)
{
if (!this.IsRelevant(file))
continue;
if (this.StrictXnbModExtensions.Contains(file.Extension))
{
hasXnbFile = true;
continue;
}
if (!this.PotentialXnbModExtensions.Contains(file.Extension))
return false;
}
return hasXnbFile;
}
/// <summary>Get whether a set of files looks like an XNB mod.</summary>
/// <param name="files">The files in the mod.</param>
private bool IsEmptyVortexFolder(IEnumerable<FileInfo> 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;
}
}