diff options
Diffstat (limited to 'src')
30 files changed, 968 insertions, 91 deletions
diff --git a/src/StardewModdingAPI.Models/ModInfoModel.cs b/src/StardewModdingAPI.Models/ModInfoModel.cs new file mode 100644 index 00000000..44071230 --- /dev/null +++ b/src/StardewModdingAPI.Models/ModInfoModel.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Models +{ + /// <summary>Generic metadata about a mod.</summary> + internal class ModInfoModel + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; } + + /// <summary>The mod's semantic version number.</summary> + public string Version { get; } + + /// <summary>The mod's web URL.</summary> + public string Url { get; } + + /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> + public string Error { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct a valid instance.</summary> + /// <param name="name">The mod name.</param> + /// <param name="version">The mod's semantic version number.</param> + /// <param name="url">The mod's web URL.</param> + /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> + [JsonConstructor] + public ModInfoModel(string name, string version, string url, string error = null) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Error = error; // mainly initialised here for the JSON deserialiser + } + + /// <summary>Construct an valid instance.</summary> + /// <param name="error">The error message indicating why the mod is invalid.</param> + public ModInfoModel(string error) + { + this.Error = error; + } + } +} diff --git a/src/StardewModdingAPI.Models/StardewModdingAPI.Models.projitems b/src/StardewModdingAPI.Models/StardewModdingAPI.Models.projitems new file mode 100644 index 00000000..2465760e --- /dev/null +++ b/src/StardewModdingAPI.Models/StardewModdingAPI.Models.projitems @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup> + <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects> + <HasSharedItems>true</HasSharedItems> + <SharedGUID>2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc</SharedGUID> + </PropertyGroup> + <PropertyGroup Label="Configuration"> + <Import_RootNamespace>StardewModdingAPI.Models</Import_RootNamespace> + </PropertyGroup> + <ItemGroup> + <Compile Include="$(MSBuildThisFileDirectory)ModInfoModel.cs" /> + </ItemGroup> +</Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI.Models/StardewModdingAPI.Models.shproj b/src/StardewModdingAPI.Models/StardewModdingAPI.Models.shproj new file mode 100644 index 00000000..c80517af --- /dev/null +++ b/src/StardewModdingAPI.Models/StardewModdingAPI.Models.shproj @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup Label="Globals"> + <ProjectGuid>2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc</ProjectGuid> + <MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion> + </PropertyGroup> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" /> + <PropertyGroup /> + <Import Project="StardewModdingAPI.Models.projitems" Label="Shared" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" /> +</Project> diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index f3dbcdd4..41525bcb 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -39,8 +39,8 @@ <Reference Include="Newtonsoft.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> <HintPath>..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll</HintPath> </Reference> - <Reference Include="nunit.framework, Version=3.7.1.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.7.1\lib\net45\nunit.framework.dll</HintPath> + <Reference Include="nunit.framework, Version=3.8.1.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> + <HintPath>..\packages\NUnit.3.8.1\lib\net45\nunit.framework.dll</HintPath> </Reference> <Reference Include="System" /> </ItemGroup> diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config index 6f04e625..5fdfebdb 100644 --- a/src/StardewModdingAPI.Tests/packages.config +++ b/src/StardewModdingAPI.Tests/packages.config @@ -3,5 +3,5 @@ <package id="Castle.Core" version="4.1.1" targetFramework="net45" /> <package id="Moq" version="4.7.99" targetFramework="net45" /> <package id="Newtonsoft.Json" version="8.0.3" targetFramework="net45" /> - <package id="NUnit" version="3.7.1" targetFramework="net45" /> -</packages>
\ No newline at end of file + <package id="NUnit" version="3.8.1" targetFramework="net45" /> +</packages> diff --git a/src/StardewModdingAPI.Web/Controllers/ModsController.cs b/src/StardewModdingAPI.Web/Controllers/ModsController.cs new file mode 100644 index 00000000..8fc2cb51 --- /dev/null +++ b/src/StardewModdingAPI.Web/Controllers/ModsController.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Models; + +namespace StardewModdingAPI.Web.Controllers +{ + /// <summary>Provides an API to perform mod update checks.</summary> + [Produces("application/json")] + internal class ModsController : Controller + { + /********* + ** Properties + *********/ + /// <summary>The mod repositories which provide mod metadata.</summary> + private readonly IDictionary<string, IModRepository> Repositories; + + /// <summary>The cache in which to store mod metadata.</summary> + private readonly IMemoryCache Cache; + + /// <summary>The number of minutes update checks should be cached before refetching them.</summary> + private readonly int CacheMinutes; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="cache">The cache in which to store mod metadata.</param> + /// <param name="configProvider">The config settings for mod update checks.</param> + public ModsController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider) + { + ModUpdateCheckConfig config = configProvider.Value; + + this.Cache = cache; + this.CacheMinutes = config.CacheMinutes; + + this.Repositories = + new IModRepository[] + { + new GitHubRepository( + vendorKey: config.GitHubKey, + baseUrl: config.GitHubBaseUrl, + releaseUrlFormat: config.GitHubReleaseUrlFormat, + userAgent: config.GitHubUserAgent, + acceptHeader: config.GitHubAcceptHeader, + username: config.GitHubUsername, + password: config.GitHubPassword + ), + new NexusRepository( + vendorKey: config.NexusKey, + userAgent: config.NexusUserAgent, + baseUrl: config.NexusBaseUrl, + modUrlFormat: config.NexusModUrlFormat + ) + } + .ToDictionary(p => p.VendorKey, StringComparer.CurrentCultureIgnoreCase); + } + + /// <summary>Fetch version metadata for the given mods.</summary> + /// <param name="modKeys">The namespaced mod keys to search as a comma-delimited array.</param> + [HttpGet] + public async Task<IDictionary<string, ModInfoModel>> GetAsync(string modKeys) + { + // sort & filter keys + string[] modKeysArray = (modKeys?.Split(',').Select(p => p.Trim()).ToArray() ?? new string[0]) + .Distinct(StringComparer.CurrentCultureIgnoreCase) + .OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + + // fetch mod info + IDictionary<string, ModInfoModel> result = new Dictionary<string, ModInfoModel>(StringComparer.CurrentCultureIgnoreCase); + foreach (string modKey in modKeysArray) + { + // parse mod key + if (!this.TryParseModKey(modKey, out string vendorKey, out string modID)) + { + result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the mod repository key and mod ID like 'Nexus:541'."); + continue; + } + + // get matching repository + if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository)) + { + result[modKey] = new ModInfoModel("There's no mod repository matching this namespaced mod ID."); + continue; + } + + // fetch mod info + result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.CacheMinutes); + return await repository.GetModInfoAsync(modID); + }); + } + + return result; + } + + + /********* + ** Private methods + *********/ + /// <summary>Parse a namespaced mod ID.</summary> + /// <param name="raw">The raw mod ID to parse.</param> + /// <param name="vendorKey">The parsed vendor key.</param> + /// <param name="modID">The parsed mod ID.</param> + /// <returns>Returns whether the value could be parsed.</returns> + private bool TryParseModKey(string raw, out string vendorKey, out string modID) + { + // split parts + string[] parts = raw?.Split(':'); + if (parts == null || parts.Length != 2) + { + vendorKey = null; + modID = null; + return false; + } + + // parse + vendorKey = parts[0]; + modID = parts[1]; + return true; + } + } +} diff --git a/src/StardewModdingAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/StardewModdingAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs new file mode 100644 index 00000000..5d55ba18 --- /dev/null +++ b/src/StardewModdingAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -0,0 +1,54 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for mod update checks.</summary> + public class ModUpdateCheckConfig + { + /********* + ** Accessors + *********/ + /**** + ** General + ****/ + /// <summary>The number of minutes update checks should be cached before refetching them.</summary> + public int CacheMinutes { get; set; } + + /**** + ** GitHub + ****/ + /// <summary>The repository key for Nexus Mods.</summary> + public string GitHubKey { get; set; } + + /// <summary>The user agent for the GitHub API client.</summary> + public string GitHubUserAgent { get; set; } + + /// <summary>The base URL for the GitHub API.</summary> + public string GitHubBaseUrl { get; set; } + + /// <summary>The URL for a GitHub API latest-release query excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary> + public string GitHubReleaseUrlFormat { get; set; } + + /// <summary>The Accept header value expected by the GitHub API.</summary> + public string GitHubAcceptHeader { get; set; } + + /// <summary>The username with which to authenticate to the GitHub API (if any).</summary> + public string GitHubUsername { get; set; } + + /// <summary>The password with which to authenticate to the GitHub API (if any).</summary> + public string GitHubPassword { get; set; } + + /**** + ** Nexus Mods + ****/ + /// <summary>The repository key for Nexus Mods.</summary> + public string NexusKey { get; set; } + + /// <summary>The user agent for the Nexus Mods API client.</summary> + public string NexusUserAgent { get; set; } + + /// <summary>The base URL for the Nexus Mods API.</summary> + public string NexusBaseUrl { get; set; } + + /// <summary>The URL for a Nexus Mods API query excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> + public string NexusModUrlFormat { get; set; } + } +} diff --git a/src/StardewModdingAPI.Web/Framework/InternalControllerFeatureProvider.cs b/src/StardewModdingAPI.Web/Framework/InternalControllerFeatureProvider.cs new file mode 100644 index 00000000..2c24c610 --- /dev/null +++ b/src/StardewModdingAPI.Web/Framework/InternalControllerFeatureProvider.cs @@ -0,0 +1,27 @@ +using System; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Discovers controllers with support for non-public controllers.</summary> + internal class InternalControllerFeatureProvider : ControllerFeatureProvider + { + /********* + ** Public methods + *********/ + /// <summary>Determines if a given type is a controller.</summary> + /// <param name="type">The <see cref="T:System.Reflection.TypeInfo" /> candidate.</param> + /// <returns><code>true</code> if the type is a controller; otherwise <code>false</code>.</returns> + protected override bool IsController(TypeInfo type) + { + return + type.IsClass + && !type.IsAbstract + && (/*type.IsPublic &&*/ !type.ContainsGenericParameters) + && (!type.IsDefined(typeof(NonControllerAttribute)) + && (type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) || type.IsDefined(typeof(ControllerAttribute)))); + } + } +} diff --git a/src/StardewModdingAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/StardewModdingAPI.Web/Framework/ModRepositories/GitHubRepository.cs new file mode 100644 index 00000000..421220de --- /dev/null +++ b/src/StardewModdingAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Pathoschild.Http.Client; +using StardewModdingAPI.Models; + +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>An HTTP client for fetching mod metadata from GitHub project releases.</summary> + internal class GitHubRepository : IModRepository + { + /********* + ** Properties + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + + /********* + ** Accessors + *********/ + /// <summary>The unique key for this vendor.</summary> + public string VendorKey { get; } + + /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary> + public string ReleaseUrlFormat { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="vendorKey">The unique key for this vendor.</param> + /// <param name="baseUrl">The base URL for the Nexus Mods API.</param> + /// <param name="releaseUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> + /// <param name="userAgent">The user agent for the GitHub API client.</param> + /// <param name="acceptHeader">The Accept header value expected by the GitHub API.</param> + /// <param name="username">The username with which to authenticate to the GitHub API.</param> + /// <param name="password">The password with which to authenticate to the GitHub API.</param> + public GitHubRepository(string vendorKey, string baseUrl, string releaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + { + this.VendorKey = vendorKey; + this.ReleaseUrlFormat = releaseUrlFormat; + + this.Client = new FluentClient(baseUrl) + .SetUserAgent(string.Format(userAgent, this.GetType().Assembly.GetName().Version)) + .AddDefault(req => req.WithHeader("Accept", acceptHeader)); + if (!string.IsNullOrWhiteSpace(username)) + this.Client = this.Client.SetBasicAuthentication(username, password); + } + + /// <summary>Get metadata about a mod in the repository.</summary> + /// <param name="id">The mod ID in this repository.</param> + public async Task<ModInfoModel> GetModInfoAsync(string id) + { + try + { + GitRelease release = await this.Client + .GetAsync(string.Format(this.ReleaseUrlFormat, id)) + .As<GitRelease>(); + + return new ModInfoModel(id, release.Tag, $"https://github.com/{id}/releases"); + } + catch (Exception ex) + { + return new ModInfoModel(ex.ToString()); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private models + *********/ + /// <summary>Metadata about a GitHub release tag.</summary> + private class GitRelease + { + /********* + ** Accessors + *********/ + /// <summary>The display name.</summary> + [JsonProperty("name")] + public string Name { get; set; } + + /// <summary>The semantic version string.</summary> + [JsonProperty("tag_name")] + public string Tag { get; set; } + } + } +} diff --git a/src/StardewModdingAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/StardewModdingAPI.Web/Framework/ModRepositories/IModRepository.cs new file mode 100644 index 00000000..98e4c957 --- /dev/null +++ b/src/StardewModdingAPI.Web/Framework/ModRepositories/IModRepository.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Models; + +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>A repository which provides mod metadata.</summary> + internal interface IModRepository : IDisposable + { + /********* + ** Accessors + *********/ + /// <summary>The unique key for this vendor.</summary> + string VendorKey { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get metadata about a mod in the repository.</summary> + /// <param name="id">The mod ID in this repository.</param> + Task<ModInfoModel> GetModInfoAsync(string id); + } +} diff --git a/src/StardewModdingAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/StardewModdingAPI.Web/Framework/ModRepositories/NexusRepository.cs new file mode 100644 index 00000000..6cf5b04a --- /dev/null +++ b/src/StardewModdingAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Pathoschild.Http.Client; +using StardewModdingAPI.Models; + +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary> + internal class NexusRepository : IModRepository + { + /********* + ** Properties + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + + /********* + ** Accessors + *********/ + /// <summary>The unique key for this vendor.</summary> + public string VendorKey { get; } + + /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary> + public string ModUrlFormat { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="vendorKey">The unique key for this vendor.</param> + /// <param name="userAgent">The user agent for the Nexus Mods API client.</param> + /// <param name="baseUrl">The base URL for the Nexus Mods API.</param> + /// <param name="modUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> + public NexusRepository(string vendorKey, string userAgent, string baseUrl, string modUrlFormat) + { + this.VendorKey = vendorKey; + this.ModUrlFormat = modUrlFormat; + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// <summary>Get metadata about a mod in the repository.</summary> + /// <param name="id">The mod ID in this repository.</param> + public async Task<ModInfoModel> GetModInfoAsync(string id) + { + try + { + NexusResponseModel response = await this.Client + .GetAsync(string.Format(this.ModUrlFormat, id)) + .As<NexusResponseModel>(); + + return response != null + ? new ModInfoModel(response.Name, response.Version, response.Url) + : new ModInfoModel("Found no mod with this ID."); + } + catch (Exception ex) + { + return new ModInfoModel(ex.ToString()); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client.Dispose(); + } + + + /********* + ** Private models + *********/ + /// <summary>A mod metadata response from Nexus Mods.</summary> + private class NexusResponseModel + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The mod's semantic version number.</summary> + public string Version { get; set; } + + /// <summary>The mod's web URL.</summary> + [JsonProperty("mod_page_uri")] + public string Url { get; set; } + } + } +} diff --git a/src/StardewModdingAPI.Web/Framework/RewriteSubdomainRule.cs b/src/StardewModdingAPI.Web/Framework/RewriteSubdomainRule.cs new file mode 100644 index 00000000..5a56844f --- /dev/null +++ b/src/StardewModdingAPI.Web/Framework/RewriteSubdomainRule.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Rewrite requests to prepend the subdomain portion (if any) to the path.</summary> + /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" />.</remarks> + internal class RewriteSubdomainRule : IRule + { + /// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary> + /// <param name="context">The rewrite context.</param> + public void ApplyRule(RewriteContext context) + { + context.Result = RuleResult.ContinueRules; + + // get host parts + string host = context.HttpContext.Request.Host.Host; + string[] parts = host.Split('.'); + + // validate + if (parts.Length < 2) + return; + if (parts.Length < 3 && !"localhost".Equals(parts[1], StringComparison.InvariantCultureIgnoreCase)) + return; + + // prepend to path + context.HttpContext.Request.Path = $"/{parts[0]}{context.HttpContext.Request.Path}"; + } + } +} diff --git a/src/StardewModdingAPI.Web/Program.cs b/src/StardewModdingAPI.Web/Program.cs new file mode 100644 index 00000000..eeecb791 --- /dev/null +++ b/src/StardewModdingAPI.Web/Program.cs @@ -0,0 +1,26 @@ +using System.IO; +using Microsoft.AspNetCore.Hosting; + +namespace StardewModdingAPI.Web +{ + /// <summary>The main app entry point.</summary> + public class Program + { + /********* + ** Public methods + *********/ + /// <summary>The main app entry point.</summary> + /// <param name="args">The command-line arguments.</param> + public static void Main(string[] args) + { + // configure web server + new WebHostBuilder() + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .UseStartup<Startup>() + .Build() + .Run(); + } + } +} diff --git a/src/StardewModdingAPI.Web/Properties/launchSettings.json b/src/StardewModdingAPI.Web/Properties/launchSettings.json new file mode 100644 index 00000000..3acee14d --- /dev/null +++ b/src/StardewModdingAPI.Web/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:59482/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/v1.0/mods", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Dewdrop": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/v1.0/mods", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:59483" + } + } +} diff --git a/src/StardewModdingAPI.Web/StardewModdingAPI.Web.csproj b/src/StardewModdingAPI.Web/StardewModdingAPI.Web.csproj new file mode 100644 index 00000000..c30abc55 --- /dev/null +++ b/src/StardewModdingAPI.Web/StardewModdingAPI.Web.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>netcoreapp2.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore" Version="2.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.0.0" /> + <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.0.0" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.1.0" /> + </ItemGroup> + <ItemGroup> + <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> + </ItemGroup> + <Import Project="..\StardewModdingAPI.Models\StardewModdingAPI.Models.projitems" Label="Shared" /> + +</Project> diff --git a/src/StardewModdingAPI.Web/Startup.cs b/src/StardewModdingAPI.Web/Startup.cs new file mode 100644 index 00000000..d5b828b7 --- /dev/null +++ b/src/StardewModdingAPI.Web/Startup.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.ConfigModels; + +namespace StardewModdingAPI.Web +{ + /// <summary>The web app startup configuration.</summary> + internal class Startup + { + /********* + ** Accessors + *********/ + /// <summary>The web app configuration.</summary> + public IConfigurationRoot Configuration { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="env">The hosting environment.</param> + public Startup(IHostingEnvironment env) + { + this.Configuration = new ConfigurationBuilder() + .SetBasePath(env.ContentRootPath) + .AddEnvironmentVariables() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables() + .Build(); + } + + /// <summary>The method called by the runtime to add services to the container.</summary> + /// <param name="services">The service injection container.</param> + public void ConfigureServices(IServiceCollection services) + { + services + .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) + .AddMemoryCache() + .AddMvc() + .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())) + .AddJsonOptions(options => + { + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + }); + } + + /// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary> + /// <param name="app">The application builder.</param> + /// <param name="env">The hosting environment.</param> + /// <param name="loggerFactory">The logger factory.</param> + public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + { + loggerFactory.AddConsole(this.Configuration.GetSection("Logging")); + loggerFactory.AddDebug(); + app + .UseRewriter(new RewriteOptions().Add(new RewriteSubdomainRule())) // convert subdomain.smapi.io => smapi.io/subdomain for routing + .UseMvc(route => + { + route.MapRoute( + name: "API", + template: "api/{version}/{controller}/{action?}", + defaults: new + { + action = "GetAsync" + }, + constraints: new + { + // version regex from SMAPI's SemanticVersion implementation + version = @"^v(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\-\.]?)+))?$" + } + ); + }); + } + } +} diff --git a/src/StardewModdingAPI.Web/appsettings.Development.json b/src/StardewModdingAPI.Web/appsettings.Development.json new file mode 100644 index 00000000..fa8ce71a --- /dev/null +++ b/src/StardewModdingAPI.Web/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/StardewModdingAPI.Web/appsettings.json b/src/StardewModdingAPI.Web/appsettings.json new file mode 100644 index 00000000..29fb195e --- /dev/null +++ b/src/StardewModdingAPI.Web/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Warning" + } + }, + "ModUpdateCheck": { + "CacheMinutes": 60, + + "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, /* set via environment properties */ + "GitHubPassword": null, /* set via environment properties */ + + "NexusKey": "Nexus", + "NexusUserAgent": "Nexus Client v0.63.15", + "NexusBaseUrl": "http://www.nexusmods.com/stardewvalley", + "NexusModUrlFormat": "mods/{0}" + } +} diff --git a/src/StardewModdingAPI.sln b/src/StardewModdingAPI.sln index a2e0ec44..9d7baa51 100644 --- a/src/StardewModdingAPI.sln +++ b/src/StardewModdingAPI.sln @@ -1,7 +1,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 +VisualStudioVersion = 15.0.26730.16 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainerMod", "TrainerMod\TrainerMod.csproj", "{28480467-1A48-46A7-99F8-236D95225359}" EndProject @@ -29,7 +29,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Installer EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Tests", "StardewModdingAPI.Tests\StardewModdingAPI.Tests.csproj", "{36CCB19E-92EB-48C7-9615-98EEFD45109B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Web", "StardewModdingAPI.Web\StardewModdingAPI.Web.csproj", "{A308F679-51A3-4006-92D5-BAEC7EBD01A1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Internal", "Internal", "{82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}" +EndProject +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "StardewModdingAPI.Models", "StardewModdingAPI.Models\StardewModdingAPI.Models.shproj", "{2AA02FB6-FF03-41CF-A215-2EE60AB4F5DC}" +EndProject Global + GlobalSection(SharedMSBuildProjectFiles) = preSolution + StardewModdingAPI.Models\StardewModdingAPI.Models.projitems*{2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc}*SharedItemsImports = 13 + StardewModdingAPI.Models\StardewModdingAPI.Models.projitems*{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}*SharedItemsImports = 4 + EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|Mixed Platforms = Debug|Mixed Platforms @@ -79,8 +89,27 @@ Global {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.Build.0 = Release|x86 {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.ActiveCfg = Release|x86 {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.Build.0 = Release|x86 + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|x86.Build.0 = Debug|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Any CPU.Build.0 = Release|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|x86.ActiveCfg = Release|Any CPU + {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {36CCB19E-92EB-48C7-9615-98EEFD45109B} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {2AA02FB6-FF03-41CF-A215-2EE60AB4F5DC} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC} + EndGlobalSection EndGlobal diff --git a/src/StardewModdingAPI/Events/GameEvents.cs b/src/StardewModdingAPI/Events/GameEvents.cs index 5610e67a..deb71a86 100644 --- a/src/StardewModdingAPI/Events/GameEvents.cs +++ b/src/StardewModdingAPI/Events/GameEvents.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Framework; @@ -39,9 +39,6 @@ namespace StardewModdingAPI.Events /// <summary>Raised during launch after configuring XNA or MonoGame. The game window hasn't been opened by this point. Called after <see cref="Microsoft.Xna.Framework.Game.Initialize"/>.</summary> internal static event EventHandler InitializeInternal; - /// <summary>Raised during launch after configuring Stardew Valley, loading it into memory, and opening the game window. The window is still blank by this point.</summary> - internal static event EventHandler GameLoadedInternal; - #if SMAPI_1_x /// <summary>Raised during launch after configuring XNA or MonoGame. The game window hasn't been opened by this point. Called after <see cref="Microsoft.Xna.Framework.Game.Initialize"/>.</summary> [Obsolete("The " + nameof(Mod) + "." + nameof(Mod.Entry) + " method is now called after the " + nameof(GameEvents.Initialize) + " event, so any contained logic can be done directly in " + nameof(Mod.Entry) + ".")] @@ -143,19 +140,14 @@ namespace StardewModdingAPI.Events { monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.LoadContent)}", GameEvents._LoadContent?.GetInvocationList()); } -#endif /// <summary>Raise a <see cref="GameLoadedInternal"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> internal static void InvokeGameLoaded(IMonitor monitor) { - monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoadedInternal)}", GameEvents.GameLoadedInternal?.GetInvocationList()); -#if SMAPI_1_x monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoaded)}", GameEvents._GameLoaded?.GetInvocationList()); -#endif } -#if SMAPI_1_x /// <summary>Raise a <see cref="FirstUpdateTick"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> internal static void InvokeFirstUpdateTick(IMonitor monitor) diff --git a/src/StardewModdingAPI/Framework/Models/GitRelease.cs b/src/StardewModdingAPI/Framework/Models/GitRelease.cs deleted file mode 100644 index bc53468f..00000000 --- a/src/StardewModdingAPI/Framework/Models/GitRelease.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Newtonsoft.Json; - -namespace StardewModdingAPI.Framework.Models -{ - /// <summary>Metadata about a GitHub release tag.</summary> - internal class GitRelease - { - /********* - ** Accessors - *********/ - /// <summary>The display name.</summary> - [JsonProperty("name")] - public string Name { get; set; } - - /// <summary>The semantic version string.</summary> - [JsonProperty("tag_name")] - public string Tag { get; set; } - } -}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs index 29c3517e..f97cb8ff 100644 --- a/src/StardewModdingAPI/Framework/Models/Manifest.cs +++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Newtonsoft.Json; using StardewModdingAPI.Framework.Serialisation; @@ -35,6 +35,14 @@ namespace StardewModdingAPI.Framework.Models [JsonConverter(typeof(SFieldConverter))] public IManifestDependency[] Dependencies { get; set; } +#if !SMAPI_1_x + /// <summary>The mod's unique ID in Nexus Mods (if any), used for update checks.</summary> + public string NexusID { get; set; } + + /// <summary>The mod's organisation and project name on GitHub (if any), used for update checks.</summary> + public string GitHubProject { get; set; } +#endif + /// <summary>The unique mod ID.</summary> public string UniqueID { get; set; } diff --git a/src/StardewModdingAPI/Framework/Models/SConfig.cs b/src/StardewModdingAPI/Framework/Models/SConfig.cs index b2ca4113..36799400 100644 --- a/src/StardewModdingAPI/Framework/Models/SConfig.cs +++ b/src/StardewModdingAPI/Framework/Models/SConfig.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework.Models +namespace StardewModdingAPI.Framework.Models { /// <summary>The SMAPI configuration settings.</summary> internal class SConfig @@ -9,11 +9,17 @@ /// <summary>Whether to enable development features.</summary> public bool DeveloperMode { get; set; } - /// <summary>Whether to check if a newer version of SMAPI is available on startup.</summary> - public bool CheckForUpdates { get; set; } = true; + /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> + public bool CheckForUpdates { get; set; } + + /// <summary>SMAPI's GitHub project name, used to perform update checks.</summary> + public string GitHubProjectName { get; set; } + + /// <summary>The base URL for SMAPI's web API, used to perform update checks.</summary> + public string WebApiBaseUrl { get; set; } /// <summary>Whether SMAPI should log more information about the game context.</summary> - public bool VerboseLogging { get; set; } = false; + public bool VerboseLogging { get; set; } /// <summary>A list of mod versions which should be considered compatible or incompatible regardless of whether SMAPI detects incompatible code.</summary> public ModCompatibility[] ModCompatibility { get; set; } diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs index 76c106d7..387aeacc 100644 --- a/src/StardewModdingAPI/Framework/SGame.cs +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -297,8 +297,8 @@ namespace StardewModdingAPI.Framework GameEvents.InvokeInitialize(this.Monitor); #if SMAPI_1_x GameEvents.InvokeLoadContent(this.Monitor); -#endif GameEvents.InvokeGameLoaded(this.Monitor); +#endif } /********* diff --git a/src/StardewModdingAPI/Framework/UpdateHelper.cs b/src/StardewModdingAPI/Framework/UpdateHelper.cs deleted file mode 100644 index e01e55c8..00000000 --- a/src/StardewModdingAPI/Framework/UpdateHelper.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.IO; -using System.Net; -using System.Reflection; -using System.Threading.Tasks; -using Newtonsoft.Json; -using StardewModdingAPI.Framework.Models; - -namespace StardewModdingAPI.Framework -{ - /// <summary>Provides utility methods for mod updates.</summary> - internal class UpdateHelper - { - /********* - ** Public methods - *********/ - /// <summary>Get the latest release from a GitHub repository.</summary> - /// <param name="repository">The name of the repository from which to fetch releases (like "cjsu/SMAPI").</param> - public static async Task<GitRelease> GetLatestVersionAsync(string repository) - { - // build request - // (avoid HttpClient for Mac compatibility) - HttpWebRequest request = WebRequest.CreateHttp($"https://api.github.com/repos/{repository}/releases/latest"); - AssemblyName assembly = typeof(UpdateHelper).Assembly.GetName(); - request.UserAgent = $"{assembly.Name}/{assembly.Version}"; - request.Accept = "application/vnd.github.v3+json"; - - // fetch data - using (WebResponse response = await request.GetResponseAsync()) - using (Stream responseStream = response.GetResponseStream()) - using (StreamReader reader = new StreamReader(responseStream)) - { - string responseText = reader.ReadToEnd(); - return JsonConvert.DeserializeObject<GitRelease>(responseText); - } - } - } -} diff --git a/src/StardewModdingAPI/Framework/WebApiClient.cs b/src/StardewModdingAPI/Framework/WebApiClient.cs new file mode 100644 index 00000000..0ee57648 --- /dev/null +++ b/src/StardewModdingAPI/Framework/WebApiClient.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using Newtonsoft.Json; +using StardewModdingAPI.Models; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides methods for interacting with the SMAPI web API.</summary> + internal class WebApiClient + { + /********* + ** Properties + *********/ + /// <summary>The base URL for the web API.</summary> + private readonly Uri BaseUrl; + + /// <summary>The API version number.</summary> + private readonly ISemanticVersion Version; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="baseUrl">The base URL for the web API.</param> + /// <param name="version">The web API version.</param> + public WebApiClient(string baseUrl, ISemanticVersion version) + { +#if !SMAPI_FOR_WINDOWS + baseUrl = baseUrl.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac +#endif + this.BaseUrl = new Uri(baseUrl); + this.Version = version; + } + + /// <summary>Get the latest SMAPI version.</summary> + /// <param name="modKeys">The mod keys for which to fetch the latest version.</param> + public async Task<IDictionary<string, ModInfoModel>> GetModInfoAsync(params string[] modKeys) + { + string url = $"v{this.Version}/mods?modKeys={Uri.EscapeDataString(string.Join(",", modKeys))}"; + return await this.GetAsync<Dictionary<string, ModInfoModel>>(url); + } + + + /********* + ** Private methods + *********/ + /// <summary>Fetch the response from the backend API.</summary> + /// <typeparam name="T">The expected response type.</typeparam> + /// <param name="url">The request URL, optionally excluding the base URL.</param> + private async Task<T> GetAsync<T>(string url) + { + // build request (avoid HttpClient for Mac compatibility) + HttpWebRequest request = WebRequest.CreateHttp(new Uri(this.BaseUrl, url).ToString()); + request.UserAgent = $"SMAPI/{this.Version}"; + + // fetch data + using (WebResponse response = await request.GetResponseAsync()) + using (Stream responseStream = response.GetResponseStream()) + using (StreamReader reader = new StreamReader(responseStream)) + { + string responseText = reader.ReadToEnd(); + return JsonConvert.DeserializeObject<T>(responseText); + } + } + } +} diff --git a/src/StardewModdingAPI/IManifest.cs b/src/StardewModdingAPI/IManifest.cs index 407db1ce..28f6570c 100644 --- a/src/StardewModdingAPI/IManifest.cs +++ b/src/StardewModdingAPI/IManifest.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace StardewModdingAPI { @@ -32,7 +32,13 @@ namespace StardewModdingAPI /// <summary>The other mods that must be loaded before this mod.</summary> IManifestDependency[] Dependencies { get; } + /// <summary>The mod's unique ID in Nexus Mods (if any), used for update checks.</summary> + string NexusID { get; set; } + + /// <summary>The mod's organisation and project name on GitHub (if any), used for update checks.</summary> + string GitHubProject { get; set; } + /// <summary>Any manifest fields which didn't match a valid field.</summary> IDictionary<string, object> ExtraFields { get; } } -}
\ No newline at end of file +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 84af2777..cee3aefd 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -21,6 +21,7 @@ using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Models; using StardewValley; using Monitor = StardewModdingAPI.Framework.Monitor; using SObject = StardewValley.Object; @@ -187,7 +188,6 @@ namespace StardewModdingAPI #endif this.GameInstance.Exiting += (sender, e) => this.Dispose(); GameEvents.InitializeInternal += (sender, e) => this.InitialiseAfterGameStart(); - GameEvents.GameLoadedInternal += (sender, e) => this.CheckForUpdateAsync(); ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); // set window titles @@ -436,6 +436,9 @@ namespace StardewModdingAPI #else this.LoadMods(mods, new JsonHelper(), this.ContentManager); #endif + + // check for updates + this.CheckForUpdatesAsync(mods); } if (this.Monitor.IsExiting) { @@ -562,25 +565,113 @@ namespace StardewModdingAPI return !issuesFound; } - /// <summary>Asynchronously check for a new version of SMAPI, and print a message to the console if an update is available.</summary> - private void CheckForUpdateAsync() + /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary> + /// <param name="mods">The mods to include in the update check (if eligible).</param> + private void CheckForUpdatesAsync(IModMetadata[] mods) { if (!this.Settings.CheckForUpdates) return; new Thread(() => { + // update info + List<string> updates = new List<string>(); + bool smapiUpdate = false; + int modUpdates = 0; + + // create client + WebApiClient client = new WebApiClient(this.Settings.WebApiBaseUrl, Constants.ApiVersion); + + // fetch SMAPI version try { - GitRelease release = UpdateHelper.GetLatestVersionAsync(Constants.GitHubRepository).Result; - ISemanticVersion latestVersion = new SemanticVersion(release.Tag); - if (latestVersion.IsNewerThan(Constants.ApiVersion)) - this.Monitor.Log($"You can update SMAPI from version {Constants.ApiVersion} to {latestVersion}", LogLevel.Alert); + ModInfoModel response = client.GetModInfoAsync($"GitHub:{this.Settings.GitHubProjectName}").Result.Single().Value; + if (response.Error != null) + this.Monitor.Log($"Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.\n{response.Error}", LogLevel.Warn); + else if (new SemanticVersion(response.Version).IsNewerThan(Constants.ApiVersion)) + { + smapiUpdate = true; + updates.Add($"SMAPI {response.Version}: {response.Url}"); + } } catch (Exception ex) { this.Monitor.Log($"Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.\n{ex.GetLogSummary()}"); } + + // fetch mod versions +#if !SMAPI_1_x + try + { + // prepare update-check data + IDictionary<string, IModMetadata> modsByKey = new Dictionary<string, IModMetadata>(StringComparer.InvariantCultureIgnoreCase); + foreach (IModMetadata mod in mods) + { + if (!string.IsNullOrWhiteSpace(mod.Manifest.NexusID)) + modsByKey[$"Nexus:{mod.Manifest.NexusID}"] = mod; + if (!string.IsNullOrWhiteSpace(mod.Manifest.GitHubProject)) + modsByKey[$"GitHub:{mod.Manifest.GitHubProject}"] = mod; + } + + // fetch results + IDictionary<string, ModInfoModel> response = client.GetModInfoAsync(modsByKey.Keys.ToArray()).Result; + IDictionary<IModMetadata, ModInfoModel> updatesByMod = new Dictionary<IModMetadata, ModInfoModel>(); + foreach (var entry in response) + { + // handle error + if (entry.Value.Error != null) + { + this.Monitor.Log($"Couldn't fetch version of {modsByKey[entry.Key].DisplayName} with key {entry.Key}:\n{entry.Value.Error}", LogLevel.Trace); + continue; + } + + // collect latest mod version + IModMetadata mod = modsByKey[entry.Key]; + ISemanticVersion version = new SemanticVersion(entry.Value.Version); + if (version.IsNewerThan(mod.Manifest.Version)) + { + if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || version.IsNewerThan(other.Version)) + { + updatesByMod[mod] = entry.Value; + modUpdates++; + } + } + } + + // add to output queue + if (updatesByMod.Any()) + { + foreach (var entry in updatesByMod.OrderBy(p => p.Key.DisplayName)) + updates.Add($"{entry.Key.DisplayName} {entry.Value.Version}: {entry.Value.Url}"); + } + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't check for new mod versions:\n{ex.GetLogSummary()}", LogLevel.Trace); + } +#endif + + // output + if (updates.Any()) + { +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + + // print intro + string intro = ""; + if (smapiUpdate) + intro = "You can update SMAPI"; + if (modUpdates > 0) + intro += $"{(smapiUpdate ? " and" : "You can update")} {modUpdates} mod{(modUpdates != 1 ? "s" : "")}"; + intro += ":"; + this.Monitor.Log(intro, LogLevel.Alert); + + // print update list + foreach (string line in updates) + this.Monitor.Log($" {line}", LogLevel.Alert); + } + }).Start(); } diff --git a/src/StardewModdingAPI/StardewModdingAPI.config.json b/src/StardewModdingAPI/StardewModdingAPI.config.json index d393f5a9..c91d169c 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.config.json +++ b/src/StardewModdingAPI/StardewModdingAPI.config.json @@ -1,4 +1,4 @@ -/* +/* @@ -15,13 +15,24 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "DeveloperMode": true, /** - * Whether SMAPI should check for a newer version when you load the game. If a new version is - * available, a small message will appear in the console. This doesn't affect the load time even - * if your connection is offline or slow, because it happens in the background. + * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new + * versions are available, an alert will be shown in the console. This doesn't affect the load + * time even if your connection is offline or slow, because it happens in the background. */ "CheckForUpdates": true, /** + * SMAPI's GitHub project name, used to perform update checks. + */ + "GitHubProjectName": "Pathoschild/SMAPI", + + /** + * The base URL for SMAPI's web API, used to perform update checks. + * Note: the protocol will be changed to http:// on Linux/Mac due to OpenSSL issues with the game's bundled Mono. + */ + "WebApiBaseUrl": "https://api.smapi.io", + + /** * Whether SMAPI should log more information about the game context. */ "VerboseLogging": false, diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 8da93bf4..07e98674 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -218,8 +218,7 @@ <Compile Include="ITranslationHelper.cs" /> <Compile Include="LogLevel.cs" /> <Compile Include="Framework\ModRegistry.cs" /> - <Compile Include="Framework\UpdateHelper.cs" /> - <Compile Include="Framework\Models\GitRelease.cs" /> + <Compile Include="Framework\WebApiClient.cs" /> <Compile Include="IMonitor.cs" /> <Compile Include="Events\ChangeType.cs" /> <Compile Include="Events\ItemStackChange.cs" /> @@ -274,6 +273,7 @@ <Install>false</Install> </BootstrapperPackage> </ItemGroup> + <Import Project="..\StardewModdingAPI.Models\StardewModdingAPI.Models.projitems" Label="Shared" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(SolutionDir)\common.targets" /> </Project>
\ No newline at end of file |