diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-10-08 21:22:08 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2017-10-08 21:22:08 -0400 |
commit | 65e8997fdbe630af10d28b6428bec943477e059d (patch) | |
tree | 9346d06e09fc4fbf207c5001a7690a58a363a628 | |
parent | fd060a092786d703817fa5a65452b4efe52db8cd (diff) | |
parent | d47105a27841bcae80fcaa2351a2a658cd3d7fdb (diff) | |
download | SMAPI-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.md | 136 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/DeployModTask.cs | 182 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs | 162 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs | 16 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj | 4 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs | 153 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/build/smapi.targets | 78 | ||||
-rw-r--r-- | src/SMAPI.ModBuildConfig/package.nuspec | 20 |
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> |