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; namespace StardewModdingAPI.ModBuildConfig.Framework { /// Manages the files that are part of a mod package. internal class ModFileManager { /********* ** Fields *********/ /// The name of the manifest file. private readonly string ManifestFileName = "manifest.json"; /// The files that are part of the package. private readonly IDictionary Files; /// The file extensions used by assembly files. private readonly ISet AssemblyFileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".dll", ".exe", ".pdb", ".xml" }; /// The DLLs which match the type. private readonly ISet GameDllNames = new HashSet { // SMAPI "0Harmony", "Mono.Cecil", "Mono.Cecil.Mdb", "Mono.Cecil.Pdb", "MonoMod.Common", "Newtonsoft.Json", "StardewModdingAPI", "SMAPI.Toolkit", "SMAPI.Toolkit.CoreInterfaces", "TMXTile", // game + framework "BmFont", "FAudio-CS", "GalaxyCSharp", "GalaxyCSharpGlue", "Lidgren.Network", "MonoGame.Framework", "SkiaSharp", "Stardew Valley", "StardewValley.GameData", "Steamworks.NET", "TextCopy", "xTile" }; /********* ** Public methods *********/ /// Construct an instance. /// The folder containing the project files. /// The folder containing the build output. /// The custom relative file paths provided by the user to ignore. /// Custom regex patterns matching files to ignore when deploying or zipping the mod. /// The extra assembly types which should be bundled with the mod. /// The name (without extension or path) for the current mod's DLL. /// Whether to validate that required mod files like the manifest are present. /// The mod package isn't valid. public ModFileManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, bool validateRequiredModFiles) { this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); // validate paths if (!Directory.Exists(projectDir)) throw new UserErrorException("Could not create mod package because the project folder wasn't found."); if (!Directory.Exists(targetDir)) throw new UserErrorException("Could not create mod package because no build output was found."); // collect files foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir)) { string relativePath = entry.Item1; FileInfo file = entry.Item2; if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, modDllName)) this.Files[relativePath] = file; } // check for required files if (validateRequiredModFiles) { // manifest if (!this.Files.ContainsKey(this.ManifestFileName)) throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output."); // DLL // ReSharper disable once SimplifyLinqExpression if (!this.Files.Any(p => !p.Key.EndsWith(".dll"))) throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output."); } } /// Get the files in the mod package. public IDictionary GetFiles() { return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); } /// Get a semantic version from the mod manifest. /// The manifest is missing or invalid. public string GetManifestVersion() { if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest)) throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor return manifest.Version.ToString(); } /********* ** Private methods *********/ /// Get all files to include in the mod folder, not accounting for ignore patterns. /// The folder containing the project files. /// The folder containing the build output. /// Returns tuples containing the relative path within the mod folder, and the file to copy to it. private IEnumerable> GetPossibleFiles(string projectDir, string targetDir) { // project manifest bool hasProjectManifest = false; { FileInfo manifest = new(Path.Combine(projectDir, this.ManifestFileName)); if (manifest.Exists) { yield return Tuple.Create(this.ManifestFileName, manifest); hasProjectManifest = true; } } // project i18n files bool hasProjectTranslations = false; DirectoryInfo translationsFolder = new(Path.Combine(projectDir, "i18n")); if (translationsFolder.Exists) { foreach (FileInfo file in translationsFolder.EnumerateFiles()) yield return Tuple.Create(Path.Combine("i18n", file.Name), file); hasProjectTranslations = true; } // project assets folder bool hasAssetsFolder = false; DirectoryInfo assetsFolder = new(Path.Combine(projectDir, "assets")); if (assetsFolder.Exists) { foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) { string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); yield return Tuple.Create(relativePath, file); } hasAssetsFolder = true; } // build output DirectoryInfo buildFolder = new(targetDir); foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) { // get path info string relativePath = PathUtilities.GetRelativePath(buildFolder.FullName, file.FullName); string[] segments = PathUtilities.GetSegments(relativePath); // prefer project manifest/i18n/assets files if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) continue; if (hasProjectTranslations && this.EqualsInvariant(segments[0], "i18n")) continue; if (hasAssetsFolder && this.EqualsInvariant(segments[0], "assets")) continue; // add file yield return Tuple.Create(relativePath, file); } } /// Get whether a build output file should be ignored. /// The file to check. /// The file's relative path in the package. /// The custom relative file paths provided by the user to ignore. /// Custom regex patterns matching files to ignore when deploying or zipping the mod. /// The extra assembly types which should be bundled with the mod. /// The name (without extension or path) for the current mod's DLL. private bool ShouldIgnore(FileInfo file, string relativePath, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName) { // apply custom patterns if (ignoreFilePaths.Any(p => p == relativePath) || ignoreFilePatterns.Any(p => p.IsMatch(relativePath))) return true; // ignore unneeded files { bool shouldIgnore = // release zips this.EqualsInvariant(file.Extension, ".zip") // *.deps.json (only SMAPI's top-level one is used) || file.Name.EndsWith(".deps.json") // code analysis files || file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.OrdinalIgnoreCase) || file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.OrdinalIgnoreCase) // translation class builder (not used at runtime) || ( file.Name.StartsWith("Pathoschild.Stardew.ModTranslationClassBuilder") && this.AssemblyFileExtensions.Contains(file.Extension) ) // OS metadata files || this.EqualsInvariant(file.Name, ".DS_Store") || this.EqualsInvariant(file.Name, "Thumbs.db"); if (shouldIgnore) return true; } // check for bundled assembly types // When bundleAssemblyTypes is set, *all* dependencies are copied into the build output but only those which match the given assembly types should be bundled. if (bundleAssemblyTypes != ExtraAssemblyTypes.None) { var type = this.GetExtraAssemblyType(file, modDllName); if (type != ExtraAssemblyTypes.None && !bundleAssemblyTypes.HasFlag(type)) return true; } return false; } /// Get the extra assembly type for a file, assuming that the user specified one or more extra types to bundle. /// The file to check. /// The name (without extension or path) for the current mod's DLL. private ExtraAssemblyTypes GetExtraAssemblyType(FileInfo file, string modDllName) { string baseName = Path.GetFileNameWithoutExtension(file.Name); string extension = file.Extension; if (baseName == modDllName || !this.AssemblyFileExtensions.Contains(extension)) return ExtraAssemblyTypes.None; if (this.GameDllNames.Contains(baseName)) return ExtraAssemblyTypes.Game; if (baseName.StartsWith("System.", StringComparison.OrdinalIgnoreCase) || baseName.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase)) return ExtraAssemblyTypes.System; return ExtraAssemblyTypes.ThirdParty; } /// Get whether a string is equal to another case-insensitively. /// The string value. /// The string to compare with. private bool EqualsInvariant(string str, string other) { if (str == null) return other == null; return str.Equals(other, StringComparison.OrdinalIgnoreCase); } } }