From abe52deba7e2b8081babd69785f902c7f7a52100 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 14:50:04 -0400 Subject: rename build task for broader use --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 153 +++++++++++++++++++++ .../StardewModdingAPI.ModBuildConfig.csproj | 2 +- .../Tasks/CreateModReleaseZip.cs | 153 --------------------- src/SMAPI.ModBuildConfig/build/smapi.targets | 4 +- 4 files changed, 156 insertions(+), 156 deletions(-) create mode 100644 src/SMAPI.ModBuildConfig/DeployModTask.cs delete mode 100644 src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs (limited to 'src') diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs new file mode 100644 index 00000000..0483f651 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -0,0 +1,153 @@ +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 +{ + /// A build task which deploys the mod files and prepares a release zip. + public class DeployModTask : Task + { + /********* + ** Properties + *********/ + /// The name of the manifest file. + private readonly string ManifestFileName = "manifest.json"; + + + /********* + ** Accessors + *********/ + /// The mod files to pack. + [Required] + public ITaskItem[] Files { get; set; } + + /// The name of the mod. + [Required] + public string ModName { get; set; } + + /// The absolute or relative path to the folder which should contain the generated zip file. + [Required] + public string OutputFolderPath { 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() + { + 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; + } + } + + /// Get a semantic version from the mod manifest (if available). + /// The manifest file wasn't found or is invalid. + 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 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 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+ + } + + /// Get a case-insensitive dictionary matching the given JSON. + /// The JSON to parse. + private IDictionary Parse(string json) + { + IDictionary MakeCaseInsensitive(IDictionary dict) + { + foreach (var field in dict.ToArray()) + { + if (field.Value is IDictionary value) + dict[field.Key] = MakeCaseInsensitive(value); + } + return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); + } + + IDictionary data = (IDictionary)new JavaScriptSerializer().DeserializeObject(json); + return MakeCaseInsensitive(data); + } + + /// 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; + } + } +} diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index 3ca3cca8..f943bc97 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -38,7 +38,7 @@ - + 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 -{ - /// A build task which packs mod files into a conventional release zip. - public class CreateModReleaseZip : Task - { - /********* - ** Properties - *********/ - /// The name of the manifest file. - private readonly string ManifestFileName = "manifest.json"; - - - /********* - ** Accessors - *********/ - /// The mod files to pack. - [Required] - public ITaskItem[] Files { get; set; } - - /// The name of the mod. - [Required] - public string ModName { get; set; } - - /// The absolute or relative path to the folder which should contain the generated zip file. - [Required] - public string OutputFolderPath { 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() - { - 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; - } - } - - /// Get a semantic version from the mod manifest (if available). - /// The manifest file wasn't found or is invalid. - 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 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 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+ - } - - /// Get a case-insensitive dictionary matching the given JSON. - /// The JSON to parse. - private IDictionary Parse(string json) - { - IDictionary MakeCaseInsensitive(IDictionary dict) - { - foreach (var field in dict.ToArray()) - { - if (field.Value is IDictionary value) - dict[field.Key] = MakeCaseInsensitive(value); - } - return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); - } - - IDictionary data = (IDictionary)new JavaScriptSerializer().DeserializeObject(json); - return MakeCaseInsensitive(data); - } - - /// 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; - } - } -} diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index b4bc8d8b..61bf96ac 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -2,7 +2,7 @@ - + - + -- cgit From cd93382c645da3c6d3ce4e532307f42704ba4c76 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 15:03:31 -0400 Subject: move zip logic into method --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 79 +++++++++++++++++----------- src/SMAPI.ModBuildConfig/build/smapi.targets | 2 +- 2 files changed, 48 insertions(+), 33 deletions(-) (limited to 'src') diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 0483f651..2018ab06 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.ModBuildConfig /// The absolute or relative path to the folder which should contain the generated zip file. [Required] - public string OutputFolderPath { get; set; } + public string ModZipPath { get; set; } /********* @@ -45,32 +45,8 @@ namespace StardewModdingAPI.ModBuildConfig { 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); - } - } - } + string modVersion = this.GetManifestVersion(); + this.CreateReleaseZip(this.Files, this.ModName, modVersion, this.ModZipPath); return true; } @@ -81,9 +57,48 @@ namespace StardewModdingAPI.ModBuildConfig } } + + /********* + ** Private methods + *********/ + /// Create a release zip in the recommended format for uploading to mod sites. + /// The files to include. + /// The name of the mod. + /// The mod version string. + /// The absolute or relative path to the folder which should contain the generated zip file. + private void CreateReleaseZip(ITaskItem[] 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 (ITaskItem file in 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); + } + } + } + } + /// Get a semantic version from the mod manifest (if available). /// The manifest file wasn't found or is invalid. - public string GetManifestVersion() + private string GetManifestVersion() { // find manifest file ITaskItem file = this.Files.FirstOrDefault(p => this.ManifestFileName.Equals(Path.GetFileName(p.ItemSpec), StringComparison.InvariantCultureIgnoreCase)); @@ -114,10 +129,10 @@ namespace StardewModdingAPI.ModBuildConfig // get version string if (versionObj is IDictionary 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; + 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+ diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 61bf96ac..46e8428d 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -152,6 +152,6 @@ - + -- cgit From 475efa12febcb1f1f0976cb6c84e445a263daed9 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 18:05:47 -0400 Subject: rewrite mod build package per new docs --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 176 +++++++++++---------- .../Framework/ModFileManager.cs | 162 +++++++++++++++++++ .../Framework/UserErrorException.cs | 16 ++ .../StardewModdingAPI.ModBuildConfig.csproj | 3 + src/SMAPI.ModBuildConfig/build/smapi.targets | 76 ++++----- 5 files changed, 303 insertions(+), 130 deletions(-) create mode 100644 src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs create mode 100644 src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs (limited to 'src') diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 2018ab06..a693fe32 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -2,11 +2,9 @@ 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; +using StardewModdingAPI.ModBuildConfig.Framework; namespace StardewModdingAPI.ModBuildConfig { @@ -16,25 +14,53 @@ namespace StardewModdingAPI.ModBuildConfig /********* ** Properties *********/ - /// The name of the manifest file. - private readonly string ManifestFileName = "manifest.json"; + /// The MSBuild platforms recognised by the build configuration. + private readonly HashSet ValidPlatforms = new HashSet(new[] { "OSX", "Unix", "Windows_NT" }, StringComparer.InvariantCultureIgnoreCase); + + /// The name of the game's main executable file. + private string GameExeName => this.Platform == "Windows_NT" + ? "Stardew Valley.exe" + : "StardewValley.exe"; + + /// The name of SMAPI's main executable file. + private readonly string SmapiExeName = "StardewModdingAPI.exe"; /********* ** Accessors *********/ - /// The mod files to pack. - [Required] - public ITaskItem[] Files { get; set; } - - /// The name of the mod. + /// The name of the mod folder. [Required] - public string ModName { get; set; } + 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 files. + [Required] + public string GameDir { get; set; } + + /// The MSBuild OS value. + [Required] + public string Platform { 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; } + /********* ** Public methods @@ -43,33 +69,80 @@ namespace StardewModdingAPI.ModBuildConfig /// true if the task successfully executed; otherwise, false. public override bool Execute() { + if (!this.EnableModDeploy && !this.EnableModZip) + return true; // nothing to do + try { - string modVersion = this.GetManifestVersion(); - this.CreateReleaseZip(this.Files, this.ModName, modVersion, this.ModZipPath); + // 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 (Exception ex) + 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 *********/ + /// 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) + { + 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); + } + } + /// Create a release zip in the recommended format for uploading to mod sites. /// The files to include. /// The name of the mod. /// The mod version string. /// The absolute or relative path to the folder which should contain the generated zip file. - private void CreateReleaseZip(ITaskItem[] files, string modName, string modVersion, string outputFolderPath) + private void CreateReleaseZip(IDictionary files, string modName, string modVersion, string outputFolderPath) { // get names - string zipName = this.EscapeInvalidFilenameCharacters($"{modName}-{modVersion}.zip"); + string zipName = this.EscapeInvalidFilenameCharacters($"{modName} {modVersion}.zip"); string folderName = this.EscapeInvalidFilenameCharacters(modName); string zipPath = Path.Combine(outputFolderPath, zipName); @@ -78,84 +151,25 @@ namespace StardewModdingAPI.ModBuildConfig using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) { - foreach (ITaskItem file in files) + foreach (var fileEntry in files) { + string relativePath = fileEntry.Key; + FileInfo file = fileEntry.Value; + // get file info - string filePath = file.ItemSpec; - string entryName = folderName + '/' + file.GetMetadata("RecursiveDir") + file.GetMetadata("Filename") + file.GetMetadata("Extension"); + 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); - } } } } - /// Get a semantic version from the mod manifest (if available). - /// The manifest file wasn't found or is invalid. - private 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 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 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+ - } - - /// Get a case-insensitive dictionary matching the given JSON. - /// The JSON to parse. - private IDictionary Parse(string json) - { - IDictionary MakeCaseInsensitive(IDictionary dict) - { - foreach (var field in dict.ToArray()) - { - if (field.Value is IDictionary value) - dict[field.Key] = MakeCaseInsensitive(value); - } - return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); - } - - IDictionary data = (IDictionary)new JavaScriptSerializer().DeserializeObject(json); - return MakeCaseInsensitive(data); - } - /// Get a copy of a filename with all invalid filename characters substituted. /// The filename. private string EscapeInvalidFilenameCharacters(string name) diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs new file mode 100644 index 00000000..9d9054f1 --- /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 +{ + /// Manages the files that are part of a mod package. + internal class ModFileManager + { + /********* + ** Properties + *********/ + /// The name of the manifest file. + private readonly string ManifestFileName = "manifest.json"; + + /// The files that are part of the package. + private readonly IDictionary Files; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The folder containing the project files. + /// The folder containing the build output. + /// The mod package isn't valid. + public ModFileManager(string projectDir, string targetDir) + { + this.Files = new Dictionary(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."); + } + + /// Get the files in the mod package. + public IDictionary GetFiles() + { + return new Dictionary(this.Files, StringComparer.InvariantCultureIgnoreCase); + } + + /// Get a semantic version from the mod manifest. + /// The manifest is missing or invalid. + 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 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 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 + *********/ + /// Get a case-insensitive dictionary matching the given JSON. + /// The JSON to parse. + private IDictionary Parse(string json) + { + IDictionary MakeCaseInsensitive(IDictionary dict) + { + foreach (var field in dict.ToArray()) + { + if (field.Value is IDictionary value) + dict[field.Key] = MakeCaseInsensitive(value); + } + return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); + } + + IDictionary data = (IDictionary)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 +{ + /// A user error whose message can be displayed to the user. + internal class UserErrorException : Exception + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + public UserErrorException(string message) + : base(message) { } + } +} diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index f943bc97..2a445f72 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -39,12 +39,15 @@ + + Designer + PreserveNewest Designer diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 46e8428d..0010d8ff 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -7,15 +7,24 @@ - + - + + + + $(DeployModFolderName) + $(DeployModZipTo) + + + $(MSBuildProjectName) + $(TargetDir) + True + True + + + @@ -106,52 +115,21 @@ - - - - - - - - - - - - - - - - - $(GamePath)\Mods\$(DeployModFolderName) - - - - - - - - - - - + + - - + EnableModDeploy="$(EnableModDeploy)" + EnableModZip="$(EnableModZip)" - - - - + ProjectDir="$(ProjectDir)" + TargetDir="$(TargetDir)" + GameDir="$(GamePath)" - - - + Platform="$(OS)" + /> -- cgit From d47105a27841bcae80fcaa2351a2a658cd3d7fdb Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 21:21:11 -0400 Subject: update mod build package nuspec --- src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs | 2 +- .../StardewModdingAPI.ModBuildConfig.csproj | 1 - src/SMAPI.ModBuildConfig/package.nuspec | 20 ++++++++++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index 9d9054f1..10c55d4c 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -73,7 +73,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework continue; // ignore release zips - if (file.Extension.Equals("zip", StringComparison.InvariantCultureIgnoreCase)) + if (file.Extension.Equals(".zip", StringComparison.InvariantCultureIgnoreCase)) continue; // add file diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index 2a445f72..e04f09a7 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -47,7 +47,6 @@ Designer - PreserveNewest Designer 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 @@ Pathoschild.Stardew.ModBuildConfig - 1.7.1 - MSBuild config for Stardew Valley mods + 2.0-alpha + Build package for SMAPI mods Pathoschild Pathoschild false - https://github.com/Pathoschild/Stardew.ModBuildConfig/blob/1.7.1/LICENSE.txt - https://github.com/Pathoschild/Stardew.ModBuildConfig#readme - https://raw.githubusercontent.com/Pathoschild/Stardew.ModBuildConfig/1.7.1/assets/nuget-icon.png + https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt + https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme + https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png Automates the build configuration for crossplatform Stardew Valley SMAPI mods. - 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. + - 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. + - -- cgit