summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-10-08 21:22:08 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-10-08 21:22:08 -0400
commit65e8997fdbe630af10d28b6428bec943477e059d (patch)
tree9346d06e09fc4fbf207c5001a7690a58a363a628
parentfd060a092786d703817fa5a65452b4efe52db8cd (diff)
parentd47105a27841bcae80fcaa2351a2a658cd3d7fdb (diff)
downloadSMAPI-65e8997fdbe630af10d28b6428bec943477e059d.tar.gz
SMAPI-65e8997fdbe630af10d28b6428bec943477e059d.tar.bz2
SMAPI-65e8997fdbe630af10d28b6428bec943477e059d.zip
Merge branch 'revamp-mod-build-logic' into develop
-rw-r--r--docs/mod-build-config.md136
-rw-r--r--src/SMAPI.ModBuildConfig/DeployModTask.cs182
-rw-r--r--src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs162
-rw-r--r--src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs16
-rw-r--r--src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj4
-rw-r--r--src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs153
-rw-r--r--src/SMAPI.ModBuildConfig/build/smapi.targets78
-rw-r--r--src/SMAPI.ModBuildConfig/package.nuspec20
8 files changed, 477 insertions, 274 deletions
diff --git a/docs/mod-build-config.md b/docs/mod-build-config.md
index f76a69ad..f02981b9 100644
--- a/docs/mod-build-config.md
+++ b/docs/mod-build-config.md
@@ -1,17 +1,17 @@
-**Stardew.ModBuildConfig** is an open-source NuGet package which automates the build configuration
-for [Stardew Valley](http://stardewvalley.net/) [SMAPI](https://github.com/Pathoschild/SMAPI) mods.
+The **mod build package** is an open-source NuGet package which automates the MSBuild configuration
+for SMAPI mods.
The package...
-* lets you write your mod once, and compile it on any computer. It detects the current platform
- (Linux, Mac, or Windows) and game install path, and injects the right references automatically.
+* lets your code compile on any computer (Linux/Mac/Windows) without needing to change the assembly
+ references or game path.
+* packages the mod into the game's `Mods` folder when you rebuild the code (configurable).
* configures Visual Studio so you can debug into the mod code when the game is running (_Windows
only_).
-* packages the mod automatically into the game's mod folder when you build the code (_optional_).
## Contents
* [Install](#install)
-* [Simplify mod development](#simplify-mod-development)
+* [Configure](#configure)
* [Troubleshoot](#troubleshoot)
* [Release notes](#release-notes)
@@ -20,7 +20,7 @@ The package...
1. Create an empty library project.
2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig).
-3. [Write your code](http://canimod.com/guides/creating-a-smapi-mod).
+3. [Write your code](https://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod).
4. Compile on any platform.
**When migrating an existing mod:**
@@ -30,59 +30,61 @@ The package...
2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig).
3. Compile on any platform.
-## Simplify mod development
-### Package your mod into the game folder automatically
-You can copy your mod files into the `Mods` folder automatically each time you build, so you don't
-need to do it manually:
-
-1. Edit your mod's `.csproj` file.
-2. Add this block above the first `</PropertyGroup>` line:
-
- ```xml
- <DeployModFolderName>$(MSBuildProjectName)</DeployModFolderName>
- ```
-
-That's it! Each time you build, the files in `<game path>\Mods\<mod name>` will be updated with
-your `manifest.json`, build output, and any `i18n` files.
-
-Notes:
-* To add custom files, just [add them to the build output](https://stackoverflow.com/a/10828462/262123).
-* To customise the folder name, just replace `$(MSBuildProjectName)` with the folder name you want.
-* If your project references another mod, make sure the reference is [_not_ marked 'copy local'](https://msdn.microsoft.com/en-us/library/t1zz5y8c(v=vs.100).aspx).
-
-### Debug into the mod code (Windows-only)
-Stepping into your mod code when the game is running is straightforward, since this package injects
-the configuration automatically. To do it:
-
-1. [Package your mod into the game folder automatically](#package-your-mod-into-the-game-folder-automatically).
-2. Launch the project with debugging in Visual Studio or MonoDevelop.
-
-This will deploy your mod files into the game folder, launch SMAPI, and attach a debugger
-automatically. Now you can step through your code, set breakpoints, etc.
-
-### Create release zips automatically (Windows-only)
-You can create the mod package automatically when you build:
-
-1. Edit your mod's `.csproj` file.
-2. Add this block above the first `</PropertyGroup>` line:
-
- ```xml
- <DeployModZipTo>$(SolutionDir)\_releases</DeployModZipTo>
- ```
-
-That's it! Each time you build, the mod files will be zipped into `_releases\<mod name>.zip`. (You
-can change the value to save the zips somewhere else.)
-
-## Troubleshoot
-### "Failed to find the game install path"
-That error means the package couldn't figure out where the game is installed. You need to specify
-the game location yourself. There's two ways to do that:
-
-* **Option 1: set the path globally.**
- _This will apply to every project that uses version 1.5+ of package._
+## Configure
+### Deploy files into the `Mods` folder
+By default, your mod will be copied into the game's `Mods` folder (with a subfolder matching your
+project name) when you rebuild the code. The package will automatically include your
+`manifest.json`, any `i18n` files, and the build output.
+
+To add custom files to the mod folder, just [add them to the build output](https://stackoverflow.com/a/10828462/262123).
+(If your project references another mod, make sure the reference is [_not_ marked 'copy local'](https://msdn.microsoft.com/en-us/library/t1zz5y8c(v=vs.100).aspx).)
+
+You can change the mod's folder name by adding this above the first `</PropertyGroup>` in your
+`.csproj`:
+```xml
+<ModFolderName>YourModName</ModFolderName>
+```
+
+If you don't want to deploy the mod automatically, you can add this:
+```xml
+<EnableModDeploy>False</EnableModDeploy>
+```
+
+### Create release zip
+By default, a zip file will be created in the build output when you rebuild the code. This zip file
+contains all the files needed to share your mod in the recommended format for uploading to Nexus
+Mods or other sites.
+
+You can change the zipped folder name (and zip name) by adding this above the first
+`</PropertyGroup>` in your `.csproj`:
+```xml
+<ModFolderName>YourModName</ModFolderName>
+```
+
+You can change the folder path where the zip is created like this:
+```xml
+<ModZipPath>$(SolutionDir)\_releases</ModZipPath>
+```
+
+Finally, you can disable the zip creation with this:
+```xml
+<EnableModZip>False</EnableModZip>
+```
+
+Or only create it in release builds with this:
+```xml
+<EnableModZip Condition="$(Configuration) != 'Release'">False</EnableModZip>
+```
+
+### Game path
+The package usually detects where your game is installed automatically. If it can't find your game
+or you have multiple installs, you can specify the path yourself. There's two ways to do that:
+
+* **Option 1: global game path (recommended).**
+ _This will apply to every project that uses the package._
1. Get the full folder path containing the Stardew Valley executable.
- 2. Create this file path:
+ 2. Create this file:
platform | path
--------- | ----
@@ -99,10 +101,11 @@ the game location yourself. There's two ways to do that:
</Project>
```
- 4. Replace `PATH_HERE` with your custom game install path.
+ 4. Replace `PATH_HERE` with your game path.
+
+* **Option 2: path in the project file.**
+ _You'll need to do this for each project that uses the package._
-* **Option 2: set the path in the project file.**
- _(You'll need to do it for every project that uses the package.)_
1. Get the folder path containing the Stardew Valley `.exe` file.
2. Add this to your `.csproj` file under the `<Project` line:
@@ -117,9 +120,18 @@ the game location yourself. There's two ways to do that:
The configuration will check your custom path first, then fall back to the default paths (so it'll
still compile on a different computer).
+
+## Troubleshoot
+### "Failed to find the game install path"
+That error means the package couldn't find your game. You can specify the game path yourself; see
+_[Game path](#game-path)_ above.
+
## Release notes
-### 1.8
+### 2.0
+* Added: mods are now copied into the `Mods` folder automatically (configurable).
+* Added: release zips are now created automatically in your build output folder (configurable).
* Added mod's version to release zip filename.
+* Improved errors to simplify troubleshooting.
* Fixed release zip not having a mod folder.
* Fixed release zip failing if mod name contains characters that aren't valid in a filename.
diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs
new file mode 100644
index 00000000..a693fe32
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using StardewModdingAPI.ModBuildConfig.Framework;
+
+namespace StardewModdingAPI.ModBuildConfig
+{
+ /// <summary>A build task which deploys the mod files and prepares a release zip.</summary>
+ public class DeployModTask : Task
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The MSBuild platforms recognised by the build configuration.</summary>
+ private readonly HashSet<string> ValidPlatforms = new HashSet<string>(new[] { "OSX", "Unix", "Windows_NT" }, StringComparer.InvariantCultureIgnoreCase);
+
+ /// <summary>The name of the game's main executable file.</summary>
+ private string GameExeName => this.Platform == "Windows_NT"
+ ? "Stardew Valley.exe"
+ : "StardewValley.exe";
+
+ /// <summary>The name of SMAPI's main executable file.</summary>
+ private readonly string SmapiExeName = "StardewModdingAPI.exe";
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The name of the mod folder.</summary>
+ [Required]
+ public string ModFolderName { get; set; }
+
+ /// <summary>The absolute or relative path to the folder which should contain the generated zip file.</summary>
+ [Required]
+ public string ModZipPath { get; set; }
+
+ /// <summary>The folder containing the project files.</summary>
+ [Required]
+ public string ProjectDir { get; set; }
+
+ /// <summary>The folder containing the build output.</summary>
+ [Required]
+ public string TargetDir { get; set; }
+
+ /// <summary>The folder containing the game files.</summary>
+ [Required]
+ public string GameDir { get; set; }
+
+ /// <summary>The MSBuild OS value.</summary>
+ [Required]
+ public string Platform { get; set; }
+
+ /// <summary>Whether to enable copying the mod files into the game's Mods folder.</summary>
+ [Required]
+ public bool EnableModDeploy { get; set; }
+
+ /// <summary>Whether to enable the release zip.</summary>
+ [Required]
+ public bool EnableModZip { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>When overridden in a derived class, executes the task.</summary>
+ /// <returns>true if the task successfully executed; otherwise, false.</returns>
+ public override bool Execute()
+ {
+ if (!this.EnableModDeploy && !this.EnableModZip)
+ return true; // nothing to do
+
+ try
+ {
+ // validate context
+ if (!this.ValidPlatforms.Contains(this.Platform))
+ throw new UserErrorException($"The mod build package doesn't recognise OS type '{this.Platform}'.");
+ if (!Directory.Exists(this.GameDir))
+ throw new UserErrorException("The mod build package can't find your game path. See https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md for help specifying it.");
+ if (!File.Exists(Path.Combine(this.GameDir, this.GameExeName)))
+ throw new UserErrorException($"The mod build package found a game folder at {this.GameDir}, but it doesn't contain the {this.GameExeName} file. If this folder is invalid, delete it and the package will autodetect another game install path.");
+ if (!File.Exists(Path.Combine(this.GameDir, this.SmapiExeName)))
+ throw new UserErrorException($"The mod build package found a game folder at {this.GameDir}, but it doesn't contain SMAPI. You need to install SMAPI before building the mod.");
+
+ // get mod info
+ ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir);
+
+ // deploy mod files
+ if (this.EnableModDeploy)
+ {
+ string outputPath = Path.Combine(this.GameDir, "Mods", this.EscapeInvalidFilenameCharacters(this.ModFolderName));
+ this.Log.LogMessage(MessageImportance.High, $"The mod build package is copying the mod files to {outputPath}...");
+ this.CreateModFolder(package.GetFiles(), outputPath);
+ }
+
+ // create release zip
+ if (this.EnableModZip)
+ {
+ this.Log.LogMessage(MessageImportance.High, $"The mod build package is generating a release zip at {this.ModZipPath} for {this.ModFolderName}...");
+ this.CreateReleaseZip(package.GetFiles(), this.ModFolderName, package.GetManifestVersion(), this.ModZipPath);
+ }
+
+ return true;
+ }
+ catch (UserErrorException ex)
+ {
+ this.Log.LogErrorFromException(ex);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ this.Log.LogError($"The mod build package failed trying to deploy the mod.\n{ex}");
+ return false;
+ }
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Copy the mod files into the game's mod folder.</summary>
+ /// <param name="files">The files to include.</param>
+ /// <param name="modFolderPath">The folder path to create with the mod files.</param>
+ private void CreateModFolder(IDictionary<string, FileInfo> files, string modFolderPath)
+ {
+ Directory.CreateDirectory(modFolderPath);
+ foreach (var entry in files)
+ {
+ string fromPath = entry.Value.FullName;
+ string toPath = Path.Combine(modFolderPath, entry.Key);
+ File.Copy(fromPath, toPath, overwrite: true);
+ }
+ }
+
+ /// <summary>Create a release zip in the recommended format for uploading to mod sites.</summary>
+ /// <param name="files">The files to include.</param>
+ /// <param name="modName">The name of the mod.</param>
+ /// <param name="modVersion">The mod version string.</param>
+ /// <param name="outputFolderPath">The absolute or relative path to the folder which should contain the generated zip file.</param>
+ private void CreateReleaseZip(IDictionary<string, FileInfo> files, string modName, string modVersion, string outputFolderPath)
+ {
+ // get names
+ string zipName = this.EscapeInvalidFilenameCharacters($"{modName} {modVersion}.zip");
+ string folderName = this.EscapeInvalidFilenameCharacters(modName);
+ string zipPath = Path.Combine(outputFolderPath, zipName);
+
+ // create zip file
+ Directory.CreateDirectory(outputFolderPath);
+ using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write))
+ using (ZipArchive archive = new ZipArchive(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, '/');
+ if (new FileInfo(filePath).Directory.Name.Equals("i18n", StringComparison.InvariantCultureIgnoreCase))
+ entryName = Path.Combine("i18n", entryName);
+
+ // add to zip
+ using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
+ using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open())
+ fileStream.CopyTo(fileStreamInZip);
+ }
+ }
+ }
+
+ /// <summary>Get a copy of a filename with all invalid filename characters substituted.</summary>
+ /// <param name="name">The filename.</param>
+ private string EscapeInvalidFilenameCharacters(string name)
+ {
+ foreach (char invalidChar in Path.GetInvalidFileNameChars())
+ name = name.Replace(invalidChar, '.');
+ return name;
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
new file mode 100644
index 00000000..10c55d4c
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Web.Script.Serialization;
+using StardewModdingAPI.Common;
+
+namespace StardewModdingAPI.ModBuildConfig.Framework
+{
+ /// <summary>Manages the files that are part of a mod package.</summary>
+ internal class ModFileManager
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The name of the manifest file.</summary>
+ private readonly string ManifestFileName = "manifest.json";
+
+ /// <summary>The files that are part of the package.</summary>
+ private readonly IDictionary<string, FileInfo> Files;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="projectDir">The folder containing the project files.</param>
+ /// <param name="targetDir">The folder containing the build output.</param>
+ /// <exception cref="UserErrorException">The mod package isn't valid.</exception>
+ public ModFileManager(string projectDir, string targetDir)
+ {
+ this.Files = new Dictionary<string, FileInfo>(StringComparer.InvariantCultureIgnoreCase);
+
+ // 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.");
+
+ // project manifest
+ bool hasProjectManifest = false;
+ {
+ FileInfo manifest = new FileInfo(Path.Combine(projectDir, "manifest.json"));
+ if (manifest.Exists)
+ {
+ this.Files[this.ManifestFileName] = manifest;
+ hasProjectManifest = true;
+ }
+ }
+
+ // project i18n files
+ bool hasProjectTranslations = false;
+ DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n"));
+ if (translationsFolder.Exists)
+ {
+ foreach (FileInfo file in translationsFolder.EnumerateFiles())
+ this.Files[Path.Combine("i18n", file.Name)] = file;
+ hasProjectTranslations = true;
+ }
+
+ // build output
+ DirectoryInfo buildFolder = new DirectoryInfo(targetDir);
+ foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories))
+ {
+ // get relative paths
+ string relativePath = file.FullName.Replace(buildFolder.FullName, "");
+ string relativeDirPath = file.Directory.FullName.Replace(buildFolder.FullName, "");
+
+ // prefer project manifest/i18n files
+ if (hasProjectManifest && relativePath.Equals(this.ManifestFileName, StringComparison.InvariantCultureIgnoreCase))
+ continue;
+ if (hasProjectTranslations && relativeDirPath.Equals("i18n", StringComparison.InvariantCultureIgnoreCase))
+ continue;
+
+ // ignore release zips
+ if (file.Extension.Equals(".zip", StringComparison.InvariantCultureIgnoreCase))
+ continue;
+
+ // add file
+ this.Files[relativePath] = file;
+ }
+
+ // check for missing 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.");
+
+ // check for missing 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.");
+ }
+
+ /// <summary>Get the files in the mod package.</summary>
+ public IDictionary<string, FileInfo> GetFiles()
+ {
+ return new Dictionary<string, FileInfo>(this.Files, StringComparer.InvariantCultureIgnoreCase);
+ }
+
+ /// <summary>Get a semantic version from the mod manifest.</summary>
+ /// <exception cref="UserErrorException">The manifest is missing or invalid.</exception>
+ public string GetManifestVersion()
+ {
+ // get manifest file
+ if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile))
+ throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor
+
+ // read content
+ string json = File.ReadAllText(manifestFile.FullName);
+ if (string.IsNullOrWhiteSpace(json))
+ throw new UserErrorException("The mod's manifest must not be empty.");
+
+ // parse JSON
+ IDictionary<string, object> data;
+ try
+ {
+ data = this.Parse(json);
+ }
+ catch (Exception ex)
+ {
+ throw new UserErrorException($"The mod's manifest couldn't be parsed. It doesn't seem to be valid JSON.\n{ex}");
+ }
+
+ // get version field
+ object versionObj = data.ContainsKey("Version") ? data["Version"] : null;
+ if (versionObj == null)
+ throw new UserErrorException("The mod's manifest must have a version field.");
+
+ // get version string
+ if (versionObj is IDictionary<string, object> versionFields) // SMAPI 1.x
+ {
+ int major = versionFields.ContainsKey("MajorVersion") ? (int)versionFields["MajorVersion"] : 0;
+ int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0;
+ int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0;
+ string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null;
+ return new SemanticVersionImpl(major, minor, patch, tag).ToString();
+ }
+ return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get a case-insensitive dictionary matching the given JSON.</summary>
+ /// <param name="json">The JSON to parse.</param>
+ private IDictionary<string, object> Parse(string json)
+ {
+ IDictionary<string, object> MakeCaseInsensitive(IDictionary<string, object> dict)
+ {
+ foreach (var field in dict.ToArray())
+ {
+ if (field.Value is IDictionary<string, object> value)
+ dict[field.Key] = MakeCaseInsensitive(value);
+ }
+ return new Dictionary<string, object>(dict, StringComparer.InvariantCultureIgnoreCase);
+ }
+
+ IDictionary<string, object> data = (IDictionary<string, object>)new JavaScriptSerializer().DeserializeObject(json);
+ return MakeCaseInsensitive(data);
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs b/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs
new file mode 100644
index 00000000..64e31c29
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace StardewModdingAPI.ModBuildConfig.Framework
+{
+ /// <summary>A user error whose message can be displayed to the user.</summary>
+ internal class UserErrorException : Exception
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The error message.</param>
+ public UserErrorException(string message)
+ : base(message) { }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
index 3ca3cca8..e04f09a7 100644
--- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
+++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
@@ -38,7 +38,9 @@
<Reference Include="System.Web.Extensions" />
</ItemGroup>
<ItemGroup>
- <Compile Include="Tasks\CreateModReleaseZip.cs" />
+ <Compile Include="DeployModTask.cs" />
+ <Compile Include="Framework\UserErrorException.cs" />
+ <Compile Include="Framework\ModFileManager.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
diff --git a/src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs b/src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs
deleted file mode 100644
index b9460b39..00000000
--- a/src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs
+++ /dev/null
@@ -1,153 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.IO.Compression;
-using System.Linq;
-using System.Web.Script.Serialization;
-using Microsoft.Build.Framework;
-using Microsoft.Build.Utilities;
-using StardewModdingAPI.Common;
-
-namespace StardewModdingAPI.ModBuildConfig.Tasks
-{
- /// <summary>A build task which packs mod files into a conventional release zip.</summary>
- public class CreateModReleaseZip : Task
- {
- /*********
- ** Properties
- *********/
- /// <summary>The name of the manifest file.</summary>
- private readonly string ManifestFileName = "manifest.json";
-
-
- /*********
- ** Accessors
- *********/
- /// <summary>The mod files to pack.</summary>
- [Required]
- public ITaskItem[] Files { get; set; }
-
- /// <summary>The name of the mod.</summary>
- [Required]
- public string ModName { get; set; }
-
- /// <summary>The absolute or relative path to the folder which should contain the generated zip file.</summary>
- [Required]
- public string OutputFolderPath { get; set; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>When overridden in a derived class, executes the task.</summary>
- /// <returns>true if the task successfully executed; otherwise, false.</returns>
- public override bool Execute()
- {
- try
- {
- // get names
- string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModName}-{this.GetManifestVersion()}.zip");
- string folderName = this.EscapeInvalidFilenameCharacters(this.ModName);
- string zipPath = Path.Combine(this.OutputFolderPath, zipName);
-
- // create zip file
- Directory.CreateDirectory(this.OutputFolderPath);
- using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write))
- using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create))
- {
- foreach (ITaskItem file in this.Files)
- {
- // get file info
- string filePath = file.ItemSpec;
- string entryName = folderName + '/' + file.GetMetadata("RecursiveDir") + file.GetMetadata("Filename") + file.GetMetadata("Extension");
- if (new FileInfo(filePath).Directory.Name.Equals("i18n", StringComparison.InvariantCultureIgnoreCase))
- entryName = Path.Combine("i18n", entryName);
-
- // add to zip
- using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
- using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open())
- {
- fileStream.CopyTo(fileStreamInZip);
- }
- }
- }
-
- return true;
- }
- catch (Exception ex)
- {
- this.Log.LogErrorFromException(ex);
- return false;
- }
- }
-
- /// <summary>Get a semantic version from the mod manifest (if available).</summary>
- /// <exception cref="InvalidOperationException">The manifest file wasn't found or is invalid.</exception>
- public string GetManifestVersion()
- {
- // find manifest file
- ITaskItem file = this.Files.FirstOrDefault(p => this.ManifestFileName.Equals(Path.GetFileName(p.ItemSpec), StringComparison.InvariantCultureIgnoreCase));
- if (file == null)
- throw new InvalidOperationException($"The mod must include a {this.ManifestFileName} file.");
-
- // read content
- string json = File.ReadAllText(file.ItemSpec);
- if (string.IsNullOrWhiteSpace(json))
- throw new InvalidOperationException($"The mod's {this.ManifestFileName} file must not be empty.");
-
- // parse JSON
- IDictionary<string, object> data;
- try
- {
- data = this.Parse(json);
- }
- catch (Exception ex)
- {
- throw new InvalidOperationException($"The mod's {this.ManifestFileName} couldn't be parsed. It doesn't seem to be valid JSON.", ex);
- }
-
- // get version field
- object versionObj = data.ContainsKey("Version") ? data["Version"] : null;
- if (versionObj == null)
- throw new InvalidOperationException($"The mod's {this.ManifestFileName} must have a version field.");
-
- // get version string
- if (versionObj is IDictionary<string, object> versionFields) // SMAPI 1.x
- {
- int major = versionFields.ContainsKey("MajorVersion") ? (int) versionFields["MajorVersion"] : 0;
- int minor = versionFields.ContainsKey("MinorVersion") ? (int) versionFields["MinorVersion"] : 0;
- int patch = versionFields.ContainsKey("PatchVersion") ? (int) versionFields["PatchVersion"] : 0;
- string tag = versionFields.ContainsKey("Build") ? (string) versionFields["Build"] : null;
- return new SemanticVersionImpl(major, minor, patch, tag).ToString();
- }
- return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+
- }
-
- /// <summary>Get a case-insensitive dictionary matching the given JSON.</summary>
- /// <param name="json">The JSON to parse.</param>
- private IDictionary<string, object> Parse(string json)
- {
- IDictionary<string, object> MakeCaseInsensitive(IDictionary<string, object> dict)
- {
- foreach (var field in dict.ToArray())
- {
- if (field.Value is IDictionary<string, object> value)
- dict[field.Key] = MakeCaseInsensitive(value);
- }
- return new Dictionary<string, object>(dict, StringComparer.InvariantCultureIgnoreCase);
- }
-
- IDictionary<string, object> data = (IDictionary<string, object>)new JavaScriptSerializer().DeserializeObject(json);
- return MakeCaseInsensitive(data);
- }
-
- /// <summary>Get a copy of a filename with all invalid filename characters substituted.</summary>
- /// <param name="name">The filename.</param>
- private string EscapeInvalidFilenameCharacters(string name)
- {
- foreach (char invalidChar in Path.GetInvalidFileNameChars())
- name = name.Replace(invalidChar, '.');
- return name;
- }
- }
-}
diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets
index b4bc8d8b..0010d8ff 100644
--- a/src/SMAPI.ModBuildConfig/build/smapi.targets
+++ b/src/SMAPI.ModBuildConfig/build/smapi.targets
@@ -2,20 +2,29 @@
<!--*********************************************
** Import build tasks
**********************************************-->
- <UsingTask TaskName="CreateModReleaseZip" AssemblyFile="StardewModdingAPI.ModBuildConfig.dll" />
+ <UsingTask TaskName="DeployModTask" AssemblyFile="StardewModdingAPI.ModBuildConfig.dll" />
<!--*********************************************
** Find the basic mod metadata
**********************************************-->
- <!--######
- ## import developer's custom settings (if any)
- #######-->
+ <!-- import developer's custom settings (if any) -->
<Import Condition="$(OS) != 'Windows_NT' AND Exists('$(HOME)\stardewvalley.targets')" Project="$(HOME)\stardewvalley.targets" />
<Import Condition="$(OS) == 'Windows_NT' AND Exists('$(USERPROFILE)\stardewvalley.targets')" Project="$(USERPROFILE)\stardewvalley.targets" />
- <!--######
- ## find platform + game path
- #######-->
+ <!-- set setting defaults -->
+ <PropertyGroup>
+ <!-- map legacy settings -->
+ <ModFolderName Condition="'$(ModFolderName)' == '' AND '$(DeployModFolderName)' != ''">$(DeployModFolderName)</ModFolderName>
+ <ModZipPath Condition="'$(ModZipPath)' == '' AND '$(DeployModZipTo)' != ''">$(DeployModZipTo)</ModZipPath>
+
+ <!-- set default settings -->
+ <ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName>
+ <ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath>
+ <EnableModDeploy Condition="'$(EnableModDeploy)' == ''">True</EnableModDeploy>
+ <EnableModZip Condition="'$(EnableModZip)' == ''">True</EnableModZip>
+ </PropertyGroup>
+
+ <!-- find platform + game path -->
<Choose>
<When Condition="$(OS) == 'Unix' OR $(OS) == 'OSX'">
<PropertyGroup>
@@ -106,52 +115,21 @@
<!--*********************************************
- ** Perform build logic
+ ** Deploy mod files & create release zip after build
**********************************************-->
- <!--######
- ## validate metadata before build
- #######-->
- <Target Name="BeforeBuild">
- <!-- show error for unknown platform -->
- <Error Condition="'$(OS)' != 'OSX' AND '$(OS)' != 'Unix' AND '$(OS)' != 'Windows_NT'" Text="The build config package doesn't recognise OS type '$(OS)'." />
-
- <!-- if game path is invalid, show one user-friendly error instead of a slew of reference errors -->
- <Error Condition="!Exists('$(GamePath)')" Text="Failed to find the game install path. See https://github.com/Pathoschild/Stardew.ModBuildConfig#troubleshoot for help." />
- <Error Condition="'$(OS)' == 'Windows_NT' AND !Exists('$(GamePath)\Stardew Valley.exe')" Text="Found a game folder at $(GamePath), but it doesn't contain Stardew Valley. You should delete this folder if it's empty." />
- <Error Condition="'$(OS)' != 'Windows_NT' AND !Exists('$(GamePath)\StardewValley.exe')" Text="Found a game folder at $(GamePath), but it doesn't contain Stardew Valley. You should delete this folder if it's empty." />
- <Error Condition="!Exists('$(GamePath)\StardewModdingAPI.exe')" Text="Found a game folder at $(GamePath), but it doesn't contain SMAPI." />
- </Target>
-
- <!--######
- ## Deploy files after build
- #######-->
- <Target Name="AfterBuild" Condition="'$(DeployModFolderName)' != '' OR '$(DeployModZipTo)' != ''">
- <!--collect file paths-->
- <PropertyGroup>
- <ModDeployPath>$(GamePath)\Mods\$(DeployModFolderName)</ModDeployPath>
- <DeployModZipTo Condition="'$(OS)' != 'Windows_NT'"><!--disable on Linux/Mac where CodeTaskFactory doesn't seem to be available--></DeployModZipTo>
- </PropertyGroup>
- <ItemGroup>
- <BuildFiles Include="$(TargetDir)\**\*.*" Exclude="$(TargetDir)\manifest.json;$(TargetDir)\i18n\**\*.*" />
-
- <BuildFiles Include="$(ProjectDir)\manifest.json" Condition="'@(BuildFiles)' != ''" />
- <BuildFiles Include="$(TargetDir)\manifest.json" Condition="'@(BuildFiles)' != '' AND !EXISTS('$(ProjectDir)\manifest.json')" />
-
- <I18nFiles Include="$(ProjectDir)\i18n\*.json" Condition="'@(BuildFiles)' != ''" />
- <I18nFiles Include="$(TargetDir)\i18n\*.json" Condition="'@(BuildFiles)' != '' AND !EXISTS('$(ProjectDir)\i18n')" />
- </ItemGroup>
+ <Target Name="AfterBuild">
+ <DeployModTask
+ ModFolderName="$(ModFolderName)"
+ ModZipPath="$(ModZipPath)"
- <!--validate paths-->
- <Error Text="Could not deploy mod automatically because no build output was found." Condition="'@(BuildFiles)' == ''" />
- <Error Text="Could not deploy mod automatically because no manifest.json was found in the project or build output." Condition="!Exists('$(TargetDir)\manifest.json') AND !Exists('$(ProjectDir)\manifest.json')" />
+ EnableModDeploy="$(EnableModDeploy)"
+ EnableModZip="$(EnableModZip)"
- <!-- copy mod files into mod folder if <DeployModFolderName> property is set -->
- <Message Text="Deploying mod to $(ModDeployPath)..." Importance="high" Condition="'$(DeployModFolderName)' != ''" />
- <Copy SourceFiles="@(BuildFiles)" DestinationFolder="$(ModDeployPath)\%(RecursiveDir)" SkipUnchangedFiles="true" Condition="'$(DeployModFolderName)' != ''" />
- <Copy SourceFiles="@(I18nFiles)" DestinationFolder="$(ModDeployPath)\i18n" SkipUnchangedFiles="true" Condition="'$(DeployModFolderName)' != ''" />
+ ProjectDir="$(ProjectDir)"
+ TargetDir="$(TargetDir)"
+ GameDir="$(GamePath)"
- <!-- create release zip if <DeployModZipTo> property is set -->
- <Message Text="Generating mod release at $(DeployModZipTo)\$(MSBuildProjectName).zip..." Importance="high" Condition="'$(DeployModZipTo)' != ''" />
- <CreateModReleaseZip ModName="$(MSBuildProjectName)" Files="@(BuildFiles);@(I18nFiles)" OutputFolderPath="$(DeployModZipTo)" Condition="'$(DeployModZipTo)' != ''" />
+ Platform="$(OS)"
+ />
</Target>
</Project>
diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec
index 9d547e28..4242489e 100644
--- a/src/SMAPI.ModBuildConfig/package.nuspec
+++ b/src/SMAPI.ModBuildConfig/package.nuspec
@@ -2,22 +2,26 @@
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Pathoschild.Stardew.ModBuildConfig</id>
- <version>1.7.1</version>
- <title>MSBuild config for Stardew Valley mods</title>
+ <version>2.0-alpha</version>
+ <title>Build package for SMAPI mods</title>
<authors>Pathoschild</authors>
<owners>Pathoschild</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
- <licenseUrl>https://github.com/Pathoschild/Stardew.ModBuildConfig/blob/1.7.1/LICENSE.txt</licenseUrl>
- <projectUrl>https://github.com/Pathoschild/Stardew.ModBuildConfig#readme</projectUrl>
- <iconUrl>https://raw.githubusercontent.com/Pathoschild/Stardew.ModBuildConfig/1.7.1/assets/nuget-icon.png</iconUrl>
+ <licenseUrl>https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt</licenseUrl>
+ <projectUrl>https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme</projectUrl>
+ <iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl>
<description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods.</description>
<releaseNotes>
- 1.7 added an option to create release zips on build and added a reference to XNA's XACT library for audio-related mods.
- 1.7.1 fixed an issue where i18n folders were flattened, and ensures that the manifest/i18n files in the project take precedence over those in the build output if both are present.</releaseNotes>
+ - Added: mods are now copied into the `Mods` folder automatically (configurable).
+ - Added: release zips are now created automatically in your build output folder (configurable).
+ - Added mod's version to release zip filename.
+ - Improved errors to simplify troubleshooting.
+ - Fixed release zip not having a mod folder.
+ - Fixed release zip failing if mod name contains characters that aren't valid in a filename.
+ </releaseNotes>
</metadata>
<files>
<file src="build/smapi.targets" target="build/Pathoschild.Stardew.ModBuildConfig.targets" />
<file src="bin/StardewModdingAPI.ModBuildConfig.dll" target="build/StardewModdingAPI.ModBuildConfig.dll" />
- <file src="readme.md" />
</files>
</package>