summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/StardewModdingAPI.Models/ModInfoModel.cs48
-rw-r--r--src/StardewModdingAPI.Models/StardewModdingAPI.Models.projitems14
-rw-r--r--src/StardewModdingAPI.Models/StardewModdingAPI.Models.shproj13
-rw-r--r--src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj4
-rw-r--r--src/StardewModdingAPI.Tests/packages.config4
-rw-r--r--src/StardewModdingAPI.Web/Controllers/ModsController.cs132
-rw-r--r--src/StardewModdingAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs54
-rw-r--r--src/StardewModdingAPI.Web/Framework/InternalControllerFeatureProvider.cs27
-rw-r--r--src/StardewModdingAPI.Web/Framework/ModRepositories/GitHubRepository.cs95
-rw-r--r--src/StardewModdingAPI.Web/Framework/ModRepositories/IModRepository.cs24
-rw-r--r--src/StardewModdingAPI.Web/Framework/ModRepositories/NexusRepository.cs91
-rw-r--r--src/StardewModdingAPI.Web/Framework/RewriteSubdomainRule.cs30
-rw-r--r--src/StardewModdingAPI.Web/Program.cs26
-rw-r--r--src/StardewModdingAPI.Web/Properties/launchSettings.json29
-rw-r--r--src/StardewModdingAPI.Web/StardewModdingAPI.Web.csproj20
-rw-r--r--src/StardewModdingAPI.Web/Startup.cs83
-rw-r--r--src/StardewModdingAPI.Web/appsettings.Development.json10
-rw-r--r--src/StardewModdingAPI.Web/appsettings.json24
-rw-r--r--src/StardewModdingAPI.sln31
-rw-r--r--src/StardewModdingAPI/Events/GameEvents.cs10
-rw-r--r--src/StardewModdingAPI/Framework/Models/GitRelease.cs19
-rw-r--r--src/StardewModdingAPI/Framework/Models/Manifest.cs10
-rw-r--r--src/StardewModdingAPI/Framework/Models/SConfig.cs14
-rw-r--r--src/StardewModdingAPI/Framework/SGame.cs2
-rw-r--r--src/StardewModdingAPI/Framework/UpdateHelper.cs37
-rw-r--r--src/StardewModdingAPI/Framework/WebApiClient.cs70
-rw-r--r--src/StardewModdingAPI/IManifest.cs10
-rw-r--r--src/StardewModdingAPI/Program.cs105
-rw-r--r--src/StardewModdingAPI/StardewModdingAPI.config.json19
-rw-r--r--src/StardewModdingAPI/StardewModdingAPI.csproj4
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