diff --git a/build/GlobalAssemblyInfo.cs b/build/GlobalAssemblyInfo.cs index 65211aad..bcfdc124 100644 --- a/build/GlobalAssemblyInfo.cs +++ b/build/GlobalAssemblyInfo.cs @@ -2,5 +2,5 @@ using System.Reflection; using System.Runtime.InteropServices; [assembly: ComVisible(false)] -[assembly: AssemblyVersion("2.2.0.0")] -[assembly: AssemblyFileVersion("2.2.0.0")] +[assembly: AssemblyVersion("2.3.0.0")] +[assembly: AssemblyFileVersion("2.3.0.0")] diff --git a/docs/release-notes.md b/docs/release-notes.md index 4cf8efa2..165e7d4e 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,4 +1,24 @@ # Release notes +## 2.3 +* For players: + * Added a user-friendly [download page](https://smapi.io). + * Improved cryptic libgdiplus errors on Mac when Mono isn't installed. + * Fixed mod UIs hidden when menu backgrounds are enabled. + +* For modders: + * **Added mod-provided APIs** to allow simple integrations between mods, even without direct assembly references. + * Added `GameEvents.FirstUpdateTick` event (called once after all mods are initialised). + * Added `IsSuppressed` to input events so mods can optionally avoid handling keys another mod has already handled. + * Added trace message for mods with no update keys. + * Adjusted reflection API to match actual usage (e.g. renamed `GetPrivate*` to `Get*`), and deprecated previous methods. + * Fixed `GraphicsEvents.OnPostRenderEvent` not being raised in some specialised cases. + * Fixed reflection API error for properties missing a `get` and `set`. + * Fixed issue where a mod could change the cursor position reported to other mods. + * Updated compatibility list. + +* For the [log parser][]: + * Fixed broken favicon. + ## 2.2 * For players: * Fixed error when a mod loads custom assets on Linux/Mac. diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 83f353ae..43d31eea 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -97,8 +97,8 @@ namespace StardewModdingApi.Installer yield return GetInstallPath("StardewModdingAPI.pdb"); // obsolete - yield return GetInstallPath("Mods/.cache"); // 1.3-1.4 - yield return GetInstallPath("Mods/TrainerMod"); // *–2.0 (renamed to ConsoleCommands) + yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 + yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // *–2.0 (renamed to ConsoleCommands) yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8 yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 if (modsDir.Exists) diff --git a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj index f228bb25..a65ad72c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj @@ -41,10 +41,6 @@ False - - - - diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 664dfabf..06ab1b54 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -3,7 +3,7 @@ "Author": "SMAPI", "Version": { "MajorVersion": 2, - "MinorVersion": 0, + "MinorVersion": 3, "PatchVersion": 0, "Build": null }, diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs new file mode 100644 index 00000000..5d45118f --- /dev/null +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -0,0 +1,93 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using StardewModdingAPI.Web.Framework.Clients.GitHub; +using StardewModdingAPI.Web.ViewModels; + +namespace StardewModdingAPI.Web.Controllers +{ + /// Provides an info/download page about SMAPI. + [Route("")] + [Route("install")] + internal class IndexController : Controller + { + /********* + ** Properties + *********/ + /// The cache in which to store release data. + private readonly IMemoryCache Cache; + + /// The GitHub API client. + private readonly IGitHubClient GitHub; + + /// The cache time for release info. + private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(5); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The cache in which to store release data. + /// The GitHub API client. + public IndexController(IMemoryCache cache, IGitHubClient github) + { + this.Cache = cache; + this.GitHub = github; + } + + /// Display the index page. + [HttpGet] + public async Task Index() + { + // fetch latest SMAPI release + GitRelease release = await this.Cache.GetOrCreateAsync("latest-smapi-release", async entry => + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); + return await this.GitHub.GetLatestReleaseAsync("Pathoschild/SMAPI"); + }); + string downloadUrl = this.GetMainDownloadUrl(release); + string devDownloadUrl = this.GetDevDownloadUrl(release); + + // render view + var model = new IndexModel(release.Name, release.Body, downloadUrl, devDownloadUrl); + return this.View(model); + } + + + /********* + ** Private methods + *********/ + /// Get the main download URL for a SMAPI release. + /// The SMAPI release. + private string GetMainDownloadUrl(GitRelease release) + { + // get main download URL + foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + { + if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip")) + return asset.DownloadUrl; + } + + // fallback just in case + return "https://github.com/pathoschild/SMAPI/releases"; + } + + /// Get the for-developers download URL for a SMAPI release. + /// The SMAPI release. + private string GetDevDownloadUrl(GitRelease release) + { + // get dev download URL + foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + { + if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip")) + return asset.DownloadUrl; + } + + // fallback just in case + return "https://github.com/pathoschild/SMAPI/releases"; + } + } +} diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 454440bb..04a11a82 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -6,8 +6,8 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.ConfigModels; -using StardewModdingAPI.Web.Framework.LogParser; using StardewModdingAPI.Web.ViewModels; namespace StardewModdingAPI.Web.Controllers @@ -19,10 +19,10 @@ namespace StardewModdingAPI.Web.Controllers ** Properties *********/ /// The log parser config settings. - private readonly LogParserConfig Config; + private readonly ContextConfig Config; /// The underlying Pastebin client. - private readonly PastebinClient PastebinClient; + private readonly IPastebinClient Pastebin; /// The first bytes in a valid zip file. /// See . @@ -36,14 +36,12 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// Construct an instance. - /// The log parser config settings. - public LogParserController(IOptions configProvider) + /// The context config settings. + /// The Pastebin API client. + public LogParserController(IOptions contextProvider, IPastebinClient pastebin) { - // init Pastebin client - this.Config = configProvider.Value; - string version = this.GetType().Assembly.GetName().Version.ToString(3); - string userAgent = string.Format(this.Config.PastebinUserAgent, version); - this.PastebinClient = new PastebinClient(this.Config.PastebinBaseUrl, userAgent, this.Config.PastebinUserKey, this.Config.PastebinDevKey); + this.Config = contextProvider.Value; + this.Pastebin = pastebin; } /*** @@ -52,12 +50,11 @@ namespace StardewModdingAPI.Web.Controllers /// Render the log parser UI. /// The paste ID. [HttpGet] - [Route("")] [Route("log")] [Route("log/{id}")] public ViewResult Index(string id = null) { - return this.View("Index", new LogParserModel(this.Config.SectionUrl, id)); + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id)); } /*** @@ -67,9 +64,9 @@ namespace StardewModdingAPI.Web.Controllers /// The Pastebin paste ID. [HttpGet, Produces("application/json")] [Route("log/fetch/{id}")] - public async Task GetAsync(string id) + public async Task GetAsync(string id) { - GetPasteResponse response = await this.PastebinClient.GetAsync(id); + PasteInfo response = await this.Pastebin.GetAsync(id); response.Content = this.DecompressString(response.Content); return response; } @@ -78,10 +75,10 @@ namespace StardewModdingAPI.Web.Controllers /// The log content to save. [HttpPost, Produces("application/json"), AllowLargePosts] [Route("log/save")] - public async Task PostAsync([FromBody] string content) + public async Task PostAsync([FromBody] string content) { content = this.CompressString(content); - return await this.PastebinClient.PostAsync(content); + return await this.Pastebin.PostAsync(content); } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index a600662c..dcb4ec52 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -7,6 +7,9 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Web.Framework.Clients.Chucklefish; +using StardewModdingAPI.Web.Framework.Clients.GitHub; +using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ModRepositories; @@ -39,39 +42,22 @@ namespace StardewModdingAPI.Web.Controllers /// Construct an instance. /// The cache in which to store mod metadata. /// The config settings for mod update checks. - public ModsApiController(IMemoryCache cache, IOptions configProvider) + /// The Chucklefish API client. + /// The GitHub API client. + /// The Nexus API client. + public ModsApiController(IMemoryCache cache, IOptions configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus) { ModUpdateCheckConfig config = configProvider.Value; this.Cache = cache; this.CacheMinutes = config.CacheMinutes; this.VersionRegex = config.SemanticVersionRegex; - - string version = this.GetType().Assembly.GetName().Version.ToString(3); this.Repositories = new IModRepository[] { - new ChucklefishRepository( - vendorKey: config.ChucklefishKey, - userAgent: string.Format(config.ChucklefishUserAgent, version), - baseUrl: config.ChucklefishBaseUrl, - modPageUrlFormat: config.ChucklefishModPageUrlFormat - ), - new GitHubRepository( - vendorKey: config.GitHubKey, - baseUrl: config.GitHubBaseUrl, - releaseUrlFormat: config.GitHubReleaseUrlFormat, - userAgent: string.Format(config.GitHubUserAgent, version), - acceptHeader: config.GitHubAcceptHeader, - username: config.GitHubUsername, - password: config.GitHubPassword - ), - new NexusRepository( - vendorKey: config.NexusKey, - userAgent: config.NexusUserAgent, - baseUrl: config.NexusBaseUrl, - modUrlFormat: config.NexusModUrlFormat - ) + new ChucklefishRepository(config.ChucklefishKey, chucklefish), + new GitHubRepository(config.GitHubKey, github), + new NexusRepository(config.NexusKey, nexus) } .ToDictionary(p => p.VendorKey, StringComparer.CurrentCultureIgnoreCase); } diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs new file mode 100644 index 00000000..6772672c --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -0,0 +1,83 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish +{ + /// An HTTP client for fetching mod metadata from the Chucklefish mod site. + internal class ChucklefishClient : IChucklefishClient + { + /********* + ** Properties + *********/ + /// The base URL for the Chucklefish mod site. + private readonly string BaseUrl; + + /// The URL for a mod page excluding the base URL, where {0} is the mod ID. + private readonly string ModPageUrlFormat; + + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the API client. + /// The base URL for the Chucklefish mod site. + /// The URL for a mod page excluding the , where {0} is the mod ID. + public ChucklefishClient(string userAgent, string baseUrl, string modPageUrlFormat) + { + this.BaseUrl = baseUrl; + this.ModPageUrlFormat = modPageUrlFormat; + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// Get metadata about a mod. + /// The Chucklefish mod ID. + /// Returns the mod info if found, else null. + public async Task GetModAsync(uint id) + { + // fetch HTML + string html; + try + { + html = await this.Client + .GetAsync(string.Format(this.ModPageUrlFormat, id)) + .AsString(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // extract mod info + string url = new UriBuilder(new Uri(this.BaseUrl)) { Path = string.Format(this.ModPageUrlFormat, id) }.Uri.ToString(); + string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value; + if (name.StartsWith("[SMAPI] ")) + name = name.Substring("[SMAPI] ".Length); + string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText; + + // create model + return new ChucklefishMod + { + Name = name, + Version = version, + Url = url + }; + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client?.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs new file mode 100644 index 00000000..fd0101d4 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish +{ + /// Mod metadata from the Chucklefish mod site. + internal class ChucklefishMod + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The mod's semantic version number. + public string Version { get; set; } + + /// The mod's web URL. + public string Url { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs new file mode 100644 index 00000000..1d8b256e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish +{ + /// An HTTP client for fetching mod metadata from the Chucklefish mod site. + internal interface IChucklefishClient : IDisposable + { + /********* + ** Methods + *********/ + /// Get metadata about a mod. + /// The Chucklefish mod ID. + /// Returns the mod info if found, else null. + Task GetModAsync(uint id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs new file mode 100644 index 00000000..73ce4025 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// A GitHub download attached to a release. + internal class GitAsset + { + /// The file name. + [JsonProperty("name")] + public string FileName { get; set; } + + /// The file content type. + [JsonProperty("content_type")] + public string ContentType { get; set; } + + /// The download URL. + [JsonProperty("browser_download_url")] + public string DownloadUrl { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs new file mode 100644 index 00000000..0b205660 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -0,0 +1,70 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// An HTTP client for fetching metadata from GitHub. + internal class GitHubClient : IGitHubClient + { + /********* + ** Properties + *********/ + /// The URL for a GitHub releases API query excluding the base URL, where {0} is the repository owner and name. + private readonly string ReleaseUrlFormat; + + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The base URL for the GitHub API. + /// The URL for a GitHub releases API query excluding the , where {0} is the repository owner and name. + /// The user agent for the API client. + /// The Accept header value expected by the GitHub API. + /// The username with which to authenticate to the GitHub API. + /// The password with which to authenticate to the GitHub API. + public GitHubClient(string baseUrl, string releaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + { + this.ReleaseUrlFormat = releaseUrlFormat; + + this.Client = new FluentClient(baseUrl) + .SetUserAgent(userAgent) + .AddDefault(req => req.WithHeader("Accept", acceptHeader)); + if (!string.IsNullOrWhiteSpace(username)) + this.Client = this.Client.SetBasicAuthentication(username, password); + } + + /// Get the latest release for a GitHub repository. + /// The repository key (like Pathoschild/SMAPI). + /// Returns the latest release if found, else null. + public async Task GetLatestReleaseAsync(string repo) + { + // validate key format + if (!repo.Contains("/") || repo.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != repo.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) + throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo)); + + // fetch info + try + { + return await this.Client + .GetAsync(string.Format(this.ReleaseUrlFormat, repo)) + .As(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client?.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs new file mode 100644 index 00000000..b944088d --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// A GitHub project release. + internal class GitRelease + { + /********* + ** Accessors + *********/ + /// The display name. + [JsonProperty("name")] + public string Name { get; set; } + + /// The semantic version string. + [JsonProperty("tag_name")] + public string Tag { get; set; } + + /// The Markdown description for the release. + public string Body { get; set; } + + /// The attached files. + public GitAsset[] Assets { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs new file mode 100644 index 00000000..6e8eadff --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// An HTTP client for fetching metadata from GitHub. + internal interface IGitHubClient : IDisposable + { + /********* + ** Methods + *********/ + /// Get the latest release for a GitHub repository. + /// The repository key (like Pathoschild/SMAPI). + /// Returns the latest release if found, else null. + Task GetLatestReleaseAsync(string repo); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs new file mode 100644 index 00000000..e56e7af4 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// An HTTP client for fetching mod metadata from Nexus Mods. + internal interface INexusClient : IDisposable + { + /********* + ** Methods + *********/ + /// Get metadata about a mod. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + Task GetModAsync(uint id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs new file mode 100644 index 00000000..1a7011e2 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// An HTTP client for fetching mod metadata from Nexus Mods. + internal class NexusClient : INexusClient + { + /********* + ** Properties + *********/ + /// The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID. + private readonly string ModUrlFormat; + + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the Nexus Mods API client. + /// The base URL for the Nexus Mods API. + /// The URL for a Nexus Mods API query excluding the , where {0} is the mod ID. + public NexusClient(string userAgent, string baseUrl, string modUrlFormat) + { + this.ModUrlFormat = modUrlFormat; + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// Get metadata about a mod. + /// The Nexus mod ID. + /// Returns the mod info if found, else null. + public async Task GetModAsync(uint id) + { + return await this.Client + .GetAsync(string.Format(this.ModUrlFormat, id)) + .As(); + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client?.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs new file mode 100644 index 00000000..2b04104f --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// Mod metadata from Nexus Mods. + internal class NexusMod + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The mod's semantic version number. + public string Version { get; set; } + + /// The mod's web URL. + [JsonProperty("mod_page_uri")] + public string Url { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs new file mode 100644 index 00000000..630dfb76 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin +{ + /// An API client for Pastebin. + internal interface IPastebinClient : IDisposable + { + /// Fetch a saved paste. + /// The paste ID. + Task GetAsync(string id); + + /// Save a paste to Pastebin. + /// The paste content. + Task PostAsync(string content); + } +} diff --git a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs similarity index 83% rename from src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs rename to src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 4f8794db..955156eb 100644 --- a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,7 +1,7 @@ -namespace StardewModdingAPI.Web.Framework.LogParser +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// The response for a get-paste request. - internal class GetPasteResponse + internal class PasteInfo { /// Whether the log was successfully fetched. public bool Success { get; set; } diff --git a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs similarity index 77% rename from src/SMAPI.Web/Framework/LogParser/PastebinClient.cs rename to src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 1cfaed17..ef83a91e 100644 --- a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -8,10 +8,10 @@ using System.Threading.Tasks; using System.Web; using Pathoschild.Http.Client; -namespace StardewModdingAPI.Web.Framework.LogParser +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// An API client for Pastebin. - internal class PastebinClient : IDisposable + internal class PastebinClient : IPastebinClient { /********* ** Properties @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.LogParser /// Fetch a saved paste. /// The paste ID. - public async Task GetAsync(string id) + public async Task GetAsync(string id) { try { @@ -54,30 +54,30 @@ namespace StardewModdingAPI.Web.Framework.LogParser // handle Pastebin errors if (string.IsNullOrWhiteSpace(content)) - return new GetPasteResponse { Error = "Received an empty response from Pastebin." }; + return new PasteInfo { Error = "Received an empty response from Pastebin." }; if (content.StartsWith("Save a paste to Pastebin. /// The paste content. - public async Task PostAsync(string content) + public async Task PostAsync(string content) { try { // validate if (string.IsNullOrWhiteSpace(content)) - return new SavePasteResponse { Error = "The log content can't be empty." }; + return new SavePasteResult { Error = "The log content can't be empty." }; // post to API string response = await this.Client @@ -96,19 +96,19 @@ namespace StardewModdingAPI.Web.Framework.LogParser // handle Pastebin errors if (string.IsNullOrWhiteSpace(response)) - return new SavePasteResponse { Error = "Received an empty response from Pastebin." }; + return new SavePasteResult { Error = "Received an empty response from Pastebin." }; if (response.StartsWith("Bad API request")) - return new SavePasteResponse { Error = response }; + return new SavePasteResult { Error = response }; if (!response.Contains("/")) - return new SavePasteResponse { Error = $"Received an unknown response: {response}" }; + return new SavePasteResult { Error = $"Received an unknown response: {response}" }; // return paste ID string pastebinID = response.Split("/").Last(); - return new SavePasteResponse { Success = true, ID = pastebinID }; + return new SavePasteResult { Success = true, ID = pastebinID }; } catch (Exception ex) { - return new SavePasteResponse { Success = false, Error = ex.ToString() }; + return new SavePasteResult { Success = false, Error = ex.ToString() }; } } diff --git a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs similarity index 82% rename from src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs rename to src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs index 1c0960a4..89dab697 100644 --- a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs @@ -1,7 +1,7 @@ -namespace StardewModdingAPI.Web.Framework.LogParser +namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// The response for a save-log request. - internal class SavePasteResponse + internal class SavePasteResult { /// Whether the log was successfully saved. public bool Success { get; set; } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs new file mode 100644 index 00000000..61219414 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -0,0 +1,72 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// The config settings for the API clients. + internal class ApiClientsConfig + { + /********* + ** Accessors + *********/ + /**** + ** Generic + ****/ + /// The user agent for API clients, where {0} is the SMAPI version. + public string UserAgent { get; set; } + + + /**** + ** Chucklefish + ****/ + /// The base URL for the Chucklefish mod site. + public string ChucklefishBaseUrl { get; set; } + + /// The URL for a mod page on the Chucklefish mod site excluding the , where {0} is the mod ID. + public string ChucklefishModPageUrlFormat { get; set; } + + + /**** + ** GitHub + ****/ + /// The base URL for the GitHub API. + public string GitHubBaseUrl { get; set; } + + /// The URL for a GitHub API latest-release query excluding the , where {0} is the organisation and project name. + public string GitHubReleaseUrlFormat { get; set; } + + /// The Accept header value expected by the GitHub API. + public string GitHubAcceptHeader { get; set; } + + /// The username with which to authenticate to the GitHub API (if any). + public string GitHubUsername { get; set; } + + /// The password with which to authenticate to the GitHub API (if any). + public string GitHubPassword { get; set; } + + /**** + ** Nexus Mods + ****/ + /// The user agent for the Nexus Mods API client. + public string NexusUserAgent { get; set; } + + /// The base URL for the Nexus Mods API. + public string NexusBaseUrl { get; set; } + + /// The URL for a Nexus Mods API query excluding the , where {0} is the mod ID. + public string NexusModUrlFormat { get; set; } + + /**** + ** Pastebin + ****/ + /// The base URL for the Pastebin API. + public string PastebinBaseUrl { get; set; } + + /// The user agent for the Pastebin API client, where {0} is the SMAPI version. + public string PastebinUserAgent { get; set; } + + /// The user key used to authenticate with the Pastebin API. + public string PastebinUserKey { get; set; } + + /// The developer key used to authenticate with the Pastebin API. + public string PastebinDevKey { get; set; } + + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs new file mode 100644 index 00000000..117462f4 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// The config settings for the app context. + public class ContextConfig // must be public to pass into views + { + /********* + ** Accessors + *********/ + /// The root URL for the app. + public string RootUrl { get; set; } + + /// The root URL for the log parser. + public string LogParserUrl { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs deleted file mode 100644 index df5d605d..00000000 --- a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels -{ - /// The config settings for the log parser. - internal class LogParserConfig - { - /********* - ** Accessors - *********/ - /// The root URL for the log parser controller. - public string SectionUrl { get; set; } - - /// The base URL for the Pastebin API. - public string PastebinBaseUrl { get; set; } - - /// The user agent for the Pastebin API client, where {0} is the SMAPI version. - public string PastebinUserAgent { get; set; } - - /// The user key used to authenticate with the Pastebin API. - public string PastebinUserKey { get; set; } - - /// The developer key used to authenticate with the Pastebin API. - public string PastebinDevKey { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index 2fb5b97e..58c3a100 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -6,9 +6,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /**** - ** General - ****/ /// The number of minutes update checks should be cached before refetching them. public int CacheMinutes { get; set; } @@ -16,59 +13,13 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// Derived from SMAPI's SemanticVersion implementation. public string SemanticVersionRegex { get; set; } - /**** - ** Chucklefish mod site - ****/ /// The repository key for the Chucklefish mod site. public string ChucklefishKey { get; set; } - /// The user agent for the Chucklefish API client, where {0} is the SMAPI version. - public string ChucklefishUserAgent { get; set; } - - /// The base URL for the Chucklefish mod site. - public string ChucklefishBaseUrl { get; set; } - - /// The URL for a mod page on the Chucklefish mod site excluding the , where {0} is the mod ID. - public string ChucklefishModPageUrlFormat { get; set; } - - - /**** - ** GitHub - ****/ /// The repository key for Nexus Mods. public string GitHubKey { get; set; } - /// The user agent for the GitHub API client, where {0} is the SMAPI version. - public string GitHubUserAgent { get; set; } - - /// The base URL for the GitHub API. - public string GitHubBaseUrl { get; set; } - - /// The URL for a GitHub API latest-release query excluding the , where {0} is the organisation and project name. - public string GitHubReleaseUrlFormat { get; set; } - - /// The Accept header value expected by the GitHub API. - public string GitHubAcceptHeader { get; set; } - - /// The username with which to authenticate to the GitHub API (if any). - public string GitHubUsername { get; set; } - - /// The password with which to authenticate to the GitHub API (if any). - public string GitHubPassword { get; set; } - - /**** - ** Nexus Mods - ****/ /// The repository key for Nexus Mods. public string NexusKey { get; set; } - - /// The user agent for the Nexus Mods API client. - public string NexusUserAgent { get; set; } - - /// The base URL for the Nexus Mods API. - public string NexusBaseUrl { get; set; } - - /// The URL for a Nexus Mods API query excluding the , where {0} is the mod ID. - public string NexusModUrlFormat { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs index 06ec58ed..266055a6 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs @@ -1,9 +1,7 @@ using System; -using System.Net; using System.Threading.Tasks; -using HtmlAgilityPack; -using Pathoschild.Http.Client; using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Web.Framework.Clients.Chucklefish; namespace StardewModdingAPI.Web.Framework.ModRepositories { @@ -13,14 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /********* ** Properties *********/ - /// The base URL for the Chucklefish mod site. - private readonly string BaseUrl; - - /// The URL for a mod page excluding the base URL, where {0} is the mod ID. - private readonly string ModPageUrlFormat; - /// The underlying HTTP client. - private readonly IClient Client; + private readonly IChucklefishClient Client; /********* @@ -28,15 +20,11 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories *********/ /// Construct an instance. /// The unique key for this vendor. - /// The user agent for the API client. - /// The base URL for the Chucklefish mod site. - /// The URL for a mod page excluding the , where {0} is the mod ID. - public ChucklefishRepository(string vendorKey, string userAgent, string baseUrl, string modPageUrlFormat) + /// The underlying HTTP client. + public ChucklefishRepository(string vendorKey, IChucklefishClient client) : base(vendorKey) { - this.BaseUrl = baseUrl; - this.ModPageUrlFormat = modPageUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + this.Client = client; } /// Get metadata about a mod in the repository. @@ -44,38 +32,18 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories public override async Task GetModInfoAsync(string id) { // validate ID format - if (!uint.TryParse(id, out uint _)) + if (!uint.TryParse(id, out uint realID)) return new ModInfoModel($"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); // fetch info try { - // fetch HTML - string html; - try - { - html = await this.Client - .GetAsync(string.Format(this.ModPageUrlFormat, id)) - .AsString(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { + var mod = await this.Client.GetModAsync(realID); + if (mod == null) return new ModInfoModel("Found no mod with this ID."); - } - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // extract mod info - string url = new UriBuilder(new Uri(this.BaseUrl)) { Path = string.Format(this.ModPageUrlFormat, id) }.Uri.ToString(); - string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value; - if (name.StartsWith("[SMAPI] ")) - name = name.Substring("[SMAPI] ".Length); - string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText; // create model - return new ModInfoModel(name, this.NormaliseVersion(version), url); + return new ModInfoModel(mod.Name, this.NormaliseVersion(mod.Version), mod.Url); } catch (Exception ex) { diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs index 9d43adf0..7bad6127 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -1,9 +1,7 @@ using System; -using System.Net; using System.Threading.Tasks; -using Newtonsoft.Json; -using Pathoschild.Http.Client; using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Web.Framework.Clients.GitHub; namespace StardewModdingAPI.Web.Framework.ModRepositories { @@ -13,11 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /********* ** Properties *********/ - /// The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID. - private readonly string ReleaseUrlFormat; - - /// The underlying HTTP client. - private readonly IClient Client; + /// The underlying GitHub API client. + private readonly IGitHubClient Client; /********* @@ -25,22 +20,11 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories *********/ /// Construct an instance. /// The unique key for this vendor. - /// The base URL for the Nexus Mods API. - /// The URL for a Nexus Mods API query excluding the , where {0} is the mod ID. - /// The user agent for the API client. - /// The Accept header value expected by the GitHub API. - /// The username with which to authenticate to the GitHub API. - /// The password with which to authenticate to the GitHub API. - public GitHubRepository(string vendorKey, string baseUrl, string releaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + /// The underlying GitHub API client. + public GitHubRepository(string vendorKey, IGitHubClient client) : base(vendorKey) { - this.ReleaseUrlFormat = releaseUrlFormat; - - this.Client = new FluentClient(baseUrl) - .SetUserAgent(userAgent) - .AddDefault(req => req.WithHeader("Accept", acceptHeader)); - if (!string.IsNullOrWhiteSpace(username)) - this.Client = this.Client.SetBasicAuthentication(username, password); + this.Client = client; } /// Get metadata about a mod in the repository. @@ -54,14 +38,10 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories // fetch info try { - GitRelease release = await this.Client - .GetAsync(string.Format(this.ReleaseUrlFormat, id)) - .As(); - return new ModInfoModel(id, this.NormaliseVersion(release.Tag), $"https://github.com/{id}/releases"); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return new ModInfoModel("Found no mod with this ID."); + GitRelease release = await this.Client.GetLatestReleaseAsync(id); + return release != null + ? new ModInfoModel(id, this.NormaliseVersion(release.Tag), $"https://github.com/{id}/releases") + : new ModInfoModel("Found no mod with this ID."); } catch (Exception ex) { @@ -74,24 +54,5 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { this.Client.Dispose(); } - - - /********* - ** Private models - *********/ - /// Metadata about a GitHub release tag. - private class GitRelease - { - /********* - ** Accessors - *********/ - /// The display name. - [JsonProperty("name")] - public string Name { get; set; } - - /// The semantic version string. - [JsonProperty("tag_name")] - public string Tag { get; set; } - } } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs index 8a4bb0d8..cfa757ab 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -1,8 +1,7 @@ using System; using System.Threading.Tasks; -using Newtonsoft.Json; -using Pathoschild.Http.Client; using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Web.Framework.Clients.Nexus; namespace StardewModdingAPI.Web.Framework.ModRepositories { @@ -12,11 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /********* ** Properties *********/ - /// The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID. - private readonly string ModUrlFormat; - - /// The underlying HTTP client. - private readonly IClient Client; + /// The underlying Nexus Mods API client. + private readonly INexusClient Client; /********* @@ -24,14 +20,11 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories *********/ /// Construct an instance. /// The unique key for this vendor. - /// The user agent for the Nexus Mods API client. - /// The base URL for the Nexus Mods API. - /// The URL for a Nexus Mods API query excluding the , where {0} is the mod ID. - public NexusRepository(string vendorKey, string userAgent, string baseUrl, string modUrlFormat) + /// The underlying Nexus Mods API client. + public NexusRepository(string vendorKey, INexusClient client) : base(vendorKey) { - this.ModUrlFormat = modUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + this.Client = client; } /// Get metadata about a mod in the repository. @@ -39,18 +32,15 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories public override async Task GetModInfoAsync(string id) { // validate ID format - if (!uint.TryParse(id, out uint _)) + if (!uint.TryParse(id, out uint nexusID)) return new ModInfoModel($"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); // fetch info try { - NexusResponseModel response = await this.Client - .GetAsync(string.Format(this.ModUrlFormat, id)) - .As(); - - return response != null - ? new ModInfoModel(response.Name, this.NormaliseVersion(response.Version), response.Url) + NexusMod mod = await this.Client.GetModAsync(nexusID); + return mod != null + ? new ModInfoModel(mod.Name, this.NormaliseVersion(mod.Version), mod.Url) : new ModInfoModel("Found no mod with this ID."); } catch (Exception ex) @@ -64,26 +54,5 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { this.Client.Dispose(); } - - - /********* - ** Private models - *********/ - /// A mod metadata response from Nexus Mods. - private class NexusResponseModel - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's semantic version number. - public string Version { get; set; } - - /// The mod's web URL. - [JsonProperty("mod_page_uri")] - public string Url { get; set; } - } } } diff --git a/src/SMAPI.Web/Properties/launchSettings.json b/src/SMAPI.Web/Properties/launchSettings.json index e485e4e3..88179044 100644 --- a/src/SMAPI.Web/Properties/launchSettings.json +++ b/src/SMAPI.Web/Properties/launchSettings.json @@ -11,7 +11,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "log", + "launchUrl": "", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj index b5b0ff07..19198503 100644 --- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj +++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj @@ -11,6 +11,7 @@ + diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 16952124..e5e759e7 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -7,6 +7,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Clients.Chucklefish; +using StardewModdingAPI.Web.Framework.Clients.GitHub; +using StardewModdingAPI.Web.Framework.Clients.Nexus; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.RewriteRules; @@ -41,9 +45,10 @@ namespace StardewModdingAPI.Web /// The service injection container. public void ConfigureServices(IServiceCollection services) { + // init configuration services .Configure(this.Configuration.GetSection("ModUpdateCheck")) - .Configure(this.Configuration.GetSection("LogParser")) + .Configure(this.Configuration.GetSection("Context")) .Configure(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddMemoryCache() .AddMvc() @@ -53,6 +58,41 @@ namespace StardewModdingAPI.Web options.SerializerSettings.Formatting = Formatting.Indented; options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; }); + + // init API clients + { + ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get(); + string version = this.GetType().Assembly.GetName().Version.ToString(3); + string userAgent = string.Format(api.UserAgent, version); + + services.AddSingleton(new ChucklefishClient( + userAgent: userAgent, + baseUrl: api.ChucklefishBaseUrl, + modPageUrlFormat: api.ChucklefishModPageUrlFormat + )); + + services.AddSingleton(new GitHubClient( + baseUrl: api.GitHubBaseUrl, + releaseUrlFormat: api.GitHubReleaseUrlFormat, + userAgent: userAgent, + acceptHeader: api.GitHubAcceptHeader, + username: api.GitHubUsername, + password: api.GitHubPassword + )); + + services.AddSingleton(new NexusClient( + userAgent: api.NexusUserAgent, + baseUrl: api.NexusBaseUrl, + modUrlFormat: api.NexusModUrlFormat + )); + + services.AddSingleton(new PastebinClient( + baseUrl: api.PastebinBaseUrl, + userAgent: userAgent, + userKey: api.PastebinUserKey, + devKey: api.PastebinDevKey + )); + } } /// The method called by the runtime to configure the HTTP request pipeline. @@ -89,11 +129,11 @@ namespace StardewModdingAPI.Web req.Host.Host != "localhost" && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.")) && !req.Path.StartsWithSegments("/content") + && !req.Path.StartsWithSegments("/favicon.ico") )) // shortcut redirects .Add(new RedirectToUrlRule("^/docs$", "https://stardewvalleywiki.com/Modding:Index")) - .Add(new RedirectToUrlRule("^/install$", "https://stardewvalleywiki.com/Modding:Installing_SMAPI")) ) .UseStaticFiles() // wwwroot folder .UseMvc(); diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs new file mode 100644 index 00000000..6d3da91e --- /dev/null +++ b/src/SMAPI.Web/ViewModels/IndexModel.cs @@ -0,0 +1,41 @@ +namespace StardewModdingAPI.Web.ViewModels +{ + /// The view model for the index page. + public class IndexModel + { + /********* + ** Accessors + *********/ + /// The latest SMAPI version. + public string LatestVersion { get; set; } + + /// The Markdown description for the release. + public string Description { get; set; } + + /// The main download URL. + public string DownloadUrl { get; set; } + + /// The for-developers download URL. + public string DevDownloadUrl { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public IndexModel() { } + + /// Construct an instance. + /// The latest SMAPI version. + /// The Markdown description for the release. + /// The main download URL. + /// The for-developers download URL. + internal IndexModel(string latestVersion, string description, string downloadUrl, string devDownloadUrl) + { + this.LatestVersion = latestVersion; + this.Description = description; + this.DownloadUrl = downloadUrl; + this.DevDownloadUrl = devDownloadUrl; + } + } +} diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml new file mode 100644 index 00000000..2d76cc15 --- /dev/null +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -0,0 +1,68 @@ +@{ + ViewData["Title"] = "SMAPI"; +} +@model StardewModdingAPI.Web.ViewModels.IndexModel +@section Head { + +} + +

