using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Newtonsoft.Json; using StardewModdingAPI.ModBuildConfig.Framework; using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig { /// A build task which deploys the mod files and prepares a release zip. public class DeployModTask : Task { /********* ** Accessors *********/ /// The name (without extension or path) of the current mod's DLL. [Required] public string ModDllName { get; set; } /// The name of the mod folder. [Required] public string ModFolderName { get; set; } /// The absolute or relative path to the folder which should contain the generated zip file. [Required] public string ModZipPath { get; set; } /// The folder containing the project files. [Required] public string ProjectDir { get; set; } /// The folder containing the build output. [Required] public string TargetDir { get; set; } /// The folder containing the game's mod folders. [Required] public string GameModsDir { get; set; } /// Whether to enable copying the mod files into the game's Mods folder. [Required] public bool EnableModDeploy { get; set; } /// Whether to enable the release zip. [Required] public bool EnableModZip { get; set; } /// A comma-separated list of regex patterns matching files to ignore when deploying or zipping the mod. public string IgnoreModFilePatterns { get; set; } /// A comma-separated list of relative file paths to ignore when deploying or zipping the mod. public string IgnoreModFilePaths { get; set; } /// A comma-separated list of values which indicate which extra DLLs to bundle. public string BundleExtraAssemblies { get; set; } /********* ** Public methods *********/ /// When overridden in a derived class, executes the task. /// true if the task successfully executed; otherwise, false. public override bool Execute() { // log build settings { var properties = this .GetPropertiesToLog() .Select(p => $"{p.Key}: {p.Value}"); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}"); } // skip if nothing to do // (This must be checked before the manifest validation, to allow cases like unit test projects.) if (!this.EnableModDeploy && !this.EnableModZip) return true; // validate the manifest file IManifest manifest; { try { string manifestPath = Path.Combine(this.ProjectDir, "manifest.json"); if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) { this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist."); return false; } manifest = rawManifest; } catch (JsonReaderException ex) { // log the inner exception, otherwise the message will be generic Exception exToShow = ex.InnerException ?? ex; this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}"); return false; } // validate manifest fields if (!ManifestValidator.TryValidateFields(manifest, out string error)) { this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}"); return false; } } // deploy files try { // parse extra DLLs to bundle ExtraAssemblyTypes bundleAssemblyTypes = this.GetExtraAssembliesToBundleOption(); // parse ignore patterns string[] ignoreFilePaths = this.GetCustomIgnoreFilePaths().ToArray(); Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray(); // get mod info ModFileManager package = new(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip); // deploy mod files if (this.EnableModDeploy) { string outputPath = Path.Combine(this.GameModsDir, this.EscapeInvalidFilenameCharacters(this.ModFolderName)); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Copying the mod files to {outputPath}..."); this.CreateModFolder(package.GetFiles(), outputPath); } // create release zip if (this.EnableModZip) { string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); string zipPath = Path.Combine(this.ModZipPath, zipName); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); this.CreateReleaseZip(package.GetFiles(), this.ModFolderName, zipPath); } return true; } catch (UserErrorException ex) { this.Log.LogErrorFromException(ex); return false; } catch (Exception ex) { this.Log.LogError($"[mod build package] Failed trying to deploy the mod.\n{ex}"); return false; } } /********* ** Private methods *********/ /// Get the properties to write to the log. private IEnumerable> GetPropertiesToLog() { var properties = this.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); foreach (PropertyInfo property in properties.OrderBy(p => p.Name)) { if (property.Name == nameof(this.IgnoreModFilePatterns) && string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) continue; string name = property.Name; string value = property.GetValue(this)?.ToString(); if (value == null) value = "null"; else if (property.PropertyType == typeof(bool)) value = value.ToLower(); else value = $"'{value}'"; yield return new KeyValuePair(name, value); } } /// Parse the extra assembly types which should be bundled with the mod. private ExtraAssemblyTypes GetExtraAssembliesToBundleOption() { ExtraAssemblyTypes flags = ExtraAssemblyTypes.None; if (!string.IsNullOrWhiteSpace(this.BundleExtraAssemblies)) { foreach (string raw in this.BundleExtraAssemblies.Split(',')) { if (!Enum.TryParse(raw, out ExtraAssemblyTypes type)) { this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.BundleExtraAssemblies)}> value '{raw}', expected one of '{string.Join("', '", Enum.GetNames(typeof(ExtraAssemblyTypes)))}'."); continue; } flags |= type; } } return flags; } /// Get the custom ignore patterns provided by the user. private IEnumerable GetCustomIgnorePatterns() { if (string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) yield break; foreach (string raw in this.IgnoreModFilePatterns.Split(',')) { Regex regex; try { regex = new Regex(raw.Trim(), RegexOptions.IgnoreCase); } catch (Exception ex) { this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.IgnoreModFilePatterns)}> pattern {raw}:\n{ex}"); continue; } yield return regex; } } /// Get the custom relative file paths provided by the user to ignore. private IEnumerable GetCustomIgnoreFilePaths() { if (string.IsNullOrWhiteSpace(this.IgnoreModFilePaths)) yield break; foreach (string raw in this.IgnoreModFilePaths.Split(',')) { string path; try { path = PathUtilities.NormalizePath(raw); } catch (Exception ex) { this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.IgnoreModFilePaths)}> path {raw}:\n{ex}"); continue; } yield return path; } } /// Copy the mod files into the game's mod folder. /// The files to include. /// The folder path to create with the mod files. private void CreateModFolder(IDictionary files, string modFolderPath) { foreach (var entry in files) { string fromPath = entry.Value.FullName; string toPath = Path.Combine(modFolderPath, entry.Key); Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); File.Copy(fromPath, toPath, overwrite: true); } } /// Create a release zip in the recommended format for uploading to mod sites. /// The files to include. /// The name of the mod. /// The absolute path to the zip file to create. private void CreateReleaseZip(IDictionary files, string modName, string zipPath) { // get folder name within zip string folderName = this.EscapeInvalidFilenameCharacters(modName); // create zip file Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); using ZipArchive archive = new(zipStream, ZipArchiveMode.Create); foreach (var fileEntry in files) { string relativePath = fileEntry.Key; FileInfo file = fileEntry.Value; // get file info string filePath = file.FullName; string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); // add to zip using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); using Stream fileStreamInZip = archive.CreateEntry(entryName).Open(); fileStream.CopyTo(fileStreamInZip); } } /// Get a copy of a filename with all invalid filename characters substituted. /// The filename. private string EscapeInvalidFilenameCharacters(string name) { foreach (char invalidChar in Path.GetInvalidFileNameChars()) name = name.Replace(invalidChar, '.'); return name; } } }