diff options
32 files changed, 977 insertions, 334 deletions
@@ -1,262 +1,25 @@ -# SMAPI Specific Ignores -StardewModdingAPI/bin/ -StardewModdingAPI/obj/ -TrainerMod/bin/ -TrainerMod/obj/ -StardewInjector/bin/ -StardewInjector/obj/ -packages/ -steamapps/ - -*.symlink -*.lnk -!*.exe -!*.dll - -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# User-specific files +# user-specific files *.suo *.user *.userosscache *.sln.docstates -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results +# build results [Dd]ebug/ -[Dd]ebugPublic/ [Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ [Bb]in/ [Oo]bj/ -[Ll]og/ -# Visual Studio 2015 cache/options directory +# Visual Studio cache/options .vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in +# ReSharper _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# NuGet Packages +# NuGet packages *.nupkg -# The packages folder can be ignored because of Package Restore **/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files *.nuget.props *.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Microsoft Azure ApplicationInsights config file -ApplicationInsights.config - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml
\ No newline at end of file diff --git a/release-notes.md b/release-notes.md index c31d2bae..a07de71f 100644 --- a/release-notes.md +++ b/release-notes.md @@ -6,12 +6,15 @@ For players: * The console is now simpler and easier to read. * The console now adjusts its colors when you have a light terminal background. * SMAPI now detects mods which may impact game stability and shows a warning in the console. -* Updated compatibility list. +* SMAPI now alerts you in the console when one of your mods has a new version. * Renamed installer folder from `SMAPI 2.0` to `SMAPI 2.0 installer` to avoid confusion. +* Updated compatibility list. +* Fixed update check errors on Linux/Mac. For mod developers: * Added new APIs to edit, inject, and reload XNB assets loaded by the game at any time. <small>_This let mods do anything previously only possible with XNB mods, plus enables new mod scenarios (e.g. seasonal textures, NPC clothing that depend on the weather or location, etc)._</small> +* Added new manifest fields to enable automatic update checks. * Added new input events. <small>_The new `InputEvents` combine keyboard + mouse + controller input into one event for easy handling, add metadata like the cursor position and grab tile to support click handling, and add an option to suppress input from the game to enable new scenarios like action highjacking and UI overlays._</small> * Added support for optional dependencies. 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 |