+ The mod loader for Stardew Valley. It works fine with GOG and Steam achievements, it's + compatible with Linux/Mac/Windows, you can uninstall it anytime, and there's a friendly + community if you need help. It's a cool pufferchick. +

+ +
+ +

Get help

+ + +

What's new in SMAPI @Model.LatestVersion?

+
+ @Html.Raw(Markdig.Markdown.ToHtml(Model.Description)) +
+ +

See the release notes and mod compatibility list for more info.

+ +

Donate to support SMAPI ♥

+

+ SMAPI is an open-source project by Pathoschild. It will always be free, but donations + are much appreciated to help pay for development, server hosting, domain fees, coffee, etc. +

+ + +

+ Special thanks to + acerbicon, + ChefRude, + jwdred, + OfficialPiAddict, + Robby LaFarge, + and a few anonymous users for their ongoing support; you're awesome! 🏅 +

+ +

For mod creators

+ diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 547a8178..ac98c71b 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -1,3 +1,7 @@ +@using Microsoft.Extensions.Options +@using StardewModdingAPI.Web.Framework.ConfigModels +@inject IOptions ContextConfig + @@ -10,9 +14,9 @@
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 87c35ca9..495af120 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -16,12 +16,14 @@ "Microsoft": "Information" } }, - "ModUpdateCheck": { - "GitHubUsername": null, - "GitHubPassword": null + "Context": { + "RootUrl": "http://localhost:59482/", + "LogParserUrl": "http://localhost:59482/log/" }, - "LogParser": { - "SectionUrl": "http://localhost:59482/log/", + "ApiClients": { + "GitHubUsername": null, + "GitHubPassword": null, + "PastebinUserKey": null, "PastebinDevKey": null } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index eb6ecc9b..9758f4a7 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -13,33 +13,37 @@ "Default": "Warning" } }, - "ModUpdateCheck": { - "CacheMinutes": 60, - "SemanticVersionRegex": "^(?>(?0|[1-9]\\d*))\\.(?>(?0|[1-9]\\d*))(?>(?:\\.(?0|[1-9]\\d*))?)(?:-(?(?>[a-z0-9]+[\\-\\.]?)+))?$", + "Context": { + "RootUrl": null, // see top note + "LogParserUrl": null // see top note + }, + "ApiClients": { + "UserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)", - "ChucklefishKey": "Chucklefish", - "ChucklefishUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)", "ChucklefishBaseUrl": "https://community.playstarbound.com", "ChucklefishModPageUrlFormat": "resources/{0}", - "GitHubKey": "GitHub", - "GitHubUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)", "GitHubBaseUrl": "https://api.github.com", "GitHubReleaseUrlFormat": "repos/{0}/releases/latest", "GitHubAcceptHeader": "application/vnd.github.v3+json", "GitHubUsername": null, // see top note "GitHubPassword": null, // see top note - "NexusKey": "Nexus", "NexusUserAgent": "Nexus Client v0.63.15", "NexusBaseUrl": "http://www.nexusmods.com/stardewvalley", - "NexusModUrlFormat": "mods/{0}" - }, - "LogParser": { - "SectionUrl": null, // see top note + "NexusModUrlFormat": "mods/{0}", + "PastebinBaseUrl": "https://pastebin.com/", - "PastebinUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)", "PastebinUserKey": null, // see top note "PastebinDevKey": null // see top note + }, + + "ModUpdateCheck": { + "CacheMinutes": 60, + "SemanticVersionRegex": "^(?>(?0|[1-9]\\d*))\\.(?>(?0|[1-9]\\d*))(?>(?:\\.(?0|[1-9]\\d*))?)(?:-(?(?>[a-z0-9]+[\\-\\.]?)+))?$", + + "ChucklefishKey": "Chucklefish", + "GitHubKey": "GitHub", + "NexusKey": "Nexus" } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css new file mode 100644 index 00000000..06cd6fb4 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/index.css @@ -0,0 +1,58 @@ +/********* +** Intro +*********/ +h1 { + text-align: center; + font-size: 6em; + color: #000; +} + +#blurb { + margin: auto; + width: 30em; + text-align: center; +} + +#call-to-action { + margin: 3em 0; + text-align: center; +} + +#call-to-action a { + box-shadow: #caefab 0 1px 0 0 inset; + background: linear-gradient(#77d42a 5%, #5cb811 100%) #77d42a; + border-radius: 6px; + border: 1px solid #268a16; + display: inline-block; + cursor: pointer; + color: #306108; + font-weight: bold; + margin-bottom: 1em; + padding: 6px 24px; + text-decoration: none; + text-shadow: #aade7c 0 1px 0; +} + +#call-to-action a.secondary-cta { + background: #768d87; + border: 1px solid #566963; + color: #ffffff; + text-shadow: #2b665e 0 1px 0; +} + +/********* +** Subsections +*********/ +.github-description { + border-left: 0.25em solid #dfe2e5; + padding-left: 1em; +} + +.github-description .noinclude { + display: none; +} + +#support-links li small { + display: block; + width: 50em; +} diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 41b79272..786ae32b 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -29,7 +29,7 @@ namespace StardewModdingAPI ** Public ****/ /// SMAPI's current semantic version. - public static ISemanticVersion ApiVersion { get; } = new SemanticVersion("2.2"); + public static ISemanticVersion ApiVersion { get; } = new SemanticVersion("2.3"); /// The minimum supported version of Stardew Valley. public static ISemanticVersion MinimumGameVersion { get; } = new SemanticVersion("1.2.30"); diff --git a/src/SMAPI/Events/EventArgsInput.cs b/src/SMAPI/Events/EventArgsInput.cs index 54ce9b53..a5325b76 100644 --- a/src/SMAPI/Events/EventArgsInput.cs +++ b/src/SMAPI/Events/EventArgsInput.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Events public SButton Button { get; } /// The current cursor position. - public ICursorPosition Cursor { get; set; } + public ICursorPosition Cursor { get; } /// Whether the input is considered a 'click' by the game for enabling action. [Obsolete("Use " + nameof(EventArgsInput.IsActionButton) + " or " + nameof(EventArgsInput.IsUseToolButton) + " instead")] // deprecated in SMAPI 2.1 @@ -28,6 +28,9 @@ namespace StardewModdingAPI.Events /// Whether the input should use tools on the affected tile. public bool IsUseToolButton { get; } + /// Whether a mod has indicated the key was already handled. + public bool IsSuppressed { get; private set; } + /********* ** Public methods @@ -55,6 +58,9 @@ namespace StardewModdingAPI.Events /// The button to suppress. public void SuppressButton(SButton button) { + if (button == this.Button) + this.IsSuppressed = true; + // keyboard if (button.TryGetKeyboard(out Keys key)) Game1.oldKBState = new KeyboardState(Game1.oldKBState.GetPressedKeys().Union(new[] { key }).ToArray()); diff --git a/src/SMAPI/Events/GameEvents.cs b/src/SMAPI/Events/GameEvents.cs index b477376e..3466470d 100644 --- a/src/SMAPI/Events/GameEvents.cs +++ b/src/SMAPI/Events/GameEvents.cs @@ -33,6 +33,9 @@ namespace StardewModdingAPI.Events /// Raised every 60th tick (≈once per second). public static event EventHandler OneSecondTick; + /// Raised once after the game initialises and all methods have been called. + public static event EventHandler FirstUpdateTick; + /********* ** Internal methods @@ -92,5 +95,12 @@ namespace StardewModdingAPI.Events { monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.OneSecondTick)}", GameEvents.OneSecondTick?.GetInvocationList()); } + + /// Raise a event. + /// Encapsulates monitoring and logging. + internal static void InvokeFirstUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.FirstUpdateTick)}", GameEvents.FirstUpdateTick?.GetInvocationList()); + } } } diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 10c41d08..4508e641 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -57,14 +57,14 @@ namespace StardewModdingAPI.Framework.Content public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator) { // init - this.Cache = reflection.GetPrivateField>(contentManager, "loadedAssets").GetValue(); + this.Cache = reflection.GetField>(contentManager, "loadedAssets").GetValue(); this.PossiblePathSeparators = possiblePathSeparators; this.PreferredPathSeparator = preferredPathSeparator; // get key normalisation logic if (Constants.TargetPlatform == Platform.Windows) { - IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath"); + IReflectedMethod method = reflection.GetMethod(typeof(TitleContainer), "GetCleanPath"); this.NormaliseAssetNameForPlatform = path => method.Invoke(path); } else diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index b07c6c7d..7a824a05 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework /// How deprecated the code is. public void Warn(string nounPhrase, string version, DeprecationLevel severity) { - this.Warn(this.ModRegistry.GetModFromStack(), nounPhrase, version, severity); + this.Warn(this.ModRegistry.GetFromStack()?.DisplayName, nounPhrase, version, severity); } /// Log a deprecation warning. @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework return; // build message - string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase})."; + string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase} is deprecated since SMAPI {version})."; if (source == null) message += $"{Environment.NewLine}{Environment.StackTrace}"; @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework /// Returns whether the deprecation was successfully marked as warned. Returns false if it was already marked. public bool MarkWarned(string nounPhrase, string version) { - return this.MarkWarned(this.ModRegistry.GetModFromStack(), nounPhrase, version); + return this.MarkWarned(this.ModRegistry.GetFromStack()?.DisplayName, nounPhrase, version); } /// Mark a deprecation warning as already logged. diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index c21734a7..a36994fd 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -30,6 +30,9 @@ namespace StardewModdingAPI.Framework /// The mod instance (if it was loaded). IMod Mod { get; } + /// The mod-provided API (if any). + object Api { get; } + /********* ** Public methods @@ -43,5 +46,9 @@ namespace StardewModdingAPI.Framework /// Set the mod instance. /// The mod instance to set. IModMetadata SetMod(IMod mod); + + /// Set the mod-provided API instance. + /// The mod-provided API. + IModMetadata SetApi(object api); } } diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index 3709e05d..bec6c183 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -108,6 +108,15 @@ namespace StardewModdingAPI.Framework } } + /// Get the lowest exception in an exception stack. + /// The exception from which to search. + public static Exception GetInnermostException(this Exception exception) + { + while (exception.InnerException != null) + exception = exception.InnerException; + return exception; + } + /**** ** Sprite batch ****/ @@ -125,7 +134,7 @@ namespace StardewModdingAPI.Framework #endif // get result - return reflection.GetPrivateField(Game1.spriteBatch, fieldName).GetValue(); + return reflection.GetField(Game1.spriteBatch, fieldName).GetValue(); } } } diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index 9e824694..ea0dbb38 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers { @@ -11,6 +13,15 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The underlying mod registry. private readonly ModRegistry Registry; + /// Encapsulates monitoring and logging for the mod. + private readonly IMonitor Monitor; + + /// The mod IDs for APIs accessed by this instanced. + private readonly HashSet AccessedModApis = new HashSet(); + + /// Generates proxy classes to access mod APIs through an arbitrary interface. + private readonly InterfaceProxyBuilder ProxyBuilder; + /********* ** Public methods @@ -18,16 +29,20 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Construct an instance. /// The unique ID of the relevant mod. /// The underlying mod registry. - public ModRegistryHelper(string modID, ModRegistry registry) + /// Generates proxy classes to access mod APIs through an arbitrary interface. + /// Encapsulates monitoring and logging for the mod. + public ModRegistryHelper(string modID, ModRegistry registry, InterfaceProxyBuilder proxyBuilder, IMonitor monitor) : base(modID) { this.Registry = registry; + this.ProxyBuilder = proxyBuilder; + this.Monitor = monitor; } /// Get metadata for all loaded mods. public IEnumerable GetAll() { - return this.Registry.GetAll(); + return this.Registry.GetAll().Select(p => p.Manifest); } /// Get metadata for a loaded mod. @@ -35,14 +50,56 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Returns the matching mod's metadata, or null if not found. public IManifest Get(string uniqueID) { - return this.Registry.Get(uniqueID); + return this.Registry.Get(uniqueID)?.Manifest; } /// Get whether a mod has been loaded. /// The mod's unique ID. public bool IsLoaded(string uniqueID) { - return this.Registry.IsLoaded(uniqueID); + return this.Registry.Get(uniqueID) != null; + } + + /// Get the API provided by a mod, or null if it has none. This signature requires using the API to access the API's properties and methods. + public object GetApi(string uniqueID) + { + IModMetadata mod = this.Registry.Get(uniqueID); + if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID)) + this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.", LogLevel.Trace); + return mod?.Api; + } + + /// Get the API provided by a mod, mapped to a given interface which specifies the expected properties and methods. If the mod has no API or it's not compatible with the given interface, get null. + /// The interface which matches the properties and methods you intend to access. + /// The mod's unique ID. + public TInterface GetApi(string uniqueID) where TInterface : class + { + // validate + if (!this.Registry.AreAllModsInitialised) + { + this.Monitor.Log("Tried to access a mod-provided API before all mods were initialised.", LogLevel.Error); + return null; + } + if (!typeof(TInterface).IsInterface) + { + this.Monitor.Log("Tried to map a mod-provided API to a class; must be a public interface.", LogLevel.Error); + return null; + } + if (!typeof(TInterface).IsPublic) + { + this.Monitor.Log("Tried to map a mod-provided API to a non-public interface; must be a public interface.", LogLevel.Error); + return null; + } + + // get raw API + object api = this.GetApi(uniqueID); + if (api == null) + return null; + + // get API of type + if (api is TInterface castApi) + return castApi; + return this.ProxyBuilder.CreateProxy(api, this.ModID, uniqueID); } } } diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 8788b142..81453003 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -17,6 +17,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The mod name for error messages. private readonly string ModName; + /// Manages deprecation warnings. + private readonly DeprecationManager DeprecationManager; + /********* ** Public methods @@ -25,15 +28,88 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The unique ID of the relevant mod. /// The mod name for error messages. /// The underlying reflection helper. - public ReflectionHelper(string modID, string modName, Reflector reflector) + /// Manages deprecation warnings. + public ReflectionHelper(string modID, string modName, Reflector reflector, DeprecationManager deprecationManager) : base(modID) { this.ModName = modName; this.Reflector = reflector; + this.DeprecationManager = deprecationManager; } + /// Get an instance field. + /// The field type. + /// The object which has the field. + /// The field name. + /// Whether to throw an exception if the field is not found. + public IReflectedField GetField(object obj, string name, bool required = true) + { + return this.AssertAccessAllowed( + this.Reflector.GetField(obj, name, required) + ); + } + + /// Get a static field. + /// The field type. + /// The type which has the field. + /// The field name. + /// Whether to throw an exception if the field is not found. + public IReflectedField GetField(Type type, string name, bool required = true) + { + return this.AssertAccessAllowed( + this.Reflector.GetField(type, name, required) + ); + } + + /// Get an instance property. + /// The property type. + /// The object which has the property. + /// The property name. + /// Whether to throw an exception if the property is not found. + public IReflectedProperty GetProperty(object obj, string name, bool required = true) + { + return this.AssertAccessAllowed( + this.Reflector.GetProperty(obj, name, required) + ); + } + + /// Get a static property. + /// The property type. + /// The type which has the property. + /// The property name. + /// Whether to throw an exception if the property is not found. + public IReflectedProperty GetProperty(Type type, string name, bool required = true) + { + return this.AssertAccessAllowed( + this.Reflector.GetProperty(type, name, required) + ); + } + + /// Get an instance method. + /// The object which has the method. + /// The field name. + /// Whether to throw an exception if the field is not found. + public IReflectedMethod GetMethod(object obj, string name, bool required = true) + { + return this.AssertAccessAllowed( + this.Reflector.GetMethod(obj, name, required) + ); + } + + /// Get a static method. + /// The type which has the method. + /// The field name. + /// Whether to throw an exception if the field is not found. + public IReflectedMethod GetMethod(Type type, string name, bool required = true) + { + return this.AssertAccessAllowed( + this.Reflector.GetMethod(type, name, required) + ); + } + + /**** - ** Fields + ** Obsolete ****/ /// Get a private instance field. /// The field type. @@ -41,11 +117,11 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The field name. /// Whether to throw an exception if the private field is not found. /// Returns the field wrapper, or null if the field doesn't exist and is false. + [Obsolete] public IPrivateField GetPrivateField(object obj, string name, bool required = true) { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateField(obj, name, required) - ); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + return (IPrivateField)this.GetField(obj, name, required); } /// Get a private static field. @@ -53,26 +129,23 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The type which has the field. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete] public IPrivateField GetPrivateField(Type type, string name, bool required = true) { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateField(type, name, required) - ); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + return (IPrivateField)this.GetField(type, name, required); } - /**** - ** Properties - ****/ /// Get a private instance property. /// The property type. /// The object which has the property. /// The property name. /// Whether to throw an exception if the private property is not found. + [Obsolete] public IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true) { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateProperty(obj, name, required) - ); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + return (IPrivateProperty)this.GetProperty(obj, name, required); } /// Get a private static property. @@ -80,17 +153,13 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The type which has the property. /// The property name. /// Whether to throw an exception if the private property is not found. + [Obsolete] public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateProperty(type, name, required) - ); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + return (IPrivateProperty)this.GetProperty(type, name, required); } - /**** - ** Field values - ** (shorthand since this is the most common case) - ****/ /// Get the value of a private instance field. /// The field type. /// The object which has the field. @@ -101,9 +170,11 @@ namespace StardewModdingAPI.Framework.ModHelpers /// This is a shortcut for followed by . /// When is false, this will return the default value if reflection fails. If you need to check whether the field exists, use instead. /// + [Obsolete] public TValue GetPrivateValue(object obj, string name, bool required = true) { - IPrivateField field = this.GetPrivateField(obj, name, required); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + IPrivateField field = (IPrivateField)this.GetField(obj, name, required); return field != null ? field.GetValue() : default(TValue); @@ -119,64 +190,36 @@ namespace StardewModdingAPI.Framework.ModHelpers /// This is a shortcut for followed by . /// When is false, this will return the default value if reflection fails. If you need to check whether the field exists, use instead. /// + [Obsolete] public TValue GetPrivateValue(Type type, string name, bool required = true) { - IPrivateField field = this.GetPrivateField(type, name, required); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + IPrivateField field = (IPrivateField)this.GetField(type, name, required); return field != null ? field.GetValue() : default(TValue); } - /**** - ** Methods - ****/ /// Get a private instance method. /// The object which has the method. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete] public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateMethod(obj, name, required) - ); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + return (IPrivateMethod)this.GetMethod(obj, name, required); } /// Get a private static method. /// The type which has the method. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete] public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateMethod(type, name, required) - ); - } - - /**** - ** Methods by signature - ****/ - /// Get a private instance method. - /// The object which has the method. - /// The field name. - /// The argument types of the method signature to find. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) - { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required) - ); - } - - /// Get a private static method. - /// The type which has the method. - /// The field name. - /// The argument types of the method signature to find. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) - { - return this.AssertAccessAllowed( - this.Reflector.GetPrivateMethod(type, name, argumentTypes, required) - ); + this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice); + return (IPrivateMethod)this.GetMethod(type, name, required); } @@ -187,7 +230,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The field value type. /// The field being accessed. /// Returns the same field instance for convenience. - private IPrivateField AssertAccessAllowed(IPrivateField field) + private IReflectedField AssertAccessAllowed(IReflectedField field) { this.AssertAccessAllowed(field?.FieldInfo); return field; @@ -197,7 +240,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The property value type. /// The property being accessed. /// Returns the same property instance for convenience. - private IPrivateProperty AssertAccessAllowed(IPrivateProperty property) + private IReflectedProperty AssertAccessAllowed(IReflectedProperty property) { this.AssertAccessAllowed(property?.PropertyInfo); return property; @@ -206,7 +249,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Assert that mods can use the reflection helper to access the given member. /// The method being accessed. /// Returns the same method instance for convenience. - private IPrivateMethod AssertAccessAllowed(IPrivateMethod method) + private IReflectedMethod AssertAccessAllowed(IReflectedMethod method) { this.AssertAccessAllowed(method?.MethodInfo); return method; diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 5055da75..30fe211b 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -29,6 +29,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mod instance (if it was loaded). public IMod Mod { get; private set; } + /// The mod-provided API (if any). + public object Api { get; private set; } + /********* ** Public methods @@ -64,5 +67,13 @@ namespace StardewModdingAPI.Framework.ModLoading this.Mod = mod; return this; } + + /// Set the mod-provided API instance. + /// The mod-provided API. + public IModMetadata SetApi(object api) + { + this.Api = api; + return this; + } } } diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index 9dde7a20..453d2868 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -15,26 +15,34 @@ namespace StardewModdingAPI.Framework /// The registered mod data. private readonly List Mods = new List(); - /// The friendly mod names treated as deprecation warning sources (assembly full name => mod name). - private readonly IDictionary ModNamesByAssembly = new Dictionary(); + /// An assembly full name => mod lookup. + private readonly IDictionary ModNamesByAssembly = new Dictionary(); + + /// Whether all mods have been initialised and their method called. + public bool AreAllModsInitialised { get; set; } /********* ** Public methods *********/ - /**** - ** Basic metadata - ****/ - /// Get metadata for all loaded mods. - public IEnumerable GetAll() + /// Register a mod as a possible source of deprecation warnings. + /// The mod metadata. + public void Add(IModMetadata metadata) { - return this.Mods.Select(p => p.Manifest); + this.Mods.Add(metadata); + this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; + } + + /// Get metadata for all loaded mods. + public IEnumerable GetAll() + { + return this.Mods.Select(p => p); } /// Get metadata for a loaded mod. /// The mod's unique ID. /// Returns the matching mod's metadata, or null if not found. - public IManifest Get(string uniqueID) + public IModMetadata Get(string uniqueID) { // normalise search ID if (string.IsNullOrWhiteSpace(uniqueID)) @@ -42,37 +50,13 @@ namespace StardewModdingAPI.Framework uniqueID = uniqueID.Trim(); // find match - return this.GetAll().FirstOrDefault(p => p.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase)); + return this.GetAll().FirstOrDefault(p => p.Manifest.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase)); } - /// Get whether a mod has been loaded. - /// The mod's unique ID. - public bool IsLoaded(string uniqueID) - { - return this.Get(uniqueID) != null; - } - - /**** - ** Mod data - ****/ - /// Register a mod as a possible source of deprecation warnings. - /// The mod metadata. - public void Add(IModMetadata metadata) - { - this.Mods.Add(metadata); - this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata.DisplayName; - } - - /// Get all enabled mods. - public IEnumerable GetMods() - { - return (from mod in this.Mods select mod); - } - - /// Get the friendly mod name which defines a type. + /// Get the mod metadata from one of its assemblies. /// The type to check. /// Returns the mod name, or null if the type isn't part of a known mod. - public string GetModFrom(Type type) + public IModMetadata GetFrom(Type type) { // null if (type == null) @@ -89,7 +73,7 @@ namespace StardewModdingAPI.Framework /// Get the friendly name for the closest assembly registered as a source of deprecation warnings. /// Returns the source name, or null if no registered assemblies were found. - public string GetModFromStack() + public IModMetadata GetFromStack() { // get stack frames StackTrace stack = new StackTrace(); @@ -101,9 +85,9 @@ namespace StardewModdingAPI.Framework foreach (StackFrame frame in frames) { MethodBase method = frame.GetMethod(); - string name = this.GetModFrom(method.ReflectedType); - if (name != null) - return name; + IModMetadata mod = this.GetFrom(method.ReflectedType); + if (mod != null) + return mod; } // no known assembly found diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs new file mode 100644 index 00000000..5abebc18 --- /dev/null +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// Generates proxy classes to access mod APIs through an arbitrary interface. + internal class InterfaceProxyBuilder + { + /********* + ** Properties + *********/ + /// The CLR module in which to create proxy classes. + private readonly ModuleBuilder ModuleBuilder; + + /// The generated proxy types. + private readonly IDictionary GeneratedTypes = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public InterfaceProxyBuilder() + { + AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run); + this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies"); + } + + /// Create an API proxy. + /// The interface through which to access the API. + /// The API instance to access. + /// The unique ID of the mod consuming the API. + /// The unique ID of the mod providing the API. + public TInterface CreateProxy(object instance, string sourceModID, string targetModID) + where TInterface : class + { + // validate + if (instance == null) + throw new InvalidOperationException("Can't proxy access to a null API."); + if (!typeof(TInterface).IsInterface) + throw new InvalidOperationException("The proxy type must be an interface, not a class."); + + // get proxy type + Type targetType = instance.GetType(); + string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; + if (!this.GeneratedTypes.TryGetValue(proxyTypeName, out Type type)) + { + type = this.CreateProxyType(proxyTypeName, typeof(TInterface), targetType); + this.GeneratedTypes[proxyTypeName] = type; + } + + // create instance + ConstructorInfo constructor = type.GetConstructor(new[] { targetType }); + if (constructor == null) + throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{proxyTypeName}'."); // should never happen + return (TInterface)constructor.Invoke(new[] { instance }); + } + + + /********* + ** Private methods + *********/ + /// Define a class which proxies access to a target type through an interface. + /// The name of the proxy type to generate. + /// The interface type through which to access the target. + /// The target type to access. + private Type CreateProxyType(string proxyTypeName, Type interfaceType, Type targetType) + { + // define proxy type + TypeBuilder proxyBuilder = this.ModuleBuilder.DefineType(proxyTypeName, TypeAttributes.Public | TypeAttributes.Class); + proxyBuilder.AddInterfaceImplementation(interfaceType); + + // create field to store target instance + FieldBuilder field = proxyBuilder.DefineField("__Target", targetType, FieldAttributes.Private); + + // create constructor which accepts target instance + { + ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { targetType }); + ILGenerator il = constructor.GetILGenerator(); + + il.Emit(OpCodes.Ldarg_0); // this + // ReSharper disable once AssignNullToNotNullAttribute -- never null + il.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[0])); // call base constructor + il.Emit(OpCodes.Ldarg_0); // this + il.Emit(OpCodes.Ldarg_1); // load argument + il.Emit(OpCodes.Stfld, field); // set field to loaded argument + il.Emit(OpCodes.Ret); + } + + // proxy methods + foreach (MethodInfo proxyMethod in interfaceType.GetMethods()) + { + var targetMethod = targetType.GetMethod(proxyMethod.Name, proxyMethod.GetParameters().Select(a => a.ParameterType).ToArray()); + if (targetMethod == null) + throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); + + this.ProxyMethod(proxyBuilder, targetMethod, field); + } + + // create type + return proxyBuilder.CreateType(); + } + + /// Define a method which proxies access to a method on the target. + /// The proxy type being generated. + /// The target method. + /// The proxy field containing the API instance. + private void ProxyMethod(TypeBuilder proxyBuilder, MethodInfo target, FieldBuilder instanceField) + { + Type[] argTypes = target.GetParameters().Select(a => a.ParameterType).ToArray(); + + // create method + MethodBuilder methodBuilder = proxyBuilder.DefineMethod(target.Name, MethodAttributes.Public | MethodAttributes.Final | MethodAttributes.Virtual); + methodBuilder.SetParameters(argTypes); + methodBuilder.SetReturnType(target.ReturnType); + + // create method body + { + ILGenerator il = methodBuilder.GetILGenerator(); + + // load target instance + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, instanceField); + + // invoke target method on instance + for (int i = 0; i < argTypes.Length; i++) + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Call, target); + + // return result + il.Emit(OpCodes.Ret); + } + } + } +} diff --git a/src/SMAPI/Framework/Reflection/PrivateField.cs b/src/SMAPI/Framework/Reflection/ReflectedField.cs similarity index 84% rename from src/SMAPI/Framework/Reflection/PrivateField.cs rename to src/SMAPI/Framework/Reflection/ReflectedField.cs index 0bf45969..ad1557bb 100644 --- a/src/SMAPI/Framework/Reflection/PrivateField.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedField.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Reflection; namespace StardewModdingAPI.Framework.Reflection { - /// A private field obtained through reflection. + /// A field obtained through reflection. /// The field value type. - internal class PrivateField : IPrivateField + internal class ReflectedField : IPrivateField, IReflectedField { /********* ** Properties @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.Reflection /// Whether the field is static. /// The or is null. /// The is null for a non-static field, or not null for a static field. - public PrivateField(Type parentType, object obj, FieldInfo field, bool isStatic) + public ReflectedField(Type parentType, object obj, FieldInfo field, bool isStatic) { // validate if (parentType == null) @@ -64,11 +64,11 @@ namespace StardewModdingAPI.Framework.Reflection } catch (InvalidCastException) { - throw new InvalidCastException($"Can't convert the private {this.DisplayName} field from {this.FieldInfo.FieldType.FullName} to {typeof(TValue).FullName}."); + throw new InvalidCastException($"Can't convert the {this.DisplayName} field from {this.FieldInfo.FieldType.FullName} to {typeof(TValue).FullName}."); } catch (Exception ex) { - throw new Exception($"Couldn't get the value of the private {this.DisplayName} field", ex); + throw new Exception($"Couldn't get the value of the {this.DisplayName} field", ex); } } @@ -82,11 +82,11 @@ namespace StardewModdingAPI.Framework.Reflection } catch (InvalidCastException) { - throw new InvalidCastException($"Can't assign the private {this.DisplayName} field a {typeof(TValue).FullName} value, must be compatible with {this.FieldInfo.FieldType.FullName}."); + throw new InvalidCastException($"Can't assign the {this.DisplayName} field a {typeof(TValue).FullName} value, must be compatible with {this.FieldInfo.FieldType.FullName}."); } catch (Exception ex) { - throw new Exception($"Couldn't set the value of the private {this.DisplayName} field", ex); + throw new Exception($"Couldn't set the value of the {this.DisplayName} field", ex); } } } diff --git a/src/SMAPI/Framework/Reflection/PrivateMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs similarity index 83% rename from src/SMAPI/Framework/Reflection/PrivateMethod.cs rename to src/SMAPI/Framework/Reflection/ReflectedMethod.cs index ba2374f4..376de869 100644 --- a/src/SMAPI/Framework/Reflection/PrivateMethod.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs @@ -3,8 +3,8 @@ using System.Reflection; namespace StardewModdingAPI.Framework.Reflection { - /// A private method obtained through reflection. - internal class PrivateMethod : IPrivateMethod + /// A method obtained through reflection. + internal class ReflectedMethod : IPrivateMethod, IReflectedMethod { /********* ** Properties @@ -33,10 +33,10 @@ namespace StardewModdingAPI.Framework.Reflection /// The type that has the method. /// The object that has the instance method(if applicable). /// The reflection metadata. - /// Whether the field is static. + /// Whether the method is static. /// The or is null. /// The is null for a non-static method, or not null for a static method. - public PrivateMethod(Type parentType, object obj, MethodInfo method, bool isStatic) + public ReflectedMethod(Type parentType, object obj, MethodInfo method, bool isStatic) { // validate if (parentType == null) @@ -67,7 +67,7 @@ namespace StardewModdingAPI.Framework.Reflection } catch (Exception ex) { - throw new Exception($"Couldn't invoke the private {this.DisplayName} field", ex); + throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex); } // cast return value @@ -77,7 +77,7 @@ namespace StardewModdingAPI.Framework.Reflection } catch (InvalidCastException) { - throw new InvalidCastException($"Can't convert the return value of the private {this.DisplayName} method from {this.MethodInfo.ReturnType.FullName} to {typeof(TValue).FullName}."); + throw new InvalidCastException($"Can't convert the return value of the {this.DisplayName} method from {this.MethodInfo.ReturnType.FullName} to {typeof(TValue).FullName}."); } } @@ -92,8 +92,8 @@ namespace StardewModdingAPI.Framework.Reflection } catch (Exception ex) { - throw new Exception($"Couldn't invoke the private {this.DisplayName} field", ex); + throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex); } } } -} \ No newline at end of file +} diff --git a/src/SMAPI/Framework/Reflection/PrivateProperty.cs b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs similarity index 59% rename from src/SMAPI/Framework/Reflection/PrivateProperty.cs rename to src/SMAPI/Framework/Reflection/ReflectedProperty.cs index be346d71..d6c964c1 100644 --- a/src/SMAPI/Framework/Reflection/PrivateProperty.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs @@ -3,9 +3,9 @@ using System.Reflection; namespace StardewModdingAPI.Framework.Reflection { - /// A private property obtained through reflection. + /// A property obtained through reflection. /// The property value type. - internal class PrivateProperty : IPrivateProperty + internal class ReflectedProperty : IPrivateProperty, IReflectedProperty { /********* ** Properties @@ -14,10 +14,10 @@ namespace StardewModdingAPI.Framework.Reflection private readonly string DisplayName; /// The underlying property getter. - private readonly Func GetterDelegate; + private readonly Func GetMethod; /// The underlying property setter. - private readonly Action SetterDelegate; + private readonly Action SetMethod; /********* @@ -31,13 +31,13 @@ namespace StardewModdingAPI.Framework.Reflection ** Public methods *********/ /// Construct an instance. - /// The type that has the field. - /// The object that has the instance field (if applicable). + /// The type that has the property. + /// The object that has the instance property (if applicable). /// The reflection metadata. - /// Whether the field is static. + /// Whether the property is static. /// The or is null. - /// The is null for a non-static field, or not null for a static field. - public PrivateProperty(Type parentType, object obj, PropertyInfo property, bool isStatic) + /// The is null for a non-static property, or not null for a static property. + public ReflectedProperty(Type parentType, object obj, PropertyInfo property, bool isStatic) { // validate input if (parentType == null) @@ -55,24 +55,29 @@ namespace StardewModdingAPI.Framework.Reflection this.DisplayName = $"{parentType.FullName}::{property.Name}"; this.PropertyInfo = property; - this.GetterDelegate = (Func)Delegate.CreateDelegate(typeof(Func), obj, this.PropertyInfo.GetMethod); - this.SetterDelegate = (Action)Delegate.CreateDelegate(typeof(Action), obj, this.PropertyInfo.SetMethod); + if (this.PropertyInfo.GetMethod != null) + this.GetMethod = (Func)Delegate.CreateDelegate(typeof(Func), obj, this.PropertyInfo.GetMethod); + if (this.PropertyInfo.SetMethod != null) + this.SetMethod = (Action)Delegate.CreateDelegate(typeof(Action), obj, this.PropertyInfo.SetMethod); } /// Get the property value. public TValue GetValue() { + if (this.GetMethod == null) + throw new InvalidOperationException($"The {this.DisplayName} property has no get method."); + try { - return this.GetterDelegate(); + return this.GetMethod(); } catch (InvalidCastException) { - throw new InvalidCastException($"Can't convert the private {this.DisplayName} property from {this.PropertyInfo.PropertyType.FullName} to {typeof(TValue).FullName}."); + throw new InvalidCastException($"Can't convert the {this.DisplayName} property from {this.PropertyInfo.PropertyType.FullName} to {typeof(TValue).FullName}."); } catch (Exception ex) { - throw new Exception($"Couldn't get the value of the private {this.DisplayName} property", ex); + throw new Exception($"Couldn't get the value of the {this.DisplayName} property", ex); } } @@ -80,17 +85,20 @@ namespace StardewModdingAPI.Framework.Reflection //// The value to set. public void SetValue(TValue value) { + if (this.SetMethod == null) + throw new InvalidOperationException($"The {this.DisplayName} property has no set method."); + try { - this.SetterDelegate(value); + this.SetMethod(value); } catch (InvalidCastException) { - throw new InvalidCastException($"Can't assign the private {this.DisplayName} property a {typeof(TValue).FullName} value, must be compatible with {this.PropertyInfo.PropertyType.FullName}."); + throw new InvalidCastException($"Can't assign the {this.DisplayName} property a {typeof(TValue).FullName} value, must be compatible with {this.PropertyInfo.PropertyType.FullName}."); } catch (Exception ex) { - throw new Exception($"Couldn't set the value of the private {this.DisplayName} property", ex); + throw new Exception($"Couldn't set the value of the {this.DisplayName} property", ex); } } } diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs index 23a48505..910e3a54 100644 --- a/src/SMAPI/Framework/Reflection/Reflector.cs +++ b/src/SMAPI/Framework/Reflection/Reflector.cs @@ -5,7 +5,7 @@ using System.Runtime.Caching; namespace StardewModdingAPI.Framework.Reflection { - /// Provides helper methods for accessing private game code. + /// Provides helper methods for accessing inaccessible code. /// This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage). internal class Reflector { @@ -25,139 +25,139 @@ namespace StardewModdingAPI.Framework.Reflection /**** ** Fields ****/ - /// Get a private instance field. + /// Get a instance field. /// The field type. /// The object which has the field. /// The field name. - /// Whether to throw an exception if the private field is not found. + /// Whether to throw an exception if the field is not found. /// Returns the field wrapper, or null if the field doesn't exist and is false. - public IPrivateField GetPrivateField(object obj, string name, bool required = true) + public IReflectedField GetField(object obj, string name, bool required = true) { // validate if (obj == null) - throw new ArgumentNullException(nameof(obj), "Can't get a private instance field from a null object."); + throw new ArgumentNullException(nameof(obj), "Can't get a instance field from a null object."); // get field from hierarchy - IPrivateField field = this.GetFieldFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + IReflectedField field = this.GetFieldFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && field == null) - throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance field."); + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance field."); return field; } - /// Get a private static field. + /// Get a static field. /// The field type. /// The type which has the field. /// The field name. - /// Whether to throw an exception if the private field is not found. - public IPrivateField GetPrivateField(Type type, string name, bool required = true) + /// Whether to throw an exception if the field is not found. + public IReflectedField GetField(Type type, string name, bool required = true) { // get field from hierarchy - IPrivateField field = this.GetFieldFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public); + IReflectedField field = this.GetFieldFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public); if (required && field == null) - throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static field."); + throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static field."); return field; } /**** ** Properties ****/ - /// Get a private instance property. + /// Get a instance property. /// The property type. /// The object which has the property. /// The property name. - /// Whether to throw an exception if the private property is not found. - public IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true) + /// Whether to throw an exception if the property is not found. + public IReflectedProperty GetProperty(object obj, string name, bool required = true) { // validate if (obj == null) - throw new ArgumentNullException(nameof(obj), "Can't get a private instance property from a null object."); + throw new ArgumentNullException(nameof(obj), "Can't get a instance property from a null object."); // get property from hierarchy - IPrivateProperty property = this.GetPropertyFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + IReflectedProperty property = this.GetPropertyFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && property == null) - throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance property."); + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance property."); return property; } - /// Get a private static property. + /// Get a static property. /// The property type. /// The type which has the property. /// The property name. - /// Whether to throw an exception if the private property is not found. - public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) + /// Whether to throw an exception if the property is not found. + public IReflectedProperty GetProperty(Type type, string name, bool required = true) { // get field from hierarchy - IPrivateProperty property = this.GetPropertyFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + IReflectedProperty property = this.GetPropertyFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); if (required && property == null) - throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static property."); + throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static property."); return property; } /**** ** Methods ****/ - /// Get a private instance method. + /// Get a instance method. /// The object which has the method. /// The field name. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) + /// Whether to throw an exception if the field is not found. + public IReflectedMethod GetMethod(object obj, string name, bool required = true) { // validate if (obj == null) - throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); + throw new ArgumentNullException(nameof(obj), "Can't get a instance method from a null object."); // get method from hierarchy - IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + IReflectedMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && method == null) - throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method."); + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance method."); return method; } - /// Get a private static method. + /// Get a static method. /// The type which has the method. /// The field name. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) + /// Whether to throw an exception if the field is not found. + public IReflectedMethod GetMethod(Type type, string name, bool required = true) { // get method from hierarchy - IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + IReflectedMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); if (required && method == null) - throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method."); + throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static method."); return method; } /**** ** Methods by signature ****/ - /// Get a private instance method. + /// Get a instance method. /// The object which has the method. /// The field name. /// The argument types of the method signature to find. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) + /// Whether to throw an exception if the field is not found. + public IReflectedMethod GetMethod(object obj, string name, Type[] argumentTypes, bool required = true) { // validate parent if (obj == null) - throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); + throw new ArgumentNullException(nameof(obj), "Can't get a instance method from a null object."); // get method from hierarchy - PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, argumentTypes); + ReflectedMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, argumentTypes); if (required && method == null) - throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method with that signature."); + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance method with that signature."); return method; } - /// Get a private static method. + /// Get a static method. /// The type which has the method. /// The field name. /// The argument types of the method signature to find. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) + /// Whether to throw an exception if the field is not found. + public IReflectedMethod GetMethod(Type type, string name, Type[] argumentTypes, bool required = true) { // get field from hierarchy - PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, argumentTypes); + ReflectedMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, argumentTypes); if (required && method == null) - throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method with that signature."); + throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static method with that signature."); return method; } @@ -171,7 +171,7 @@ namespace StardewModdingAPI.Framework.Reflection /// The object which has the field. /// The field name. /// The reflection binding which flags which indicates what type of field to find. - private IPrivateField GetFieldFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) + private IReflectedField GetFieldFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); FieldInfo field = this.GetCached($"field::{isStatic}::{type.FullName}::{name}", () => @@ -183,7 +183,7 @@ namespace StardewModdingAPI.Framework.Reflection }); return field != null - ? new PrivateField(type, obj, field, isStatic) + ? new ReflectedField(type, obj, field, isStatic) : null; } @@ -193,7 +193,7 @@ namespace StardewModdingAPI.Framework.Reflection /// The object which has the property. /// The property name. /// The reflection binding which flags which indicates what type of property to find. - private IPrivateProperty GetPropertyFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) + private IReflectedProperty GetPropertyFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); PropertyInfo property = this.GetCached($"property::{isStatic}::{type.FullName}::{name}", () => @@ -205,7 +205,7 @@ namespace StardewModdingAPI.Framework.Reflection }); return property != null - ? new PrivateProperty(type, obj, property, isStatic) + ? new ReflectedProperty(type, obj, property, isStatic) : null; } @@ -214,7 +214,7 @@ namespace StardewModdingAPI.Framework.Reflection /// The object which has the method. /// The method name. /// The reflection binding which flags which indicates what type of method to find. - private IPrivateMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) + private IReflectedMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () => @@ -226,7 +226,7 @@ namespace StardewModdingAPI.Framework.Reflection }); return method != null - ? new PrivateMethod(type, obj, method, isStatic: bindingFlags.HasFlag(BindingFlags.Static)) + ? new ReflectedMethod(type, obj, method, isStatic: bindingFlags.HasFlag(BindingFlags.Static)) : null; } @@ -236,7 +236,7 @@ namespace StardewModdingAPI.Framework.Reflection /// The method name. /// The reflection binding which flags which indicates what type of method to find. /// The argument types of the method signature to find. - private PrivateMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags, Type[] argumentTypes) + private ReflectedMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags, Type[] argumentTypes) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}({string.Join(",", argumentTypes.Select(p => p.FullName))})", () => @@ -247,7 +247,7 @@ namespace StardewModdingAPI.Framework.Reflection return methodInfo; }); return method != null - ? new PrivateMethod(type, obj, method, isStatic) + ? new ReflectedMethod(type, obj, method, isStatic) : null; } diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs index 524b2d17..ebea6c84 100644 --- a/src/SMAPI/Framework/SContentManager.cs +++ b/src/SMAPI/Framework/SContentManager.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework private readonly ContentCache Cache; /// The private method which generates the locale portion of an asset name. - private readonly IPrivateMethod GetKeyLocale; + private readonly IReflectedMethod GetKeyLocale; /// The language codes used in asset keys. private readonly IDictionary KeyLocales; @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework // init this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator); - this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); + this.GetKeyLocale = reflection.GetMethod(this, "languageCode"); this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath); // get asset data @@ -205,7 +205,7 @@ namespace StardewModdingAPI.Framework return this.LoadImpl(assetName, instance); // load mod content - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}."); + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}"); try { return this.WithWriteLock(() => @@ -252,6 +252,8 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) when (!(ex is SContentLoadException)) { + if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib") + throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher."); throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex); } } @@ -413,7 +415,7 @@ namespace StardewModdingAPI.Framework private IDictionary GetKeyLocales(Reflector reflection) { // get the private code field directly to avoid changed-code logic - IPrivateField codeField = reflection.GetPrivateField(typeof(LocalizedContentManager), "_currentLangCode"); + IReflectedField codeField = reflection.GetField(typeof(LocalizedContentManager), "_currentLangCode"); // remember previous settings LanguageCode previousCode = codeField.GetValue(); diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index c886a4b7..0a614f17 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -133,20 +133,20 @@ namespace StardewModdingAPI.Framework // ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming /// Used to access private fields and methods. - private static List _fpsList => SGame.Reflection.GetPrivateField>(typeof(Game1), nameof(_fpsList)).GetValue(); - private static Stopwatch _fpsStopwatch => SGame.Reflection.GetPrivateField(typeof(Game1), nameof(SGame._fpsStopwatch)).GetValue(); + private static List _fpsList => SGame.Reflection.GetField>(typeof(Game1), nameof(_fpsList)).GetValue(); + private static Stopwatch _fpsStopwatch => SGame.Reflection.GetField(typeof(Game1), nameof(SGame._fpsStopwatch)).GetValue(); private static float _fps { - set => SGame.Reflection.GetPrivateField(typeof(Game1), nameof(_fps)).SetValue(value); + set => SGame.Reflection.GetField(typeof(Game1), nameof(_fps)).SetValue(value); } - private static Task _newDayTask => SGame.Reflection.GetPrivateField(typeof(Game1), nameof(_newDayTask)).GetValue(); - private Color bgColor => SGame.Reflection.GetPrivateField(this, nameof(bgColor)).GetValue(); - public RenderTarget2D screenWrapper => SGame.Reflection.GetPrivateProperty(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop - public BlendState lightingBlend => SGame.Reflection.GetPrivateField(this, nameof(lightingBlend)).GetValue(); - private readonly Action drawFarmBuildings = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke(); - private readonly Action drawHUD = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawHUD)).Invoke(); - private readonly Action drawDialogueBox = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke(); - private readonly Action renderScreenBuffer = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke(); + private static Task _newDayTask => SGame.Reflection.GetField(typeof(Game1), nameof(_newDayTask)).GetValue(); + private Color bgColor => SGame.Reflection.GetField(this, nameof(bgColor)).GetValue(); + public RenderTarget2D screenWrapper => SGame.Reflection.GetProperty(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop + public BlendState lightingBlend => SGame.Reflection.GetField(this, nameof(lightingBlend)).GetValue(); + private readonly Action drawFarmBuildings = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke(); + private readonly Action drawHUD = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawHUD)).Invoke(); + private readonly Action drawDialogueBox = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke(); + private readonly Action renderScreenBuffer = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke(); // ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming @@ -182,7 +182,7 @@ namespace StardewModdingAPI.Framework this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor, reflection); this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content"); Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content"); - reflection.GetPrivateField(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager + reflection.GetField(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager } /**** @@ -557,9 +557,12 @@ namespace StardewModdingAPI.Framework /********* ** Update events *********/ - GameEvents.InvokeUpdateTick(this.Monitor); if (this.FirstUpdate) + { this.FirstUpdate = false; + GameEvents.InvokeFirstUpdateTick(this.Monitor); + } + GameEvents.InvokeUpdateTick(this.Monitor); if (this.CurrentUpdateTick % 2 == 0) GameEvents.InvokeSecondUpdateTick(this.Monitor); if (this.CurrentUpdateTick % 4 == 0) @@ -689,6 +692,7 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); activeClickableMenu.exitThisMenu(); } + this.RaisePostRender(); Game1.spriteBatch.End(); } //base.Draw(gameTime); @@ -712,6 +716,7 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); Game1.activeClickableMenu.exitThisMenu(); } + this.RaisePostRender(); Game1.spriteBatch.End(); if ((double)Game1.options.zoomLevel != 1.0) { @@ -721,11 +726,12 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } - if (Game1.overlayMenu == null) - return; - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } } else if ((int)Game1.gameMode == 11) { @@ -733,6 +739,7 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0)); Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + this.RaisePostRender(); Game1.spriteBatch.End(); } else if (Game1.currentMinigame != null) @@ -744,6 +751,7 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); Game1.spriteBatch.End(); } + this.RaisePostRender(needsNewBatch: true); if ((double)Game1.options.zoomLevel != 1.0) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); @@ -752,11 +760,12 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } - if (Game1.overlayMenu == null) - return; - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } } else if (Game1.showingEndOfNightStuff) { @@ -775,6 +784,7 @@ namespace StardewModdingAPI.Framework Game1.activeClickableMenu.exitThisMenu(); } } + this.RaisePostRender(); Game1.spriteBatch.End(); if ((double)Game1.options.zoomLevel != 1.0) { @@ -784,11 +794,12 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } - if (Game1.overlayMenu == null) - return; - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } } else if ((int)Game1.gameMode == 6) { @@ -806,6 +817,7 @@ namespace StardewModdingAPI.Framework int x = 64; int y = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - height; SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str5, -1); + this.RaisePostRender(); Game1.spriteBatch.End(); if ((double)Game1.options.zoomLevel != 1.0) { @@ -815,11 +827,12 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } - if (Game1.overlayMenu == null) - return; - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.overlayMenu.draw(Game1.spriteBatch); - Game1.spriteBatch.End(); + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } } else { @@ -1265,6 +1278,8 @@ namespace StardewModdingAPI.Framework } else if (Game1.farmEvent != null) Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); + + this.RaisePostRender(); Game1.spriteBatch.End(); if (Game1.overlayMenu != null) { @@ -1272,14 +1287,6 @@ namespace StardewModdingAPI.Framework Game1.overlayMenu.draw(Game1.spriteBatch); Game1.spriteBatch.End(); } - - if (GraphicsEvents.HasPostRenderListeners()) - { - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); - Game1.spriteBatch.End(); - } - this.renderScreenBuffer(); } } @@ -1401,5 +1408,19 @@ namespace StardewModdingAPI.Framework hash ^= v.GetHashCode(); return hash; } + + /// Raise the if there are any listeners. + /// Whether to create a new sprite batch. + private void RaisePostRender(bool needsNewBatch = false) + { + if (GraphicsEvents.HasPostRenderListeners()) + { + if (needsNewBatch) + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); + if (needsNewBatch) + Game1.spriteBatch.End(); + } + } } } diff --git a/src/SMAPI/IMod.cs b/src/SMAPI/IMod.cs index 35ac7c0f..44ef32c9 100644 --- a/src/SMAPI/IMod.cs +++ b/src/SMAPI/IMod.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI +namespace StardewModdingAPI { /// The implementation for a Stardew Valley mod. public interface IMod @@ -22,5 +22,8 @@ /// The mod entry point, called after the mod is first loaded. /// Provides simplified APIs for writing mods. void Entry(IModHelper helper); + + /// Get an API that other mods can access. This is always called after . + object GetApi(); } -} \ No newline at end of file +} diff --git a/src/SMAPI/IModRegistry.cs b/src/SMAPI/IModRegistry.cs index 5ef3fd65..a06e099e 100644 --- a/src/SMAPI/IModRegistry.cs +++ b/src/SMAPI/IModRegistry.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace StardewModdingAPI { @@ -16,5 +16,14 @@ namespace StardewModdingAPI /// Get whether a mod has been loaded. /// The mod's unique ID. bool IsLoaded(string uniqueID); + + /// Get the API provided by a mod, or null if it has none. This signature requires using the API to access the API's properties and methods. + /// The mod's unique ID. + object GetApi(string uniqueID); + + /// Get the API provided by a mod, mapped to a given interface which specifies the expected properties and methods. If the mod has no API or it's not compatible with the given interface, get null. + /// The interface which matches the properties and methods you intend to access. + /// The mod's unique ID. + TInterface GetApi(string uniqueID) where TInterface : class; } -} \ No newline at end of file +} diff --git a/src/SMAPI/IPrivateField.cs b/src/SMAPI/IPrivateField.cs index 3e681c12..512bfdab 100644 --- a/src/SMAPI/IPrivateField.cs +++ b/src/SMAPI/IPrivateField.cs @@ -1,9 +1,11 @@ -using System.Reflection; +using System; +using System.Reflection; namespace StardewModdingAPI { /// A private field obtained through reflection. /// The field value type. + [Obsolete("Use " + nameof(IReflectedField) + " instead")] public interface IPrivateField { /********* @@ -23,4 +25,4 @@ namespace StardewModdingAPI //// The value to set. void SetValue(TValue value); } -} \ No newline at end of file +} diff --git a/src/SMAPI/IPrivateMethod.cs b/src/SMAPI/IPrivateMethod.cs index 67fc8b3c..b2fdaaeb 100644 --- a/src/SMAPI/IPrivateMethod.cs +++ b/src/SMAPI/IPrivateMethod.cs @@ -1,8 +1,10 @@ -using System.Reflection; +using System; +using System.Reflection; namespace StardewModdingAPI { /// A private method obtained through reflection. + [Obsolete("Use " + nameof(IReflectedMethod) + " instead")] public interface IPrivateMethod { /********* @@ -24,4 +26,4 @@ namespace StardewModdingAPI /// The method arguments to pass in. void Invoke(params object[] arguments); } -} \ No newline at end of file +} diff --git a/src/SMAPI/IPrivateProperty.cs b/src/SMAPI/IPrivateProperty.cs index 8d67fa7a..a24495dd 100644 --- a/src/SMAPI/IPrivateProperty.cs +++ b/src/SMAPI/IPrivateProperty.cs @@ -1,9 +1,11 @@ -using System.Reflection; +using System; +using System.Reflection; namespace StardewModdingAPI { /// A private property obtained through reflection. /// The property value type. + [Obsolete("Use " + nameof(IPrivateProperty) + " instead")] public interface IPrivateProperty { /********* @@ -23,4 +25,4 @@ namespace StardewModdingAPI //// The value to set. void SetValue(TValue value); } -} \ No newline at end of file +} diff --git a/src/SMAPI/IReflectedField.cs b/src/SMAPI/IReflectedField.cs new file mode 100644 index 00000000..43ddad42 --- /dev/null +++ b/src/SMAPI/IReflectedField.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// A field obtained through reflection. + /// The field value type. + public interface IReflectedField + { + /********* + ** Accessors + *********/ + /// The reflection metadata. + FieldInfo FieldInfo { get; } + + + /********* + ** Public methods + *********/ + /// Get the field value. + TValue GetValue(); + + /// Set the field value. + //// The value to set. + void SetValue(TValue value); + } +} \ No newline at end of file diff --git a/src/SMAPI/IReflectedMethod.cs b/src/SMAPI/IReflectedMethod.cs new file mode 100644 index 00000000..de83b98c --- /dev/null +++ b/src/SMAPI/IReflectedMethod.cs @@ -0,0 +1,27 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// A method obtained through reflection. + public interface IReflectedMethod + { + /********* + ** Accessors + *********/ + /// The reflection metadata. + MethodInfo MethodInfo { get; } + + + /********* + ** Public methods + *********/ + /// Invoke the method. + /// The return type. + /// The method arguments to pass in. + TValue Invoke(params object[] arguments); + + /// Invoke the method. + /// The method arguments to pass in. + void Invoke(params object[] arguments); + } +} \ No newline at end of file diff --git a/src/SMAPI/IReflectedProperty.cs b/src/SMAPI/IReflectedProperty.cs new file mode 100644 index 00000000..73ad9f30 --- /dev/null +++ b/src/SMAPI/IReflectedProperty.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// A property obtained through reflection. + /// The property value type. + public interface IReflectedProperty + { + /********* + ** Accessors + *********/ + /// The reflection metadata. + PropertyInfo PropertyInfo { get; } + + + /********* + ** Public methods + *********/ + /// Get the property value. + TValue GetValue(); + + /// Set the property value. + //// The value to set. + void SetValue(TValue value); + } +} diff --git a/src/SMAPI/IReflectionHelper.cs b/src/SMAPI/IReflectionHelper.cs index fb2c7861..fcebae42 100644 --- a/src/SMAPI/IReflectionHelper.cs +++ b/src/SMAPI/IReflectionHelper.cs @@ -1,18 +1,62 @@ -using System; +using System; namespace StardewModdingAPI { - /// Provides an API for accessing private game code. + /// Provides an API for accessing inaccessible code. public interface IReflectionHelper : IModLinked { /********* ** Public methods *********/ + /// Get an instance field. + /// The field type. + /// The object which has the field. + /// The field name. + /// Whether to throw an exception if the field is not found. + IReflectedField GetField(object obj, string name, bool required = true); + + /// Get a static field. + /// The field type. + /// The type which has the field. + /// The field name. + /// Whether to throw an exception if the field is not found. + IReflectedField GetField(Type type, string name, bool required = true); + + /// Get an instance property. + /// The property type. + /// The object which has the property. + /// The property name. + /// Whether to throw an exception if the property is not found. + IReflectedProperty GetProperty(object obj, string name, bool required = true); + + /// Get a static property. + /// The property type. + /// The type which has the property. + /// The property name. + /// Whether to throw an exception if the property is not found. + IReflectedProperty GetProperty(Type type, string name, bool required = true); + + /// Get an instance method. + /// The object which has the method. + /// The field name. + /// Whether to throw an exception if the field is not found. + IReflectedMethod GetMethod(object obj, string name, bool required = true); + + /// Get a static method. + /// The type which has the method. + /// The field name. + /// Whether to throw an exception if the field is not found. + IReflectedMethod GetMethod(Type type, string name, bool required = true); + + /***** + ** Obsolete + *****/ /// Get a private instance field. /// The field type. /// The object which has the field. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " instead")] IPrivateField GetPrivateField(object obj, string name, bool required = true); /// Get a private static field. @@ -20,6 +64,7 @@ namespace StardewModdingAPI /// The type which has the field. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " instead")] IPrivateField GetPrivateField(Type type, string name, bool required = true); /// Get a private instance property. @@ -27,6 +72,7 @@ namespace StardewModdingAPI /// The object which has the property. /// The property name. /// Whether to throw an exception if the private property is not found. + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")] IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true); /// Get a private static property. @@ -34,6 +80,7 @@ namespace StardewModdingAPI /// The type which has the property. /// The property name. /// Whether to throw an exception if the private property is not found. + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")] IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true); /// Get the value of a private instance field. @@ -42,6 +89,7 @@ namespace StardewModdingAPI /// The field name. /// Whether to throw an exception if the private field is not found. /// This is a shortcut for followed by . + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " or " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")] TValue GetPrivateValue(object obj, string name, bool required = true); /// Get the value of a private static field. @@ -50,18 +98,21 @@ namespace StardewModdingAPI /// The field name. /// Whether to throw an exception if the private field is not found. /// This is a shortcut for followed by . + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " or " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")] TValue GetPrivateValue(Type type, string name, bool required = true); /// Get a private instance method. /// The object which has the method. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetMethod) + " instead")] IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true); /// Get a private static method. /// The type which has the method. /// The field name. /// Whether to throw an exception if the private field is not found. + [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetMethod) + " instead")] IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true); } } diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 3346f1ac..f285764c 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -50,7 +50,6 @@ namespace StardewModdingAPI.Metadata new EventFinder("StardewModdingAPI.Events.GameEvents", "Initialize", InstructionHandleResult.NotCompatible), new EventFinder("StardewModdingAPI.Events.GameEvents", "LoadContent", InstructionHandleResult.NotCompatible), new EventFinder("StardewModdingAPI.Events.GameEvents", "GameLoaded", InstructionHandleResult.NotCompatible), - new EventFinder("StardewModdingAPI.Events.GameEvents", "FirstUpdateTick", InstructionHandleResult.NotCompatible), new EventFinder("StardewModdingAPI.Events.PlayerEvents", "LoadedGame", InstructionHandleResult.NotCompatible), new EventFinder("StardewModdingAPI.Events.PlayerEvents", "FarmerChanged", InstructionHandleResult.NotCompatible), new EventFinder("StardewModdingAPI.Events.TimeEvents", "DayOfMonthChanged", InstructionHandleResult.NotCompatible), diff --git a/src/SMAPI/Mod.cs b/src/SMAPI/Mod.cs index ee75ba54..3a753afc 100644 --- a/src/SMAPI/Mod.cs +++ b/src/SMAPI/Mod.cs @@ -25,6 +25,9 @@ namespace StardewModdingAPI /// Provides simplified APIs for writing mods. public abstract void Entry(IModHelper helper); + /// Get an API that other mods can access. This is always called after . + public virtual object GetApi() => null; + /// Release or reset unmanaged resources. public void Dispose() { diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 3ba35e43..7eda9c66 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -247,7 +247,7 @@ namespace StardewModdingAPI this.IsDisposed = true; // dispose mod data - foreach (IModMetadata mod in this.ModRegistry.GetMods()) + foreach (IModMetadata mod in this.ModRegistry.GetAll()) { try { @@ -374,7 +374,7 @@ namespace StardewModdingAPI } // update window titles - int modsLoaded = this.ModRegistry.GetMods().Count(); + int modsLoaded = this.ModRegistry.GetAll().Count(); this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; @@ -390,7 +390,7 @@ namespace StardewModdingAPI LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetMods()) + foreach (IModMetadata mod in this.ModRegistry.GetAll()) (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); } @@ -500,12 +500,11 @@ namespace StardewModdingAPI { // create client WebApiClient client = new WebApiClient(this.Settings.WebApiBaseUrl, Constants.ApiVersion); + this.Monitor.Log("Checking for updates...", LogLevel.Trace); // check SMAPI version try { - this.Monitor.Log("Checking for SMAPI update...", LogLevel.Trace); - ModInfoModel response = client.GetModInfo($"GitHub:{this.Settings.GitHubProjectName}").Single().Value; if (response.Error != null) { @@ -515,7 +514,7 @@ namespace StardewModdingAPI else if (new SemanticVersion(response.Version).IsNewerThan(Constants.ApiVersion)) this.Monitor.Log($"You can update SMAPI to {response.Version}: {response.Url}", LogLevel.Alert); else - this.VerboseLog(" OK."); + this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); } catch (Exception ex) { @@ -527,94 +526,101 @@ namespace StardewModdingAPI } // check mod versions - try + if (mods.Any()) { - // log issues - if (this.Settings.VerboseLogging) + try { - this.VerboseLog("Validating mod update keys..."); - foreach (IModMetadata mod in mods) + // prepare update keys + Dictionary modsByKey = + ( + from mod in mods + where mod.Manifest?.UpdateKeys != null + from key in mod.Manifest.UpdateKeys + select new { key, mod } + ) + .GroupBy(p => p.key, StringComparer.InvariantCultureIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.Select(p => p.mod).ToArray(), + StringComparer.InvariantCultureIgnoreCase + ); + + // report update keys { - if (mod.Manifest == null) - this.VerboseLog($" {mod.DisplayName}: no manifest."); - else if (mod.Manifest.UpdateKeys == null || !mod.Manifest.UpdateKeys.Any()) - this.VerboseLog($" {mod.DisplayName}: no update keys."); + IModMetadata[] modsWithoutKeys = ( + from mod in mods + where + mod.Manifest != null + && (mod.Manifest.UpdateKeys == null || !mod.Manifest.UpdateKeys.Any()) + && (mod.Manifest?.UniqueID != "SMAPI.ConsoleCommands" && mod.Manifest?.UniqueID != "SMAPI.TrainerMod") + orderby mod.DisplayName + select mod + ).ToArray(); + + string message = $"Checking {modsByKey.Count} mod update keys."; + if (modsWithoutKeys.Any()) + message += $" {modsWithoutKeys.Length} mods have no update keys: {string.Join(", ", modsWithoutKeys.Select(p => p.DisplayName))}."; + this.Monitor.Log($" {message}", LogLevel.Trace); + } + + // fetch results + var results = + ( + from entry in client.GetModInfo(modsByKey.Keys.ToArray()) + from mod in modsByKey[entry.Key] + orderby mod.DisplayName + select new { entry.Key, Mod = mod, Info = entry.Value } + ) + .ToArray(); + + // extract latest versions + IDictionary updatesByMod = new Dictionary(); + foreach (var result in results) + { + IModMetadata mod = result.Mod; + ModInfoModel info = result.Info; + + // handle error + if (info.Error != null) + { + this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: {info.Error}", LogLevel.Trace); + continue; + } + + // track update + ISemanticVersion localVersion = mod.DataRecord != null + ? new SemanticVersion(mod.DataRecord.GetLocalVersionForUpdateChecks(mod.Manifest.Version.ToString())) + : mod.Manifest.Version; + ISemanticVersion latestVersion = new SemanticVersion(mod.DataRecord != null + ? mod.DataRecord.GetRemoteVersionForUpdateChecks(new SemanticVersion(info.Version).ToString()) + : info.Version + ); + bool isUpdate = latestVersion.IsNewerThan(localVersion); + this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {info.Version}{(!latestVersion.Equals(new SemanticVersion(info.Version)) ? $" [{latestVersion}]" : "")}" : "okay")}."); + if (isUpdate) + { + if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || latestVersion.IsNewerThan(other.Version)) + updatesByMod[mod] = info; + } + } + + // output + if (updatesByMod.Any()) + { + this.Monitor.Newline(); + this.Monitor.Log($"You can update {updatesByMod.Count} mod{(updatesByMod.Count != 1 ? "s" : "")}:", LogLevel.Alert); + foreach (var entry in updatesByMod.OrderBy(p => p.Key.DisplayName)) + this.Monitor.Log($" {entry.Key.DisplayName} {entry.Value.Version}: {entry.Value.Url}", LogLevel.Alert); } } - - // prepare update keys - Dictionary modsByKey = - ( - from mod in mods - where mod.Manifest?.UpdateKeys != null - from key in mod.Manifest.UpdateKeys - select new { key, mod } - ) - .GroupBy(p => p.key, StringComparer.InvariantCultureIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.Select(p => p.mod).ToArray(), - StringComparer.InvariantCultureIgnoreCase + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? ex.Message + : ex.ToString() ); - - // fetch results - this.Monitor.Log($"Checking for updates to {modsByKey.Keys.Count} keys...", LogLevel.Trace); - var results = - ( - from entry in client.GetModInfo(modsByKey.Keys.ToArray()) - from mod in modsByKey[entry.Key] - orderby mod.DisplayName - select new { entry.Key, Mod = mod, Info = entry.Value } - ) - .ToArray(); - - // extract latest versions - IDictionary updatesByMod = new Dictionary(); - foreach (var result in results) - { - IModMetadata mod = result.Mod; - ModInfoModel info = result.Info; - - // handle error - if (info.Error != null) - { - this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: {info.Error}", LogLevel.Trace); - continue; - } - - // track update - ISemanticVersion localVersion = mod.DataRecord != null - ? new SemanticVersion(mod.DataRecord.GetLocalVersionForUpdateChecks(mod.Manifest.Version.ToString())) - : mod.Manifest.Version; - ISemanticVersion latestVersion = new SemanticVersion(mod.DataRecord != null - ? mod.DataRecord.GetRemoteVersionForUpdateChecks(new SemanticVersion(info.Version).ToString()) - : info.Version - ); - bool isUpdate = latestVersion.IsNewerThan(localVersion); - this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {info.Version}{(!latestVersion.Equals(new SemanticVersion(info.Version)) ? $" [{latestVersion}]" : "")}" : "OK")}."); - if (isUpdate) - { - if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || latestVersion.IsNewerThan(other.Version)) - updatesByMod[mod] = info; - } } - - // output - if (updatesByMod.Any()) - { - this.Monitor.Newline(); - this.Monitor.Log($"You can update {updatesByMod.Count} mod{(updatesByMod.Count != 1 ? "s" : "")}:", LogLevel.Alert); - foreach (var entry in updatesByMod.OrderBy(p => p.Key.DisplayName)) - this.Monitor.Log($" {entry.Key.DisplayName} {entry.Value.Version}: {entry.Value.Url}", LogLevel.Alert); - } - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); - this.Monitor.Log(ex is WebException && ex.InnerException == null - ? ex.Message - : ex.ToString() - ); } }).Start(); } @@ -649,6 +655,7 @@ namespace StardewModdingAPI AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor, this.Settings.DeveloperMode); AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); + InterfaceProxyBuilder proxyBuilder = new InterfaceProxyBuilder(); foreach (IModMetadata metadata in mods) { // get basic info @@ -690,53 +697,30 @@ namespace StardewModdingAPI continue; } - // validate assembly - try - { - int modEntries = modAssembly.DefinedTypes.Count(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); - if (modEntries == 0) - { - TrackSkip(metadata, $"its DLL has no '{nameof(Mod)}' subclass."); - continue; - } - if (modEntries > 1) - { - TrackSkip(metadata, $"its DLL contains multiple '{nameof(Mod)}' subclasses."); - continue; - } - } - catch (Exception ex) - { - TrackSkip(metadata, $"its DLL couldn't be loaded:\n{ex.GetLogSummary()}"); - continue; - } - // initialise mod try { - // get implementation - TypeInfo modEntryType = modAssembly.DefinedTypes.First(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); - Mod mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); - if (mod == null) + // init mod helpers + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + IModHelper modHelper; { - TrackSkip(metadata, "its entry class couldn't be instantiated."); - continue; - } - - // inject data - { - IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager); IContentHelper contentHelper = new ContentHelper(contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); - IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection); - IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyBuilder, monitor); ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); - - mod.ModManifest = manifest; - mod.Helper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); - mod.Monitor = monitor; + modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); } + // get mod instance + if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod)) + continue; + + // init mod + mod.ModManifest = manifest; + mod.Helper = modHelper; + mod.Monitor = monitor; + // track mod metadata.SetMod(mod); this.ModRegistry.Add(metadata); @@ -747,7 +731,7 @@ namespace StardewModdingAPI } } } - IModMetadata[] loadedMods = this.ModRegistry.GetMods().ToArray(); + IModMetadata[] loadedMods = this.ModRegistry.GetAll().ToArray(); // log skipped mods this.Monitor.Newline(); @@ -811,6 +795,19 @@ namespace StardewModdingAPI { this.Monitor.Log($"{metadata.DisplayName} failed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); } + + // get mod API + try + { + object api = metadata.Mod.GetApi(); + if (api != null) + this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); + metadata.SetApi(api); + } + catch (Exception ex) + { + this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error); + } } // invalidate cache entries when needed @@ -846,13 +843,48 @@ namespace StardewModdingAPI this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); this.ContentManager.InvalidateCacheFor(editors, loaders); } + + // unlock mod integrations + this.ModRegistry.AreAllModsInitialised = true; + } + + /// Load a mod's entry class. + /// The mod assembly. + /// A callback invoked when loading fails. + /// The loaded instance. + private bool TryLoadModEntry(Assembly modAssembly, Action onError, out Mod mod) + { + mod = null; + + // find type + TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray(); + if (modEntries.Length == 0) + { + onError($"its DLL has no '{nameof(Mod)}' subclass."); + return false; + } + if (modEntries.Length > 1) + { + onError($"its DLL contains multiple '{nameof(Mod)}' subclasses."); + return false; + } + + // get implementation + mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString()); + if (mod == null) + { + onError("its entry class couldn't be instantiated."); + return false; + } + + return true; } /// Reload translations for all mods. private void ReloadTranslations() { JsonHelper jsonHelper = new JsonHelper(); - foreach (IModMetadata metadata in this.ModRegistry.GetMods()) + foreach (IModMetadata metadata in this.ModRegistry.GetAll()) { // read translation files IDictionary> translations = new Dictionary>(); diff --git a/src/SMAPI/StardewModdingAPI.config.json b/src/SMAPI/StardewModdingAPI.config.json index 6718806e..18a9f978 100644 --- a/src/SMAPI/StardewModdingAPI.config.json +++ b/src/SMAPI/StardewModdingAPI.config.json @@ -1890,6 +1890,16 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "ID": "BlueMod_TractorMod | PhthaloBlue.TractorMod | community.TractorMod | Pathoschild.TractorMod", // changed in 3.2, 4.0 beta, and 4.0 "UpdateKeys": [ "Nexus:1401" ] }, + { + // TrainerMod + "ID": "SMAPI.TrainerMod", + "Compatibility": { + "~": { + "Status": "Obsolete", + "ReasonPhrase": "replaced by ConsoleCommands, which is added by the SMAPI installer." + } + } + }, { // Tree Transplant "ID": "TreeTransplant", diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index 380ed733..f76ac439 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -79,10 +79,6 @@ True - - - - @@ -110,7 +106,11 @@ + + + + @@ -169,7 +169,7 @@ - + @@ -198,8 +198,8 @@ - - + +