diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets index bd84ee11b..3fc4720d2 100644 --- a/build/deploy-local-smapi.targets +++ b/build/deploy-local-smapi.targets @@ -21,7 +21,10 @@ This assumes `find-game-folder.targets` has already been imported and validated. + + + diff --git a/build/unix/prepare-install-package.sh b/build/unix/prepare-install-package.sh index bd7df36a8..c7d7a7756 100755 --- a/build/unix/prepare-install-package.sh +++ b/build/unix/prepare-install-package.sh @@ -134,7 +134,7 @@ for folder in ${folders[@]}; do cp -r "$smapiBin/i18n" "$bundlePath/smapi-internal" # bundle smapi-internal - for name in "0Harmony.dll" "0Harmony.xml" "Mono.Cecil.dll" "Mono.Cecil.Mdb.dll" "Mono.Cecil.Pdb.dll" "MonoMod.Common.dll" "Newtonsoft.Json.dll" "Pathoschild.Http.Client.dll" "Pintail.dll" "TMXTile.dll" "SMAPI.Toolkit.dll" "SMAPI.Toolkit.xml" "SMAPI.Toolkit.CoreInterfaces.dll" "SMAPI.Toolkit.CoreInterfaces.xml" "System.Net.Http.Formatting.dll"; do + for name in "0Harmony.dll" "0Harmony.xml" "Mono.Cecil.dll" "Mono.Cecil.Mdb.dll" "Mono.Cecil.Pdb.dll" "MonoMod.Common.dll" "Namotion.Reflection.dll" "Newtonsoft.Json.dll" "NJsonSchema.dll" "NJsonSchema.Annotations.dll" "Pathoschild.Http.Client.dll" "Pintail.dll" "TMXTile.dll" "SMAPI.Toolkit.dll" "SMAPI.Toolkit.xml" "SMAPI.Toolkit.CoreInterfaces.dll" "SMAPI.Toolkit.CoreInterfaces.xml" "System.Net.Http.Formatting.dll"; do cp "$smapiBin/$name" "$bundlePath/smapi-internal" done diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1 index 08ed88fca..d02070ef5 100644 --- a/build/windows/prepare-install-package.ps1 +++ b/build/windows/prepare-install-package.ps1 @@ -155,7 +155,7 @@ foreach ($folder in $folders) { cp -Recurse "$smapiBin/i18n" "$bundlePath/smapi-internal" # bundle smapi-internal - foreach ($name in @("0Harmony.dll", "0Harmony.xml", "Mono.Cecil.dll", "Mono.Cecil.Mdb.dll", "Mono.Cecil.Pdb.dll", "MonoMod.Common.dll", "Newtonsoft.Json.dll", "Pathoschild.Http.Client.dll", "Pintail.dll", "TMXTile.dll", "SMAPI.Toolkit.dll", "SMAPI.Toolkit.xml", "SMAPI.Toolkit.CoreInterfaces.dll", "SMAPI.Toolkit.CoreInterfaces.xml", "System.Net.Http.Formatting.dll")) { + foreach ($name in @("0Harmony.dll", "0Harmony.xml", "Mono.Cecil.dll", "Mono.Cecil.Mdb.dll", "Mono.Cecil.Pdb.dll", "MonoMod.Common.dll", "Namotion.Reflection.dll", "Newtonsoft.Json.dll", "NJsonSchema.dll", "NJsonSchema.Annotations.dll", "Pathoschild.Http.Client.dll", "Pintail.dll", "TMXTile.dll", "SMAPI.Toolkit.dll", "SMAPI.Toolkit.xml", "SMAPI.Toolkit.CoreInterfaces.dll", "SMAPI.Toolkit.CoreInterfaces.xml", "System.Net.Http.Formatting.dll")) { cp "$smapiBin/$name" "$bundlePath/smapi-internal" } diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 2ad7798a7..028df7fc4 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs index 208cd6567..539ef344e 100644 --- a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs +++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using NJsonSchema; using StardewModdingAPI.Toolkit.Serialization.Converters; namespace StardewModdingAPI.Toolkit.Serialization @@ -45,7 +45,7 @@ public static JsonSerializerSettings CreateDefaultSettings() /// The file contains invalid JSON. public bool ReadJsonFileIfExists(string fullPath, #if NET6_0_OR_GREATER - [NotNullWhen(true)] + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] #endif out TModel? result ) @@ -100,9 +100,7 @@ public void WriteJsonFile(string fullPath, TModel model) throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); // create directory if needed - string dir = Path.GetDirectoryName(fullPath)!; - if (dir == null) - throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + string dir = Path.GetDirectoryName(fullPath) ?? throw new ArgumentException("The file path is invalid.", nameof(fullPath)); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); @@ -111,6 +109,28 @@ public void WriteJsonFile(string fullPath, TModel model) File.WriteAllText(fullPath, json); } + /// Save a data model schema to a JSON file. + /// The model type. + /// The absolute file path. + /// The given path is empty or invalid. + public void WriteJsonSchemaFile(string fullPath) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // create directory if needed + string dir = Path.GetDirectoryName(fullPath) ?? throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // write file + JsonSchema schema = JsonSchema.FromType(); + string json = schema.ToJson(); + File.WriteAllText(fullPath, json); + } + /// Deserialize JSON text if possible. /// The model type. /// The raw JSON text. diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 2eaa940a7..69b20b1ac 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -28,7 +28,7 @@ internal class DataHelper : BaseHelper, IDataHelper /// Construct an instance. /// The mod using this instance. /// The absolute path to the mod folder. - /// The absolute path to the mod folder. + /// Encapsulates SMAPI's JSON file parsing. public DataHelper(IModMetadata mod, string modFolderPath, JsonHelper jsonHelper) : base(mod) { @@ -67,6 +67,18 @@ public void WriteJsonFile(string path, TModel? data) File.Delete(path); } + /// + public void WriteJsonSchemaFile(string path) + where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonSchemaFile)} with a relative path (without directory climbing)."); + + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path)); + + this.JsonHelper.WriteJsonSchemaFile(path); + } + /**** ** Save file ****/ diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index d1cf357e9..e48052aab 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -8,6 +8,13 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Provides simplified APIs for writing mods. internal class ModHelper : BaseHelper, IModHelper, IDisposable { + /********* + ** Fields + *********/ + /// Whether to generate a config.schema.json file based on the mod's config model when it's loaded or saved. + private readonly bool GenerateConfigSchema; + + /********* ** Accessors *********/ @@ -65,9 +72,10 @@ internal class ModHelper : BaseHelper, IModHelper, IDisposable /// An API for accessing private game code. /// Provides multiplayer utilities. /// An API for reading translations stored in the mod's i18n folder. + /// Whether to generate a config.schema.json file based on the mod's config model when it's loaded or saved. /// An argument is null or empty. /// The path does not exist on disk. - public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) + public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, bool generateConfigSchema) : base(mod) { // validate directory @@ -89,6 +97,7 @@ public ModHelper(IModMetadata mod, string modDirectory, Func curren this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); this.Events = events; + this.GenerateConfigSchema = generateConfigSchema; } /**** @@ -108,6 +117,9 @@ public void WriteConfig(TConfig config) where TConfig : class, new() { this.Data.WriteJsonFile("config.json", config); + + if (this.GenerateConfigSchema) + this.Data.WriteJsonSchemaFile("config.schema.json"); } /**** diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 40bdb1304..5f4e8e66d 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -26,7 +26,8 @@ internal class SConfig [nameof(RewriteMods)] = true, [nameof(FixHarmony)] = true, [nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux, - [nameof(SuppressHarmonyDebugMode)] = true + [nameof(SuppressHarmonyDebugMode)] = true, + [nameof(GenerateConfigSchemas)] = false }; /// The default values for , to log changes if different. @@ -99,6 +100,9 @@ internal class SConfig /// The mod IDs SMAPI should load after any other mods. public HashSet ModsToLoadLate { get; set; } + /// Whether to generate config.schema.json files for external tools like mod managers. + public bool GenerateConfigSchemas { get; set; } + /******** ** Public methods @@ -122,7 +126,8 @@ internal class SConfig /// /// /// - public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate) + /// + public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate, bool? generateConfigSchemas) { this.DeveloperMode = developerMode; this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)]; @@ -142,6 +147,7 @@ public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsole this.SuppressUpdateChecks = new HashSet(suppressUpdateChecks ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); this.ModsToLoadEarly = new HashSet(modsToLoadEarly ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); this.ModsToLoadLate = new HashSet(modsToLoadLate ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + this.GenerateConfigSchemas = generateConfigSchemas ?? (bool)SConfig.DefaultValues[nameof(this.GenerateConfigSchemas)]; } /// Override the value of . diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 0a6221c84..a894fa7a2 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -2050,7 +2050,7 @@ IContentPack[] GetContentPacks() IModRegistry modRegistryHelper = new ModRegistryHelper(mod, this.ModRegistry, proxyFactory, monitor); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(mod, this.Multiplayer); - modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, this.Settings.GenerateConfigSchemas); } // init mod diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs index 7ddf851ef..ff778bc24 100644 --- a/src/SMAPI/IDataHelper.cs +++ b/src/SMAPI/IDataHelper.cs @@ -27,6 +27,13 @@ public interface IDataHelper void WriteJsonFile(string path, TModel? data) where TModel : class; + /// Save a data model schema to a JSON file in the mod's folder. + /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. + /// The file path relative to the mod folder. + /// The is not relative or contains directory climbing (../). + void WriteJsonSchemaFile(string path) + where TModel : class; + /**** ** Save file ****/ diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 55f9869b6..42c3339cf 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -172,5 +172,12 @@ in future SMAPI versions. * the mod author. */ "ModsToLoadEarly": [], - "ModsToLoadLate": [] + "ModsToLoadLate": [], + + /** + * Whether to generate a `config.schema.json` file next to each mod's `config.json` file. + * + * This can be used by separate tools like mod managers to enable config editing features. + */ + "GenerateConfigSchemas": false }