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;
}
}
}