diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-06-20 12:43:08 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-06-20 12:43:08 -0400 |
commit | e64ecc89f94641d4054162eff4943f660f43030f (patch) | |
tree | c43ca79f9947b3e16f946e1dc5fd1d02f70ce571 /src | |
parent | df6e745c6b842290338317ed1d3e969ee222998c (diff) | |
parent | cb9ff7019995eff92104703f097856d2523e02ce (diff) | |
download | SMAPI-e64ecc89f94641d4054162eff4943f660f43030f.tar.gz SMAPI-e64ecc89f94641d4054162eff4943f660f43030f.tar.bz2 SMAPI-e64ecc89f94641d4054162eff4943f660f43030f.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src')
184 files changed, 3751 insertions, 3113 deletions
diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 5b0c6e1f..1457848b 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Threading; using Microsoft.Win32; using StardewModdingApi.Installer.Enums; using StardewModdingAPI.Installer.Framework; @@ -624,7 +623,7 @@ namespace StardewModdingApi.Installer { try { - this.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path)); + FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path)); break; } catch (Exception ex) @@ -665,41 +664,6 @@ namespace StardewModdingApi.Installer } } - /// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary> - /// <param name="entry">The file or folder to reset.</param> - /// <remarks>This method is mirrored from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks> - private void ForceDelete(FileSystemInfo entry) - { - // ignore if already deleted - entry.Refresh(); - if (!entry.Exists) - return; - - // delete children - if (entry is DirectoryInfo folder) - { - foreach (FileSystemInfo child in folder.GetFileSystemInfos()) - this.ForceDelete(child); - } - - // reset permissions & delete - entry.Attributes = FileAttributes.Normal; - entry.Delete(); - - // wait for deletion to finish - for (int i = 0; i < 10; i++) - { - entry.Refresh(); - if (entry.Exists) - Thread.Sleep(500); - } - - // throw exception if deletion didn't happen before timeout - entry.Refresh(); - if (entry.Exists) - throw new IOException($"Timed out trying to delete {entry.FullName}"); - } - /// <summary>Interactively ask the user to choose a value.</summary> /// <param name="print">A callback which prints a message to the console.</param> /// <param name="message">The message to print.</param> @@ -707,7 +671,7 @@ namespace StardewModdingApi.Installer /// <param name="indent">The indentation to prefix to output.</param> private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null) { - print = print ?? this.PrintInfo; + print ??= this.PrintInfo; while (true) { diff --git a/src/SMAPI.Installer/SMAPI.Installer.csproj b/src/SMAPI.Installer/SMAPI.Installer.csproj index 79e19d89..44ed3bd1 100644 --- a/src/SMAPI.Installer/SMAPI.Installer.csproj +++ b/src/SMAPI.Installer/SMAPI.Installer.csproj @@ -1,11 +1,8 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <AssemblyName>SMAPI.Installer</AssemblyName> <RootNamespace>StardewModdingAPI.Installer</RootNamespace> <Description>The SMAPI installer for players.</Description> <TargetFramework>net45</TargetFramework> - <LangVersion>latest</LangVersion> <OutputType>Exe</OutputType> <PlatformTarget>x86</PlatformTarget> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> @@ -16,13 +13,10 @@ </ItemGroup> <ItemGroup> - <None Update="assets\*"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> + <None Update="assets\*" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\prepare-install-package.targets" /> - </Project> diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index e2be66d9..5ae6574d 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -7,7 +7,7 @@ <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> <PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.16.1"> <PrivateAssets>all</PrivateAssets> diff --git a/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj index 3659e25a..0d109b83 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj @@ -1,11 +1,8 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <AssemblyName>SMAPI.ModBuildConfig.Analyzer</AssemblyName> <RootNamespace>StardewModdingAPI.ModBuildConfig.Analyzer</RootNamespace> <Version>3.0.0</Version> <TargetFramework>netstandard2.0</TargetFramework> - <LangVersion>latest</LangVersion> <IncludeBuildOutput>false</IncludeBuildOutput> <OutputPath>bin</OutputPath> <LangVersion>latest</LangVersion> @@ -19,5 +16,4 @@ <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> - </Project> diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 96d95e06..ced05a28 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -153,23 +153,22 @@ namespace StardewModdingAPI.ModBuildConfig // create zip file Directory.CreateDirectory(outputFolderPath); - using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) - using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) + using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); + using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + + foreach (var fileEntry in files) { - foreach (var fileEntry in files) - { - string relativePath = fileEntry.Key; - FileInfo file = fileEntry.Value; + string relativePath = fileEntry.Key; + FileInfo file = fileEntry.Value; - // get file info - string filePath = file.FullName; - string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); + // get file info + string filePath = file.FullName; + string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); - // add to zip - using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) - using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) - fileStream.CopyTo(fileStreamInZip); - } + // add to zip + using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + using Stream fileStreamInZip = archive.CreateEntry(entryName).Open(); + fileStream.CopyTo(fileStreamInZip); } } diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index ccbd9a85..5061b01b 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -1,25 +1,13 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <AssemblyName>SMAPI.ModBuildConfig</AssemblyName> <RootNamespace>StardewModdingAPI.ModBuildConfig</RootNamespace> - <Version>3.0.0</Version> + <Version>3.1.0</Version> <TargetFramework>net45</TargetFramework> - <LangVersion>latest</LangVersion> <PlatformTarget>x86</PlatformTarget> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> - </ItemGroup> - - <ItemGroup> - <None Include="..\..\build\find-game-folder.targets" Link="build\find-game-folder.targets" /> - <None Include="..\..\docs\technical\mod-package.md" Link="mod-build-config.md" /> - </ItemGroup> - - <ItemGroup> <Reference Include="Microsoft.Build" /> <Reference Include="Microsoft.Build.Framework" /> <Reference Include="Microsoft.Build.Utilities.v4.0" /> @@ -28,19 +16,16 @@ </ItemGroup> <ItemGroup> - <None Include="..\..\docs\technical\mod-package.md"> - <Link>mod-package.md</Link> - </None> + <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> </ItemGroup> <ItemGroup> - <None Update="assets\nuget-icon.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> + <None Include="..\..\build\find-game-folder.targets" Link="build\find-game-folder.targets" /> + <None Include="..\..\docs\technical\mod-package.md" Link="mod-package.md" /> + <None Update="assets\nuget-icon.png" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> <Import Project="..\..\build\prepare-nuget-package.targets" /> - </Project> diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 5ca9f032..bfee3b33 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -38,58 +38,26 @@ **********************************************--> <!-- common --> <ItemGroup> - <Reference Include="$(GameExecutableName)"> - <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="StardewValley.GameData"> - <HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="StardewModdingAPI"> - <HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="SMAPI.Toolkit.CoreInterfaces"> - <HintPath>$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="xTile"> - <HintPath>$(GamePath)\xTile.dll</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'"> - <HintPath>$(GamePath)\smapi-internal\0Harmony.dll</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> + <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="StardewModdingAPI" HintPath="$(GamePath)\StardewModdingAPI.exe" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="SMAPI.Toolkit.CoreInterfaces" HintPath="$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'" HintPath="$(GamePath)\smapi-internal\0Harmony.dll" Private="$(CopyModReferencesToBuildOutput)" /> </ItemGroup> <!-- Windows --> <ItemGroup Condition="$(OS) == 'Windows_NT'"> - <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> - <Reference Include="Netcode"> - <HintPath>$(GamePath)\Netcode.dll</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="$(CopyModReferencesToBuildOutput)" /> + <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="$(CopyModReferencesToBuildOutput)" /> </ItemGroup> <!-- Linux/Mac --> <ItemGroup Condition="$(OS) != 'Windows_NT'"> - <Reference Include="MonoGame.Framework"> - <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> - <Private>$(CopyModReferencesToBuildOutput)</Private> - </Reference> + <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="$(CopyModReferencesToBuildOutput)" /> </ItemGroup> diff --git a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj index 526d406b..1e3208de 100644 --- a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj @@ -1,72 +1,45 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> <AssemblyName>ConsoleCommands</AssemblyName> <RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace> <TargetFramework>net45</TargetFramework> - <LangVersion>latest</LangVersion> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <PlatformTarget>x86</PlatformTarget> </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\SMAPI\SMAPI.csproj"> - <Private>False</Private> - </ProjectReference> + <ProjectReference Include="..\SMAPI\SMAPI.csproj" Private="False" /> </ItemGroup> <ItemGroup> - <Reference Include="$(GameExecutableName)"> - <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="StardewValley.GameData"> - <HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> - <Private>False</Private> - </Reference> + <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" /> + <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="False" /> </ItemGroup> <Choose> <!-- Windows --> <When Condition="$(OS) == 'Windows_NT'"> <ItemGroup> - <Reference Include="Netcode"> - <HintPath>$(GamePath)\Netcode.dll</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> + <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" /> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> </ItemGroup> </When> <!-- Linux/Mac --> <Otherwise> <ItemGroup> - <Reference Include="MonoGame.Framework"> - <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> - <Private>False</Private> - </Reference> + <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="False" /> </ItemGroup> </Otherwise> </Choose> <ItemGroup> - <None Update="manifest.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> + <None Update="manifest.json" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> - </Project> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 908d4f65..05304312 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.5.0", + "Version": "3.6.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.5.0" + "MinimumApiVersion": "3.6.0" } diff --git a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj index 970ccea8..98a3f0cc 100644 --- a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj +++ b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj @@ -1,34 +1,24 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> <AssemblyName>SaveBackup</AssemblyName> <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace> <TargetFramework>net45</TargetFramework> - <LangVersion>latest</LangVersion> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <PlatformTarget>x86</PlatformTarget> </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\SMAPI\SMAPI.csproj"> - <Private>False</Private> - </ProjectReference> + <ProjectReference Include="..\SMAPI\SMAPI.csproj" Private="False" /> </ItemGroup> <ItemGroup> - <Reference Include="$(GameExecutableName)"> - <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> - <Private>False</Private> - </Reference> + <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" /> </ItemGroup> <ItemGroup> - <None Update="manifest.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> + <None Update="manifest.json" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> - </Project> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index cd42459e..5d5336e7 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.5.0", + "Version": "3.6.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.5.0" + "MinimumApiVersion": "3.6.0" } diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 639c22a4..b1548e3a 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -16,7 +16,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Moq" Version="4.13.1" /> + <PackageReference Include="Moq" Version="4.14.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="NUnit" Version="3.12.0" /> </ItemGroup> diff --git a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj index accc9175..2bddc46a 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj +++ b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj @@ -1,15 +1,11 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <AssemblyName>SMAPI.Toolkit.CoreInterfaces</AssemblyName> <RootNamespace>StardewModdingAPI</RootNamespace> <Description>Provides toolkit interfaces which are available to SMAPI mods.</Description> <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> - <LangVersion>latest</LangVersion> - <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile> + <GenerateDocumentationFile>true</GenerateDocumentationFile> <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> </PropertyGroup> <Import Project="..\..\build\common.targets" /> - </Project> diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index f0a7c82a..2fb6ed20 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -62,16 +62,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi private TResult Post<TBody, TResult>(string url, TBody content) { // note: avoid HttpClient for Mac compatibility - using (WebClient client = new WebClient()) - { - Uri fullUrl = new Uri(this.BaseUrl, url); - string data = JsonConvert.SerializeObject(content); + using WebClient client = new WebClient(); - client.Headers["Content-Type"] = "application/json"; - client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; - string response = client.UploadString(fullUrl, data); - return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); - } + Uri fullUrl = new Uri(this.BaseUrl, url); + string data = JsonConvert.SerializeObject(content); + + client.Headers["Content-Type"] = "application/json"; + client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; + string response = client.UploadString(fullUrl, data); + return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index c829c0f4..34e2e1b8 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -105,6 +105,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string pullRequestUrl = this.GetAttribute(node, "data-pr"); IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); + string[] changeUpdateKeys = this.GetAttributeAsCsv(node, "data-change-update-keys"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo @@ -153,6 +154,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Warnings = warnings, PullRequestUrl = pullRequestUrl, DevNote = devNote, + ChangeUpdateKeys = changeUpdateKeys, MapLocalVersions = mapLocalVersions, MapRemoteVersions = mapRemoteVersions, Anchor = anchor diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs index a1d2dfae..5cdf489f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs @@ -3,25 +3,28 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>The compatibility status for a mod.</summary> public enum WikiCompatibilityStatus { + /// <summary>The status is unknown.</summary> + Unknown, + /// <summary>The mod is compatible.</summary> - Ok = 0, + Ok, /// <summary>The mod is compatible if you use an optional official download.</summary> - Optional = 1, + Optional, /// <summary>The mod is compatible if you use an unofficial update.</summary> - Unofficial = 2, + Unofficial, /// <summary>The mod isn't compatible, but the player can fix it or there's a good alternative.</summary> - Workaround = 3, + Workaround, /// <summary>The mod isn't compatible.</summary> - Broken = 4, + Broken, /// <summary>The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely.</summary> - Abandoned = 5, + Abandoned, /// <summary>The mod is no longer needed and should be removed.</summary> - Obsolete = 6 + Obsolete } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 474dce3d..21466c6a 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -63,6 +63,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> public string DevNote { get; set; } + /// <summary>Update keys to add (optionally prefixed by '+') or remove (prefixed by '-').</summary> + public string[] ChangeUpdateKeys { get; set; } + /// <summary>Maps local versions to a semantic version for update checks.</summary> public IDictionary<string, string> MapLocalVersions { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 212c70ef..4eec3424 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -124,8 +124,8 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning XElement root; try { - using (FileStream stream = file.OpenRead()) - root = XElement.Load(stream); + using FileStream stream = file.OpenRead(); + root = XElement.Load(stream); } catch { diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index f11cc1a7..f4857c7d 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning { // OS metadata files new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager - new Regex(@"^(?:__MACOSX|\._\.DS_Store|\.DS_Store|mcs)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS + new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows new Regex(@"\.(?:url|lnk)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows shortcut files diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs index 765ca334..47cd3f7e 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData { - /// <summary>A mod repository which SMAPI can check for updates.</summary> - public enum ModRepositoryKey + /// <summary>A mod site which SMAPI can check for updates.</summary> + public enum ModSiteKey { /// <summary>An unknown or invalid mod repository.</summary> Unknown, diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 3fc1759e..7e4d0220 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -11,12 +11,15 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <summary>The raw update key text.</summary> public string RawText { get; } - /// <summary>The mod repository containing the mod.</summary> - public ModRepositoryKey Repository { get; } + /// <summary>The mod site containing the mod.</summary> + public ModSiteKey Site { get; } /// <summary>The mod ID within the repository.</summary> public string ID { get; } + /// <summary>If specified, a substring in download names/descriptions to match.</summary> + public string Subkey { get; } + /// <summary>Whether the update key seems to be valid.</summary> public bool LooksValid { get; } @@ -26,53 +29,71 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData *********/ /// <summary>Construct an instance.</summary> /// <param name="rawText">The raw update key text.</param> - /// <param name="repository">The mod repository containing the mod.</param> - /// <param name="id">The mod ID within the repository.</param> - public UpdateKey(string rawText, ModRepositoryKey repository, string id) + /// <param name="site">The mod site containing the mod.</param> + /// <param name="id">The mod ID within the site.</param> + /// <param name="subkey">If specified, a substring in download names/descriptions to match.</param> + public UpdateKey(string rawText, ModSiteKey site, string id, string subkey) { - this.RawText = rawText; - this.Repository = repository; - this.ID = id; + this.RawText = rawText?.Trim(); + this.Site = site; + this.ID = id?.Trim(); + this.Subkey = subkey?.Trim(); this.LooksValid = - repository != ModRepositoryKey.Unknown + site != ModSiteKey.Unknown && !string.IsNullOrWhiteSpace(id); } /// <summary>Construct an instance.</summary> - /// <param name="repository">The mod repository containing the mod.</param> - /// <param name="id">The mod ID within the repository.</param> - public UpdateKey(ModRepositoryKey repository, string id) - : this($"{repository}:{id}", repository, id) { } + /// <param name="site">The mod site containing the mod.</param> + /// <param name="id">The mod ID within the site.</param> + /// <param name="subkey">If specified, a substring in download names/descriptions to match.</param> + public UpdateKey(ModSiteKey site, string id, string subkey) + : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } /// <summary>Parse a raw update key.</summary> /// <param name="raw">The raw update key to parse.</param> public static UpdateKey Parse(string raw) { - // split parts - string[] parts = raw?.Split(':'); - if (parts == null || parts.Length != 2) - return new UpdateKey(raw, ModRepositoryKey.Unknown, null); - - // extract parts - string repositoryKey = parts[0].Trim(); - string id = parts[1].Trim(); + // extract site + ID + string rawSite; + string id; + { + string[] parts = raw?.Trim().Split(':'); + if (parts == null || parts.Length != 2) + return new UpdateKey(raw, ModSiteKey.Unknown, null, null); + + rawSite = parts[0].Trim(); + id = parts[1].Trim(); + } if (string.IsNullOrWhiteSpace(id)) id = null; + // extract subkey + string subkey = null; + if (id != null) + { + string[] parts = id.Split('@'); + if (parts.Length == 2) + { + id = parts[0].Trim(); + subkey = $"@{parts[1]}".Trim(); + } + } + // parse - if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository)) - return new UpdateKey(raw, ModRepositoryKey.Unknown, id); + if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) + return new UpdateKey(raw, ModSiteKey.Unknown, id, subkey); if (id == null) - return new UpdateKey(raw, repository, null); + return new UpdateKey(raw, site, null, subkey); - return new UpdateKey(raw, repository, id); + return new UpdateKey(raw, site, id, subkey); } /// <summary>Get a string that represents the current object.</summary> public override string ToString() { return this.LooksValid - ? $"{this.Repository}:{this.ID}" + ? UpdateKey.GetString(this.Site, this.ID, this.Subkey) : this.RawText; } @@ -80,10 +101,18 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <param name="other">An object to compare with this object.</param> public bool Equals(UpdateKey other) { + if (!this.LooksValid) + { + return + other?.LooksValid == false + && this.RawText.Equals(other.RawText, StringComparison.OrdinalIgnoreCase); + } + return other != null - && this.Repository == other.Repository - && string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase); + && this.Site == other.Site + && string.Equals(this.ID, other.ID, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Subkey, other.Subkey, StringComparison.OrdinalIgnoreCase); } /// <summary>Determines whether the specified object is equal to the current object.</summary> @@ -97,7 +126,16 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <returns>A hash code for the current object.</returns> public override int GetHashCode() { - return $"{this.Repository}:{this.ID}".ToLower().GetHashCode(); + return this.ToString().ToLower().GetHashCode(); + } + + /// <summary>Get the string representation of an update key.</summary> + /// <param name="site">The mod site containing the mod.</param> + /// <param name="id">The mod ID within the repository.</param> + /// <param name="subkey">If specified, a substring in download names/descriptions to match.</param> + public static string GetString(ModSiteKey site, string id, string subkey = null) + { + return $"{site}:{id}{subkey}".Trim(); } } } diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs index 80b14659..08fe0fed 100644 --- a/src/SMAPI.Toolkit/ModToolkit.cs +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading.Tasks; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -11,8 +10,6 @@ using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Serialization; -[assembly: InternalsVisibleTo("StardewModdingAPI")] -[assembly: InternalsVisibleTo("SMAPI.Web")] namespace StardewModdingAPI.Toolkit { /// <summary>A convenience wrapper for the various tools.</summary> diff --git a/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..233e680b --- /dev/null +++ b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StardewModdingAPI")] +[assembly: InternalsVisibleTo("SMAPI.Web")] diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index edb1d612..71ea0f12 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -1,20 +1,16 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <AssemblyName>SMAPI.Toolkit</AssemblyName> <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> <Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description> <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> - <LangVersion>latest</LangVersion> - <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile> + <GenerateDocumentationFile>true</GenerateDocumentationFile> <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> - <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> </PropertyGroup> <ItemGroup> <PackageReference Include="HtmlAgilityPack" Version="1.11.23" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" /> <PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" /> <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT' AND '$(TargetFramework)' == 'netstandard2.0'" /> </ItemGroup> @@ -24,5 +20,4 @@ </ItemGroup> <Import Project="..\..\build\common.targets" /> - </Project> diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index c45448f3..1e490448 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -30,10 +30,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Detect the current OS.</summary> public static Platform DetectPlatform() { - if (EnvironmentUtility.CachedPlatform == null) - EnvironmentUtility.CachedPlatform = EnvironmentUtility.DetectPlatformImpl(); - - return EnvironmentUtility.CachedPlatform.Value; + return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl(); } diff --git a/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs b/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs deleted file mode 100644 index 44ed0b6b..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Controllers/ModsApiController.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; - -namespace SMAPI.Web.LegacyRedirects.Controllers -{ - /// <summary>Provides an API to perform mod update checks.</summary> - [ApiController] - [Produces("application/json")] - [Route("api/v{version}/mods")] - public class ModsApiController : Controller - { - /********* - ** Public methods - *********/ - /// <summary>Fetch version metadata for the given mods.</summary> - /// <param name="model">The mod search criteria.</param> - [HttpPost] - public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model) - { - using IClient client = new FluentClient("https://smapi.io/api"); - - Startup.ConfigureJsonNet(client.Formatters.JsonFormatter.SerializerSettings); - - return await client - .PostAsync(this.Request.Path) - .WithBody(model) - .AsArray<ModEntryModel>(); - } - } -} diff --git a/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs b/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs deleted file mode 100644 index e5138e5c..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Framework/LambdaRewriteRule.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace SMAPI.Web.LegacyRedirects.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 LambdaRewriteRule : IRule - { - /********* - ** Accessors - *********/ - /// <summary>Rewrite an HTTP request if needed.</summary> - private readonly Action<RewriteContext, HttpRequest, HttpResponse> Rewrite; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="rewrite">Rewrite an HTTP request if needed.</param> - public LambdaRewriteRule(Action<RewriteContext, HttpRequest, HttpResponse> rewrite) - { - this.Rewrite = rewrite ?? throw new ArgumentNullException(nameof(rewrite)); - } - - /// <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) - { - HttpRequest request = context.HttpContext.Request; - HttpResponse response = context.HttpContext.Response; - this.Rewrite(context, request, response); - } - } -} diff --git a/src/SMAPI.Web.LegacyRedirects/Program.cs b/src/SMAPI.Web.LegacyRedirects/Program.cs deleted file mode 100644 index 6adee877..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Program.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; - -namespace SMAPI.Web.LegacyRedirects -{ - /// <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) - { - Host - .CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(builder => builder.UseStartup<Startup>()) - .Build() - .Run(); - } - } -} diff --git a/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json b/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json deleted file mode 100644 index e9a1b210..00000000 --- a/src/SMAPI.Web.LegacyRedirects/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:52756", - "sslPort": 0 - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "SMAPI.Web.LegacyRedirects": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "/", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" - } - } -}
\ No newline at end of file diff --git a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj b/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj deleted file mode 100644 index 36831961..00000000 --- a/src/SMAPI.Web.LegacyRedirects/SMAPI.Web.LegacyRedirects.csproj +++ /dev/null @@ -1,21 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> - - <PropertyGroup> - <TargetFramework>netcoreapp3.0</TargetFramework> - </PropertyGroup> - - <ItemGroup> - <Content Remove="aws-beanstalk-tools-defaults.json" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" /> - <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> - </ItemGroup> - -</Project> diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index ee7a60f3..64bd5ca5 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Hangfire; @@ -36,7 +37,9 @@ namespace StardewModdingAPI.Web /// <summary>Construct an instance.</summary> /// <param name="wikiCache">The cache in which to store wiki metadata.</param> /// <param name="modCache">The cache in which to store mod data.</param> - public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache) + /// <param name="hangfireStorage">The Hangfire storage implementation.</param> + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")] + public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, JobStorage hangfireStorage) { BackgroundService.WikiCache = wikiCache; BackgroundService.ModCache = modCache; @@ -81,7 +84,7 @@ namespace StardewModdingAPI.Web public static async Task UpdateWikiAsync() { WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); - BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods, out _, out _); + BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); } /// <summary>Remove mods which haven't been requested in over 48 hours.</summary> diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index 2ade3e3d..5f83eafd 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -27,12 +27,13 @@ namespace StardewModdingAPI.Web.Controllers private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> { ["none"] = "None", - ["manifest"] = "Manifest", + ["manifest"] = "SMAPI: manifest", + ["i18n"] = "SMAPI: translations (i18n)", ["content-patcher"] = "Content Patcher" }; /// <summary>The schema ID to use if none was specified.</summary> - private string DefaultSchemaID = "manifest"; + private string DefaultSchemaID = "none"; /// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary> private readonly string TransparentToken = "$transparent"; @@ -57,16 +58,22 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Render the schema validator UI.</summary> /// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param> /// <param name="id">The stored file ID.</param> + /// <param name="operation">The operation to perform for the selected log ID. This can be 'edit', or any other value to view.</param> [HttpGet] [Route("json")] [Route("json/{schemaName}")] [Route("json/{schemaName}/{id}")] - public async Task<ViewResult> Index(string schemaName = null, string id = null) + [Route("json/{schemaName}/{id}/{operation}")] + public async Task<ViewResult> Index(string schemaName = null, string id = null, string operation = null) { + // parse arguments schemaName = this.NormalizeSchemaName(schemaName); + bool hasId = !string.IsNullOrWhiteSpace(id); + bool isEditView = !hasId || operation?.Trim().ToLower() == "edit"; - var result = new JsonValidatorModel(id, schemaName, this.SchemaFormats); - if (string.IsNullOrWhiteSpace(id)) + // build result model + var result = this.GetModel(id, schemaName, isEditView); + if (!hasId) return this.View("Index", result); // fetch raw JSON @@ -76,7 +83,7 @@ namespace StardewModdingAPI.Web.Controllers result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning); // skip parsing if we're going to the edit screen - if (schemaName?.ToLower() == "edit") + if (isEditView) return this.View("Index", result); // parse JSON @@ -130,7 +137,7 @@ namespace StardewModdingAPI.Web.Controllers public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) { if (request == null) - return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid.")); + return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid.")); // normalize schema name string schemaName = this.NormalizeSchemaName(request.SchemaName); @@ -138,12 +145,12 @@ namespace StardewModdingAPI.Web.Controllers // get raw text string input = request.Content; if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty.")); + return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty.")); // upload file UploadResult result = await this.Storage.SaveAsync(input); if (!result.Succeeded) - return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError)); + return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null).SetUploadError(result.UploadError)); // redirect to view return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID })); @@ -156,9 +163,10 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Build a JSON validator model.</summary> /// <param name="pasteID">The stored file ID.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param> - private JsonValidatorModel GetModel(string pasteID, string schemaName) + /// <param name="isEditView">Whether to show the edit view.</param> + private JsonValidatorModel GetModel(string pasteID, string schemaName, bool isEditView) { - return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats); + return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); } /// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary> @@ -275,21 +283,20 @@ namespace StardewModdingAPI.Web.Controllers errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase); // match error by type and message - foreach (var pair in errors) + foreach ((string target, string errorMessage) in errors) { - if (!pair.Key.Contains(":")) + if (!target.Contains(":")) continue; - string[] parts = pair.Key.Split(':', 2); + string[] parts = target.Split(':', 2); if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) - return pair.Value?.Trim(); + return errorMessage?.Trim(); } // match by type - if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) - return message?.Trim(); - - return null; + return errors.TryGetValue(error.ErrorType.ToString(), out string message) + ? message?.Trim() + : null; } return GetRawOverrideError() @@ -304,10 +311,10 @@ namespace StardewModdingAPI.Web.Controllers { if (schema.ExtensionData != null) { - foreach (var pair in schema.ExtensionData) + foreach ((string curKey, JToken value) in schema.ExtensionData) { - if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) - return pair.Value.ToObject<T>(); + if (curKey.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + return value.ToObject<T>(); } } @@ -318,14 +325,11 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="value">The value to format.</param> private string FormatValue(object value) { - switch (value) + return value switch { - case List<string> list: - return string.Join(", ", list); - - default: - return value?.ToString() ?? "null"; - } + List<string> list => string.Join(", ", list), + _ => value?.ToString() ?? "null" + }; } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 06768f03..db669bf9 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -12,15 +12,16 @@ using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; +using StardewModdingAPI.Web.Framework.Clients; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.ConfigModels; -using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Controllers { @@ -32,8 +33,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// <summary>The mod repositories which provide mod metadata.</summary> - private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories; + /// <summary>The mod sites which provide mod metadata.</summary> + private readonly ModSiteManager ModSites; /// <summary>The cache in which to store wiki data.</summary> private readonly IWikiCacheRepository WikiCache; @@ -61,23 +62,14 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="github">The GitHub API client.</param> /// <param name="modDrop">The ModDrop API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + public ModsApiController(IWebHostEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); this.WikiCache = wikiCache; this.ModCache = modCache; this.Config = config; - this.Repositories = - new IModRepository[] - { - new ChucklefishRepository(chucklefish), - new CurseForgeRepository(curseForge), - new GitHubRepository(github), - new ModDropRepository(modDrop), - new NexusRepository(nexus) - } - .ToDictionary(p => p.VendorKey); + this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus }); } /// <summary>Fetch version metadata for the given mods.</summary> @@ -90,7 +82,7 @@ namespace StardewModdingAPI.Web.Controllers return new ModEntryModel[0]; // fetch wiki data - WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray(); + WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.Data).ToArray(); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { @@ -143,45 +135,23 @@ namespace StardewModdingAPI.Web.Controllers // validate update key if (!updateKey.LooksValid) { - errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541', with an optional subkey like 'Nexus:541@subkey'."); continue; } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions); - if (data.Error != null) + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions); + if (data.Status != RemoteModStatus.Ok) { - errors.Add(data.Error); + errors.Add(data.Error ?? data.Status.ToString()); continue; } - // handle main version - if (data.Version != null) - { - ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); - if (version == null) - { - errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); - continue; - } - - if (this.IsNewer(version, main?.Version)) - main = new ModEntryVersionModel(version, data.Url); - } - - // handle optional version - if (data.PreviewVersion != null) - { - ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); - if (version == null) - { - errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); - continue; - } - - if (this.IsNewer(version, optional?.Version)) - optional = new ModEntryVersionModel(version, data.Url); - } + // handle versions + if (this.IsNewer(data.Version, main?.Version)) + main = new ModEntryVersionModel(data.Version, data.Url); + if (this.IsNewer(data.PreviewVersion, optional?.Version)) + optional = new ModEntryVersionModel(data.PreviewVersion, data.Url); } // get unofficial version @@ -221,7 +191,7 @@ namespace StardewModdingAPI.Web.Controllers } // get recommended update (if any) - ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -281,29 +251,27 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions) + /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) { - // get mod - if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) + // get mod page + IModPage page; { - // get site - if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + bool isCached = + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached<IModPage> cachedMod) + && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); - // fetch mod - ModInfoModel result = await repository.GetModInfoAsync(updateKey.ID); - if (result.Error == null) + if (isCached) + page = cachedMod.Data; + else { - if (result.Version == null) - result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!SemanticVersion.TryParse(result.Version, allowNonStandardVersions, out _)) - result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."); + page = await this.ModSites.GetModPageAsync(updateKey); + this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); } - - // cache mod - this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, result, out mod); } - return mod.GetModel(); + + // get version info + return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions); } /// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary> @@ -312,90 +280,79 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="entry">The mod's entry in the wiki list.</param> private IEnumerable<UpdateKey> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { - IEnumerable<string> GetRaw() - { - // specified update keys - if (specifiedKeys != null) - { - foreach (string key in specifiedKeys) - yield return key?.Trim(); - } + // get unique update keys + List<UpdateKey> updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) + .Select(UpdateKey.Parse) + .Distinct() + .ToList(); - // default update key - string defaultKey = record?.GetDefaultUpdateKey(); - if (defaultKey != null) - yield return defaultKey; - - // wiki metadata - if (entry != null) - { - if (entry.NexusID.HasValue) - yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}"; - if (entry.ModDropID.HasValue) - yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}"; - if (entry.CurseForgeID.HasValue) - yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}"; - if (entry.ChucklefishID.HasValue) - yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}"; - } + // apply remove overrides from wiki + { + var removeKeys = new HashSet<UpdateKey>( + from key in entry?.ChangeUpdateKeys ?? new string[0] + where key.StartsWith('-') + select UpdateKey.Parse(key.Substring(1)) + ); + if (removeKeys.Any()) + updateKeys.RemoveAll(removeKeys.Contains); } - HashSet<UpdateKey> seen = new HashSet<UpdateKey>(); - foreach (string rawKey in GetRaw()) + // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority { - if (string.IsNullOrWhiteSpace(rawKey)) - continue; - - UpdateKey key = UpdateKey.Parse(rawKey); - if (seen.Add(key)) - yield return key; + var removeKeys = new HashSet<UpdateKey>(); + foreach (var key in updateKeys) + { + if (key.Subkey != null) + removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); + } + if (removeKeys.Any()) + updateKeys.RemoveAll(removeKeys.Contains); } - } - /// <summary>Get a semantic local version for update checks.</summary> - /// <param name="version">The version to parse.</param> - /// <param name="map">A map of version replacements.</param> - /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - private ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) - { - // try mapped version - string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); - if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) - return parsedNew; - - // return original version - return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) - ? parsedOld - : null; + return updateKeys; } - /// <summary>Get a semantic local version for update checks.</summary> - /// <param name="version">The version to map.</param> - /// <param name="map">A map of version replacements.</param> - /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + /// <summary>Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered.</summary> + /// <param name="specifiedKeys">The specified update keys.</param> + /// <param name="record">The mod's entry in SMAPI's internal database.</param> + /// <param name="entry">The mod's entry in the wiki list.</param> + private IEnumerable<string> GetUnfilteredUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { - if (version == null || map == null || !map.Any()) - return version; - - // match exact raw version - if (map.ContainsKey(version)) - return map[version]; + // specified update keys + foreach (string key in specifiedKeys ?? Array.Empty<string>()) + { + if (!string.IsNullOrWhiteSpace(key)) + yield return key.Trim(); + } - // match parsed version - if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) + // default update key { - if (map.ContainsKey(parsed.ToString())) - return map[parsed.ToString()]; + string defaultKey = record?.GetDefaultUpdateKey(); + if (!string.IsNullOrWhiteSpace(defaultKey)) + yield return defaultKey; + } - foreach (var pair in map) - { - if (SemanticVersion.TryParse(pair.Key, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, allowNonStandard, out ISemanticVersion newVersion)) - return newVersion.ToString(); - } + // wiki metadata + if (entry != null) + { + if (entry.NexusID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); + if (entry.ModDropID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); + if (entry.CurseForgeID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); + if (entry.ChucklefishID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); } - return version; + // overrides from wiki + foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty<string>()) + { + if (key.StartsWith('+')) + yield return key.Substring(1); + else if (!key.StartsWith("-")) + yield return key; + } } } } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index b621ded0..24e36709 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; @@ -51,16 +52,16 @@ namespace StardewModdingAPI.Web.Controllers public ModListModel FetchData() { // fetch cached data - if (!this.Cache.TryGetWikiMetadata(out CachedWikiMetadata metadata)) + if (!this.Cache.TryGetWikiMetadata(out Cached<WikiMetadata> metadata)) return new ModListModel(); // build model return new ModListModel( - stableVersion: metadata.StableVersion, - betaVersion: metadata.BetaVersion, + stableVersion: metadata.Data.StableVersion, + betaVersion: metadata.Data.BetaVersion, mods: this.Cache .GetWikiMods() - .Select(mod => new ModModel(mod.GetModel())) + .Select(mod => new ModModel(mod.Data)) .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting lastUpdated: metadata.LastUpdated, isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs new file mode 100644 index 00000000..52041a16 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Cached.cs @@ -0,0 +1,37 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>A cache entry.</summary> + /// <typeparam name="T">The cached value type.</typeparam> + internal class Cached<T> + { + /********* + ** Accessors + *********/ + /// <summary>The cached data.</summary> + public T Data { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>When the data was last requested through the mod API.</summary> + public DateTimeOffset LastRequested { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public Cached() { } + + /// <summary>Construct an instance.</summary> + /// <param name="data">The cached data.</param> + public Cached(T data) + { + this.Data = data; + this.LastUpdated = DateTimeOffset.UtcNow; + this.LastRequested = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs deleted file mode 100644 index 96eca847..00000000 --- a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; - -namespace StardewModdingAPI.Web.Framework.Caching.Mods -{ - /// <summary>The model for cached mod data.</summary> - internal class CachedMod - { - /********* - ** Accessors - *********/ - /**** - ** Tracking - ****/ - /// <summary>The internal MongoDB ID.</summary> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] - [BsonIgnoreIfDefault] - public ObjectId _id { get; set; } - - /// <summary>When the data was last updated.</summary> - public DateTimeOffset LastUpdated { get; set; } - - /// <summary>When the data was last requested through the web API.</summary> - public DateTimeOffset LastRequested { get; set; } - - /**** - ** Metadata - ****/ - /// <summary>The mod site on which the mod is found.</summary> - public ModRepositoryKey Site { get; set; } - - /// <summary>The mod's unique ID within the <see cref="Site"/>.</summary> - public string ID { get; set; } - - /// <summary>The mod availability status on the remote site.</summary> - public RemoteModStatus FetchStatus { get; set; } - - /// <summary>The error message providing more info for the <see cref="FetchStatus"/>, if applicable.</summary> - public string FetchError { get; set; } - - - /**** - ** Mod info - ****/ - /// <summary>The mod's display name.</summary> - public string Name { get; set; } - - /// <summary>The mod's latest version.</summary> - public string MainVersion { get; set; } - - /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="MainVersion"/>.</summary> - public string PreviewVersion { get; set; } - - /// <summary>The URL for the mod page.</summary> - public string Url { get; set; } - - /// <summary>The license URL, if available.</summary> - public string LicenseUrl { get; set; } - - /// <summary>The license name, if available.</summary> - public string LicenseName { get; set; } - - - /********* - ** Accessors - *********/ - /// <summary>Construct an instance.</summary> - public CachedMod() { } - - /// <summary>Construct an instance.</summary> - /// <param name="site">The mod site on which the mod is found.</param> - /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> - /// <param name="mod">The mod data.</param> - public CachedMod(ModRepositoryKey site, string id, ModInfoModel mod) - { - // tracking - this.LastUpdated = DateTimeOffset.UtcNow; - this.LastRequested = DateTimeOffset.UtcNow; - - // metadata - this.Site = site; - this.ID = id; - this.FetchStatus = mod.Status; - this.FetchError = mod.Error; - - // mod info - this.Name = mod.Name; - this.MainVersion = mod.Version; - this.PreviewVersion = mod.PreviewVersion; - this.Url = mod.Url; - this.LicenseUrl = mod.LicenseUrl; - this.LicenseName = mod.LicenseName; - } - - /// <summary>Get the API model for the cached data.</summary> - public ModInfoModel GetModel() - { - return new ModInfoModel(name: this.Name, version: this.MainVersion, url: this.Url, previewVersion: this.PreviewVersion) - .SetLicense(this.LicenseUrl, this.LicenseName) - .SetError(this.FetchStatus, this.FetchError); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index bcec8b36..0d912c7b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -1,10 +1,10 @@ using System; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { - /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + /// <summary>Manages cached mod data.</summary> internal interface IModCacheRepository : ICacheRepository { /********* @@ -15,14 +15,13 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The fetched mod.</param> /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> - bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true); /// <summary>Save data fetched for a mod.</summary> /// <param name="site">The mod site on which the mod is found.</param> /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The mod data.</param> - /// <param name="cachedMod">The stored mod record.</param> - void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod); + void SaveMod(ModSiteKey site, string id, IModPage mod); /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> /// <param name="age">The minimum age for which to remove mods.</param> diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs new file mode 100644 index 00000000..6b0ec1ec --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Manages cached mod data in-memory.</summary> + internal class ModCacheMemoryRepository : BaseCacheRepository, IModCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The cached mod data indexed by <c>{site key}:{ID}</c>.</summary> + private readonly IDictionary<string, Cached<IModPage>> Mods = new Dictionary<string, Cached<IModPage>>(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Get the cached mod data.</summary> + /// <param name="site">The mod site to search.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The fetched mod.</param> + /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> + public bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true) + { + // get mod + if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) + { + mod = null; + return false; + } + + // bump 'last requested' + if (markRequested) + cachedMod.LastRequested = DateTimeOffset.UtcNow; + + mod = cachedMod; + return true; + } + + /// <summary>Save data fetched for a mod.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + public void SaveMod(ModSiteKey site, string id, IModPage mod) + { + string key = this.GetKey(site, id); + this.Mods[key] = new Cached<IModPage>(mod); + } + + /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> + /// <param name="age">The minimum age for which to remove mods.</param> + public void RemoveStaleMods(TimeSpan age) + { + DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); + + string[] staleKeys = this.Mods + .Where(p => p.Value.LastRequested < minDate) + .Select(p => p.Key) + .ToArray(); + + foreach (string key in staleKeys) + this.Mods.Remove(key); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a cache key.</summary> + /// <param name="site">The mod site.</param> + /// <param name="id">The mod ID.</param> + private string GetKey(ModSiteKey site, string id) + { + return $"{site}:{id.Trim()}".ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs deleted file mode 100644 index 2e7804a7..00000000 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using MongoDB.Driver; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; - -namespace StardewModdingAPI.Web.Framework.Caching.Mods -{ - /// <summary>Encapsulates logic for accessing the mod data cache.</summary> - internal class ModCacheRepository : BaseCacheRepository, IModCacheRepository - { - /********* - ** Fields - *********/ - /// <summary>The collection for cached mod data.</summary> - private readonly IMongoCollection<CachedMod> Mods; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="database">The authenticated MongoDB database.</param> - public ModCacheRepository(IMongoDatabase database) - { - // get collections - this.Mods = database.GetCollection<CachedMod>("mods"); - - // add indexes if needed - this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedMod>(Builders<CachedMod>.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); - } - - /********* - ** Public methods - *********/ - /// <summary>Get the cached mod data.</summary> - /// <param name="site">The mod site to search.</param> - /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> - /// <param name="mod">The fetched mod.</param> - /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> - public bool TryGetMod(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) - { - // get mod - id = this.NormalizeId(id); - mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault(); - if (mod == null) - return false; - - // bump 'last requested' - if (markRequested) - { - mod.LastRequested = DateTimeOffset.UtcNow; - mod = this.SaveMod(mod); - } - - return true; - } - - /// <summary>Save data fetched for a mod.</summary> - /// <param name="site">The mod site on which the mod is found.</param> - /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> - /// <param name="mod">The mod data.</param> - /// <param name="cachedMod">The stored mod record.</param> - public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) - { - id = this.NormalizeId(id); - - cachedMod = this.SaveMod(new CachedMod(site, id, mod)); - } - - /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> - /// <param name="age">The minimum age for which to remove mods.</param> - public void RemoveStaleMods(TimeSpan age) - { - DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); - var result = this.Mods.DeleteMany(p => p.LastRequested < minDate); - } - - - /********* - ** Private methods - *********/ - /// <summary>Save data fetched for a mod.</summary> - /// <param name="mod">The mod data.</param> - public CachedMod SaveMod(CachedMod mod) - { - string id = this.NormalizeId(mod.ID); - - this.Mods.ReplaceOne( - entry => entry.ID == id && entry.Site == mod.Site, - mod, - new UpdateOptions { IsUpsert = true } - ); - - return mod; - } - - /// <summary>Normalize a mod ID for case-insensitive search.</summary> - /// <param name="id">The mod ID.</param> - public string NormalizeId(string id) - { - return id.Trim().ToLower(); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs deleted file mode 100644 index 6a103e37..00000000 --- a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; - -namespace StardewModdingAPI.Web.Framework.Caching -{ - /// <summary>Serializes <see cref="DateTimeOffset"/> to a UTC date field instead of the default array.</summary> - public class UtcDateTimeOffsetSerializer : StructSerializerBase<DateTimeOffset> - { - /********* - ** Fields - *********/ - /// <summary>The underlying date serializer.</summary> - private static readonly DateTimeSerializer DateTimeSerializer = new DateTimeSerializer(DateTimeKind.Utc, BsonType.DateTime); - - - /********* - ** Public methods - *********/ - /// <summary>Deserializes a value.</summary> - /// <param name="context">The deserialization context.</param> - /// <param name="args">The deserialization args.</param> - /// <returns>A deserialized value.</returns> - public override DateTimeOffset Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) - { - DateTime date = UtcDateTimeOffsetSerializer.DateTimeSerializer.Deserialize(context, args); - return new DateTimeOffset(date, TimeSpan.Zero); - } - - /// <summary>Serializes a value.</summary> - /// <param name="context">The serialization context.</param> - /// <param name="args">The serialization args.</param> - /// <param name="value">The object.</param> - public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, DateTimeOffset value) - { - UtcDateTimeOffsetSerializer.DateTimeSerializer.Serialize(context, args, value.UtcDateTime); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs deleted file mode 100644 index 7e7c99bc..00000000 --- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using MongoDB.Bson.Serialization.Options; -using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; - -namespace StardewModdingAPI.Web.Framework.Caching.Wiki -{ - /// <summary>The model for cached wiki mods.</summary> - internal class CachedWikiMod - { - /********* - ** Accessors - *********/ - /**** - ** Tracking - ****/ - /// <summary>The internal MongoDB ID.</summary> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] - public ObjectId _id { get; set; } - - /// <summary>When the data was last updated.</summary> - public DateTimeOffset LastUpdated { get; set; } - - /**** - ** Mod info - ****/ - /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary> - public string[] ID { get; set; } - - /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary> - public string[] Name { get; set; } - - /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> - public string[] Author { get; set; } - - /// <summary>The mod ID on Nexus.</summary> - public int? NexusID { get; set; } - - /// <summary>The mod ID in the Chucklefish mod repo.</summary> - public int? ChucklefishID { get; set; } - - /// <summary>The mod ID in the CurseForge mod repo.</summary> - public int? CurseForgeID { get; set; } - - /// <summary>The mod key in the CurseForge mod repo (used in mod page URLs).</summary> - public string CurseForgeKey { get; set; } - - /// <summary>The mod ID in the ModDrop mod repo.</summary> - public int? ModDropID { get; set; } - - /// <summary>The GitHub repository in the form 'owner/repo'.</summary> - public string GitHubRepo { get; set; } - - /// <summary>The URL to a non-GitHub source repo.</summary> - public string CustomSourceUrl { get; set; } - - /// <summary>The custom mod page URL (if applicable).</summary> - public string CustomUrl { get; set; } - - /// <summary>The name of the mod which loads this content pack, if applicable.</summary> - public string ContentPackFor { get; set; } - - /// <summary>The human-readable warnings for players about this mod.</summary> - public string[] Warnings { get; set; } - - /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary> - public string PullRequestUrl { get; set; } - - /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> - public string DevNote { get; set; } - - /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> - public string Anchor { get; set; } - - /**** - ** Stable compatibility - ****/ - /// <summary>The compatibility status.</summary> - public WikiCompatibilityStatus MainStatus { get; set; } - - /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> - public string MainSummary { get; set; } - - /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> - public string MainBrokeIn { get; set; } - - /// <summary>The version of the latest unofficial update, if applicable.</summary> - public string MainUnofficialVersion { get; set; } - - /// <summary>The URL to the latest unofficial update, if applicable.</summary> - public string MainUnofficialUrl { get; set; } - - /**** - ** Beta compatibility - ****/ - /// <summary>The compatibility status.</summary> - public WikiCompatibilityStatus? BetaStatus { get; set; } - - /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> - public string BetaSummary { get; set; } - - /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> - public string BetaBrokeIn { get; set; } - - /// <summary>The version of the latest unofficial update, if applicable.</summary> - public string BetaUnofficialVersion { get; set; } - - /// <summary>The URL to the latest unofficial update, if applicable.</summary> - public string BetaUnofficialUrl { get; set; } - - /**** - ** Version maps - ****/ - /// <summary>Maps local versions to a semantic version for update checks.</summary> - [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] - public IDictionary<string, string> MapLocalVersions { get; set; } - - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] - public IDictionary<string, string> MapRemoteVersions { get; set; } - - - /********* - ** Accessors - *********/ - /// <summary>Construct an instance.</summary> - public CachedWikiMod() { } - - /// <summary>Construct an instance.</summary> - /// <param name="mod">The mod data.</param> - public CachedWikiMod(WikiModEntry mod) - { - // tracking - this.LastUpdated = DateTimeOffset.UtcNow; - - // mod info - this.ID = mod.ID; - this.Name = mod.Name; - this.Author = mod.Author; - this.NexusID = mod.NexusID; - this.ChucklefishID = mod.ChucklefishID; - this.CurseForgeID = mod.CurseForgeID; - this.CurseForgeKey = mod.CurseForgeKey; - this.ModDropID = mod.ModDropID; - this.GitHubRepo = mod.GitHubRepo; - this.CustomSourceUrl = mod.CustomSourceUrl; - this.CustomUrl = mod.CustomUrl; - this.ContentPackFor = mod.ContentPackFor; - this.PullRequestUrl = mod.PullRequestUrl; - this.Warnings = mod.Warnings; - this.DevNote = mod.DevNote; - this.Anchor = mod.Anchor; - - // stable compatibility - this.MainStatus = mod.Compatibility.Status; - this.MainSummary = mod.Compatibility.Summary; - this.MainBrokeIn = mod.Compatibility.BrokeIn; - this.MainUnofficialVersion = mod.Compatibility.UnofficialVersion?.ToString(); - this.MainUnofficialUrl = mod.Compatibility.UnofficialUrl; - - // beta compatibility - this.BetaStatus = mod.BetaCompatibility?.Status; - this.BetaSummary = mod.BetaCompatibility?.Summary; - this.BetaBrokeIn = mod.BetaCompatibility?.BrokeIn; - this.BetaUnofficialVersion = mod.BetaCompatibility?.UnofficialVersion?.ToString(); - this.BetaUnofficialUrl = mod.BetaCompatibility?.UnofficialUrl; - - // version maps - this.MapLocalVersions = mod.MapLocalVersions; - this.MapRemoteVersions = mod.MapRemoteVersions; - } - - /// <summary>Reconstruct the original model.</summary> - public WikiModEntry GetModel() - { - var mod = new WikiModEntry - { - ID = this.ID, - Name = this.Name, - Author = this.Author, - NexusID = this.NexusID, - ChucklefishID = this.ChucklefishID, - CurseForgeID = this.CurseForgeID, - CurseForgeKey = this.CurseForgeKey, - ModDropID = this.ModDropID, - GitHubRepo = this.GitHubRepo, - CustomSourceUrl = this.CustomSourceUrl, - CustomUrl = this.CustomUrl, - ContentPackFor = this.ContentPackFor, - Warnings = this.Warnings, - PullRequestUrl = this.PullRequestUrl, - DevNote = this.DevNote, - Anchor = this.Anchor, - - // stable compatibility - Compatibility = new WikiCompatibilityInfo - { - Status = this.MainStatus, - Summary = this.MainSummary, - BrokeIn = this.MainBrokeIn, - UnofficialVersion = this.MainUnofficialVersion != null ? new SemanticVersion(this.MainUnofficialVersion) : null, - UnofficialUrl = this.MainUnofficialUrl - }, - - // version maps - MapLocalVersions = this.MapLocalVersions, - MapRemoteVersions = this.MapRemoteVersions - }; - - // beta compatibility - if (this.BetaStatus != null) - { - mod.BetaCompatibility = new WikiCompatibilityInfo - { - Status = this.BetaStatus.Value, - Summary = this.BetaSummary, - BrokeIn = this.BetaBrokeIn, - UnofficialVersion = this.BetaUnofficialVersion != null ? new SemanticVersion(this.BetaUnofficialVersion) : null, - UnofficialUrl = this.BetaUnofficialUrl - }; - } - - return mod; - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index b54c8a2f..2ab7ea5a 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Generic; -using System.Linq.Expressions; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki { - /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> + /// <summary>Manages cached wiki data.</summary> internal interface IWikiCacheRepository : ICacheRepository { /********* @@ -13,18 +12,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// <summary>Get the cached wiki metadata.</summary> /// <param name="metadata">The fetched metadata.</param> - bool TryGetWikiMetadata(out CachedWikiMetadata metadata); + bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata); /// <summary>Get the cached wiki mods.</summary> /// <param name="filter">A filter to apply, if any.</param> - IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null); + IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null); /// <summary>Save data fetched from the wiki compatibility list.</summary> /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> /// <param name="mods">The mod data.</param> - /// <param name="cachedMetadata">The stored metadata record.</param> - /// <param name="cachedMods">The stored mod records.</param> - void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods); + void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods); } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs new file mode 100644 index 00000000..064a7c3c --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>Manages cached wiki data in-memory.</summary> + internal class WikiCacheMemoryRepository : BaseCacheRepository, IWikiCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The saved wiki metadata.</summary> + private Cached<WikiMetadata> Metadata; + + /// <summary>The cached wiki data.</summary> + private Cached<WikiModEntry>[] Mods = new Cached<WikiModEntry>[0]; + + + /********* + ** Public methods + *********/ + /// <summary>Get the cached wiki metadata.</summary> + /// <param name="metadata">The fetched metadata.</param> + public bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata) + { + metadata = this.Metadata; + return metadata != null; + } + + /// <summary>Get the cached wiki mods.</summary> + /// <param name="filter">A filter to apply, if any.</param> + public IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null) + { + foreach (var mod in this.Mods) + { + if (filter == null || filter(mod.Data)) + yield return mod; + } + } + + /// <summary>Save data fetched from the wiki compatibility list.</summary> + /// <param name="stableVersion">The current stable Stardew Valley version.</param> + /// <param name="betaVersion">The current beta Stardew Valley version.</param> + /// <param name="mods">The mod data.</param> + public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods) + { + this.Metadata = new Cached<WikiMetadata>(new WikiMetadata(stableVersion, betaVersion)); + this.Mods = mods.Select(mod => new Cached<WikiModEntry>(mod)).ToArray(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs deleted file mode 100644 index 1ae9d38f..00000000 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using MongoDB.Driver; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; - -namespace StardewModdingAPI.Web.Framework.Caching.Wiki -{ - /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> - internal class WikiCacheRepository : BaseCacheRepository, IWikiCacheRepository - { - /********* - ** Fields - *********/ - /// <summary>The collection for wiki metadata.</summary> - private readonly IMongoCollection<CachedWikiMetadata> WikiMetadata; - - /// <summary>The collection for wiki mod data.</summary> - private readonly IMongoCollection<CachedWikiMod> WikiMods; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="database">The authenticated MongoDB database.</param> - public WikiCacheRepository(IMongoDatabase database) - { - // get collections - this.WikiMetadata = database.GetCollection<CachedWikiMetadata>("wiki-metadata"); - this.WikiMods = database.GetCollection<CachedWikiMod>("wiki-mods"); - - // add indexes if needed - this.WikiMods.Indexes.CreateOne(new CreateIndexModel<CachedWikiMod>(Builders<CachedWikiMod>.IndexKeys.Ascending(p => p.ID))); - } - - /// <summary>Get the cached wiki metadata.</summary> - /// <param name="metadata">The fetched metadata.</param> - public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) - { - metadata = this.WikiMetadata.Find("{}").FirstOrDefault(); - return metadata != null; - } - - /// <summary>Get the cached wiki mods.</summary> - /// <param name="filter">A filter to apply, if any.</param> - public IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null) - { - return filter != null - ? this.WikiMods.Find(filter).ToList() - : this.WikiMods.Find("{}").ToList(); - } - - /// <summary>Save data fetched from the wiki compatibility list.</summary> - /// <param name="stableVersion">The current stable Stardew Valley version.</param> - /// <param name="betaVersion">The current beta Stardew Valley version.</param> - /// <param name="mods">The mod data.</param> - /// <param name="cachedMetadata">The stored metadata record.</param> - /// <param name="cachedMods">The stored mod records.</param> - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) - { - cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); - cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); - - this.WikiMods.DeleteMany("{}"); - this.WikiMods.InsertMany(cachedMods); - - this.WikiMetadata.DeleteMany("{}"); - this.WikiMetadata.InsertOne(cachedMetadata); - } - } -} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs index 6a560eb4..c04de4a5 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs @@ -1,22 +1,11 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using MongoDB.Bson; - namespace StardewModdingAPI.Web.Framework.Caching.Wiki { /// <summary>The model for cached wiki metadata.</summary> - internal class CachedWikiMetadata + internal class WikiMetadata { /********* ** Accessors *********/ - /// <summary>The internal MongoDB ID.</summary> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] - public ObjectId _id { get; set; } - - /// <summary>When the data was last updated.</summary> - public DateTimeOffset LastUpdated { get; set; } - /// <summary>The current stable Stardew Valley version.</summary> public string StableVersion { get; set; } @@ -28,16 +17,15 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Public methods *********/ /// <summary>Construct an instance.</summary> - public CachedWikiMetadata() { } + public WikiMetadata() { } /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> - public CachedWikiMetadata(string stableVersion, string betaVersion) + public WikiMetadata(string stableVersion, string betaVersion) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; - this.LastUpdated = DateTimeOffset.UtcNow; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index cdb281e2..ca156da4 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish { @@ -20,6 +21,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.Chucklefish; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -32,42 +40,40 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Chucklefish mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<ChucklefishMod> GetModAsync(uint id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + // get mod ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); + // fetch HTML string html; try { html = await this.Client - .GetAsync(string.Format(this.ModPageUrlFormat, id)) + .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) .AsString(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) { - return null; + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } - - // parse HTML var doc = new HtmlDocument(); doc.LoadHtml(html); // extract mod info - string url = this.GetModUrl(id); + string url = this.GetModUrl(parsedId); string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value; if (name.StartsWith("[SMAPI] ")) name = name.Substring("[SMAPI] ".Length); string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; - // create model - return new ChucklefishMod - { - Name = name, - Version = version, - Url = url - }; + // return info + return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty<IModDownload>()); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs deleted file mode 100644 index fd0101d4..00000000 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish -{ - /// <summary>Mod metadata from the Chucklefish mod site.</summary> - internal class ChucklefishMod - { - /********* - ** 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> - public string Url { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs index 1d8b256e..836d43f7 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish { /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary> - internal interface IChucklefishClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Chucklefish mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<ChucklefishMod> GetModAsync(uint id); - } + internal interface IChucklefishClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index 140b854e..d8008721 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -1,8 +1,8 @@ -using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading.Tasks; using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; namespace StardewModdingAPI.Web.Framework.Clients.CurseForge @@ -21,6 +21,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.CurseForge; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -31,60 +38,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The CurseForge mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<CurseForgeMod> GetModAsync(long id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + // get ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); + // get raw data ModModel mod = await this.Client - .GetAsync($"addon/{id}") + .GetAsync($"addon/{parsedId}") .As<ModModel>(); if (mod == null) - return null; + return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - // get latest versions - string invalidVersion = null; - ISemanticVersion latest = null; + // get downloads + List<IModDownload> downloads = new List<IModDownload>(); foreach (ModFileModel file in mod.LatestFiles) { - // extract version - ISemanticVersion version; - { - string raw = this.GetRawVersion(file); - if (raw == null) - continue; - - if (!SemanticVersion.TryParse(raw, out version)) - { - if (invalidVersion == null) - invalidVersion = raw; - continue; - } - } - - // track latest version - if (latest == null || version.IsNewerThan(latest)) - latest = version; - } - - // get error - string error = null; - if (latest == null && invalidVersion == null) - { - error = mod.LatestFiles.Any() - ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format." - : $"CurseForge mod {id} has no downloads."; + downloads.Add( + new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file)) + ); } - // generate result - return new CurseForgeMod - { - Name = mod.Name, - LatestVersion = latest?.ToString() ?? invalidVersion, - Url = mod.WebsiteUrl, - Error = error - }; + // return info + return page.SetInfo(name: mod.Name, version: null, url: mod.WebsiteUrl, downloads: downloads); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs deleted file mode 100644 index e5bb8cf1..00000000 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Newtonsoft.Json; - -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge -{ - /// <summary>Mod metadata from the CurseForge API.</summary> - internal class CurseForgeMod - { - /********* - ** Accessors - *********/ - /// <summary>The mod name.</summary> - public string Name { get; set; } - - /// <summary>The latest file version.</summary> - public string LatestVersion { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } - - /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - public string Error { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs index 907b4087..2018c230 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.CurseForge { /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> - internal interface ICurseForgeClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The CurseForge mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<CurseForgeMod> GetModAsync(long id); - } + internal interface ICurseForgeClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs new file mode 100644 index 00000000..f08b471c --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -0,0 +1,36 @@ +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// <summary>Generic metadata about a file download on a mod page.</summary> + internal class GenericModDownload : IModDownload + { + /********* + ** Accessors + *********/ + /// <summary>The download's display name.</summary> + public string Name { get; set; } + + /// <summary>The download's description.</summary> + public string Description { get; set; } + + /// <summary>The download's file version.</summary> + public string Version { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public GenericModDownload() { } + + /// <summary>Construct an instance.</summary> + /// <param name="name">The download's display name.</param> + /// <param name="description">The download's description.</param> + /// <param name="version">The download's file version.</param> + public GenericModDownload(string name, string description, string version) + { + this.Name = name; + this.Description = description; + this.Version = version; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs new file mode 100644 index 00000000..622e6c56 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// <summary>Generic metadata about a mod page.</summary> + internal class GenericModPage : IModPage + { + /********* + ** Accessors + *********/ + /// <summary>The mod site containing the mod.</summary> + public ModSiteKey Site { get; set; } + + /// <summary>The mod's unique ID within the site.</summary> + public string Id { get; set; } + + /// <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> + public string Url { get; set; } + + /// <summary>The mod downloads.</summary> + public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public GenericModPage() { } + + /// <summary>Construct an instance.</summary> + /// <param name="site">The mod site containing the mod.</param> + /// <param name="id">The mod's unique ID within the site.</param> + public GenericModPage(ModSiteKey site, string id) + { + this.Site = site; + this.Id = id; + } + + /// <summary>Set the fetched mod info.</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="downloads">The mod downloads.</param> + public IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Downloads = downloads.ToArray(); + + return this; + } + + /// <summary>Set a mod fetch error.</summary> + /// <param name="status">The mod availability status on the remote site.</param> + /// <param name="error">A user-friendly error which indicates why fetching the mod info failed (if applicable).</param> + public IModPage SetError(RemoteModStatus status, string error) + { + this.Status = status; + this.Error = error; + + return this; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 84c20957..2f1eb854 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Clients.GitHub { @@ -17,6 +18,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.GitHub; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -79,6 +87,54 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub } } + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) + { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); + + // fetch repo info + GitRepo repository = await this.GetRepositoryAsync(id); + if (repository == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); + string name = repository.FullName; + string url = $"{repository.WebUrl}/releases"; + + // get releases + GitRelease latest; + GitRelease preview; + { + // get latest release (whether preview or stable) + latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); + if (latest == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); + + // get stable version if different + preview = null; + if (latest.IsPrerelease) + { + GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + if (release != null) + { + preview = latest; + latest = release; + } + } + } + + // get downloads + IModDownload[] downloads = new[] { latest, preview } + .Where(release => release != null) + .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag)) + .ToArray(); + + // return info + return page.SetInfo(name: name, url: url, version: null, downloads: downloads); + } + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index a34f03bd..0d6f4643 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.GitHub { /// <summary>An HTTP client for fetching metadata from GitHub.</summary> - internal interface IGitHubClient : IDisposable + internal interface IGitHubClient : IModSiteClient, IDisposable { /********* ** Methods diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs new file mode 100644 index 00000000..33277711 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// <summary>A client for fetching update check info from a mod site.</summary> + internal interface IModSiteClient + { + /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey { get; } + + + /********* + ** Methods + *********/ + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + Task<IModPage> GetModData(string id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs index 3ede46e2..468b72b1 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop { /// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary> - internal interface IModDropClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The ModDrop mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<ModDropMod> GetModAsync(long id); - } + internal interface IModDropClient : IDisposable, IModSiteClient { } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 5ad2d2f8..3a1c5b9d 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -1,6 +1,7 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop @@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.ModDrop; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -31,60 +39,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop this.ModUrlFormat = modUrlFormat; } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The ModDrop mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<ModDropMod> GetModAsync(long id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + var page = new GenericModPage(this.SiteKey, id); + + if (!long.TryParse(id, out long parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + // get raw data ModListModel response = await this.Client .PostAsync("") .WithBody(new { - ModIDs = new[] { id }, + ModIDs = new[] { parsedId }, Files = true, Mods = true }) .As<ModListModel>(); - ModModel mod = response.Mods[id]; + ModModel mod = response.Mods[parsedId]; if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) return null; - // get latest versions - ISemanticVersion latest = null; - ISemanticVersion optional = null; + // get files + var downloads = new List<IModDownload>(); foreach (FileDataModel file in mod.Files) { if (file.IsOld || file.IsDeleted || file.IsHidden) continue; - if (!SemanticVersion.TryParse(file.Version, out ISemanticVersion version)) - continue; - - if (file.IsDefault) - { - if (latest == null || version.IsNewerThan(latest)) - latest = version; - } - else if (optional == null || version.IsNewerThan(optional)) - optional = version; + downloads.Add( + new GenericModDownload(file.Name, file.Description, file.Version) + ); } - if (latest == null) - { - latest = optional; - optional = null; - } - if (optional != null && latest.IsNewerThan(optional)) - optional = null; - // generate result - return new ModDropMod - { - Name = mod.Mod?.Title, - LatestDefaultVersion = latest, - LatestOptionalVersion = optional, - Url = string.Format(this.ModUrlFormat, id) - }; + // return info + string name = mod.Mod?.Title; + string url = string.Format(this.ModUrlFormat, id); + return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs deleted file mode 100644 index def79106..00000000 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop -{ - /// <summary>Mod metadata from the ModDrop API.</summary> - internal class ModDropMod - { - /********* - ** Accessors - *********/ - /// <summary>The mod name.</summary> - public string Name { get; set; } - - /// <summary>The latest default file version.</summary> - public ISemanticVersion LatestDefaultVersion { get; set; } - - /// <summary>The latest optional file version.</summary> - public ISemanticVersion LatestOptionalVersion { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index fa84b287..b01196f4 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -1,8 +1,21 @@ +using Newtonsoft.Json; + namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels { /// <summary>Metadata from the ModDrop API about a mod file.</summary> public class FileDataModel { + /// <summary>The file title.</summary> + [JsonProperty("title")] + public string Name { get; set; } + + /// <summary>The file description.</summary> + [JsonProperty("desc")] + public string Description { get; set; } + + /// <summary>The file version.</summary> + public string Version { get; set; } + /// <summary>Whether the file is deleted.</summary> public bool IsDeleted { get; set; } @@ -14,8 +27,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>Whether this is an archived file.</summary> public bool IsOld { get; set; } - - /// <summary>The file version.</summary> - public string Version { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs index e56e7af4..a44b8c66 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary> - internal interface INexusClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<NexusMod> GetModAsync(uint id); - } + internal interface INexusClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 753d3b4f..ef3ef22e 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -7,6 +7,8 @@ using HtmlAgilityPack; using Pathoschild.FluentNexus.Models; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels; using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; namespace StardewModdingAPI.Web.Framework.Clients.Nexus @@ -31,6 +33,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -48,20 +57,32 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<NexusMod> GetModAsync(uint id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); + // Fetch from the Nexus website when possible, since it has no rate limits. Mods with // adult content are hidden for anonymous users, so fall back to the API in that case. // Note that the API has very restrictive rate limits which means we can't just use it // for all cases. - NexusMod mod = await this.GetModFromWebsiteAsync(id); + NexusMod mod = await this.GetModFromWebsiteAsync(parsedId); if (mod?.Status == NexusModStatus.AdultContentForbidden) - mod = await this.GetModFromApiAsync(id); + mod = await this.GetModFromApiAsync(parsedId); + + // page doesn't exist + if (mod == null || mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); - return mod; + // return info + page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads); + if (mod.Status != NexusModStatus.Ok) + page.SetError(RemoteModStatus.TemporaryError, mod.Error); + return page; } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> @@ -115,37 +136,28 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus // extract mod info string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); + string name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); - // extract file versions - List<string> rawVersions = new List<string>(); + // extract files + var downloads = new List<IModDownload>(); foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) { string sectionName = fileSection.Descendants("h2").First().InnerText; if (sectionName != "Main files" && sectionName != "Optional files") continue; - rawVersions.AddRange( - from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) - from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) - select versionStat.InnerText.Trim() - ); - } - - // choose latest file version - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in rawVersions) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; + foreach (var container in fileSection.Descendants("dt")) + { + string fileName = container.GetDataAttribute("name").Value; + string fileVersion = container.GetDataAttribute("version").Value; + string description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next <dd> tag; derived from https://stackoverflow.com/a/25535623/262123 - latestFileVersion = cur; + downloads.Add( + new GenericModDownload(fileName, description, fileVersion) + ); + } } // yield info @@ -153,8 +165,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus { Name = name, Version = parsedVersion?.ToString() ?? version, - LatestFileVersion = latestFileVersion, - Url = url + Url = url, + Downloads = downloads.ToArray() }; } @@ -167,29 +179,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); - // get versions - if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion)) - mainVersion = null; - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in files.Files.Select(p => p.FileVersion)) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (mainVersion != null && !cur.IsNewerThan(mainVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; - - latestFileVersion = cur; - } - // yield info return new NexusMod { Name = mod.Name, Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, - LatestFileVersion = latestFileVersion, - Url = this.GetModUrl(id) + Url = this.GetModUrl(id), + Downloads = files.Files + .Select(file => (IModDownload)new GenericModDownload(file.Name, null, file.FileVersion)) + .ToArray() }; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs index 0f1b29d5..aef90ede 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus +namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels { /// <summary>Mod metadata from Nexus Mods.</summary> internal class NexusMod @@ -14,9 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>The mod's semantic version number.</summary> public string Version { get; set; } - /// <summary>The latest file version.</summary> - public ISemanticVersion LatestFileVersion { get; set; } - /// <summary>The mod's web URL.</summary> [JsonProperty("mod_page_uri")] public string Url { get; set; } @@ -25,7 +22,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus [JsonIgnore] public NexusModStatus Status { get; set; } = NexusModStatus.Ok; - /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + /// <summary>The files available to download.</summary> + [JsonIgnore] + public IModDownload[] Downloads { get; set; } + + /// <summary>A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> [JsonIgnore] public string Error { get; set; } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs index cc8f4737..676d660d 100644 --- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.Compression return rawText; // decompress - using (MemoryStream memoryStream = new MemoryStream()) + using MemoryStream memoryStream = new MemoryStream(); { // read length prefix int dataLength = BitConverter.ToInt32(zipBuffer, 0); diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs deleted file mode 100644 index c7b6cb00..00000000 --- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ConfigModels -{ - /// <summary>The config settings for mod compatibility list.</summary> - internal class MongoDbConfig - { - /********* - ** Accessors - *********/ - /// <summary>The MongoDB connection string.</summary> - public string ConnectionString { get; set; } - - /// <summary>The database name.</summary> - public string Database { get; set; } - - - /********* - ** Public method - *********/ - /// <summary>Get whether a MongoDB instance is configured.</summary> - public bool IsConfigured() - { - return !string.IsNullOrWhiteSpace(this.ConnectionString); - } - } -} diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index e0da1424..3a246245 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -1,14 +1,24 @@ using System; using JetBrains.Annotations; +using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Routing; +using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework { /// <summary>Provides extensions on ASP.NET Core types.</summary> public static class Extensions { + /********* + ** Public methods + *********/ + /**** + ** View helpers + ****/ /// <summary>Get a URL with the absolute path for an action method. Unlike <see cref="IUrlHelper.Action"/>, only the specified <paramref name="values"/> are added to the URL without merging values from the current HTTP request.</summary> /// <param name="helper">The URL helper to extend.</param> /// <param name="action">The name of the action method.</param> @@ -18,6 +28,7 @@ namespace StardewModdingAPI.Web.Framework /// <returns>The generated URL.</returns> public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) { + // get route values RouteValueDictionary valuesDict = new RouteValueDictionary(values); foreach (var value in helper.ActionContext.RouteData.Values) { @@ -25,14 +36,31 @@ namespace StardewModdingAPI.Web.Framework valuesDict[value.Key] = null; // explicitly remove it from the URL } + // get relative URL string url = helper.Action(action, controller, valuesDict); + if (url == null && action.EndsWith("Async")) + url = helper.Action(action[..^"Async".Length], controller, valuesDict); + + // get absolute URL if (absoluteUrl) { HttpRequest request = helper.ActionContext.HttpContext.Request; Uri baseUri = new Uri($"{request.Scheme}://{request.Host}"); url = new Uri(baseUri, url).ToString(); } + return url; } + + /// <summary>Get a serialized JSON representation of the value.</summary> + /// <param name="page">The page to extend.</param> + /// <param name="value">The value to serialize.</param> + /// <returns>The serialized JSON.</returns> + /// <remarks>This bypasses unnecessary validation (e.g. not allowing null values) in <see cref="IJsonHelper.Serialize"/>.</remarks> + public static IHtmlContent ForJson(this RazorPageBase page, object value) + { + string json = JsonConvert.SerializeObject(value); + return new HtmlString(json); + } } } diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs new file mode 100644 index 00000000..dc058bcb --- /dev/null +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Generic metadata about a file download on a mod page.</summary> + internal interface IModDownload + { + /// <summary>The download's display name.</summary> + string Name { get; } + + /// <summary>The download's description.</summary> + string Description { get; } + + /// <summary>The download's file version.</summary> + string Version { get; } + } +} diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs new file mode 100644 index 00000000..e66d401f --- /dev/null +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Generic metadata about a mod page.</summary> + internal interface IModPage + { + /********* + ** Accessors + *********/ + /// <summary>The mod site containing the mod.</summary> + ModSiteKey Site { get; } + + /// <summary>The mod's unique ID within the site.</summary> + string Id { get; } + + /// <summary>The mod name.</summary> + string Name { get; } + + /// <summary>The mod's semantic version number.</summary> + string Version { get; } + + /// <summary>The mod's web URL.</summary> + string Url { get; } + + /// <summary>The mod downloads.</summary> + IModDownload[] Downloads { get; } + + /// <summary>The mod page status.</summary> + RemoteModStatus Status { get; } + + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + string Error { get; } + + + /********* + ** Methods + *********/ + /// <summary>Set the fetched mod info.</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="downloads">The mod downloads.</param> + IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads); + + /// <summary>Set a mod fetch error.</summary> + /// <param name="status">The mod availability status on the remote site.</param> + /// <param name="error">A user-friendly error which indicates why fetching the mod info failed (if applicable).</param> + IModPage SetError(RemoteModStatus status, string error); + } +} diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index cce80816..227dcd89 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> - private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SmapiUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* @@ -181,9 +181,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing message.Section = LogSection.ModUpdateList; } - else if (message.Level == LogLevel.Alert && this.SMAPIUpdatePattern.IsMatch(message.Text)) + else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { - Match match = this.SMAPIUpdatePattern.Match(message.Text); + Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; smapiMod.UpdateVersion = version; diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index 46b98860..7845b8c5 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -1,4 +1,6 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework { /// <summary>Generic metadata about a mod.</summary> internal class ModInfoModel @@ -10,20 +12,14 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories public string Name { get; set; } /// <summary>The mod's latest version.</summary> - public string Version { get; set; } + public ISemanticVersion Version { get; set; } /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary> - public string PreviewVersion { get; set; } + public ISemanticVersion PreviewVersion { get; set; } /// <summary>The mod's web URL.</summary> public string Url { get; set; } - /// <summary>The license URL, if available.</summary> - public string LicenseUrl { get; set; } - - /// <summary>The license name, if available.</summary> - public string LicenseName { get; set; } - /// <summary>The mod availability status on the remote site.</summary> public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; @@ -42,7 +38,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> /// <param name="url">The mod's web URL.</param> - public ModInfoModel(string name, string version, string url, string previewVersion = null) + public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) { this .SetBasicInfo(name, url) @@ -63,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <summary>Set the mod version info.</summary> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> - public ModInfoModel SetVersions(string version, string previewVersion = null) + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) { this.Version = version; this.PreviewVersion = previewVersion; @@ -71,17 +67,6 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories return this; } - /// <summary>Set the license info, if available.</summary> - /// <param name="url">The license URL.</param> - /// <param name="name">The license name.</param> - public ModInfoModel SetLicense(string url, string name) - { - this.LicenseUrl = url; - this.LicenseName = name; - - return this; - } - /// <summary>Set a mod error.</summary> /// <param name="status">The mod availability status on the remote site.</param> /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs deleted file mode 100644 index f9f9f47d..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - internal abstract class RepositoryBase : IModRepository - { - /********* - ** Accessors - *********/ - /// <summary>The unique key for this vendor.</summary> - public ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public abstract void Dispose(); - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public abstract Task<ModInfoModel> GetModInfoAsync(string id); - - - /********* - ** Protected methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="vendorKey">The unique key for this vendor.</param> - protected RepositoryBase(ModRepositoryKey vendorKey) - { - this.VendorKey = vendorKey; - } - - /// <summary>Normalize a version string.</summary> - /// <param name="version">The version to normalize.</param> - protected string NormalizeVersion(string version) - { - if (string.IsNullOrWhiteSpace(version)) - return null; - - version = version.Trim(); - if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix - version = version.Substring(1); - - return version; - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs deleted file mode 100644 index 0945735a..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.Chucklefish; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary> - internal class ChucklefishRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying HTTP client.</summary> - private readonly IChucklefishClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying HTTP client.</param> - public ChucklefishRepository(IChucklefishClient client) - : base(ModRepositoryKey.Chucklefish) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint realID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); - - // fetch info - try - { - var mod = await this.Client.GetModAsync(realID); - return mod != null - ? new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), url: mod.Url) - : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs deleted file mode 100644 index 93ddc1eb..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.CurseForge; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from CurseForge.</summary> - internal class CurseForgeRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying CurseForge API client.</summary> - private readonly ICurseForgeClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying CurseForge API client.</param> - public CurseForgeRepository(ICurseForgeClient client) - : base(ModRepositoryKey.CurseForge) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint curseID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); - - // fetch info - try - { - CurseForgeMod mod = await this.Client.GetModAsync(curseID); - if (mod == null) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - if (mod.Error != null) - { - RemoteModStatus remoteStatus = RemoteModStatus.InvalidData; - return new ModInfoModel().SetError(remoteStatus, mod.Error); - } - - return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs deleted file mode 100644 index c62cb73f..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.GitHub; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from GitHub project releases.</summary> - internal class GitHubRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying GitHub API client.</summary> - private readonly IGitHubClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying GitHub API client.</param> - public GitHubRepository(IGitHubClient client) - : base(ModRepositoryKey.GitHub) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - ModInfoModel result = new ModInfoModel().SetBasicInfo(id, $"https://github.com/{id}/releases"); - - // validate ID format - if (!id.Contains("/") || id.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != id.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) - return result.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); - - // fetch info - try - { - // fetch repo info - GitRepo repository = await this.Client.GetRepositoryAsync(id); - if (repository == null) - return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); - result - .SetBasicInfo(repository.FullName, $"{repository.WebUrl}/releases") - .SetLicense(url: repository.License?.Url, name: repository.License?.SpdxId ?? repository.License?.Name); - - // get latest release (whether preview or stable) - GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); - if (latest == null) - return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); - - // split stable/prerelease if applicable - GitRelease preview = null; - if (latest.IsPrerelease) - { - GitRelease release = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); - if (release != null) - { - preview = latest; - latest = release; - } - } - - // return data - return result.SetVersions(version: this.NormalizeVersion(latest.Tag), previewVersion: this.NormalizeVersion(preview?.Tag)); - } - catch (Exception ex) - { - return result.SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs deleted file mode 100644 index 68f754ae..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; - -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> - ModRepositoryKey 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/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs deleted file mode 100644 index 62142668..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.ModDrop; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary> - internal class ModDropRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying ModDrop API client.</summary> - private readonly IModDropClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying Nexus Mods API client.</param> - public ModDropRepository(IModDropClient client) - : base(ModRepositoryKey.ModDrop) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!long.TryParse(id, out long modDropID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); - - // fetch info - try - { - ModDropMod mod = await this.Client.GetModAsync(modDropID); - return mod != null - ? new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url) - : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID."); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs deleted file mode 100644 index 9551258c..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.Nexus; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary> - internal class NexusRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying Nexus Mods API client.</summary> - private readonly INexusClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying Nexus Mods API client.</param> - public NexusRepository(INexusClient client) - : base(ModRepositoryKey.Nexus) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint nexusID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); - - // fetch info - try - { - NexusMod mod = await this.Client.GetModAsync(nexusID); - if (mod == null) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); - if (mod.Error != null) - { - RemoteModStatus remoteStatus = mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished - ? RemoteModStatus.DoesNotExist - : RemoteModStatus.TemporaryError; - return new ModInfoModel().SetError(remoteStatus, mod.Error); - } - - return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs new file mode 100644 index 00000000..68b4c6ac --- /dev/null +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Handles fetching data from mod sites.</summary> + internal class ModSiteManager + { + /********* + ** Fields + *********/ + /// <summary>The mod sites which provide mod metadata.</summary> + private readonly IDictionary<ModSiteKey, IModSiteClient> ModSites; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modSites">The mod sites which provide mod metadata.</param> + public ModSiteManager(IModSiteClient[] modSites) + { + this.ModSites = modSites.ToDictionary(p => p.SiteKey); + } + + /// <summary>Get the mod info for an update key.</summary> + /// <param name="updateKey">The namespaced update key.</param> + public async Task<IModPage> GetModPageAsync(UpdateKey updateKey) + { + // get site + if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client)) + return new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Site}'. Expected one of [{string.Join(", ", this.ModSites.Keys)}]."); + + // fetch mod + IModPage mod; + try + { + mod = await client.GetModData(updateKey.ID); + } + catch (Exception ex) + { + mod = new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.TemporaryError, ex.ToString()); + } + + // handle errors + return mod ?? new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"Found no {updateKey.Site} mod with ID '{updateKey.ID}'."); + } + + /// <summary>Parse version info for the given mod page info.</summary> + /// <param name="page">The mod page info.</param> + /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> + /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> + public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) + { + // get base model + ModInfoModel model = new ModInfoModel() + .SetBasicInfo(page.Name, page.Url) + .SetError(page.Status, page.Error); + if (page.Status != RemoteModStatus.Ok) + return model; + + // fetch versions + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion); + if (!hasVersions && subkey != null) + hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); + if (!hasVersions) + return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); + + // return info + return model.SetVersions(mainVersion, previewVersion); + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to parse.</param> + /// <param name="map">A map of version replacements.</param> + /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> + public ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + { + // try mapped version + string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) + return parsedNew; + + // return original version + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) + ? parsedOld + : null; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the mod version numbers for the given mod.</summary> + /// <param name="mod">The mod to check.</param> + /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> + /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> + /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="main">The main mod version.</param> + /// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param> + private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + { + main = null; + preview = null; + + ISemanticVersion ParseVersion(string raw) + { + raw = this.NormalizeVersion(raw); + return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); + } + + if (mod != null) + { + // get mod version + if (subkey == null) + main = ParseVersion(mod.Version); + + // get file versions + foreach (IModDownload download in mod.Downloads) + { + // check for subkey if specified + if (subkey != null && download.Name?.Contains(subkey, StringComparison.OrdinalIgnoreCase) != true && download.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) != true) + continue; + + // parse version + ISemanticVersion cur = ParseVersion(download.Version); + if (cur == null) + continue; + + // track highest versions + if (main == null || cur.IsNewerThan(main)) + main = cur; + if (cur.IsPrerelease() && (preview == null || cur.IsNewerThan(preview))) + preview = cur; + } + + if (preview != null && !preview.IsNewerThan(main)) + preview = null; + } + + return main != null; + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to map.</param> + /// <param name="map">A map of version replacements.</param> + /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> + private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + { + if (version == null || map == null || !map.Any()) + return version; + + // match exact raw version + if (map.ContainsKey(version)) + return map[version]; + + // match parsed version + if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) + { + if (map.ContainsKey(parsed.ToString())) + return map[parsed.ToString()]; + + foreach ((string fromRaw, string toRaw) in map) + { + if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) + return newVersion.ToString(); + } + } + + return version; + } + + /// <summary>Normalize a version string.</summary> + /// <param name="version">The version to normalize.</param> + private string NormalizeVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + version = version.Trim(); + if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix + version = version.Substring(1); + + return version; + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs new file mode 100644 index 00000000..d75ee791 --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs @@ -0,0 +1,54 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect hostnames to a URL if they match a condition.</summary> + internal class RedirectHostsToUrlsRule : RedirectMatchRule + { + /********* + ** Fields + *********/ + /// <summary>Maps a lowercase hostname to the resulting redirect URL.</summary> + private readonly Func<string, string> Map; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="statusCode">The status code to use for redirects.</param> + /// <param name="map">Hostnames mapped to the resulting redirect URL.</param> + public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string> map) + { + this.StatusCode = statusCode; + this.Map = map ?? throw new ArgumentNullException(nameof(map)); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected override string GetNewUrl(RewriteContext context) + { + // get requested host + string host = context.HttpContext.Request.Host.Host; + if (host == null) + return null; + + // get new host + host = this.Map(host); + if (host == null) + return null; + + // rewrite URL + UriBuilder uri = this.GetUrl(context.HttpContext.Request); + uri.Host = host; + return uri.ToString(); + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs new file mode 100644 index 00000000..6e81c4ca --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs @@ -0,0 +1,58 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect matching requests to a URL.</summary> + internal abstract class RedirectMatchRule : IRule + { + /********* + ** Fields + *********/ + /// <summary>The status code to use for redirects.</summary> + protected HttpStatusCode StatusCode { get; set; } = HttpStatusCode.Redirect; + + + /********* + ** Public methods + *********/ + /// <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) + { + string newUrl = this.GetNewUrl(context); + if (newUrl == null) + return; + + HttpResponse response = context.HttpContext.Response; + response.StatusCode = (int)HttpStatusCode.Redirect; + response.Headers["Location"] = newUrl; + context.Result = RuleResult.EndResponse; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected abstract string GetNewUrl(RewriteContext context); + + /// <summary>Get the full request URL.</summary> + /// <param name="request">The request.</param> + protected UriBuilder GetUrl(HttpRequest request) + { + return new UriBuilder + { + Scheme = request.Scheme, + Host = request.Host.Host, + Port = request.Host.Port ?? -1, + Path = request.PathBase + request.Path, + Query = request.QueryString.Value + }; + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs new file mode 100644 index 00000000..d9d44641 --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect paths to URLs if they match a condition.</summary> + internal class RedirectPathsToUrlsRule : RedirectMatchRule + { + /********* + ** Fields + *********/ + /// <summary>Regex patterns matching the current URL mapped to the resulting redirect URL.</summary> + private readonly IDictionary<Regex, string> Map; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="map">Regex patterns matching the current URL mapped to the resulting redirect URL.</param> + public RedirectPathsToUrlsRule(IDictionary<string, string> map) + { + this.StatusCode = HttpStatusCode.RedirectKeepVerb; + this.Map = map.ToDictionary( + p => new Regex(p.Key, RegexOptions.IgnoreCase | RegexOptions.Compiled), + p => p.Value + ); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected override string GetNewUrl(RewriteContext context) + { + string path = context.HttpContext.Request.Path.Value; + + if (!string.IsNullOrWhiteSpace(path)) + { + foreach ((Regex pattern, string url) in this.Map) + { + if (pattern.IsMatch(path)) + return pattern.Replace(path, url); + } + } + + return null; + } + } +} diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs new file mode 100644 index 00000000..2a503ae3 --- /dev/null +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs @@ -0,0 +1,47 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RedirectRules +{ + /// <summary>Redirect requests to HTTPS.</summary> + internal class RedirectToHttpsRule : RedirectMatchRule + { + /********* + ** Fields + *********/ + /// <summary>Matches requests which should be ignored.</summary> + private readonly Func<HttpRequest, bool> Except; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="except">Matches requests which should be ignored.</param> + public RedirectToHttpsRule(Func<HttpRequest, bool> except = null) + { + this.Except = except ?? (req => false); + this.StatusCode = HttpStatusCode.RedirectKeepVerb; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get the new redirect URL.</summary> + /// <param name="context">The rewrite context.</param> + /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> + protected override string GetNewUrl(RewriteContext context) + { + HttpRequest request = context.HttpContext.Request; + if (request.IsHttps || this.Except(request)) + return null; + + UriBuilder uri = this.GetUrl(request); + uri.Scheme = "https"; + return uri.ToString(); + } + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs index 02876556..139ecfd3 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs +++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories +namespace StardewModdingAPI.Web.Framework { /// <summary>The mod availability status on a remote site.</summary> internal enum RemoteModStatus diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs deleted file mode 100644 index 36effd82..00000000 --- a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Net; -using System.Text; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace StardewModdingAPI.Web.Framework.RewriteRules -{ - /// <summary>Redirect requests to HTTPS.</summary> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/44526747/262123" /> and <see cref="Microsoft.AspNetCore.Rewrite.Internal.RedirectToHttpsRule"/>.</remarks> - internal class ConditionalRedirectToHttpsRule : IRule - { - /********* - ** Fields - *********/ - /// <summary>A predicate which indicates when the rule should be applied.</summary> - private readonly Func<HttpRequest, bool> ShouldRewrite; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param> - public ConditionalRedirectToHttpsRule(Func<HttpRequest, bool> shouldRewrite = null) - { - this.ShouldRewrite = shouldRewrite ?? (req => true); - } - - /// <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) - { - HttpRequest request = context.HttpContext.Request; - - // check condition - if (this.IsSecure(request) || !this.ShouldRewrite(request)) - return; - - // redirect request - HttpResponse response = context.HttpContext.Response; - response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb; - response.Headers["Location"] = new StringBuilder() - .Append("https://") - .Append(request.Host.Host) - .Append(request.PathBase) - .Append(request.Path) - .Append(request.QueryString) - .ToString(); - context.Result = RuleResult.EndResponse; - } - - /// <summary>Get whether the request was received over HTTPS.</summary> - /// <param name="request">The request to check.</param> - public bool IsSecure(HttpRequest request) - { - return - request.IsHttps // HTTPS to server - || string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer - } - } -} diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs deleted file mode 100644 index ab9e019c..00000000 --- a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Net; -using System.Text.RegularExpressions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; - -namespace StardewModdingAPI.Web.Framework.RewriteRules -{ - /// <summary>Redirect requests to an external URL if they match a condition.</summary> - internal class RedirectToUrlRule : IRule - { - /********* - ** Fields - *********/ - /// <summary>Get the new URL to which to redirect (or <c>null</c> to skip).</summary> - private readonly Func<HttpRequest, string> NewUrl; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="shouldRewrite">A predicate which indicates when the rule should be applied.</param> - /// <param name="url">The new URL to which to redirect.</param> - public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url) - { - this.NewUrl = req => shouldRewrite(req) ? url : null; - } - - /// <summary>Construct an instance.</summary> - /// <param name="pathRegex">A case-insensitive regex to match against the path.</param> - /// <param name="url">The external URL.</param> - public RedirectToUrlRule(string pathRegex, string url) - { - Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled); - this.NewUrl = req => req.Path.HasValue ? regex.Replace(req.Path.Value, url) : null; - } - - /// <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) - { - HttpRequest request = context.HttpContext.Request; - - // check rewrite - string newUrl = this.NewUrl(request); - if (newUrl == null || newUrl == request.Path.Value) - return; - - // redirect request - HttpResponse response = context.HttpContext.Response; - response.StatusCode = (int)HttpStatusCode.Redirect; - response.Headers["Location"] = newUrl; - context.Result = RuleResult.EndResponse; - } - } -} diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs index 70082160..1fdd3185 100644 --- a/src/SMAPI.Web/Program.cs +++ b/src/SMAPI.Web/Program.cs @@ -1,5 +1,5 @@ -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; namespace StardewModdingAPI.Web { @@ -13,13 +13,13 @@ namespace StardewModdingAPI.Web /// <param name="args">The command-line arguments.</param> public static void Main(string[] args) { - // configure web server - WebHost + Host .CreateDefaultBuilder(args) - .CaptureStartupErrors(true) - .UseSetting("detailedErrors", "true") - .UseKestrel().UseIISIntegration() // must be used together; fixes intermittent errors on Azure: https://stackoverflow.com/a/38312175/262123 - .UseStartup<Startup>() + .ConfigureWebHostDefaults(builder => builder + .CaptureStartupErrors(true) + .UseSetting("detailedErrors", "true") + .UseStartup<Startup>() + ) .Build() .Run(); } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 0a978b30..c6c0f774 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -3,7 +3,7 @@ <PropertyGroup> <AssemblyName>SMAPI.Web</AssemblyName> <RootNamespace>StardewModdingAPI.Web</RootNamespace> - <TargetFramework>netcoreapp2.0</TargetFramework> + <TargetFramework>netcoreapp3.1</TargetFramework> <LangVersion>latest</LangVersion> </PropertyGroup> @@ -12,23 +12,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Azure.Storage.Blobs" Version="12.4.0" /> - <PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" /> + <PackageReference Include="Azure.Storage.Blobs" Version="12.4.2" /> + <PackageReference Include="Hangfire.AspNetCore" Version="1.7.11" /> <PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" /> - <PackageReference Include="Hangfire.Mongo" Version="0.6.7" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.23" /> - <PackageReference Include="Humanizer.Core" Version="2.7.9" /> - <PackageReference Include="JetBrains.Annotations" Version="2019.1.3" /> - <PackageReference Include="Markdig" Version="0.18.3" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> - <PackageReference Include="Mongo2Go" Version="2.2.12" /> - <PackageReference Include="MongoDB.Driver" Version="2.10.2" /> + <PackageReference Include="Humanizer.Core" Version="2.8.11" /> + <PackageReference Include="JetBrains.Annotations" Version="2020.1.0" /> + <PackageReference Include="Markdig" Version="0.20.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" /> - <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.0" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> + <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.1" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="4.0.0" /> </ItemGroup> <ItemGroup> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 56ef9a79..586b0c3c 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,8 +1,7 @@ -using System; using System.Collections.Generic; +using System.Net; using Hangfire; using Hangfire.MemoryStorage; -using Hangfire.Mongo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Rewrite; @@ -10,13 +9,9 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Mongo2Go; -using MongoDB.Bson.Serialization; -using MongoDB.Driver; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Web.Framework; -using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; @@ -27,7 +22,7 @@ using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; -using StardewModdingAPI.Web.Framework.RewriteRules; +using StardewModdingAPI.Web.Framework.RedirectRules; using StardewModdingAPI.Web.Framework.Storage; namespace StardewModdingAPI.Web @@ -47,7 +42,7 @@ namespace StardewModdingAPI.Web *********/ /// <summary>Construct an instance.</summary> /// <param name="env">The hosting environment.</param> - public Startup(IHostingEnvironment env) + public Startup(IWebHostEnvironment env) { this.Configuration = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) @@ -67,70 +62,41 @@ namespace StardewModdingAPI.Web .Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices")) .Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList")) .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) - .Configure<MongoDbConfig>(this.Configuration.GetSection("MongoDB")) .Configure<SiteConfig>(this.Configuration.GetSection("Site")) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddLogging() - .AddMemoryCache() - .AddMvc() - .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())) - .AddJsonOptions(options => - { - foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters) - options.SerializerSettings.Converters.Add(converter); - - options.SerializerSettings.Formatting = Formatting.Indented; - options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; - }); - MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get<MongoDbConfig>(); - - // init background service - { - BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>(); - if (config.Enabled) - services.AddHostedService<BackgroundService>(); - } + .AddMemoryCache(); - // init MongoDB - services.AddSingleton<MongoDbRunner>(serv => !mongoConfig.IsConfigured() - ? MongoDbRunner.Start() - : throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.") - ); - services.AddSingleton<IMongoDatabase>(serv => - { - // get connection string - string connectionString = mongoConfig.IsConfigured() - ? mongoConfig.ConnectionString - : serv.GetRequiredService<MongoDbRunner>().ConnectionString; + // init MVC + services + .AddControllers() + .AddNewtonsoftJson(options => this.ConfigureJsonNet(options.SerializerSettings)) + .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())); + services + .AddRazorPages(); - // get client - BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); - return new MongoClient(connectionString).GetDatabase(mongoConfig.Database); - }); - services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>())); - services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>())); + // init storage + services.AddSingleton<IModCacheRepository>(new ModCacheMemoryRepository()); + services.AddSingleton<IWikiCacheRepository>(new WikiCacheMemoryRepository()); // init Hangfire services - .AddHangfire(config => + .AddHangfire((serv, config) => { config .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings(); - - if (mongoConfig.IsConfigured()) - { - config.UseMongoStorage(mongoConfig.ConnectionString, $"{mongoConfig.Database}-hangfire", new MongoStorageOptions - { - MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), - CheckConnection = false // error on startup takes down entire process - }); - } - else - config.UseMemoryStorage(); + .UseRecommendedSerializerSettings() + .UseMemoryStorage(); }); + // init background service + { + BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>(); + if (config.Enabled) + services.AddHostedService<BackgroundService>(); + } + // init API clients { ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>(); @@ -142,6 +108,7 @@ namespace StardewModdingAPI.Web baseUrl: api.ChucklefishBaseUrl, modPageUrlFormat: api.ChucklefishModPageUrlFormat )); + services.AddSingleton<ICurseForgeClient>(new CurseForgeClient( userAgent: userAgent, apiUrl: api.CurseForgeBaseUrl @@ -188,8 +155,7 @@ namespace StardewModdingAPI.Web /// <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> - public void Configure(IApplicationBuilder app, IHostingEnvironment env) + public void Configure(IApplicationBuilder app) { // basic config app.UseDeveloperExceptionPage(); @@ -201,7 +167,13 @@ namespace StardewModdingAPI.Web ) .UseRewriter(this.GetRedirectRules()) .UseStaticFiles() // wwwroot folder - .UseMvc(); + .UseRouting() + .UseAuthorization() + .UseEndpoints(p => + { + p.MapControllers(); + p.MapRazorPages(); + }); // enable Hangfire dashboard app.UseHangfireDashboard("/tasks", new DashboardOptions @@ -215,29 +187,63 @@ namespace StardewModdingAPI.Web /********* ** Private methods *********/ + /// <summary>Configure a Json.NET serializer.</summary> + /// <param name="settings">The serializer settings to edit.</param> + private void ConfigureJsonNet(JsonSerializerSettings settings) + { + foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters) + settings.Converters.Add(converter); + + settings.Formatting = Formatting.Indented; + settings.NullValueHandling = NullValueHandling.Ignore; + } + /// <summary>Get the redirect rules to apply.</summary> private RewriteOptions GetRedirectRules() { - var redirects = new RewriteOptions(); + var redirects = new RewriteOptions() + // shortcut paths + .Add(new RedirectPathsToUrlsRule(new Dictionary<string, string> + { + [@"^/3\.0\.?$"] = "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0", + [@"^/(?:buildmsg|package)(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", // buildmsg deprecated, remove when SDV 1.4 is released + [@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community", + [@"^/compat\.?$"] = "https://smapi.io/mods", + [@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index", + [@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI", + [@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1", + [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods" + })) + + // legacy paths + .Add(new RedirectPathsToUrlsRule(this.GetLegacyPathRedirects())) + + // subdomains + .Add(new RedirectHostsToUrlsRule(HttpStatusCode.PermanentRedirect, host => host switch + { + "api.smapi.io" => "smapi.io/api", + "json.smapi.io" => "smapi.io/json", + "log.smapi.io" => "smapi.io/log", + "mods.smapi.io" => "smapi.io/mods", + _ => host.EndsWith(".smapi.io") + ? "smapi.io" + : null + })) - // redirect to HTTPS (except API for Linux/Mac Mono compatibility) - redirects.Add(new ConditionalRedirectToHttpsRule( - shouldRewrite: req => - req.Host.Host != "localhost" - && !req.Path.StartsWithSegments("/api") - )); + // redirect to HTTPS (except API for Linux/Mac Mono compatibility) + .Add( + new RedirectToHttpsRule(except: req => req.Host.Host == "localhost" || req.Path.StartsWithSegments("/api")) + ); - // shortcut redirects - redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); - redirects.Add(new RedirectToUrlRule(@"^/(?:buildmsg|package)(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1")); // buildmsg deprecated, remove when SDV 1.4 is released - redirects.Add(new RedirectToUrlRule(@"^/community\.?$", "https://stardewvalleywiki.com/Modding:Community")); - redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://smapi.io/mods")); - redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); - redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); - redirects.Add(new RedirectToUrlRule(@"^/troubleshoot(.*)$", "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1")); - redirects.Add(new RedirectToUrlRule(@"^/xnb\.?$", "https://stardewvalleywiki.com/Modding:Using_XNB_mods")); + return redirects; + } + + /// <summary>Get the redirects for legacy paths that have been moved elsewhere.</summary> + private IDictionary<string, string> GetLegacyPathRedirects() + { + var redirects = new Dictionary<string, string>(); - // redirect legacy canimod.com URLs + // canimod.com => wiki var wikiRedirects = new Dictionary<string, string[]> { ["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" }, @@ -251,10 +257,10 @@ namespace StardewModdingAPI.Web ["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" }, ["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" } }; - foreach (KeyValuePair<string, string[]> pair in wikiRedirects) + foreach ((string page, string[] patterns) in wikiRedirects) { - foreach (string pattern in pair.Value) - redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + pair.Key)); + foreach (string pattern in patterns) + redirects.Add(pattern, "https://stardewvalleywiki.com/" + page); } return redirects; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs index c0dd7184..0ea69911 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -10,6 +10,9 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /********* ** Accessors *********/ + /// <summary>Whether to show the edit view.</summary> + public bool IsEditView { get; set; } + /// <summary>The paste ID.</summary> public string PasteID { get; set; } @@ -51,11 +54,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /// <param name="pasteID">The stored file ID.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param> - public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats) + /// <param name="isEditView">Whether to show the edit view.</param> + public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats, bool isEditView) { this.PasteID = pasteID; this.SchemaName = schemaName; this.SchemaFormats = schemaFormats; + this.IsEditView = isEditView; } /// <summary>Set the validated content.</summary> diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs index ff7513bc..6b8279c1 100644 --- a/src/SMAPI.Web/ViewModels/ModListModel.cs +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.ViewModels public bool IsStale { get; set; } /// <summary>Whether the mod metadata is available.</summary> - public bool HasData => this.Mods != null; + public bool HasData => this.Mods?.Any() == true; /********* diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index 56316ab7..575d596a 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -22,6 +22,9 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The mod author's alternative names, if any.</summary> public string AlternateAuthors { get; set; } + /// <summary>The GitHub repo, if any.</summary> + public string GitHubRepo { get; set; } + /// <summary>The URL to the mod's source code, if any.</summary> public string SourceUrl { get; set; } @@ -62,6 +65,7 @@ namespace StardewModdingAPI.Web.ViewModels this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray()); this.Author = entry.Author.FirstOrDefault(); this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray()); + this.GitHubRepo = entry.GitHubRepo; this.SourceUrl = this.GetSourceUrl(entry); this.Compatibility = new ModCompatibilityModel(entry.Compatibility); this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; @@ -102,7 +106,7 @@ namespace StardewModdingAPI.Web.ViewModels if (entry.ModDropID.HasValue) { anyFound = true; - yield return new ModLinkModel($"https://www.moddrop.com/sdv/mod/{entry.ModDropID}", "ModDrop"); + yield return new ModLinkModel($"https://www.moddrop.com/stardew-valley/mod/{entry.ModDropID}", "ModDrop"); } if (!string.IsNullOrWhiteSpace(entry.CurseForgeKey)) { diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index eded9df3..d78a155e 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -9,7 +9,7 @@ } @section Head { <link rel="stylesheet" href="~/Content/css/index.css?r=20200105" /> - <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/index.js?r=20200105"></script> } diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index 7287e00b..7b89a23d 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -9,7 +9,6 @@ string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName }); string schemaDisplayName = null; bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none"; - bool isEditView = Model.Content == null || Model.SchemaName?.ToLower() == "edit"; // build title ViewData["Title"] = "JSON validator"; @@ -32,7 +31,7 @@ <link rel="stylesheet" href="~/Content/css/json-validator.css?r=202002" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" /> - <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script> @@ -40,7 +39,7 @@ <script src="~/Content/js/json-validator.js?r=202002"></script> <script> $(function() { - smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID)); + smapi.jsonValidator(@this.ForJson(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @this.ForJson(Model.PasteID)); }); </script> } @@ -63,7 +62,7 @@ else if (Model.ParseError != null) <small v-pre>Error details: @Model.ParseError</small> </div> } -else if (!isEditView && Model.PasteID != null) +else if (!Model.IsEditView && Model.PasteID != null) { <div class="banner success"> <strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br /> @@ -84,7 +83,7 @@ else if (!isEditView && Model.PasteID != null) } @* upload new file *@ -@if (isEditView) +@if (Model.IsEditView) { <h2>Upload a JSON file</h2> <form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post"> @@ -112,7 +111,7 @@ else if (!isEditView && Model.PasteID != null) } @* validation results *@ -@if (!isEditView) +@if (!Model.IsEditView) { <div id="output"> @if (Model.UploadError == null) @@ -158,7 +157,7 @@ else if (!isEditView && Model.PasteID != null) { <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> } - </select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = "edit" }))">edit this file</a>. + </select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = this.Model.SchemaName, operation = "edit" }))">edit this file</a>. </div> <pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre> diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 2183992b..71e12d47 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,5 +1,4 @@ @using Humanizer -@using Newtonsoft.Json @using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.LogParsing.Models @@ -12,7 +11,6 @@ .GetValues(typeof(LogLevel)) .Cast<LogLevel>() .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); - JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None }; string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true); } @@ -25,19 +23,19 @@ <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" /> <link rel="stylesheet" href="~/Content/css/log-parser.css?r=202002" /> - <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> - <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js?r=202002"></script> <script src="~/Content/js/log-parser.js?r=202002"></script> <script> $(function() { smapi.logParser({ - logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)), - showPopup: @Json.Serialize(Model.ParsedLog == null), - showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting), - showSections: @Json.Serialize(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false), noFormatting), - showLevels: @Json.Serialize(defaultFilters, noFormatting), - enableFilters: @Json.Serialize(!Model.ShowRaw) + logStarted: new Date(@this.ForJson(Model.ParsedLog?.Timestamp)), + showPopup: @this.ForJson(Model.ParsedLog == null), + showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)), + showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)), + showLevels: @this.ForJson(defaultFilters), + enableFilters: @this.ForJson(!Model.ShowRaw) }, '@this.Url.PlainAction("Index", "LogParser", values: null)'); }); </script> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index b1d9ae2c..fa77c220 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -1,22 +1,26 @@ @using Humanizer @using Humanizer.Localisation -@using Newtonsoft.Json +@using StardewModdingAPI.Web.Framework +@using StardewModdingAPI.Web.ViewModels @model StardewModdingAPI.Web.ViewModels.ModListModel @{ ViewData["Title"] = "Mod compatibility"; TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated; + + bool hasBeta = Model.BetaVersion != null; + string betaLabel = "SDV @Model.BetaVersion only"; } @section Head { <link rel="stylesheet" href="~/Content/css/mods.css?r=20200218" /> - <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> - <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> - <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.0/dist/js/jquery.tablesorter.combined.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.3" crossorigin="anonymous"></script> <script src="~/Content/js/mods.js?r=20200218"></script> <script> $(function() { - var data = @Json.Serialize(Model.Mods, new JsonSerializerSettings { Formatting = Formatting.None }); - var enableBeta = @Json.Serialize(Model.BetaVersion != null); + var data = @this.ForJson(Model.Mods ?? new ModModel[0]); + var enableBeta = @this.ForJson(hasBeta); smapi.modList(data, enableBeta); }); </script> @@ -39,9 +43,9 @@ else <p>The list is updated every few days (you can help <a href="https://stardewvalleywiki.com/Modding:Mod_compatibility">update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p> - @if (Model.BetaVersion != null) + @if (hasBeta) { - <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "SDV @Model.BetaVersion only" lines are for an unreleased version of the game, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of the game.</p> + <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "@betaLabel" lines are for an unreleased version of SMAPI, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of SMAPI.</p> } </div> @@ -79,14 +83,14 @@ else </tr> </thead> <tbody> - <tr v-for="mod in mods" :key="mod.Name" v-bind:id="mod.Slug" :key="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible"> + <tr v-for="mod in mods" :key="mod.Slug" v-bind:id="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible"> <td> {{mod.Name}} <small class="mod-alt-names" v-if="mod.AlternateNames">(aka {{mod.AlternateNames}})</small> </td> <td class="mod-page-links"> <span v-for="(link, i) in mod.ModPages"> - <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} + <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} </span> </td> <td> @@ -96,14 +100,20 @@ else <td> <div v-html="mod.Compatibility.Summary"></div> <div v-if="mod.BetaCompatibility" v-show="showAdvanced"> - <strong v-if="mod.BetaCompatibility">SDV @Model.BetaVersion only:</strong> + <strong v-if="mod.BetaCompatibility">@betaLabel:</strong> <span v-html="mod.BetaCompatibility.Summary"></span> </div> <div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div> </td> <td class="mod-broke-in" v-html="mod.LatestCompatibility.BrokeIn" v-show="showAdvanced"></td> <td v-show="showAdvanced"> - <span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span> + <span v-if="mod.SourceUrl"> + <a v-bind:href="mod.SourceUrl">source</a> + <span v-if="mod.GitHubRepo"> + @* see https://shields.io/category/license *@ + (<img v-bind:src="'https://img.shields.io/github/license/' + mod.GitHubRepo + '?style=flat-square.png&label='" class="license-badge" alt="source" />) + </span> + </span> <span v-else class="mod-closed-source">no source</span> </td> <td> diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 2d06ceb1..67dcd3b3 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -29,7 +29,7 @@ </div> <div id="content-column"> <div id="content"> - @if (ViewData["ViewTitle"] != string.Empty) + @if (ViewData["ViewTitle"] as string != string.Empty) { <h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1> } diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 54460c46..3aa69285 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -17,11 +17,6 @@ "NexusApiKey": null }, - "MongoDB": { - "ConnectionString": null, - "Database": "smapi-edge" - }, - "BackgroundServices": { "Enabled": true } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 9cd1efc8..22fd7396 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -39,7 +39,7 @@ "GitHubPassword": null, "ModDropApiUrl": "https://www.moddrop.com/api/mods/data", - "ModDropModPageUrl": "https://www.moddrop.com/sdv/mod/{0}", + "ModDropModPageUrl": "https://www.moddrop.com/stardew-valley/mod/{0}", "NexusApiKey": null, "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/", @@ -49,7 +49,8 @@ "PastebinBaseUrl": "https://pastebin.com/" }, - "MongoDB": { + "Storage": { + "Mode": "InMemory", "ConnectionString": null, "Database": "smapi" }, diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css index 697ba514..4f6578cb 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/mods.css +++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css @@ -153,3 +153,7 @@ table.wikitable > caption { #mod-list td.smapi-3-col span { border-bottom: 1px dashed gray; } + +#mod-list .license-badge { + vertical-align: middle; +} diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index 35098b60..ac2754a4 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -1,5 +1,3 @@ -/* globals $ */ - var smapi = smapi || {}; var app; smapi.modList = function (mods, enableBeta) { diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index f627ab95..726b50be 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -11,9 +11,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "type": "string", - "const": "1.13.0", + "const": "1.14.0", "@errorMessages": { - "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.13.0'." + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.14.0'." } }, "ConfigSchema": { diff --git a/src/SMAPI.Web/wwwroot/schemas/i18n.json b/src/SMAPI.Web/wwwroot/schemas/i18n.json new file mode 100644 index 00000000..493ad213 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/schemas/i18n.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://smapi.io/schemas/i18n.json", + "title": "SMAPI i18n file", + "description": "A translation file for a SMAPI mod or content pack.", + "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation", + "type": "object", + + "properties": { + "$schema": { + "title": "Schema", + "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", + "type": "string", + "const": "https://smapi.io/schemas/manifest.json" + } + }, + + "additionalProperties": { + "type": "string", + "@errorMessages": { + "type": "Invalid property. Translation files can only contain text property values." + } + } +} diff --git a/src/SMAPI.sln b/src/SMAPI.sln index f9c537c4..92b0cd2c 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -77,8 +77,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit.CoreInterface EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web.LegacyRedirects", "SMAPI.Web.LegacyRedirects\SMAPI.Web.LegacyRedirects.csproj", "{159AA5A5-35C2-488C-B23F-1613C80594AE}" -EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 @@ -138,10 +136,6 @@ Global {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {159AA5A5-35C2-488C-B23F-1613C80594AE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 556f1ec0..05caa938 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -58,6 +58,7 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=stackable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Stardew/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=subdomain/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=subkey/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=synchronised/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=textbox/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=thumbstick/@EntryIndexedValue">True</s:Boolean> diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index a898fccd..66971709 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.5.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.6.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1"); @@ -52,6 +52,14 @@ namespace StardewModdingAPI /**** ** Internal ****/ + /// <summary>Whether SMAPI was compiled in debug mode.</summary> + internal const bool IsDebugBuild = +#if DEBUG + true; +#else + false; +#endif + /// <summary>The URL of the SMAPI home page.</summary> internal const string HomePageUrl = "https://smapi.io"; diff --git a/src/SMAPI/Events/EventPriority.cs b/src/SMAPI/Events/EventPriority.cs new file mode 100644 index 00000000..1efb4e2a --- /dev/null +++ b/src/SMAPI/Events/EventPriority.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Events +{ + /// <summary>The event priorities for method handlers.</summary> + public enum EventPriority + { + /// <summary>Low priority.</summary> + Low = -1000, + + /// <summary>The default priority.</summary> + Normal = 0, + + /// <summary>High priority.</summary> + High = 1000 + } +} diff --git a/src/SMAPI/Events/EventPriorityAttribute.cs b/src/SMAPI/Events/EventPriorityAttribute.cs new file mode 100644 index 00000000..207e7862 --- /dev/null +++ b/src/SMAPI/Events/EventPriorityAttribute.cs @@ -0,0 +1,26 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>An attribute which specifies the priority for an event handler.</summary> + [AttributeUsage(AttributeTargets.Method)] + public class EventPriorityAttribute : Attribute + { + /********* + ** Accessors + *********/ + /// <summary>The event handler priority, relative to other handlers across all mods registered for this event.</summary> + internal EventPriority Priority { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priority">The event handler priority, relative to other handlers across all mods registered for this event. Higher-priority handlers are notified before lower-priority handlers.</param> + public EventPriorityAttribute(EventPriority priority) + { + this.Priority = priority; + } + } +} diff --git a/src/SMAPI/Events/IMultiplayerEvents.cs b/src/SMAPI/Events/IMultiplayerEvents.cs index 4a31f48e..af9b5f17 100644 --- a/src/SMAPI/Events/IMultiplayerEvents.cs +++ b/src/SMAPI/Events/IMultiplayerEvents.cs @@ -5,9 +5,12 @@ namespace StardewModdingAPI.Events /// <summary>Events raised for multiplayer messages and connections.</summary> public interface IMultiplayerEvents { - /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived; + /// <summary>Raised after a peer connection is approved by the game.</summary> + event EventHandler<PeerConnectedEventArgs> PeerConnected; + /// <summary>Raised after a mod message is received over the network.</summary> event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived; diff --git a/src/SMAPI/Events/PeerConnectedEventArgs.cs b/src/SMAPI/Events/PeerConnectedEventArgs.cs new file mode 100644 index 00000000..bfaa2bd3 --- /dev/null +++ b/src/SMAPI/Events/PeerConnectedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerConnected"/> event.</summary> + public class PeerConnectedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The peer whose metadata was received.</summary> + public IMultiplayerPeer Peer { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="peer">The peer whose metadata was received.</param> + internal PeerConnectedEventArgs(IMultiplayerPeer peer) + { + this.Peer = peer; + } + } +} diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index ceeb6f93..eaa91c86 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text; +using StardewModdingAPI.Framework.Commands; namespace StardewModdingAPI.Framework { @@ -27,7 +28,7 @@ namespace StardewModdingAPI.Framework /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> /// <exception cref="ArgumentException">There's already a command with that name.</exception> - public void Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) + public CommandManager Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) { name = this.GetNormalizedName(name); @@ -45,6 +46,16 @@ namespace StardewModdingAPI.Framework // add command this.Commands.Add(name, new Command(mod, name, documentation, callback)); + return this; + } + + /// <summary>Add a console command.</summary> + /// <param name="command">the SMAPI console command to add.</param> + /// <param name="monitor">Writes messages to the console.</param> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + public CommandManager Add(IInternalCommand command, IMonitor monitor) + { + return this.Add(null, command.Name, command.Description, (name, args) => command.HandleCommand(args, monitor)); } /// <summary>Get a command by its unique name.</summary> diff --git a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs new file mode 100644 index 00000000..8fdd4282 --- /dev/null +++ b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +#if HARMONY_2 +using HarmonyLib; +#else +using Harmony; +#endif + +namespace StardewModdingAPI.Framework.Commands +{ + /// <summary>The 'harmony_summary' SMAPI console command.</summary> + internal class HarmonySummaryCommand : IInternalCommand + { +#if !HARMONY_2 + /********* + ** Fields + *********/ + /// <summary>The Harmony instance through which to fetch patch info.</summary> + private readonly HarmonyInstance HarmonyInstance = HarmonyInstance.Create($"SMAPI.{nameof(HarmonySummaryCommand)}"); +#endif + + /********* + ** Accessors + *********/ + /// <summary>The command name, which the user must type to trigger it.</summary> + public string Name { get; } = "harmony_summary"; + + /// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary> + public string Description { get; } = "Harmony is a library which rewrites game code, used by SMAPI and some mods. This command lists current Harmony patches.\n\nUsage: harmony_summary\nList all Harmony patches.\n\nUsage: harmony_summary <search>\n- search: one more more words to search. If any word matches a method name, the method and all its patchers will be listed; otherwise only matching patchers will be listed for the method."; + + + /********* + ** Public methods + *********/ + /// <summary>Handle the console command when it's entered by the user.</summary> + /// <param name="args">The command arguments.</param> + /// <param name="monitor">Writes messages to the console.</param> + public void HandleCommand(string[] args, IMonitor monitor) + { + SearchResult[] matches = this.FilterPatches(args).OrderBy(p => p.MethodName).ToArray(); + + StringBuilder result = new StringBuilder(); + + if (!matches.Any()) + result.AppendLine("No current patches match your search."); + else + { + result.AppendLine(args.Any() ? "Harmony patches which match your search terms:" : "Current Harmony patches:"); + result.AppendLine(); + foreach (var match in matches) + { + result.AppendLine($" {match.MethodName}"); + foreach (var ownerGroup in match.PatchTypesByOwner.OrderBy(p => p.Key)) + { + var sortedTypes = ownerGroup.Value + .OrderBy(p => p switch + { + PatchType.Prefix => 0, + PatchType.Postfix => 1, +#if HARMONY_2 + PatchType.Finalizer => 2, +#endif + PatchType.Transpiler => 3, + _ => 4 + }); + + result.AppendLine($" - {ownerGroup.Key} ({string.Join(", ", sortedTypes).ToLower()})"); + } + result.AppendLine(); + } + } + + monitor.Log(result.ToString().TrimEnd(), LogLevel.Info); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get all current Harmony patches matching any of the given search terms.</summary> + /// <param name="searchTerms">The search terms to match.</param> + private IEnumerable<SearchResult> FilterPatches(string[] searchTerms) + { + bool hasSearch = searchTerms.Any(); + bool IsMatch(string target) => !hasSearch || searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1); + + foreach (var patch in this.GetAllPatches()) + { + // matches entire patch + if (IsMatch(patch.MethodDescription)) + { + yield return patch; + continue; + } + + // matches individual patchers + foreach (var pair in patch.PatchTypesByOwner.ToArray()) + { + if (!IsMatch(pair.Key) && !pair.Value.Any(type => IsMatch(type.ToString()))) + patch.PatchTypesByOwner.Remove(pair.Key); + } + + if (patch.PatchTypesByOwner.Any()) + yield return patch; + } + } + + /// <summary>Get all current Harmony patches.</summary> + private IEnumerable<SearchResult> GetAllPatches() + { +#if HARMONY_2 + foreach (MethodBase method in Harmony.GetAllPatchedMethods()) +#else + foreach (MethodBase method in this.HarmonyInstance.GetPatchedMethods()) +#endif + { + // get metadata for method +#if HARMONY_2 + HarmonyLib.Patches patchInfo = Harmony.GetPatchInfo(method); +#else + Harmony.Patches patchInfo = this.HarmonyInstance.GetPatchInfo(method); +#endif + + IDictionary<PatchType, IReadOnlyCollection<Patch>> patchGroups = new Dictionary<PatchType, IReadOnlyCollection<Patch>> + { + [PatchType.Prefix] = patchInfo.Prefixes, + [PatchType.Postfix] = patchInfo.Postfixes, +#if HARMONY_2 + [PatchType.Finalizer] = patchInfo.Finalizers, +#endif + [PatchType.Transpiler] = patchInfo.Transpilers + }; + + // get patch types by owner + var typesByOwner = new Dictionary<string, ISet<PatchType>>(); + foreach (var group in patchGroups) + { + foreach (var patch in group.Value) + { + if (!typesByOwner.TryGetValue(patch.owner, out ISet<PatchType> patchTypes)) + typesByOwner[patch.owner] = patchTypes = new HashSet<PatchType>(); + patchTypes.Add(group.Key); + } + } + + // create search result + yield return new SearchResult(method, typesByOwner); + } + } + + /// <summary>A Harmony patch type.</summary> + private enum PatchType + { + /// <summary>A prefix patch.</summary> + Prefix, + + /// <summary>A postfix patch.</summary> + Postfix, + +#if HARMONY_2 + /// <summary>A finalizer patch.</summary> + Finalizer, +#endif + + /// <summary>A transpiler patch.</summary> + Transpiler + } + + /// <summary>A patch search result for a method.</summary> + private class SearchResult + { + /********* + ** Accessors + *********/ + /// <summary>A simple human-readable name for the patched method.</summary> + public string MethodName { get; } + + /// <summary>A detailed description for the patched method.</summary> + public string MethodDescription { get; } + + /// <summary>The patch types by the Harmony instance ID that added them.</summary> + public IDictionary<string, ISet<PatchType>> PatchTypesByOwner { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="method">The patched method.</param> + /// <param name="patchTypesByOwner">The patch types by the Harmony instance ID that added them.</param> + public SearchResult(MethodBase method, IDictionary<string, ISet<PatchType>> patchTypesByOwner) + { + this.MethodName = $"{method.DeclaringType?.FullName}.{method.Name}"; + this.MethodDescription = method.FullDescription(); + this.PatchTypesByOwner = patchTypesByOwner; + } + } + } +} diff --git a/src/SMAPI/Framework/Commands/HelpCommand.cs b/src/SMAPI/Framework/Commands/HelpCommand.cs new file mode 100644 index 00000000..b8730a00 --- /dev/null +++ b/src/SMAPI/Framework/Commands/HelpCommand.cs @@ -0,0 +1,64 @@ +using System.Linq; + +namespace StardewModdingAPI.Framework.Commands +{ + /// <summary>The 'help' SMAPI console command.</summary> + internal class HelpCommand : IInternalCommand + { + /********* + ** Fields + *********/ + /// <summary>Manages console commands.</summary> + private readonly CommandManager CommandManager; + + + /********* + ** Accessors + *********/ + /// <summary>The command name, which the user must type to trigger it.</summary> + public string Name { get; } = "help"; + + /// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary> + public string Description { get; } = "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display."; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="commandManager">Manages console commands.</param> + public HelpCommand(CommandManager commandManager) + { + this.CommandManager = commandManager; + } + + /// <summary>Handle the console command when it's entered by the user.</summary> + /// <param name="args">The command arguments.</param> + /// <param name="monitor">Writes messages to the console.</param> + public void HandleCommand(string[] args, IMonitor monitor) + { + if (args.Any()) + { + Command result = this.CommandManager.Get(args[0]); + if (result == null) + monitor.Log("There's no command with that name.", LogLevel.Error); + else + monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info); + } + else + { + string message = "The following commands are registered:\n"; + IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray(); + foreach (var group in groups) + { + string modName = group.Key ?? "SMAPI"; + string[] commandNames = group.ToArray(); + message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; + } + message += "For more information about a command, type 'help command_name'."; + + monitor.Log(message, LogLevel.Info); + } + } + } +} diff --git a/src/SMAPI/Framework/Commands/IInternalCommand.cs b/src/SMAPI/Framework/Commands/IInternalCommand.cs new file mode 100644 index 00000000..abf105b6 --- /dev/null +++ b/src/SMAPI/Framework/Commands/IInternalCommand.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Framework.Commands +{ + /// <summary>A core SMAPI console command.</summary> + interface IInternalCommand + { + /********* + ** Accessors + *********/ + /// <summary>The command name, which the user must type to trigger it.</summary> + string Name { get; } + + /// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary> + string Description { get; } + + + /********* + ** Methods + *********/ + /// <summary>Handle the console command when it's entered by the user.</summary> + /// <param name="args">The command arguments.</param> + /// <param name="monitor">Writes messages to the console.</param> + void HandleCommand(string[] args, IMonitor monitor); + } +} diff --git a/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs b/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs new file mode 100644 index 00000000..12328bb6 --- /dev/null +++ b/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs @@ -0,0 +1,44 @@ +using System; + +namespace StardewModdingAPI.Framework.Commands +{ + /// <summary>The 'reload_i18n' SMAPI console command.</summary> + internal class ReloadI18nCommand : IInternalCommand + { + /********* + ** Fields + *********/ + /// <summary>Reload translations for all mods.</summary> + private readonly Action ReloadTranslations; + + + /********* + ** Accessors + *********/ + /// <summary>The command name, which the user must type to trigger it.</summary> + public string Name { get; } = "reload_i18n"; + + /// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary> + public string Description { get; } = "Reloads translation files for all mods.\n\nUsage: reload_i18n"; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="reloadTranslations">Reload translations for all mods..</param> + public ReloadI18nCommand(Action reloadTranslations) + { + this.ReloadTranslations = reloadTranslations; + } + + /// <summary>Handle the console command when it's entered by the user.</summary> + /// <param name="args">The command arguments.</param> + /// <param name="monitor">Writes messages to the console.</param> + public void HandleCommand(string[] args, IMonitor monitor) + { + this.ReloadTranslations(); + monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); + } + } +} diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index fda80a83..cfda55b9 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -246,10 +246,11 @@ namespace StardewModdingAPI.Framework.ContentManagers texture.GetData(data); for (int i = 0; i < data.Length; i++) { - if (data[i].A == byte.MinValue || data[i].A == byte.MaxValue) + var pixel = data[i]; + if (pixel.A == byte.MinValue || pixel.A == byte.MaxValue) continue; // no need to change fully transparent/opaque pixels - data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); + data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) } texture.SetData(data); diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index a9dfda97..b5a12a6e 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -109,9 +109,12 @@ namespace StardewModdingAPI.Framework.Events /**** ** Multiplayer ****/ - /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived; + /// <summary>Raised after a peer connection is approved by the game.</summary> + public readonly ManagedEvent<PeerConnectedEventArgs> PeerConnected; + /// <summary>Raised after a mod message is received over the network.</summary> public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived; @@ -174,15 +177,14 @@ namespace StardewModdingAPI.Framework.Events ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="monitor">Writes messages to the log.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> /// <param name="performanceMonitor">Tracks performance metrics.</param> - public EventManager(IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor) + public EventManager(ModRegistry modRegistry, PerformanceMonitor performanceMonitor) { // create shortcut initializers ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false) { - return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry, performanceMonitor, isPerformanceCritical); + return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, performanceMonitor, isPerformanceCritical); } // init events (new) @@ -218,6 +220,7 @@ namespace StardewModdingAPI.Framework.Events this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived)); + this.PeerConnected = ManageEventOf<PeerConnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerConnected)); this.ModMessageReceived = ManageEventOf<ModMessageReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.ModMessageReceived)); this.PeerDisconnected = ManageEventOf<PeerDisconnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerDisconnected)); diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index 118b73ac..08ac1131 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; +using StardewModdingAPI.Events; using StardewModdingAPI.Framework.PerformanceMonitoring; namespace StardewModdingAPI.Framework.Events @@ -12,24 +14,24 @@ namespace StardewModdingAPI.Framework.Events /********* ** Fields *********/ - /// <summary>The underlying event.</summary> - private event EventHandler<TEventArgs> Event; - - /// <summary>Writes messages to the log.</summary> - private readonly IMonitor Monitor; - /// <summary>The mod registry with which to identify mods.</summary> protected readonly ModRegistry ModRegistry; - /// <summary>The display names for the mods which added each delegate.</summary> - private readonly IDictionary<EventHandler<TEventArgs>, IModMetadata> SourceMods = new Dictionary<EventHandler<TEventArgs>, IModMetadata>(); - - /// <summary>The cached invocation list.</summary> - private EventHandler<TEventArgs>[] CachedInvocationList; - /// <summary>Tracks performance metrics.</summary> private readonly PerformanceMonitor PerformanceMonitor; + /// <summary>The underlying event handlers.</summary> + private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>(); + + /// <summary>A cached snapshot of <see cref="Handlers"/>, or <c>null</c> to rebuild it next raise.</summary> + private ManagedEventHandler<TEventArgs>[] CachedHandlers = new ManagedEventHandler<TEventArgs>[0]; + + /// <summary>The total number of event handlers registered for this events, regardless of whether they're still registered.</summary> + private int RegistrationIndex; + + /// <summary>Whether new handlers were added since the last raise.</summary> + private bool HasNewHandlers; + /********* ** Accessors @@ -46,14 +48,12 @@ namespace StardewModdingAPI.Framework.Events *********/ /// <summary>Construct an instance.</summary> /// <param name="eventName">A human-readable name for the event.</param> - /// <param name="monitor">Writes messages to the log.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> /// <param name="performanceMonitor">Tracks performance metrics.</param> /// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param> - public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false) + public ManagedEvent(string eventName, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false) { this.EventName = eventName; - this.Monitor = monitor; this.ModRegistry = modRegistry; this.PerformanceMonitor = performanceMonitor; this.IsPerformanceCritical = isPerformanceCritical; @@ -62,14 +62,7 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Get whether anything is listening to the event.</summary> public bool HasListeners() { - return this.CachedInvocationList?.Length > 0; - } - - /// <summary>Add an event handler.</summary> - /// <param name="handler">The event handler.</param> - public void Add(EventHandler<TEventArgs> handler) - { - this.Add(handler, this.ModRegistry.GetFromStack()); + return this.Handlers.Count > 0; } /// <summary>Add an event handler.</summary> @@ -77,64 +70,69 @@ namespace StardewModdingAPI.Framework.Events /// <param name="mod">The mod which added the event handler.</param> public void Add(EventHandler<TEventArgs> handler, IModMetadata mod) { - this.Event += handler; - this.AddTracking(mod, handler, this.Event?.GetInvocationList().Cast<EventHandler<TEventArgs>>()); + EventPriority priority = handler.Method.GetCustomAttribute<EventPriorityAttribute>()?.Priority ?? EventPriority.Normal; + var managedHandler = new ManagedEventHandler<TEventArgs>(handler, this.RegistrationIndex++, priority, mod); + + this.Handlers.Add(managedHandler); + this.CachedHandlers = null; + this.HasNewHandlers = true; } /// <summary>Remove an event handler.</summary> /// <param name="handler">The event handler.</param> public void Remove(EventHandler<TEventArgs> handler) { - this.Event -= handler; - this.RemoveTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler<TEventArgs>>()); + // match C# events: if a handler is listed multiple times, remove the last one added + for (int i = this.Handlers.Count - 1; i >= 0; i--) + { + if (this.Handlers[i].Handler != handler) + continue; + + this.Handlers.RemoveAt(i); + this.CachedHandlers = null; + break; + } } /// <summary>Raise the event and notify all handlers.</summary> /// <param name="args">The event arguments to pass.</param> - public void Raise(TEventArgs args) + /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> + public void Raise(TEventArgs args, Func<IModMetadata, bool> match = null) { - if (this.Event == null) + // skip if no handlers + if (this.Handlers.Count == 0) return; - - this.PerformanceMonitor.Track(this.EventName, () => + // update cached data + // (This is debounced here to avoid repeatedly sorting when handlers are added/removed, + // and keeping a separate cached list allows changes during enumeration.) + if (this.CachedHandlers == null) { - foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) - { - try - { - this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Invoke(null, args)); - } - catch (Exception ex) - { - this.LogError(handler, ex); - } - } - }); - } + if (this.HasNewHandlers && this.Handlers.Any(p => p.Priority != EventPriority.Normal)) + this.Handlers.Sort(); - /// <summary>Raise the event and notify all handlers.</summary> - /// <param name="args">The event arguments to pass.</param> - /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> - public void RaiseForMods(TEventArgs args, Func<IModMetadata, bool> match) - { - if (this.Event == null) - return; + this.CachedHandlers = this.Handlers.ToArray(); + this.HasNewHandlers = false; + } - foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) + // raise event + this.PerformanceMonitor.Track(this.EventName, () => { - if (match(this.GetSourceMod(handler))) + foreach (ManagedEventHandler<TEventArgs> handler in this.CachedHandlers) { + if (match != null && !match(handler.SourceMod)) + continue; + try { - handler.Invoke(null, args); + this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Handler.Invoke(null, args)); } catch (Exception ex) { this.LogError(handler, ex); } } - } + }); } @@ -143,56 +141,21 @@ namespace StardewModdingAPI.Framework.Events *********/ /// <summary>Get the mod name for a given event handler to display in performance monitoring reports.</summary> /// <param name="handler">The event handler.</param> - private string GetModNameForPerformanceCounters(EventHandler<TEventArgs> handler) + private string GetModNameForPerformanceCounters(ManagedEventHandler<TEventArgs> handler) { - IModMetadata mod = this.GetSourceMod(handler); - if (mod == null) - return Constants.GamePerformanceCounterName; + IModMetadata mod = handler.SourceMod; return mod.HasManifest() ? mod.Manifest.UniqueID : mod.DisplayName; } - /// <summary>Track an event handler.</summary> - /// <param name="mod">The mod which added the handler.</param> - /// <param name="handler">The event handler.</param> - /// <param name="invocationList">The updated event invocation list.</param> - protected void AddTracking(IModMetadata mod, EventHandler<TEventArgs> handler, IEnumerable<EventHandler<TEventArgs>> invocationList) - { - this.SourceMods[handler] = mod; - this.CachedInvocationList = invocationList?.ToArray() ?? new EventHandler<TEventArgs>[0]; - } - - /// <summary>Remove tracking for an event handler.</summary> - /// <param name="handler">The event handler.</param> - /// <param name="invocationList">The updated event invocation list.</param> - protected void RemoveTracking(EventHandler<TEventArgs> handler, IEnumerable<EventHandler<TEventArgs>> invocationList) - { - this.CachedInvocationList = invocationList?.ToArray() ?? new EventHandler<TEventArgs>[0]; - if (!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once) - this.SourceMods.Remove(handler); - } - - /// <summary>Get the mod which registered the given event handler, if available.</summary> - /// <param name="handler">The event handler.</param> - protected IModMetadata GetSourceMod(EventHandler<TEventArgs> handler) - { - return this.SourceMods.TryGetValue(handler, out IModMetadata mod) - ? mod - : null; - } - /// <summary>Log an exception from an event handler.</summary> /// <param name="handler">The event handler instance.</param> /// <param name="ex">The exception that was raised.</param> - protected void LogError(EventHandler<TEventArgs> handler, Exception ex) + protected void LogError(ManagedEventHandler<TEventArgs> handler, Exception ex) { - IModMetadata mod = this.GetSourceMod(handler); - if (mod != null) - mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); - else - this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); + handler.SourceMod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); } } } diff --git a/src/SMAPI/Framework/Events/ManagedEventHandler.cs b/src/SMAPI/Framework/Events/ManagedEventHandler.cs new file mode 100644 index 00000000..cf470c1e --- /dev/null +++ b/src/SMAPI/Framework/Events/ManagedEventHandler.cs @@ -0,0 +1,56 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>An event handler wrapper which tracks metadata about an event handler.</summary> + /// <typeparam name="TEventArgs">The event arguments type.</typeparam> + internal class ManagedEventHandler<TEventArgs> : IComparable + { + /********* + ** Accessors + *********/ + /// <summary>The event handler method.</summary> + public EventHandler<TEventArgs> Handler { get; } + + /// <summary>The order in which the event handler was registered, relative to other handlers for this event.</summary> + public int RegistrationOrder { get; } + + /// <summary>The event handler priority, relative to other handlers for this event.</summary> + public EventPriority Priority { get; } + + /// <summary>The mod which registered the handler.</summary> + public IModMetadata SourceMod { get; set; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="handler">The event handler method.</param> + /// <param name="registrationOrder">The order in which the event handler was registered, relative to other handlers for this event.</param> + /// <param name="priority">The event handler priority, relative to other handlers for this event.</param> + /// <param name="sourceMod">The mod which registered the handler.</param> + public ManagedEventHandler(EventHandler<TEventArgs> handler, int registrationOrder, EventPriority priority, IModMetadata sourceMod) + { + this.Handler = handler; + this.RegistrationOrder = registrationOrder; + this.Priority = priority; + this.SourceMod = sourceMod; + } + + /// <summary>Compares the current instance with another object of the same type and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object.</summary> + /// <param name="obj">An object to compare with this instance.</param> + /// <exception cref="T:System.ArgumentException"><paramref name="obj" /> is not the same type as this instance.</exception> + public int CompareTo(object obj) + { + if (!(obj is ManagedEventHandler<TEventArgs> other)) + throw new ArgumentException("Can't compare to an unrelated object type."); + + int priorityCompare = this.Priority.CompareTo(other.Priority); + return priorityCompare != 0 + ? priorityCompare + : this.RegistrationOrder.CompareTo(other.RegistrationOrder); + } + } +} diff --git a/src/SMAPI/Framework/Events/ModDisplayEvents.cs b/src/SMAPI/Framework/Events/ModDisplayEvents.cs index e383eec6..54d40dee 100644 --- a/src/SMAPI/Framework/Events/ModDisplayEvents.cs +++ b/src/SMAPI/Framework/Events/ModDisplayEvents.cs @@ -13,70 +13,70 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> public event EventHandler<MenuChangedEventArgs> MenuChanged { - add => this.EventManager.MenuChanged.Add(value); + add => this.EventManager.MenuChanged.Add(value, this.Mod); remove => this.EventManager.MenuChanged.Remove(value); } /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> public event EventHandler<RenderingEventArgs> Rendering { - add => this.EventManager.Rendering.Add(value); + add => this.EventManager.Rendering.Add(value, this.Mod); remove => this.EventManager.Rendering.Remove(value); } /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> public event EventHandler<RenderedEventArgs> Rendered { - add => this.EventManager.Rendered.Add(value); + add => this.EventManager.Rendered.Add(value, this.Mod); remove => this.EventManager.Rendered.Remove(value); } /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> public event EventHandler<RenderingWorldEventArgs> RenderingWorld { - add => this.EventManager.RenderingWorld.Add(value); + add => this.EventManager.RenderingWorld.Add(value, this.Mod); remove => this.EventManager.RenderingWorld.Remove(value); } /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary> public event EventHandler<RenderedWorldEventArgs> RenderedWorld { - add => this.EventManager.RenderedWorld.Add(value); + add => this.EventManager.RenderedWorld.Add(value, this.Mod); remove => this.EventManager.RenderedWorld.Remove(value); } /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary> public event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu { - add => this.EventManager.RenderingActiveMenu.Add(value); + add => this.EventManager.RenderingActiveMenu.Add(value, this.Mod); remove => this.EventManager.RenderingActiveMenu.Remove(value); } /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary> public event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu { - add => this.EventManager.RenderedActiveMenu.Add(value); + add => this.EventManager.RenderedActiveMenu.Add(value, this.Mod); remove => this.EventManager.RenderedActiveMenu.Remove(value); } /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary> public event EventHandler<RenderingHudEventArgs> RenderingHud { - add => this.EventManager.RenderingHud.Add(value); + add => this.EventManager.RenderingHud.Add(value, this.Mod); remove => this.EventManager.RenderingHud.Remove(value); } /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary> public event EventHandler<RenderedHudEventArgs> RenderedHud { - add => this.EventManager.RenderedHud.Add(value); + add => this.EventManager.RenderedHud.Add(value, this.Mod); remove => this.EventManager.RenderedHud.Remove(value); } /// <summary>Raised after the game window is resized.</summary> public event EventHandler<WindowResizedEventArgs> WindowResized { - add => this.EventManager.WindowResized.Add(value); + add => this.EventManager.WindowResized.Add(value, this.Mod); remove => this.EventManager.WindowResized.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index c15460fa..a0119bf8 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -12,84 +12,84 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after the game is launched, right before the first update tick.</summary> public event EventHandler<GameLaunchedEventArgs> GameLaunched { - add => this.EventManager.GameLaunched.Add(value); + add => this.EventManager.GameLaunched.Add(value, this.Mod); remove => this.EventManager.GameLaunched.Remove(value); } /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> public event EventHandler<UpdateTickingEventArgs> UpdateTicking { - add => this.EventManager.UpdateTicking.Add(value); + add => this.EventManager.UpdateTicking.Add(value, this.Mod); remove => this.EventManager.UpdateTicking.Remove(value); } /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> public event EventHandler<UpdateTickedEventArgs> UpdateTicked { - add => this.EventManager.UpdateTicked.Add(value); + add => this.EventManager.UpdateTicked.Add(value, this.Mod); remove => this.EventManager.UpdateTicked.Remove(value); } /// <summary>Raised once per second before the game state is updated.</summary> public event EventHandler<OneSecondUpdateTickingEventArgs> OneSecondUpdateTicking { - add => this.EventManager.OneSecondUpdateTicking.Add(value); + add => this.EventManager.OneSecondUpdateTicking.Add(value, this.Mod); remove => this.EventManager.OneSecondUpdateTicking.Remove(value); } /// <summary>Raised once per second after the game state is updated.</summary> public event EventHandler<OneSecondUpdateTickedEventArgs> OneSecondUpdateTicked { - add => this.EventManager.OneSecondUpdateTicked.Add(value); + add => this.EventManager.OneSecondUpdateTicked.Add(value, this.Mod); remove => this.EventManager.OneSecondUpdateTicked.Remove(value); } /// <summary>Raised before the game creates a new save file.</summary> public event EventHandler<SaveCreatingEventArgs> SaveCreating { - add => this.EventManager.SaveCreating.Add(value); + add => this.EventManager.SaveCreating.Add(value, this.Mod); remove => this.EventManager.SaveCreating.Remove(value); } /// <summary>Raised after the game finishes creating the save file.</summary> public event EventHandler<SaveCreatedEventArgs> SaveCreated { - add => this.EventManager.SaveCreated.Add(value); + add => this.EventManager.SaveCreated.Add(value, this.Mod); remove => this.EventManager.SaveCreated.Remove(value); } /// <summary>Raised before the game begins writes data to the save file.</summary> public event EventHandler<SavingEventArgs> Saving { - add => this.EventManager.Saving.Add(value); + add => this.EventManager.Saving.Add(value, this.Mod); remove => this.EventManager.Saving.Remove(value); } /// <summary>Raised after the game finishes writing data to the save file.</summary> public event EventHandler<SavedEventArgs> Saved { - add => this.EventManager.Saved.Add(value); + add => this.EventManager.Saved.Add(value, this.Mod); remove => this.EventManager.Saved.Remove(value); } /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> public event EventHandler<SaveLoadedEventArgs> SaveLoaded { - add => this.EventManager.SaveLoaded.Add(value); + add => this.EventManager.SaveLoaded.Add(value, this.Mod); remove => this.EventManager.SaveLoaded.Remove(value); } /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> public event EventHandler<DayStartedEventArgs> DayStarted { - add => this.EventManager.DayStarted.Add(value); + add => this.EventManager.DayStarted.Add(value, this.Mod); remove => this.EventManager.DayStarted.Remove(value); } /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="IGameLoopEvents.Saving"/>.</summary> public event EventHandler<DayEndingEventArgs> DayEnding { - add => this.EventManager.DayEnding.Add(value); + add => this.EventManager.DayEnding.Add(value, this.Mod); remove => this.EventManager.DayEnding.Remove(value); } @@ -97,14 +97,14 @@ namespace StardewModdingAPI.Framework.Events public event EventHandler<TimeChangedEventArgs> TimeChanged { - add => this.EventManager.TimeChanged.Add(value); + add => this.EventManager.TimeChanged.Add(value, this.Mod); remove => this.EventManager.TimeChanged.Remove(value); } /// <summary>Raised after the game returns to the title screen.</summary> public event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle { - add => this.EventManager.ReturnedToTitle.Add(value); + add => this.EventManager.ReturnedToTitle.Add(value, this.Mod); remove => this.EventManager.ReturnedToTitle.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs index 6a4298b4..ab26ab3e 100644 --- a/src/SMAPI/Framework/Events/ModInputEvents.cs +++ b/src/SMAPI/Framework/Events/ModInputEvents.cs @@ -12,28 +12,28 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> public event EventHandler<ButtonPressedEventArgs> ButtonPressed { - add => this.EventManager.ButtonPressed.Add(value); + add => this.EventManager.ButtonPressed.Add(value, this.Mod); remove => this.EventManager.ButtonPressed.Remove(value); } /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary> public event EventHandler<ButtonReleasedEventArgs> ButtonReleased { - add => this.EventManager.ButtonReleased.Add(value); + add => this.EventManager.ButtonReleased.Add(value, this.Mod); remove => this.EventManager.ButtonReleased.Remove(value); } /// <summary>Raised after the player moves the in-game cursor.</summary> public event EventHandler<CursorMovedEventArgs> CursorMoved { - add => this.EventManager.CursorMoved.Add(value); + add => this.EventManager.CursorMoved.Add(value, this.Mod); remove => this.EventManager.CursorMoved.Remove(value); } /// <summary>Raised after the player scrolls the mouse wheel.</summary> public event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled { - add => this.EventManager.MouseWheelScrolled.Add(value); + add => this.EventManager.MouseWheelScrolled.Add(value, this.Mod); remove => this.EventManager.MouseWheelScrolled.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs index 152c4e0c..2f9b9482 100644 --- a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs +++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs @@ -9,24 +9,31 @@ namespace StardewModdingAPI.Framework.Events /********* ** Accessors *********/ - /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived { - add => this.EventManager.PeerContextReceived.Add(value); + add => this.EventManager.PeerContextReceived.Add(value, this.Mod); remove => this.EventManager.PeerContextReceived.Remove(value); } + /// <summary>Raised after a peer connection is approved by the game.</summary> + public event EventHandler<PeerConnectedEventArgs> PeerConnected + { + add => this.EventManager.PeerConnected.Add(value, this.Mod); + remove => this.EventManager.PeerConnected.Remove(value); + } + /// <summary>Raised after a mod message is received over the network.</summary> public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived { - add => this.EventManager.ModMessageReceived.Add(value); + add => this.EventManager.ModMessageReceived.Add(value, this.Mod); remove => this.EventManager.ModMessageReceived.Remove(value); } /// <summary>Raised after the connection with a peer is severed.</summary> public event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected { - add => this.EventManager.PeerDisconnected.Add(value); + add => this.EventManager.PeerDisconnected.Add(value, this.Mod); remove => this.EventManager.PeerDisconnected.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModPlayerEvents.cs b/src/SMAPI/Framework/Events/ModPlayerEvents.cs index ca7cfd96..240beb8d 100644 --- a/src/SMAPI/Framework/Events/ModPlayerEvents.cs +++ b/src/SMAPI/Framework/Events/ModPlayerEvents.cs @@ -12,21 +12,21 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the local player.</summary> public event EventHandler<InventoryChangedEventArgs> InventoryChanged { - add => this.EventManager.InventoryChanged.Add(value); + add => this.EventManager.InventoryChanged.Add(value, this.Mod); remove => this.EventManager.InventoryChanged.Remove(value); } /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the local player.</summary> public event EventHandler<LevelChangedEventArgs> LevelChanged { - add => this.EventManager.LevelChanged.Add(value); + add => this.EventManager.LevelChanged.Add(value, this.Mod); remove => this.EventManager.LevelChanged.Remove(value); } /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the local player.</summary> public event EventHandler<WarpedEventArgs> Warped { - add => this.EventManager.Warped.Add(value); + add => this.EventManager.Warped.Add(value, this.Mod); remove => this.EventManager.Warped.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs index 9388bdb2..1d6788e1 100644 --- a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs +++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs @@ -12,21 +12,21 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised when the low-level stage in the game's loading process has changed. This is an advanced event for mods which need to run code at specific points in the loading process. The available stages or when they happen might change without warning in future versions (e.g. due to changes in the game's load process), so mods using this event are more likely to break or have bugs. Most mods should use <see cref="IGameLoopEvents"/> instead.</summary> public event EventHandler<LoadStageChangedEventArgs> LoadStageChanged { - add => this.EventManager.LoadStageChanged.Add(value); + add => this.EventManager.LoadStageChanged.Add(value, this.Mod); remove => this.EventManager.LoadStageChanged.Remove(value); } /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> public event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking { - add => this.EventManager.UnvalidatedUpdateTicking.Add(value); + add => this.EventManager.UnvalidatedUpdateTicking.Add(value, this.Mod); remove => this.EventManager.UnvalidatedUpdateTicking.Remove(value); } /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> public event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked { - add => this.EventManager.UnvalidatedUpdateTicked.Add(value); + add => this.EventManager.UnvalidatedUpdateTicked.Add(value, this.Mod); remove => this.EventManager.UnvalidatedUpdateTicked.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs index 2ae69669..21b1b664 100644 --- a/src/SMAPI/Framework/Events/ModWorldEvents.cs +++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs @@ -40,28 +40,28 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after NPCs are added or removed in a location.</summary> public event EventHandler<NpcListChangedEventArgs> NpcListChanged { - add => this.EventManager.NpcListChanged.Add(value); + add => this.EventManager.NpcListChanged.Add(value, this.Mod); remove => this.EventManager.NpcListChanged.Remove(value); } /// <summary>Raised after objects are added or removed in a location.</summary> public event EventHandler<ObjectListChangedEventArgs> ObjectListChanged { - add => this.EventManager.ObjectListChanged.Add(value); + add => this.EventManager.ObjectListChanged.Add(value, this.Mod); remove => this.EventManager.ObjectListChanged.Remove(value); } /// <summary>Raised after items are added or removed from a chest.</summary> public event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged { - add => this.EventManager.ChestInventoryChanged.Add(value); + add => this.EventManager.ChestInventoryChanged.Add(value, this.Mod); remove => this.EventManager.ChestInventoryChanged.Remove(value); } /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged { - add => this.EventManager.TerrainFeatureListChanged.Add(value); + add => this.EventManager.TerrainFeatureListChanged.Add(value, this.Mod); remove => this.EventManager.TerrainFeatureListChanged.Remove(value); } diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 37927482..1231b494 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -112,9 +112,9 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the mod has at least one valid update key set.</summary> bool HasValidUpdateKeys(); - /// <summary>Get whether the mod has a given warning and it hasn't been suppressed in the <see cref="DataRecord"/>.</summary> - /// <param name="warning">The warning to check.</param> - bool HasUnsuppressWarning(ModWarning warning); + /// <summary>Get whether the mod has any of the given warnings which haven't been suppressed in the <see cref="DataRecord"/>.</summary> + /// <param name="warnings">The warnings to check.</param> + bool HasUnsuppressedWarnings(params ModWarning[] warnings); /// <summary>Get a relative path which includes the root folder name.</summary> string GetRelativePathWithRoot(); diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs index 36622066..2657fd12 100644 --- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs +++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs @@ -205,16 +205,13 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Get the equivalent state.</summary> public GamePadState GetState() { - if (this.State == null) - { - this.State = new GamePadState( - leftThumbStick: this.LeftStickPos, - rightThumbStick: this.RightStickPos, - leftTrigger: this.LeftTrigger, - rightTrigger: this.RightTrigger, - buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values - ); - } + this.State ??= new GamePadState( + leftThumbStick: this.LeftStickPos, + rightThumbStick: this.RightStickPos, + leftTrigger: this.LeftTrigger, + rightTrigger: this.RightTrigger, + buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values + ); return this.State.Value; } diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs index 59956feb..1cc16ca9 100644 --- a/src/SMAPI/Framework/Input/MouseStateBuilder.cs +++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs @@ -89,19 +89,16 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Get the equivalent state.</summary> public MouseState GetState() { - if (this.State == null) - { - this.State = new MouseState( - x: this.X, - y: this.Y, - scrollWheel: this.ScrollWheelValue, - leftButton: this.ButtonStates[SButton.MouseLeft], - middleButton: this.ButtonStates[SButton.MouseMiddle], - rightButton: this.ButtonStates[SButton.MouseRight], - xButton1: this.ButtonStates[SButton.MouseX1], - xButton2: this.ButtonStates[SButton.MouseX2] - ); - } + this.State ??= new MouseState( + x: this.X, + y: this.Y, + scrollWheel: this.ScrollWheelValue, + leftButton: this.ButtonStates[SButton.MouseLeft], + middleButton: this.ButtonStates[SButton.MouseMiddle], + rightButton: this.ButtonStates[SButton.MouseRight], + xButton1: this.ButtonStates[SButton.MouseX1], + xButton2: this.ButtonStates[SButton.MouseX2] + ); return this.State.Value; } diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 86c327ed..916c215d 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -122,7 +122,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <returns>Returns the same property instance for convenience.</returns> private IReflectedProperty<T> AssertAccessAllowed<T>(IReflectedProperty<T> property) { - this.AssertAccessAllowed(property?.PropertyInfo); + this.AssertAccessAllowed(property?.PropertyInfo.GetMethod?.GetBaseDefinition()); + this.AssertAccessAllowed(property?.PropertyInfo.SetMethod?.GetBaseDefinition()); return property; } @@ -131,7 +132,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <returns>Returns the same method instance for convenience.</returns> private IReflectedMethod AssertAccessAllowed(IReflectedMethod method) { - this.AssertAccessAllowed(method?.MethodInfo); + this.AssertAccessAllowed(method?.MethodInfo.GetBaseDefinition()); return method; } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index b5533335..f8c901e0 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -6,6 +6,7 @@ using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.ModLoading.Framework; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Utilities; @@ -49,6 +50,8 @@ namespace StardewModdingAPI.Framework.ModLoading this.Monitor = monitor; this.ParanoidMode = paranoidMode; this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform)); + + // init resolver this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver()); this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.ExecutionPath); this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.InternalFilesPath); @@ -73,9 +76,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="mod">The mod for which the assembly is being loaded.</param> /// <param name="assemblyPath">The assembly file path.</param> /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <param name="rewriteInParallel">Whether to enable experimental parallel rewriting.</param> /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns> /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> - public Assembly Load(IModMetadata mod, string assemblyPath, bool assumeCompatible) + public Assembly Load(IModMetadata mod, string assemblyPath, bool assumeCompatible, bool rewriteInParallel) { // get referenced local assemblies AssemblyParseResult[] assemblies; @@ -105,7 +109,7 @@ namespace StardewModdingAPI.Framework.ModLoading continue; // rewrite assembly - bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " "); + bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " ", rewriteInParallel); // detect broken assembly reference foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences) @@ -124,13 +128,22 @@ namespace StardewModdingAPI.Framework.ModLoading if (changed) { if (!oneAssembly) - this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); - using (MemoryStream outStream = new MemoryStream()) + this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)...", LogLevel.Trace); + + // load PDB file if present + byte[] symbols; { - assembly.Definition.Write(outStream); - byte[] bytes = outStream.ToArray(); - lastAssembly = Assembly.Load(bytes); + string symbolsPath = Path.Combine(Path.GetDirectoryName(assemblyPath), Path.GetFileNameWithoutExtension(assemblyPath)) + ".pdb"; + symbols = File.Exists(symbolsPath) + ? File.ReadAllBytes(symbolsPath) + : null; } + + // load assembly + using MemoryStream outStream = new MemoryStream(); + assembly.Definition.Write(outStream); + byte[] bytes = outStream.ToArray(); + lastAssembly = Assembly.Load(bytes, symbols); } else { @@ -250,9 +263,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="assembly">The assembly to rewrite.</param> /// <param name="loggedMessages">The messages that have already been logged for this mod.</param> /// <param name="logPrefix">A string to prefix to log messages.</param> + /// <param name="rewriteInParallel">Whether to enable experimental parallel rewriting.</param> /// <returns>Returns whether the assembly was modified.</returns> /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> - private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet<string> loggedMessages, string logPrefix) + private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet<string> loggedMessages, string logPrefix, bool rewriteInParallel) { ModuleDefinition module = assembly.MainModule; string filename = $"{assembly.Name.Name}.dll"; @@ -282,35 +296,32 @@ namespace StardewModdingAPI.Framework.ModLoading this.ChangeTypeScope(type); } - // find (and optionally rewrite) incompatible instructions - bool anyRewritten = false; - IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode).ToArray(); - foreach (MethodDefinition method in this.GetMethods(module)) - { - // check method definition - foreach (IInstructionHandler handler in handlers) + // find or rewrite code + IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged).ToArray(); + RecursiveRewriter rewriter = new RecursiveRewriter( + module: module, + rewriteType: (type, replaceWith) => { - InstructionHandleResult result = handler.Handle(module, method, this.AssemblyMap, platformChanged); - this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, filename); - if (result == InstructionHandleResult.Rewritten) - anyRewritten = true; - } - - // check CIL instructions - ILProcessor cil = method.Body.GetILProcessor(); - var instructions = cil.Body.Instructions; - // ReSharper disable once ForCanBeConvertedToForeach -- deliberate access by index so each handler sees replacements from previous handlers - for (int offset = 0; offset < instructions.Count; offset++) + bool rewritten = false; + foreach (IInstructionHandler handler in handlers) + rewritten |= handler.Handle(module, type, replaceWith); + return rewritten; + }, + rewriteInstruction: (ref Instruction instruction, ILProcessor cil, Action<Instruction> replaceWith) => { + bool rewritten = false; foreach (IInstructionHandler handler in handlers) - { - Instruction instruction = instructions[offset]; - InstructionHandleResult result = handler.Handle(module, cil, instruction, this.AssemblyMap, platformChanged); - this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, filename); - if (result == InstructionHandleResult.Rewritten) - anyRewritten = true; - } + rewritten |= handler.Handle(module, cil, instruction, replaceWith); + return rewritten; } + ); + bool anyRewritten = rewriter.RewriteModule(rewriteInParallel); + + // handle rewrite flags + foreach (IInstructionHandler handler in handlers) + { + foreach (var flag in handler.Flags) + this.ProcessInstructionHandleResult(mod, handler, flag, loggedMessages, logPrefix, filename); } return platformChanged || anyRewritten; @@ -325,49 +336,52 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="filename">The assembly filename for log messages.</param> private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, string filename) { + // get message template + // ($phrase is replaced with the noun phrase or messages) + string template = null; switch (result) { case InstructionHandleResult.Rewritten: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewrote {filename} to fix {handler.NounPhrase}..."); + template = $"{logPrefix}Rewrote {filename} to fix $phrase..."; break; case InstructionHandleResult.NotCompatible: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Broken code in {filename}: {handler.NounPhrase}."); + template = $"{logPrefix}Broken code in {filename}: $phrase."; mod.SetWarning(ModWarning.BrokenCodeLoaded); break; case InstructionHandleResult.DetectedGamePatch: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected game patcher ({handler.NounPhrase}) in assembly {filename}."); + template = $"{logPrefix}Detected game patcher ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.PatchesGame); break; case InstructionHandleResult.DetectedSaveSerializer: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serializer change ({handler.NounPhrase}) in assembly {filename}."); + template = $"{logPrefix}Detected possible save serializer change ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.ChangesSaveSerializer); break; case InstructionHandleResult.DetectedUnvalidatedUpdateTick: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected reference to {handler.NounPhrase} in assembly {filename}."); + template = $"{logPrefix}Detected reference to $phrase in assembly {filename}."; mod.SetWarning(ModWarning.UsesUnvalidatedUpdateTick); break; case InstructionHandleResult.DetectedDynamic: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected 'dynamic' keyword ({handler.NounPhrase}) in assembly {filename}."); + template = $"{logPrefix}Detected 'dynamic' keyword ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.UsesDynamic); break; case InstructionHandleResult.DetectedConsoleAccess: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected direct console access ({handler.NounPhrase}) in assembly {filename}."); + template = $"{logPrefix}Detected direct console access ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.AccessesConsole); break; case InstructionHandleResult.DetectedFilesystemAccess: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}."); + template = $"{logPrefix}Detected filesystem access ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.AccessesFilesystem); break; case InstructionHandleResult.DetectedShellAccess: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected shell or process access ({handler.NounPhrase}) in assembly {filename}."); + template = $"{logPrefix}Detected shell or process access ($phrase) in assembly {filename}."; mod.SetWarning(ModWarning.AccessesShell); break; @@ -377,6 +391,17 @@ namespace StardewModdingAPI.Framework.ModLoading default: throw new NotSupportedException($"Unrecognized instruction handler result '{result}'."); } + if (template == null) + return; + + // format messages + if (handler.Phrases.Any()) + { + foreach (string message in handler.Phrases) + this.Monitor.LogOnce(template.Replace("$phrase", message)); + } + else + this.Monitor.LogOnce(template.Replace("$phrase", handler.DefaultPhrase ?? handler.GetType().Name)); } /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> @@ -395,18 +420,5 @@ namespace StardewModdingAPI.Framework.ModLoading AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; type.Scope = assemblyRef; } - - /// <summary>Get all methods in a module.</summary> - /// <param name="module">The module to search.</param> - private IEnumerable<MethodDefinition> GetMethods(ModuleDefinition module) - { - return ( - from type in module.GetTypes() - where type.HasMethods - from method in type.Methods - where method.HasBody - select method - ); - } } } diff --git a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs index 898bafb4..e1476b73 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs @@ -1,10 +1,12 @@ +using System; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds incompatible CIL instructions that reference a given event.</summary> - internal class EventFinder : IInstructionHandler + internal class EventFinder : BaseInstructionHandler { /********* ** Fields @@ -20,13 +22,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -34,34 +29,25 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="eventName">The event name for which to find references.</param> /// <param name="result">The result to return for matching instructions.</param> public EventFinder(string fullTypeName, string eventName, InstructionHandleResult result) + : base(defaultPhrase: $"{fullTypeName}.{eventName} event") { this.FullTypeName = fullTypeName; this.EventName = eventName; this.Result = result; - this.NounPhrase = $"{fullTypeName}.{eventName} event"; } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - return this.IsMatch(instruction) - ? this.Result - : InstructionHandleResult.None; + if (!this.Flags.Contains(this.Result) && this.IsMatch(instruction)) + this.MarkFlag(this.Result); + + return false; } diff --git a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs index 606ca8b7..c157ed9b 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs @@ -1,10 +1,12 @@ +using System; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds incompatible CIL instructions that reference a given field.</summary> - internal class FieldFinder : IInstructionHandler + internal class FieldFinder : BaseInstructionHandler { /********* ** Fields @@ -20,13 +22,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -34,49 +29,25 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="fieldName">The field name for which to find references.</param> /// <param name="result">The result to return for matching instructions.</param> public FieldFinder(string fullTypeName, string fieldName, InstructionHandleResult result) + : base(defaultPhrase: $"{fullTypeName}.{fieldName} field") { this.FullTypeName = fullTypeName; this.FieldName = fieldName; this.Result = result; - this.NounPhrase = $"{fullTypeName}.{fieldName} field"; } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - return this.IsMatch(instruction) - ? this.Result - : InstructionHandleResult.None; - } - + if (!this.Flags.Contains(this.Result) && RewriteHelper.IsFieldReferenceTo(instruction, this.FullTypeName, this.FieldName)) + this.MarkFlag(this.Result); - /********* - ** Protected methods - *********/ - /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="instruction">The IL instruction.</param> - protected bool IsMatch(Instruction instruction) - { - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - return - fieldRef != null - && fieldRef.DeclaringType.FullName == this.FullTypeName - && fieldRef.Name == this.FieldName; + return false; } } } diff --git a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs index 9ca246ff..82c93a7c 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs @@ -1,10 +1,12 @@ +using System; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds incompatible CIL instructions that reference a given method.</summary> - internal class MethodFinder : IInstructionHandler + internal class MethodFinder : BaseInstructionHandler { /********* ** Fields @@ -20,13 +22,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -34,34 +29,25 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="methodName">The method name for which to find references.</param> /// <param name="result">The result to return for matching instructions.</param> public MethodFinder(string fullTypeName, string methodName, InstructionHandleResult result) + : base(defaultPhrase: $"{fullTypeName}.{methodName} method") { this.FullTypeName = fullTypeName; this.MethodName = methodName; this.Result = result; - this.NounPhrase = $"{fullTypeName}.{methodName} method"; } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - return this.IsMatch(instruction) - ? this.Result - : InstructionHandleResult.None; + if (!this.Flags.Contains(this.Result) && this.IsMatch(instruction)) + this.MarkFlag(this.Result); + + return false; } diff --git a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs index 0677aa88..c96d61a2 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs @@ -1,10 +1,12 @@ +using System; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds incompatible CIL instructions that reference a given property.</summary> - internal class PropertyFinder : IInstructionHandler + internal class PropertyFinder : BaseInstructionHandler { /********* ** Fields @@ -20,13 +22,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -34,34 +29,25 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="propertyName">The property name for which to find references.</param> /// <param name="result">The result to return for matching instructions.</param> public PropertyFinder(string fullTypeName, string propertyName, InstructionHandleResult result) + : base(defaultPhrase: $"{fullTypeName}.{propertyName} property") { this.FullTypeName = fullTypeName; this.PropertyName = propertyName; this.Result = result; - this.NounPhrase = $"{fullTypeName}.{propertyName} property"; } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - return this.IsMatch(instruction) - ? this.Result - : InstructionHandleResult.None; + if (!this.Flags.Contains(this.Result) && this.IsMatch(instruction)) + this.MarkFlag(this.Result); + + return false; } diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs index 459e3210..a67cfa4f 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs @@ -1,13 +1,15 @@ +using System; using System.Collections.Generic; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds references to a field, property, or method which returns a different type than the code expects.</summary> /// <remarks>This implementation is purely heuristic. It should never return a false positive, but won't detect all cases.</remarks> - internal class ReferenceToMemberWithUnexpectedTypeFinder : IInstructionHandler + internal class ReferenceToMemberWithUnexpectedTypeFinder : BaseInstructionHandler { /********* ** Fields @@ -17,39 +19,23 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; private set; } = ""; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="validateReferencesToAssemblies">The assembly names to which to heuristically detect broken references.</param> public ReferenceToMemberWithUnexpectedTypeFinder(string[] validateReferencesToAssemblies) + : base(defaultPhrase: "") { this.ValidateReferencesToAssemblies = new HashSet<string>(validateReferencesToAssemblies); } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { // field reference FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); @@ -58,13 +44,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders // get target field FieldDefinition targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); if (targetField == null) - return InstructionHandleResult.None; + return false; // validate return type if (!RewriteHelper.LooksLikeSameType(fieldRef.FieldType, targetField.FieldType)) { - this.NounPhrase = $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType)}, not {this.GetFriendlyTypeName(fieldRef.FieldType)})"; - return InstructionHandleResult.NotCompatible; + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType)}, not {this.GetFriendlyTypeName(fieldRef.FieldType)})"); + return false; } } @@ -75,21 +61,21 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders // get potential targets MethodDefinition[] candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray(); if (candidateMethods == null || !candidateMethods.Any()) - return InstructionHandleResult.None; + return false; // compare return types MethodDefinition methodDef = methodReference.Resolve(); if (methodDef == null) - return InstructionHandleResult.None; // validated by ReferenceToMissingMemberFinder + return false; // validated by ReferenceToMissingMemberFinder if (candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType))) { - this.NounPhrase = $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType)})"; - return InstructionHandleResult.NotCompatible; + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType)})"); + return false; } } - return InstructionHandleResult.None; + return false; } diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs index 44b531a5..ebb62948 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs @@ -1,13 +1,15 @@ +using System; using System.Collections.Generic; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds references to a field, property, or method which no longer exists.</summary> /// <remarks>This implementation is purely heuristic. It should never return a false positive, but won't detect all cases.</remarks> - internal class ReferenceToMissingMemberFinder : IInstructionHandler + internal class ReferenceToMissingMemberFinder : BaseInstructionHandler { /********* ** Fields @@ -17,39 +19,23 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; private set; } = ""; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="validateReferencesToAssemblies">The assembly names to which to heuristically detect broken references.</param> public ReferenceToMissingMemberFinder(string[] validateReferencesToAssemblies) + : base(defaultPhrase: "") { this.ValidateReferencesToAssemblies = new HashSet<string>(validateReferencesToAssemblies); } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { // field reference FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); @@ -58,8 +44,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders FieldDefinition target = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); if (target == null) { - this.NounPhrase = $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (no such field)"; - return InstructionHandleResult.NotCompatible; + this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (no such field)"); + return false; } } @@ -70,17 +56,20 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders MethodDefinition target = methodRef.Resolve(); if (target == null) { + string phrase = null; if (this.IsProperty(methodRef)) - this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)"; + phrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)"; else if (methodRef.Name == ".ctor") - this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no matching constructor)"; + phrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no matching constructor)"; else - this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)"; - return InstructionHandleResult.NotCompatible; + phrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)"; + + this.MarkFlag(InstructionHandleResult.NotCompatible, phrase); + return false; } } - return InstructionHandleResult.None; + return false; } diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs new file mode 100644 index 00000000..a1ade536 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs @@ -0,0 +1,51 @@ +using System; +using Mono.Cecil; +using StardewModdingAPI.Framework.ModLoading.Framework; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference types in a given assembly.</summary> + internal class TypeAssemblyFinder : BaseInstructionHandler + { + /********* + ** Fields + *********/ + /// <summary>The full assembly name to which to find references.</summary> + private readonly string AssemblyName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + /// <summary>Get whether a matched type should be ignored.</summary> + private readonly Func<TypeReference, bool> ShouldIgnore; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="assemblyName">The full assembly name to which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> + public TypeAssemblyFinder(string assemblyName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null) + : base(defaultPhrase: $"{assemblyName} assembly") + { + this.AssemblyName = assemblyName; + this.Result = result; + this.ShouldIgnore = shouldIgnore; + } + + /// <summary>Rewrite a type reference if needed.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="type">The type definition to handle.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + /// <returns>Returns whether the type was changed.</returns> + public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) + { + if (type.Scope.Name == this.AssemblyName && this.ShouldIgnore?.Invoke(type) != true) + this.MarkFlag(this.Result); + + return false; + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs index 701b15f2..c285414a 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -1,31 +1,23 @@ using System; -using System.Linq; using Mono.Cecil; -using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Finders { /// <summary>Finds incompatible CIL instructions that reference a given type.</summary> - internal class TypeFinder : IInstructionHandler + internal class TypeFinder : BaseInstructionHandler { /********* - ** Accessors + ** Fields *********/ - /// <summary>The full type name for which to find references.</summary> + /// <summary>The full type name to match.</summary> private readonly string FullTypeName; /// <summary>The result to return for matching instructions.</summary> private readonly InstructionHandleResult Result; - /// <summary>A lambda which overrides a matched type.</summary> - protected readonly Func<TypeReference, bool> ShouldIgnore; - - - /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } + /// <summary>Get whether a matched type should be ignored.</summary> + private readonly Func<TypeReference, bool> ShouldIgnore; /********* @@ -34,104 +26,24 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <summary>Construct an instance.</summary> /// <param name="fullTypeName">The full type name to match.</param> /// <param name="result">The result to return for matching instructions.</param> - /// <param name="shouldIgnore">A lambda which overrides a matched type.</param> + /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null) + : base(defaultPhrase: $"{fullTypeName} type") { this.FullTypeName = fullTypeName; this.Result = result; - this.NounPhrase = $"{fullTypeName} type"; - this.ShouldIgnore = shouldIgnore ?? (p => false); - } - - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return this.IsMatch(method) - ? this.Result - : InstructionHandleResult.None; + this.ShouldIgnore = shouldIgnore; } - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a type reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return this.IsMatch(instruction) - ? this.Result - : InstructionHandleResult.None; - } - - - /********* - ** Protected methods - *********/ - /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="method">The method definition.</param> - protected bool IsMatch(MethodDefinition method) + /// <param name="type">The type definition to handle.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + /// <returns>Returns whether the type was changed.</returns> + public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { - if (this.IsMatch(method.ReturnType)) - return true; - - foreach (VariableDefinition variable in method.Body.Variables) - { - if (this.IsMatch(variable.VariableType)) - return true; - } - - return false; - } - - /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="instruction">The IL instruction.</param> - protected bool IsMatch(Instruction instruction) - { - // field reference - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - if (fieldRef != null) - { - return - this.IsMatch(fieldRef.DeclaringType) // field on target class - || this.IsMatch(fieldRef.FieldType); // field value is target class - } - - // method reference - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); - if (methodRef != null) - { - return - this.IsMatch(methodRef.DeclaringType) // method on target class - || this.IsMatch(methodRef.ReturnType) // method returns target class - || methodRef.Parameters.Any(p => this.IsMatch(p.ParameterType)); // method parameters - } - - return false; - } - - /// <summary>Get whether a type reference matches the expected type.</summary> - /// <param name="type">The type to check.</param> - protected bool IsMatch(TypeReference type) - { - // root type - if (type.FullName == this.FullTypeName && !this.ShouldIgnore(type)) - return true; - - // generic arguments - if (type is GenericInstanceType genericType) - { - if (genericType.GenericArguments.Any(this.IsMatch)) - return true; - } - - // generic parameters (e.g. constraints) - if (type.GenericParameters.Any(this.IsMatch)) - return true; + if (type.FullName == this.FullTypeName && this.ShouldIgnore?.Invoke(type) != true) + this.MarkFlag(this.Result); return false; } diff --git a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs new file mode 100644 index 00000000..79fb45b8 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Framework +{ + /// <summary>The base implementation for a CIL instruction handler or rewriter.</summary> + internal abstract class BaseInstructionHandler : IInstructionHandler + { + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the handler matches, used if <see cref="Phrases"/> is empty.</summary> + public string DefaultPhrase { get; } + + /// <summary>The rewrite flags raised for the current module.</summary> + public ISet<InstructionHandleResult> Flags { get; } = new HashSet<InstructionHandleResult>(); + + /// <summary>The brief noun phrases indicating what the handler matched for the current module.</summary> + public ISet<string> Phrases { get; } = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Rewrite a type reference if needed.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="type">The type definition to handle.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + /// <returns>Returns whether the type was changed.</returns> + public virtual bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) + { + return false; + } + + /// <summary>Rewrite a CIL instruction reference if needed.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public virtual bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + { + return false; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="defaultPhrase">A brief noun phrase indicating what the handler matches.</param> + protected BaseInstructionHandler(string defaultPhrase) + { + this.DefaultPhrase = defaultPhrase; + } + + /// <summary>Raise a result flag.</summary> + /// <param name="flag">The result flag to set.</param> + /// <param name="resultMessage">The result message to add.</param> + /// <returns>Returns true for convenience.</returns> + protected bool MarkFlag(InstructionHandleResult flag, string resultMessage = null) + { + this.Flags.Add(flag); + if (resultMessage != null) + this.Phrases.Add(resultMessage); + return true; + } + + /// <summary>Raise a generic flag indicating that the code was rewritten.</summary> + public bool MarkRewritten() + { + return this.MarkFlag(InstructionHandleResult.Rewritten); + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs new file mode 100644 index 00000000..34c78c7d --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Mono.Collections.Generic; + +namespace StardewModdingAPI.Framework.ModLoading.Framework +{ + /// <summary>Handles recursively rewriting loaded assembly code.</summary> + internal class RecursiveRewriter + { + /********* + ** Delegates + *********/ + /// <summary>Rewrite a type reference in the assembly code.</summary> + /// <param name="type">The current type reference.</param> + /// <param name="replaceWith">Replaces the type reference with the given type.</param> + /// <returns>Returns whether the type was changed.</returns> + public delegate bool RewriteTypeDelegate(TypeReference type, Action<TypeReference> replaceWith); + + /// <summary>Rewrite a CIL instruction in the assembly code.</summary> + /// <param name="instruction">The current CIL instruction.</param> + /// <param name="cil">The CIL instruction processor.</param> + /// <param name="replaceWith">Replaces the CIL instruction with the given instruction.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public delegate bool RewriteInstructionDelegate(ref Instruction instruction, ILProcessor cil, Action<Instruction> replaceWith); + + + /********* + ** Accessors + *********/ + /// <summary>The module to rewrite.</summary> + public ModuleDefinition Module { get; } + + /// <summary>Handle or rewrite a type reference if needed.</summary> + public RewriteTypeDelegate RewriteTypeImpl { get; } + + /// <summary>Handle or rewrite a CIL instruction if needed.</summary> + public RewriteInstructionDelegate RewriteInstructionImpl { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="module">The module to rewrite.</param> + /// <param name="rewriteType">Handle or rewrite a type reference if needed.</param> + /// <param name="rewriteInstruction">Handle or rewrite a CIL instruction if needed.</param> + public RecursiveRewriter(ModuleDefinition module, RewriteTypeDelegate rewriteType, RewriteInstructionDelegate rewriteInstruction) + { + this.Module = module; + this.RewriteTypeImpl = rewriteType; + this.RewriteInstructionImpl = rewriteInstruction; + } + + /// <summary>Rewrite the loaded module code.</summary> + /// <param name="rewriteInParallel">Whether to enable experimental parallel rewriting.</param> + /// <returns>Returns whether the module was modified.</returns> + public bool RewriteModule(bool rewriteInParallel) + { + IEnumerable<TypeDefinition> types = this.Module.GetTypes().Where(type => type.BaseType != null); // skip special types like <Module> + + // experimental parallel rewriting + // This may cause intermittent startup errors and is disabled by default: https://github.com/Pathoschild/SMAPI/issues/721 + if (rewriteInParallel) + { + int typesChanged = 0; + Exception exception = null; + + Parallel.ForEach(types, type => + { + if (exception != null) + return; + + bool changed = false; + try + { + changed = this.RewriteTypeDefinition(type); + } + catch (Exception ex) + { + exception ??= ex; + } + + if (changed) + Interlocked.Increment(ref typesChanged); + }); + + return exception == null + ? typesChanged > 0 + : throw new Exception($"Rewriting {this.Module.Name} failed.", exception); + } + + // non-parallel rewriting + { + bool changed = false; + + try + { + foreach (var type in types) + changed |= this.RewriteTypeDefinition(type); + } + catch (Exception ex) + { + throw new Exception($"Rewriting {this.Module.Name} failed.", ex); + } + + return changed; + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Rewrite a loaded type definition.</summary> + /// <param name="type">The type definition to rewrite.</param> + /// <returns>Returns whether the type was modified.</returns> + private bool RewriteTypeDefinition(TypeDefinition type) + { + bool changed = false; + + changed |= this.RewriteCustomAttributes(type.CustomAttributes); + changed |= this.RewriteGenericParameters(type.GenericParameters); + + foreach (InterfaceImplementation @interface in type.Interfaces) + changed |= this.RewriteTypeReference(@interface.InterfaceType, newType => @interface.InterfaceType = newType); + + if (type.BaseType.FullName != "System.Object") + changed |= this.RewriteTypeReference(type.BaseType, newType => type.BaseType = newType); + + foreach (MethodDefinition method in type.Methods) + { + changed |= this.RewriteTypeReference(method.ReturnType, newType => method.ReturnType = newType); + changed |= this.RewriteGenericParameters(method.GenericParameters); + changed |= this.RewriteCustomAttributes(method.CustomAttributes); + + foreach (ParameterDefinition parameter in method.Parameters) + changed |= this.RewriteTypeReference(parameter.ParameterType, newType => parameter.ParameterType = newType); + + foreach (var methodOverride in method.Overrides) + changed |= this.RewriteMethodReference(methodOverride); + + if (method.HasBody) + { + foreach (VariableDefinition variable in method.Body.Variables) + changed |= this.RewriteTypeReference(variable.VariableType, newType => variable.VariableType = newType); + + // check CIL instructions + ILProcessor cil = method.Body.GetILProcessor(); + Collection<Instruction> instructions = cil.Body.Instructions; + for (int i = 0; i < instructions.Count; i++) + { + var instruction = instructions[i]; + if (instruction.OpCode.Code == Code.Nop) + continue; + + changed |= this.RewriteInstruction(instruction, cil, newInstruction => + { + changed = true; + cil.Replace(instruction, newInstruction); + instruction = newInstruction; + }); + } + } + } + + return changed; + } + + /// <summary>Rewrite a CIL instruction if needed.</summary> + /// <param name="instruction">The current CIL instruction.</param> + /// <param name="cil">The CIL instruction processor.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + private bool RewriteInstruction(Instruction instruction, ILProcessor cil, Action<Instruction> replaceWith) + { + bool rewritten = false; + + // field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef != null) + { + rewritten |= this.RewriteTypeReference(fieldRef.DeclaringType, newType => fieldRef.DeclaringType = newType); + rewritten |= this.RewriteTypeReference(fieldRef.FieldType, newType => fieldRef.FieldType = newType); + } + + // method reference + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef != null) + this.RewriteMethodReference(methodRef); + + // type reference + if (instruction.Operand is TypeReference typeRef) + rewritten |= this.RewriteTypeReference(typeRef, newType => replaceWith(cil.Create(instruction.OpCode, newType))); + + // instruction itself + // (should be done after the above type rewrites to ensure valid types) + rewritten |= this.RewriteInstructionImpl(ref instruction, cil, newInstruction => + { + rewritten = true; + cil.Replace(instruction, newInstruction); + instruction = newInstruction; + }); + + return rewritten; + } + + /// <summary>Rewrite a method reference if needed.</summary> + /// <param name="methodRef">The current method reference.</param> + private bool RewriteMethodReference(MethodReference methodRef) + { + bool rewritten = false; + + rewritten |= this.RewriteTypeReference(methodRef.DeclaringType, newType => + { + // note: generic methods are wrapped into a MethodSpecification which doesn't allow changing the + // declaring type directly. For our purposes we want to change all generic versions of a matched + // method anyway, so we can use GetElementMethod to get the underlying method here. + methodRef.GetElementMethod().DeclaringType = newType; + }); + rewritten |= this.RewriteTypeReference(methodRef.ReturnType, newType => methodRef.ReturnType = newType); + + foreach (var parameter in methodRef.Parameters) + rewritten |= this.RewriteTypeReference(parameter.ParameterType, newType => parameter.ParameterType = newType); + + if (methodRef is GenericInstanceMethod genericRef) + { + for (int i = 0; i < genericRef.GenericArguments.Count; i++) + rewritten |= this.RewriteTypeReference(genericRef.GenericArguments[i], newType => genericRef.GenericArguments[i] = newType); + } + + return rewritten; + } + + /// <summary>Rewrite a type reference if needed.</summary> + /// <param name="type">The current type reference.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + private bool RewriteTypeReference(TypeReference type, Action<TypeReference> replaceWith) + { + bool rewritten = false; + + // type + rewritten |= this.RewriteTypeImpl(type, newType => + { + type = newType; + replaceWith(newType); + rewritten = true; + }); + + // generic arguments + if (type is GenericInstanceType genericType) + { + for (int i = 0; i < genericType.GenericArguments.Count; i++) + rewritten |= this.RewriteTypeReference(genericType.GenericArguments[i], typeRef => genericType.GenericArguments[i] = typeRef); + } + + // generic parameters (e.g. constraints) + rewritten |= this.RewriteGenericParameters(type.GenericParameters); + + return rewritten; + } + + /// <summary>Rewrite custom attributes if needed.</summary> + /// <param name="attributes">The current custom attributes.</param> + private bool RewriteCustomAttributes(Collection<CustomAttribute> attributes) + { + bool rewritten = false; + + for (int attrIndex = 0; attrIndex < attributes.Count; attrIndex++) + { + CustomAttribute attribute = attributes[attrIndex]; + bool curChanged = false; + + // attribute type + TypeReference newAttrType = null; + rewritten |= this.RewriteTypeReference(attribute.AttributeType, newType => + { + newAttrType = newType; + curChanged = true; + }); + + // constructor arguments + TypeReference[] argTypes = new TypeReference[attribute.ConstructorArguments.Count]; + for (int i = 0; i < argTypes.Length; i++) + { + var arg = attribute.ConstructorArguments[i]; + + argTypes[i] = arg.Type; + rewritten |= this.RewriteTypeReference(arg.Type, newType => + { + argTypes[i] = newType; + curChanged = true; + }); + } + + // swap attribute + if (curChanged) + { + // get constructor + MethodDefinition constructor = (newAttrType ?? attribute.AttributeType) + .Resolve() + .Methods + .Where(method => method.IsConstructor) + .FirstOrDefault(ctor => RewriteHelper.HasMatchingSignature(ctor, attribute.Constructor)); + if (constructor == null) + throw new InvalidOperationException($"Can't rewrite attribute type '{attribute.AttributeType.FullName}' to '{newAttrType?.FullName}', no equivalent constructor found."); + + // create new attribute + var newAttr = new CustomAttribute(this.Module.ImportReference(constructor)); + for (int i = 0; i < argTypes.Length; i++) + newAttr.ConstructorArguments.Add(new CustomAttributeArgument(argTypes[i], attribute.ConstructorArguments[i].Value)); + foreach (var prop in attribute.Properties) + newAttr.Properties.Add(new CustomAttributeNamedArgument(prop.Name, prop.Argument)); + foreach (var field in attribute.Fields) + newAttr.Fields.Add(new CustomAttributeNamedArgument(field.Name, field.Argument)); + + // swap attribute + attributes[attrIndex] = newAttr; + rewritten = true; + } + } + + return rewritten; + } + + /// <summary>Rewrites generic type parameters if needed.</summary> + /// <param name="parameters">The current generic type parameters.</param> + private bool RewriteGenericParameters(Collection<GenericParameter> parameters) + { + bool anyChanged = false; + + for (int i = 0; i < parameters.Count; i++) + { + TypeReference parameter = parameters[i]; + anyChanged |= this.RewriteTypeReference(parameter, newType => parameters[i] = new GenericParameter(parameter.Name, newType)); + } + + return anyChanged; + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs index f8f10dc4..36058b86 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteHelper.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs @@ -4,7 +4,7 @@ using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; -namespace StardewModdingAPI.Framework.ModLoading +namespace StardewModdingAPI.Framework.ModLoading.Framework { /// <summary>Provides helper methods for field rewriters.</summary> internal static class RewriteHelper @@ -28,6 +28,28 @@ namespace StardewModdingAPI.Framework.ModLoading : null; } + /// <summary>Get whether the field is a reference to the expected type and field.</summary> + /// <param name="instruction">The IL instruction.</param> + /// <param name="fullTypeName">The full type name containing the expected field.</param> + /// <param name="fieldName">The name of the expected field.</param> + public static bool IsFieldReferenceTo(Instruction instruction, string fullTypeName, string fieldName) + { + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + return RewriteHelper.IsFieldReferenceTo(fieldRef, fullTypeName, fieldName); + } + + /// <summary>Get whether the field is a reference to the expected type and field.</summary> + /// <param name="fieldRef">The field reference to check.</param> + /// <param name="fullTypeName">The full type name containing the expected field.</param> + /// <param name="fieldName">The name of the expected field.</param> + public static bool IsFieldReferenceTo(FieldReference fieldRef, string fullTypeName, string fieldName) + { + return + fieldRef != null + && fieldRef.DeclaringType.FullName == fullTypeName + && fieldRef.Name == fieldName; + } + /// <summary>Get the method reference from an instruction if it matches.</summary> /// <param name="instruction">The IL instruction.</param> public static MethodReference AsMethodReference(Instruction instruction) @@ -42,6 +64,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="reference">The type reference.</param> public static bool IsSameType(Type type, TypeReference reference) { + // + // duplicated by IsSameType(TypeReference, TypeReference) below + // + // same namespace & name if (type.Namespace != reference.Namespace || type.Name != reference.Name) return false; @@ -66,6 +92,39 @@ namespace StardewModdingAPI.Framework.ModLoading return true; } + /// <summary>Get whether a type matches a type reference.</summary> + /// <param name="type">The defined type.</param> + /// <param name="reference">The type reference.</param> + public static bool IsSameType(TypeReference type, TypeReference reference) + { + // + // duplicated by IsSameType(Type, TypeReference) above + // + + // same namespace & name + if (type.Namespace != reference.Namespace || type.Name != reference.Name) + return false; + + // same generic parameters + if (type.IsGenericInstance) + { + if (!reference.IsGenericInstance) + return false; + + TypeReference[] defGenerics = ((GenericInstanceType)type).GenericArguments.ToArray(); + TypeReference[] refGenerics = ((GenericInstanceType)reference).GenericArguments.ToArray(); + if (defGenerics.Length != refGenerics.Length) + return false; + for (int i = 0; i < defGenerics.Length; i++) + { + if (!RewriteHelper.IsSameType(defGenerics[i], refGenerics[i])) + return false; + } + } + + return true; + } + /// <summary>Determine whether two type IDs look like the same type, accounting for placeholder values such as !0.</summary> /// <param name="typeA">The type ID to compare.</param> /// <param name="typeB">The other type ID to compare.</param> @@ -78,8 +137,12 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Get whether a method definition matches the signature expected by a method reference.</summary> /// <param name="definition">The method definition.</param> /// <param name="reference">The method reference.</param> - public static bool HasMatchingSignature(MethodInfo definition, MethodReference reference) + public static bool HasMatchingSignature(MethodBase definition, MethodReference reference) { + // + // duplicated by HasMatchingSignature(MethodDefinition, MethodReference) below + // + // same name if (definition.Name != reference.Name) return false; @@ -97,13 +160,46 @@ namespace StardewModdingAPI.Framework.ModLoading return true; } + /// <summary>Get whether a method definition matches the signature expected by a method reference.</summary> + /// <param name="definition">The method definition.</param> + /// <param name="reference">The method reference.</param> + public static bool HasMatchingSignature(MethodDefinition definition, MethodReference reference) + { + // + // duplicated by HasMatchingSignature(MethodBase, MethodReference) above + // + + // same name + if (definition.Name != reference.Name) + return false; + + // same arguments + ParameterDefinition[] definitionParameters = definition.Parameters.ToArray(); + ParameterDefinition[] referenceParameters = reference.Parameters.ToArray(); + if (referenceParameters.Length != definitionParameters.Length) + return false; + for (int i = 0; i < referenceParameters.Length; i++) + { + if (!RewriteHelper.IsSameType(definitionParameters[i].ParameterType, referenceParameters[i].ParameterType)) + return false; + } + return true; + } + /// <summary>Get whether a type has a method whose signature matches the one expected by a method reference.</summary> /// <param name="type">The type to check.</param> /// <param name="reference">The method reference.</param> public static bool HasMatchingSignature(Type type, MethodReference reference) { + if (reference.Name == ".ctor") + { + return type + .GetConstructors(BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly | BindingFlags.Public) + .Any(method => RewriteHelper.HasMatchingSignature(method, reference)); + } + return type - .GetMethods(BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public) + .GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly | BindingFlags.Public) .Any(method => RewriteHelper.HasMatchingSignature(method, reference)); } } diff --git a/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs b/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs index 8830cc74..e6de6785 100644 --- a/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs +++ b/src/SMAPI/Framework/ModLoading/IInstructionHandler.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using Mono.Cecil; using Mono.Cecil.Cil; @@ -9,26 +11,32 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Accessors *********/ - /// <summary>A brief noun phrase indicating what the handler matches.</summary> - string NounPhrase { get; } + /// <summary>A brief noun phrase indicating what the handler matches, used if <see cref="Phrases"/> is empty.</summary> + string DefaultPhrase { get; } + + /// <summary>The rewrite flags raised for the current module.</summary> + ISet<InstructionHandleResult> Flags { get; } + + /// <summary>The brief noun phrases indicating what the handler matched for the current module.</summary> + ISet<string> Phrases { get; } /********* ** Methods *********/ - /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <summary>Rewrite a type reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged); + /// <param name="type">The type definition to handle.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + /// <returns>Returns whether the type was changed.</returns> + bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith); - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged); + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith); } } diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 0e90362e..30701552 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -215,13 +215,14 @@ namespace StardewModdingAPI.Framework.ModLoading return this.GetUpdateKeys(validOnly: true).Any(); } - /// <summary>Get whether the mod has a given warning and it hasn't been suppressed in the <see cref="DataRecord"/>.</summary> - /// <param name="warning">The warning to check.</param> - public bool HasUnsuppressWarning(ModWarning warning) + /// <summary>Get whether the mod has any of the given warnings which haven't been suppressed in the <see cref="IModMetadata.DataRecord"/>.</summary> + /// <param name="warnings">The warnings to check.</param> + public bool HasUnsuppressedWarnings(params ModWarning[] warnings) { - return + return warnings.Any(warning => this.Warnings.HasFlag(warning) - && (this.DataRecord?.DataRecord == null || !this.DataRecord.DataRecord.SuppressWarnings.HasFlag(warning)); + && (this.DataRecord?.DataRecord == null || !this.DataRecord.DataRecord.SuppressWarnings.HasFlag(warning)) + ); } /// <summary>Get a relative path which includes the root folder name.</summary> diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs new file mode 100644 index 00000000..102f3364 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs @@ -0,0 +1,44 @@ +#if HARMONY_2 +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using HarmonyLib; + +namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades +{ + /// <summary>Maps Harmony 1.x <see cref="AccessTools"/> methods to Harmony 2.x to avoid breaking older mods.</summary> + /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via assembly rewriting")] + [SuppressMessage("ReSharper", "CS1591", Justification = "Documentation not needed for facade classes.")] + public class AccessToolsFacade + { + /********* + ** Public methods + *********/ + public static ConstructorInfo DeclaredConstructor(Type type, Type[] parameters = null) + { + // Harmony 1.x matched both static and instance constructors + return + AccessTools.DeclaredConstructor(type, parameters, searchForStatic: false) + ?? AccessTools.DeclaredConstructor(type, parameters, searchForStatic: true); + } + + public static ConstructorInfo Constructor(Type type, Type[] parameters = null) + { + // Harmony 1.x matched both static and instance constructors + return + AccessTools.Constructor(type, parameters, searchForStatic: false) + ?? AccessTools.Constructor(type, parameters, searchForStatic: true); + } + + public static List<ConstructorInfo> GetDeclaredConstructors(Type type) + { + // Harmony 1.x matched both static and instance constructors + return + AccessTools.GetDeclaredConstructors(type, searchForStatic: false) + ?? AccessTools.GetDeclaredConstructors(type, searchForStatic: true); + } + } +} +#endif diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs new file mode 100644 index 00000000..ad6d5e4f --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs @@ -0,0 +1,84 @@ +#if HARMONY_2 +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades +{ + /// <summary>Maps Harmony 1.x <code>HarmonyInstance</code> methods to Harmony 2.x's <see cref="Harmony"/> to avoid breaking older mods.</summary> + /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via assembly rewriting")] + [SuppressMessage("ReSharper", "CS1591", Justification = "Documentation not needed for facade classes.")] + public class HarmonyInstanceFacade : Harmony + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The unique patch identifier.</param> + public HarmonyInstanceFacade(string id) + : base(id) { } + + public static Harmony Create(string id) + { + return new Harmony(id); + } + + public DynamicMethod Patch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod postfix = null, HarmonyMethod transpiler = null) + { + // In Harmony 1.x you could target a virtual method that's not implemented by the + // target type, but in Harmony 2.0 you need to target the concrete implementation. + // This just resolves the method to the concrete implementation if needed. + if (original != null) + original = original.GetDeclaredMember(); + + // call Harmony 2.0 and show a detailed exception if it fails + try + { + MethodInfo method = base.Patch(original: original, prefix: prefix, postfix: postfix, transpiler: transpiler); + return (DynamicMethod)method; + } + catch (Exception ex) + { + string patchTypes = this.GetPatchTypesLabel(prefix, postfix, transpiler); + string methodLabel = this.GetMethodLabel(original); + throw new Exception($"Harmony instance {this.Id} failed applying {patchTypes} to {methodLabel}.", ex); + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a human-readable label for the patch types being applies.</summary> + /// <param name="prefix">The prefix method, if any.</param> + /// <param name="postfix">The postfix method, if any.</param> + /// <param name="transpiler">The transpiler method, if any.</param> + private string GetPatchTypesLabel(HarmonyMethod prefix = null, HarmonyMethod postfix = null, HarmonyMethod transpiler = null) + { + var patchTypes = new List<string>(); + + if (prefix != null) + patchTypes.Add("prefix"); + if (postfix != null) + patchTypes.Add("postfix"); + if (transpiler != null) + patchTypes.Add("transpiler"); + + return string.Join("/", patchTypes); + } + + /// <summary>Get a human-readable label for the method being patched.</summary> + /// <param name="method">The method being patched.</param> + private string GetMethodLabel(MethodBase method) + { + return method != null + ? $"method {method.DeclaringType?.FullName}.{method.Name}" + : "null method"; + } + } +} +#endif diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs new file mode 100644 index 00000000..f3975558 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs @@ -0,0 +1,47 @@ +#if HARMONY_2 +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using HarmonyLib; + +namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades +{ + /// <summary>Maps Harmony 1.x <see cref="HarmonyMethod"/> methods to Harmony 2.x to avoid breaking older mods.</summary> + /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via assembly rewriting")] + [SuppressMessage("ReSharper", "CS1591", Justification = "Documentation not needed for facade classes.")] + public class HarmonyMethodFacade : HarmonyMethod + { + /********* + ** Public methods + *********/ + public HarmonyMethodFacade(MethodInfo method) + { + this.ImportMethodImpl(method); + } + + public HarmonyMethodFacade(Type type, string name, Type[] parameters = null) + { + this.ImportMethodImpl(AccessTools.Method(type, name, parameters)); + } + + + /********* + ** Private methods + *********/ + /// <summary>Import a method directly using the internal HarmonyMethod code.</summary> + /// <param name="methodInfo">The method to import.</param> + private void ImportMethodImpl(MethodInfo methodInfo) + { + // A null method is no longer allowed in the constructor with Harmony 2.0, but the + // internal code still handles null fine. For backwards compatibility, this bypasses + // the new restriction when the mod hasn't been updated for Harmony 2.0 yet. + + MethodInfo importMethod = typeof(HarmonyMethod).GetMethod("ImportMethod", BindingFlags.Instance | BindingFlags.NonPublic); + if (importMethod == null) + throw new InvalidOperationException("Can't find 'HarmonyMethod.ImportMethod' method"); + importMethod.Invoke(this, new object[] { methodInfo }); + } + } +} +#endif diff --git a/src/SMAPI/Framework/RewriteFacades/SpriteBatchMethods.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs index 26b22315..cf71af77 100644 --- a/src/SMAPI/Framework/RewriteFacades/SpriteBatchMethods.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs @@ -2,24 +2,25 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -#pragma warning disable 1591 // missing documentation -namespace StardewModdingAPI.Framework.RewriteFacades +namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades { /// <summary>Provides <see cref="SpriteBatch"/> method signatures that can be injected into mod code for compatibility between Linux/Mac or Windows.</summary> /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks> - public class SpriteBatchMethods : SpriteBatch + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via assembly rewriting")] + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Linux/Mac.")] + [SuppressMessage("ReSharper", "CS1591", Justification = "Documentation not needed for facade classes.")] + public class SpriteBatchFacade : SpriteBatch { /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public SpriteBatchMethods(GraphicsDevice graphicsDevice) : base(graphicsDevice) { } + public SpriteBatchFacade(GraphicsDevice graphicsDevice) : base(graphicsDevice) { } /**** ** MonoGame signatures ****/ - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Linux/Mac.")] public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect, Matrix? matrix) { base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect, matrix ?? Matrix.Identity); @@ -28,31 +29,26 @@ namespace StardewModdingAPI.Framework.RewriteFacades /**** ** XNA signatures ****/ - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] public new void Begin() { base.Begin(); } - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] public new void Begin(SpriteSortMode sortMode, BlendState blendState) { base.Begin(sortMode, blendState); } - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState) { base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState); } - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect) { base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect); } - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect, Matrix transformMatrix) { base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect, transformMatrix); diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs index ff86c6e2..8043b13a 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs @@ -2,16 +2,22 @@ using System; using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Rewriters { /// <summary>Rewrites references to one field with another.</summary> - internal class FieldReplaceRewriter : FieldFinder + internal class FieldReplaceRewriter : BaseInstructionHandler { /********* ** Fields *********/ + /// <summary>The type containing the field to which references should be rewritten.</summary> + private readonly Type Type; + + /// <summary>The field name to which references should be rewritten.</summary> + private readonly string FromFieldName; + /// <summary>The new field to reference.</summary> private readonly FieldInfo ToField; @@ -20,31 +26,36 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="type">The type whose field to which references should be rewritten.</param> + /// <param name="type">The type whose field to rewrite.</param> /// <param name="fromFieldName">The field name to rewrite.</param> /// <param name="toFieldName">The new field name to reference.</param> public FieldReplaceRewriter(Type type, string fromFieldName, string toFieldName) - : base(type.FullName, fromFieldName, InstructionHandleResult.None) + : base(defaultPhrase: $"{type.FullName}.{fromFieldName} field") { + this.Type = type; + this.FromFieldName = fromFieldName; this.ToField = type.GetField(toFieldName); if (this.ToField == null) throw new InvalidOperationException($"The {type.FullName} class doesn't have a {toFieldName} field."); } - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - if (!this.IsMatch(instruction)) - return InstructionHandleResult.None; + // get field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (!RewriteHelper.IsFieldReferenceTo(fieldRef, this.Type.FullName, this.FromFieldName)) + return false; + // replace with new field FieldReference newRef = module.ImportReference(this.ToField); - cil.Replace(instruction, cil.Create(instruction.OpCode, newRef)); - return InstructionHandleResult.Rewritten; + replaceWith(cil.Create(instruction.OpCode, newRef)); + return this.MarkRewritten(); } } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs index a43c5e9a..c3b5854e 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs @@ -1,21 +1,24 @@ using System; using Mono.Cecil; using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Rewriters { /// <summary>Rewrites field references into property references.</summary> - internal class FieldToPropertyRewriter : FieldFinder + internal class FieldToPropertyRewriter : BaseInstructionHandler { /********* ** Fields *********/ - /// <summary>The type whose field to which references should be rewritten.</summary> + /// <summary>The type containing the field to which references should be rewritten.</summary> private readonly Type Type; - /// <summary>The property name.</summary> - private readonly string PropertyName; + /// <summary>The field name to which references should be rewritten.</summary> + private readonly string FromFieldName; + + /// <summary>The new property name.</summary> + private readonly string ToPropertyName; /********* @@ -26,10 +29,11 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <param name="fieldName">The field name to rewrite.</param> /// <param name="propertyName">The property name (if different).</param> public FieldToPropertyRewriter(Type type, string fieldName, string propertyName) - : base(type.FullName, fieldName, InstructionHandleResult.None) + : base(defaultPhrase: $"{type.FullName}.{fieldName} field") { this.Type = type; - this.PropertyName = propertyName; + this.FromFieldName = fieldName; + this.ToPropertyName = propertyName; } /// <summary>Construct an instance.</summary> @@ -38,22 +42,24 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public FieldToPropertyRewriter(Type type, string fieldName) : this(type, fieldName, fieldName) { } - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - if (!this.IsMatch(instruction)) - return InstructionHandleResult.None; + // get field ref + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (!RewriteHelper.IsFieldReferenceTo(fieldRef, this.Type.FullName, this.FromFieldName)) + return false; + // replace with property string methodPrefix = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld ? "get" : "set"; - MethodReference propertyRef = module.ImportReference(this.Type.GetMethod($"{methodPrefix}_{this.PropertyName}")); - cil.Replace(instruction, cil.Create(OpCodes.Call, propertyRef)); - - return InstructionHandleResult.Rewritten; + MethodReference propertyRef = module.ImportReference(this.Type.GetMethod($"{methodPrefix}_{this.ToPropertyName}")); + replaceWith(cil.Create(OpCodes.Call, propertyRef)); + return this.MarkRewritten(); } } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs new file mode 100644 index 00000000..b30d686e --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/Rewriters/Harmony1AssemblyRewriter.cs @@ -0,0 +1,129 @@ +#if HARMONY_2 +using System; +using HarmonyLib; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; +using StardewModdingAPI.Framework.ModLoading.RewriteFacades; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites Harmony 1.x assembly references to work with Harmony 2.x.</summary> + internal class Harmony1AssemblyRewriter : BaseInstructionHandler + { + /********* + ** Fields + *********/ + /// <summary>Whether any Harmony 1.x types were replaced.</summary> + private bool ReplacedTypes; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public Harmony1AssemblyRewriter() + : base(defaultPhrase: "Harmony 1.x") { } + + /// <summary>Rewrite a type reference if needed.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="type">The type definition to handle.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + /// <returns>Returns whether the type was changed.</returns> + public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) + { + // rewrite Harmony 1.x type to Harmony 2.0 type + if (type.Scope is AssemblyNameReference scope && scope.Name == "0Harmony" && scope.Version.Major == 1) + { + Type targetType = this.GetMappedType(type); + replaceWith(module.ImportReference(targetType)); + this.MarkRewritten(); + this.ReplacedTypes = true; + return true; + } + + return false; + } + + /// <summary>Rewrite a CIL instruction reference if needed.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) + { + // rewrite Harmony 1.x methods to Harmony 2.0 + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (this.TryRewriteMethodsToFacade(module, methodRef)) + return true; + + // rewrite renamed fields + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef != null) + { + if (fieldRef.DeclaringType.FullName == "HarmonyLib.HarmonyMethod" && fieldRef.Name == "prioritiy") + fieldRef.Name = nameof(HarmonyMethod.priority); + } + + return false; + } + + + /********* + ** Private methods + *********/ + /// <summary>Rewrite methods to use Harmony facades if needed.</summary> + /// <param name="module">The assembly module containing the method reference.</param> + /// <param name="methodRef">The method reference to map.</param> + private bool TryRewriteMethodsToFacade(ModuleDefinition module, MethodReference methodRef) + { + if (!this.ReplacedTypes) + return false; // not Harmony (or already using Harmony 2.0) + + // get facade type + Type toType; + switch (methodRef?.DeclaringType.FullName) + { + case "HarmonyLib.Harmony": + toType = typeof(HarmonyInstanceFacade); + break; + + case "HarmonyLib.AccessTools": + toType = typeof(AccessToolsFacade); + break; + + case "HarmonyLib.HarmonyMethod": + toType = typeof(HarmonyMethodFacade); + break; + + default: + return false; + } + + // map if there's a matching method + if (RewriteHelper.HasMatchingSignature(toType, methodRef)) + { + methodRef.DeclaringType = module.ImportReference(toType); + return true; + } + + return false; + } + + /// <summary>Get an equivalent Harmony 2.x type.</summary> + /// <param name="type">The Harmony 1.x method.</param> + private Type GetMappedType(TypeReference type) + { + // main Harmony object + if (type.FullName == "Harmony.HarmonyInstance") + return typeof(Harmony); + + // other objects + string fullName = type.FullName.Replace("Harmony.", "HarmonyLib."); + string targetName = typeof(Harmony).AssemblyQualifiedName.Replace(typeof(Harmony).FullName, fullName); + return Type.GetType(targetName, throwOnError: true); + } + } +} +#endif diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs index 6b8c2de1..b8e53f40 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs @@ -1,31 +1,23 @@ using System; +using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Rewriters { /// <summary>Rewrites method references from one parent type to another if the signatures match.</summary> - internal class MethodParentRewriter : IInstructionHandler + internal class MethodParentRewriter : BaseInstructionHandler { /********* ** Fields *********/ - /// <summary>The type whose methods to remap.</summary> - private readonly Type FromType; + /// <summary>The full name of the type whose methods to remap.</summary> + private readonly string FromType; /// <summary>The type with methods to map to.</summary> private readonly Type ToType; - /// <summary>Whether to only rewrite references if loading the assembly on a different platform than it was compiled on.</summary> - private readonly bool OnlyIfPlatformChanged; - - - /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - /********* ** Public methods @@ -33,55 +25,50 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <summary>Construct an instance.</summary> /// <param name="fromType">The type whose methods to remap.</param> /// <param name="toType">The type with methods to map to.</param> - /// <param name="onlyIfPlatformChanged">Whether to only rewrite references if loading the assembly on a different platform than it was compiled on.</param> - public MethodParentRewriter(Type fromType, Type toType, bool onlyIfPlatformChanged = false) + /// <param name="nounPhrase">A brief noun phrase indicating what the instruction finder matches (or <c>null</c> to generate one).</param> + public MethodParentRewriter(string fromType, Type toType, string nounPhrase = null) + : base(nounPhrase ?? $"{fromType.Split('.').Last()} methods") { this.FromType = fromType; this.ToType = toType; - this.NounPhrase = $"{fromType.Name} methods"; - this.OnlyIfPlatformChanged = onlyIfPlatformChanged; } - /// <summary>Perform the predefined logic for a method if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - return InstructionHandleResult.None; - } + /// <summary>Construct an instance.</summary> + /// <param name="fromType">The type whose methods to remap.</param> + /// <param name="toType">The type with methods to map to.</param> + /// <param name="nounPhrase">A brief noun phrase indicating what the instruction finder matches (or <c>null</c> to generate one).</param> + public MethodParentRewriter(Type fromType, Type toType, string nounPhrase = null) + : this(fromType.FullName, toType, nounPhrase) { } - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - if (!this.IsMatch(instruction, platformChanged)) - return InstructionHandleResult.None; + // get method ref + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (!this.IsMatch(methodRef)) + return false; - MethodReference methodRef = (MethodReference)instruction.Operand; + // rewrite methodRef.DeclaringType = module.ImportReference(this.ToType); - return InstructionHandleResult.Rewritten; + return this.MarkRewritten(); } /********* - ** Protected methods + ** Private methods *********/ /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="instruction">The IL instruction.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - protected bool IsMatch(Instruction instruction, bool platformChanged) + /// <param name="methodRef">The method reference.</param> + private bool IsMatch(MethodReference methodRef) { - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); return methodRef != null - && (platformChanged || !this.OnlyIfPlatformChanged) - && methodRef.DeclaringType.FullName == this.FromType.FullName + && methodRef.DeclaringType.FullName == this.FromType && RewriteHelper.HasMatchingSignature(this.ToType, methodRef); } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs index 7e7c0efa..6ef18b26 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/StaticFieldToConstantRewriter.cs @@ -1,17 +1,23 @@ using System; using Mono.Cecil; using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Rewriters { /// <summary>Rewrites static field references into constant values.</summary> /// <typeparam name="TValue">The constant value type.</typeparam> - internal class StaticFieldToConstantRewriter<TValue> : FieldFinder + internal class StaticFieldToConstantRewriter<TValue> : BaseInstructionHandler { /********* ** Fields *********/ + /// <summary>The type containing the field to which references should be rewritten.</summary> + private readonly Type Type; + + /// <summary>The field name to which references should be rewritten.</summary> + private readonly string FromFieldName; + /// <summary>The constant value to replace with.</summary> private readonly TValue Value; @@ -24,24 +30,29 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <param name="fieldName">The field name to rewrite.</param> /// <param name="value">The constant value to replace with.</param> public StaticFieldToConstantRewriter(Type type, string fieldName, TValue value) - : base(type.FullName, fieldName, InstructionHandleResult.None) + : base(defaultPhrase: $"{type.FullName}.{fieldName} field") { + this.Type = type; + this.FromFieldName = fieldName; this.Value = value; } - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <summary>Rewrite a CIL instruction reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="instruction">The CIL instruction to handle.</param> + /// <param name="replaceWith">Replaces the CIL instruction with a new one.</param> + /// <returns>Returns whether the instruction was changed.</returns> + public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, Action<Instruction> replaceWith) { - if (!this.IsMatch(instruction)) - return InstructionHandleResult.None; + // get field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (!RewriteHelper.IsFieldReferenceTo(fieldRef, this.Type.FullName, this.FromFieldName)) + return false; - cil.Replace(instruction, this.CreateConstantInstruction(cil, this.Value)); - return InstructionHandleResult.Rewritten; + // rewrite to constant + replaceWith(this.CreateConstantInstruction(cil, this.Value)); + return this.MarkRewritten(); } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs index fade082b..c2120444 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs @@ -1,12 +1,11 @@ using System; using Mono.Cecil; -using Mono.Cecil.Cil; -using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.Framework; namespace StardewModdingAPI.Framework.ModLoading.Rewriters { /// <summary>Rewrites all references to a type.</summary> - internal class TypeReferenceRewriter : TypeFinder + internal class TypeReferenceRewriter : BaseInstructionHandler { /********* ** Fields @@ -17,6 +16,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <summary>The new type to reference.</summary> private readonly Type ToType; + /// <summary>Get whether a matched type should be ignored.</summary> + private readonly Func<TypeReference, bool> ShouldIgnore; + /********* ** Public methods @@ -24,129 +26,29 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <summary>Construct an instance.</summary> /// <param name="fromTypeFullName">The full type name to which to find references.</param> /// <param name="toType">The new type to reference.</param> - /// <param name="shouldIgnore">A lambda which overrides a matched type.</param> + /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool> shouldIgnore = null) - : base(fromTypeFullName, InstructionHandleResult.None, shouldIgnore) + : base($"{fromTypeFullName} type") { this.FromTypeName = fromTypeFullName; this.ToType = toType; + this.ShouldIgnore = shouldIgnore; } - /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <summary>Rewrite a type reference if needed.</summary> /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="method">The method definition containing the instruction.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public override InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + /// <param name="type">The type definition to handle.</param> + /// <param name="replaceWith">Replaces the type reference with a new one.</param> + /// <returns>Returns whether the type was changed.</returns> + public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { - bool rewritten = false; - - // return type - if (this.IsMatch(method.ReturnType)) - { - this.RewriteIfNeeded(module, method.ReturnType, newType => method.ReturnType = newType); - rewritten = true; - } - - // parameters - foreach (ParameterDefinition parameter in method.Parameters) - { - if (this.IsMatch(parameter.ParameterType)) - { - this.RewriteIfNeeded(module, parameter.ParameterType, newType => parameter.ParameterType = newType); - rewritten = true; - } - } - - // generic parameters - for (int i = 0; i < method.GenericParameters.Count; i++) - { - var parameter = method.GenericParameters[i]; - if (this.IsMatch(parameter)) - { - this.RewriteIfNeeded(module, parameter, newType => method.GenericParameters[i] = new GenericParameter(parameter.Name, newType)); - rewritten = true; - } - } - - // local variables - foreach (VariableDefinition variable in method.Body.Variables) - { - if (this.IsMatch(variable.VariableType)) - { - this.RewriteIfNeeded(module, variable.VariableType, newType => variable.VariableType = newType); - rewritten = true; - } - } - - return rewritten - ? InstructionHandleResult.Rewritten - : InstructionHandleResult.None; - } - - /// <summary>Perform the predefined logic for an instruction if applicable.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="cil">The CIL processor.</param> - /// <param name="instruction">The instruction to handle.</param> - /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> - /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> - public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) - { - if (!this.IsMatch(instruction)) - return InstructionHandleResult.None; - - // field reference - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - if (fieldRef != null) - { - this.RewriteIfNeeded(module, fieldRef.DeclaringType, newType => fieldRef.DeclaringType = newType); - this.RewriteIfNeeded(module, fieldRef.FieldType, newType => fieldRef.FieldType = newType); - } - - // method reference - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); - if (methodRef != null) - { - this.RewriteIfNeeded(module, methodRef.DeclaringType, newType => methodRef.DeclaringType = newType); - this.RewriteIfNeeded(module, methodRef.ReturnType, newType => methodRef.ReturnType = newType); - foreach (var parameter in methodRef.Parameters) - this.RewriteIfNeeded(module, parameter.ParameterType, newType => parameter.ParameterType = newType); - } - - // type reference - if (instruction.Operand is TypeReference typeRef) - this.RewriteIfNeeded(module, typeRef, newType => cil.Replace(instruction, cil.Create(instruction.OpCode, newType))); - - return InstructionHandleResult.Rewritten; - } - - /********* - ** Private methods - *********/ - /// <summary>Change a type reference if needed.</summary> - /// <param name="module">The assembly module containing the instruction.</param> - /// <param name="type">The type to replace if it matches.</param> - /// <param name="set">Assign the new type reference.</param> - private void RewriteIfNeeded(ModuleDefinition module, TypeReference type, Action<TypeReference> set) - { - // current type - if (type.FullName == this.FromTypeName) - { - if (!this.ShouldIgnore(type)) - set(module.ImportReference(this.ToType)); - return; - } - - // recurse into generic arguments - if (type is GenericInstanceType genericType) - { - for (int i = 0; i < genericType.GenericArguments.Count; i++) - this.RewriteIfNeeded(module, genericType.GenericArguments[i], typeRef => genericType.GenericArguments[i] = typeRef); - } + // check type reference + if (type.FullName != this.FromTypeName || this.ShouldIgnore?.Invoke(type) == true) + return false; - // recurse into generic parameters (e.g. constraints) - for (int i = 0; i < type.GenericParameters.Count; i++) - this.RewriteIfNeeded(module, type.GenericParameters[i], typeRef => type.GenericParameters[i] = new GenericParameter(typeRef)); + // rewrite to new type + replaceWith(module.ImportReference(this.ToType)); + return this.MarkRewritten(); } } } diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index b1612aa4..a98d8c54 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -15,12 +15,8 @@ namespace StardewModdingAPI.Framework.Models private static readonly IDictionary<string, object> DefaultValues = new Dictionary<string, object> { [nameof(CheckForUpdates)] = true, - [nameof(ParanoidWarnings)] = -#if DEBUG - true, -#else - false, -#endif + [nameof(ParanoidWarnings)] = Constants.IsDebugBuild, + [nameof(RewriteInParallel)] = Constants.IsDebugBuild, [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/", @@ -45,6 +41,9 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> public bool CheckForUpdates { get; set; } + /// <summary>Whether to enable experimental parallel rewriting.</summary> + public bool RewriteInParallel { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.RewriteInParallel)]; + /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> public bool ParanoidWarnings { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)]; diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs index 7dbfa767..ac9cf313 100644 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -45,23 +45,22 @@ namespace StardewModdingAPI.Framework.Networking [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) { - using (IncomingMessage message = new IncomingMessage()) - using (BinaryReader reader = new BinaryReader(messageStream)) + using IncomingMessage message = new IncomingMessage(); + using BinaryReader reader = new BinaryReader(messageStream); + + message.Read(reader); + ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead + this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => { - message.Read(reader); - ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead - this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => + if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { - if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - GalaxyID capturedPeer = new GalaxyID(peerID); - this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); - } - }); - } + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + GalaxyID capturedPeer = new GalaxyID(peerID); + this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + } + }); } /// <summary>Send a message to a remote peer.</summary> diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs index f2c61917..05c8b872 100644 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -44,25 +44,24 @@ namespace StardewModdingAPI.Framework.Networking { // add hook to call multiplayer core NetConnection peer = rawMessage.SenderConnection; - using (IncomingMessage message = new IncomingMessage()) - using (Stream readStream = new NetBufferReadStream(rawMessage)) - using (BinaryReader reader = new BinaryReader(readStream)) + using IncomingMessage message = new IncomingMessage(); + using Stream readStream = new NetBufferReadStream(rawMessage); + using BinaryReader reader = new BinaryReader(readStream); + + while (rawMessage.LengthBits - rawMessage.Position >= 8) { - while (rawMessage.LengthBits - rawMessage.Position >= 8) + message.Read(reader); + NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused + this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => { - message.Read(reader); - NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused - this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => + if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { - if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); - } - }); - } + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + } + }); } } } diff --git a/src/SMAPI/Framework/Patching/GamePatcher.cs b/src/SMAPI/Framework/Patching/GamePatcher.cs index f82159d0..82d7b9c8 100644 --- a/src/SMAPI/Framework/Patching/GamePatcher.cs +++ b/src/SMAPI/Framework/Patching/GamePatcher.cs @@ -1,5 +1,9 @@ using System; +#if HARMONY_2 +using HarmonyLib; +#else using Harmony; +#endif namespace StardewModdingAPI.Framework.Patching { @@ -27,7 +31,11 @@ namespace StardewModdingAPI.Framework.Patching /// <param name="patches">The patches to apply.</param> public void Apply(params IHarmonyPatch[] patches) { - HarmonyInstance harmony = HarmonyInstance.Create("io.smapi"); +#if HARMONY_2 + Harmony harmony = new Harmony("SMAPI"); +#else + HarmonyInstance harmony = HarmonyInstance.Create("SMAPI"); +#endif foreach (IHarmonyPatch patch in patches) { try diff --git a/src/SMAPI/Framework/Patching/IHarmonyPatch.cs b/src/SMAPI/Framework/Patching/IHarmonyPatch.cs index cb42f40e..922243fa 100644 --- a/src/SMAPI/Framework/Patching/IHarmonyPatch.cs +++ b/src/SMAPI/Framework/Patching/IHarmonyPatch.cs @@ -1,4 +1,8 @@ +#if HARMONY_2 +using HarmonyLib; +#else using Harmony; +#endif namespace StardewModdingAPI.Framework.Patching { @@ -10,6 +14,10 @@ namespace StardewModdingAPI.Framework.Patching /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + void Apply(Harmony harmony); +#else void Apply(HarmonyInstance harmony); +#endif } } diff --git a/src/SMAPI/Framework/Patching/PatchHelper.cs b/src/SMAPI/Framework/Patching/PatchHelper.cs index 4cb436f0..d1aa0185 100644 --- a/src/SMAPI/Framework/Patching/PatchHelper.cs +++ b/src/SMAPI/Framework/Patching/PatchHelper.cs @@ -1,3 +1,4 @@ +#if !HARMONY_2 using System; using System.Collections.Generic; @@ -32,3 +33,4 @@ namespace StardewModdingAPI.Framework.Patching } } } +#endif diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs index 01197f74..af630055 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>The context for an alert.</summary> - internal struct AlertContext + internal readonly struct AlertContext { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs index f5b80189..d5a0b343 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>A single alert entry.</summary> - internal struct AlertEntry + internal readonly struct AlertEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs index cff502ad..1746e358 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>A peak invocation time.</summary> - internal struct PeakEntry + internal readonly struct PeakEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs index 3cf668ee..42825999 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Harmony; namespace StardewModdingAPI.Framework.PerformanceMonitoring { @@ -57,7 +56,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring // add entry if (this.Entries.Count > this.MaxEntries) this.Entries.Pop(); - this.Entries.Add(entry); + this.Entries.Push(entry); // update metrics if (this.PeakPerformanceCounterEntry == null || entry.ElapsedMilliseconds > this.PeakPerformanceCounterEntry.Value.ElapsedMilliseconds) diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs index 8adbd88d..18cca628 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// <summary>A single performance counter entry.</summary> - internal struct PerformanceCounterEntry + internal readonly struct PerformanceCounterEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs index 382949bf..85e69ae6 100644 --- a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs @@ -2,7 +2,6 @@ using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; -using StardewValley; using xTile.Dimensions; using xTile.Layers; using xTile.ObjectModel; @@ -14,23 +13,13 @@ namespace StardewModdingAPI.Framework.Rendering internal class SDisplayDevice : SXnaDisplayDevice { /********* - ** Fields - *********/ - /// <summary>The origin to use when rotating tiles.</summary> - private readonly Vector2 RotationOrigin; - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="contentManager">The content manager through which to load tiles.</param> /// <param name="graphicsDevice">The graphics device with which to render tiles.</param> public SDisplayDevice(ContentManager contentManager, GraphicsDevice graphicsDevice) - : base(contentManager, graphicsDevice) - { - this.RotationOrigin = new Vector2((Game1.tileSize * Game1.pixelZoom) / 2f); - } + : base(contentManager, graphicsDevice) { } /// <summary>Draw a tile to the screen.</summary> /// <param name="tile">The tile to draw.</param> diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs index d4f62b4f..121e53bc 100644 --- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs @@ -10,7 +10,7 @@ using xTile.Layers; using xTile.Tiles; using Rectangle = xTile.Dimensions.Rectangle; -namespace StardewModdingAPI.Framework +namespace StardewModdingAPI.Framework.Rendering { /// <summary>A map display device which reimplements the default logic.</summary> /// <remarks>This is an exact copy of <see cref="XnaDisplayDevice"/>, except that private fields are protected and all methods are virtual.</remarks> diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index de9c955d..2b04b1dc 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -16,6 +16,7 @@ using System.Windows.Forms; #endif using Newtonsoft.Json; using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Commands; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Logging; @@ -172,7 +173,7 @@ namespace StardewModdingAPI.Framework this.MonitorForGame = this.GetSecondaryMonitor("game"); SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor); - this.EventManager = new EventManager(this.Monitor, this.ModRegistry, SCore.PerformanceMonitor); + this.EventManager = new EventManager(this.ModRegistry, SCore.PerformanceMonitor); SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager); SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); @@ -336,6 +337,8 @@ namespace StardewModdingAPI.Framework // add headers if (this.Settings.DeveloperMode) this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (this.Settings.RewriteInParallel) + this.Monitor.Log($"You enabled experimental parallel rewriting. This may result in faster startup times, but intermittent startup errors. You can disable it by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Info); if (!this.Settings.CheckForUpdates) this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); if (!this.Monitor.WriteToConsole) @@ -508,8 +511,10 @@ namespace StardewModdingAPI.Framework { // prepare console this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - this.GameInstance.CommandManager.Add(null, "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); - this.GameInstance.CommandManager.Add(null, "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); + this.GameInstance.CommandManager + .Add(new HelpCommand(this.GameInstance.CommandManager), this.Monitor) + .Add(new HarmonySummaryCommand(), this.Monitor) + .Add(new ReloadI18nCommand(this.ReloadTranslations), this.Monitor); // start handling command line input Thread inputThread = new Thread(() => @@ -978,7 +983,7 @@ namespace StardewModdingAPI.Framework Assembly modAssembly; try { - modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible); + modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible, rewriteInParallel: this.Settings.RewriteInParallel); this.ModRegistry.TrackAssemblies(mod, modAssembly); } catch (IncompatibleInstructionException) // details already in trace logs @@ -989,7 +994,7 @@ namespace StardewModdingAPI.Framework } catch (SAssemblyLoadFailedException ex) { - errorReasonPhrase = $"it DLL couldn't be loaded: {ex.Message}"; + errorReasonPhrase = $"its DLL couldn't be loaded: {ex.Message}"; return false; } catch (Exception ex) @@ -1129,67 +1134,115 @@ namespace StardewModdingAPI.Framework // log warnings if (modsWithWarnings.Any()) { - // issue block format logic - void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb) - { - IModMetadata[] matches = modsWithWarnings - .Where(mod => mod.HasUnsuppressWarning(warning)) - .ToArray(); - if (!matches.Any()) - return; - - this.Monitor.Log(" " + heading, logLevel); - this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel); - foreach (string line in blurb) - this.Monitor.Log(" " + line, logLevel); - this.Monitor.Newline(); - foreach (IModMetadata match in matches) - this.Monitor.Log($" - {match.DisplayName}", logLevel); - this.Monitor.Newline(); - } - - // supported issues - LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", + // broken code + this.LogModWarningGroup(modsWithWarnings, ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", "errors, or crashes in-game." ); - LogWarningGroup(ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + + // changes serializer + this.LogModWarningGroup(modsWithWarnings, ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", "These mods change the save serializer. They may corrupt your save files, or make them unusable if", "you uninstall these mods." ); - if (this.Settings.ParanoidWarnings) - { - LogWarningGroup(ModWarning.AccessesConsole, LogLevel.Warn, "Accesses the console directly", - "These mods directly access the SMAPI console, and you enabled paranoid warnings. (Note that this may be", - "legitimate and innocent usage; this warning is meaningless without further investigation.)" - ); - LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly", - "These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be", - "legitimate and innocent usage; this warning is meaningless without further investigation.)" - ); - LogWarningGroup(ModWarning.AccessesShell, LogLevel.Warn, "Accesses shell/process directly", - "These mods directly access the OS shell or processes, and you enabled paranoid warnings. (Note that", - "this may be legitimate and innocent usage; this warning is meaningless without further investigation.)" - ); - } - LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code", + + // patched game code + this.LogModWarningGroup(modsWithWarnings, ModWarning.PatchesGame, LogLevel.Info, "Patched game code", "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", "your game has issues, try removing these first. Otherwise you can ignore this warning." ); - LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", + + // unvalidated update tick + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", "corruption. If your game has issues, try removing these first." ); - LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", + + // paranoid warnings + if (this.Settings.ParanoidWarnings) + { + this.LogModWarningGroup( + modsWithWarnings, + match: mod => mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole, ModWarning.AccessesFilesystem, ModWarning.AccessesShell), + level: LogLevel.Debug, + heading: "Direct system access", + blurb: new[] + { + "You enabled paranoid warnings and these mods directly access the filesystem, shells/processes, or", + "SMAPI console. (This is usually legitimate and innocent usage; this warning is only useful for", + "further investigation.)" + }, + modLabel: mod => + { + List<string> labels = new List<string>(); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole)) + labels.Add("console"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesFilesystem)) + labels.Add("files"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesShell)) + labels.Add("shells/processes"); + + return $"{mod.DisplayName} ({string.Join(", ", labels)})"; + } + ); + } + + // no update keys + this.LogModWarningGroup(modsWithWarnings, ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", "mods. Consider notifying the mod authors about this problem." ); - LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", + + // not crossplatform + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." ); } } + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="match">Matches mods to include in the warning group.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> + private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> modLabel = null) + { + // get matching mods + string[] modLabels = mods + .Where(match) + .Select(mod => modLabel?.Invoke(mod) ?? mod.DisplayName) + .OrderBy(p => p) + .ToArray(); + if (!modLabels.Any()) + return; + + // log header/blurb + this.Monitor.Log(" " + heading, level); + this.Monitor.Log(" " + "".PadRight(50, '-'), level); + foreach (string line in blurb) + this.Monitor.Log(" " + line, level); + this.Monitor.Newline(); + + // log mod list + foreach (string label in modLabels) + this.Monitor.Log($" - {label}", level); + + this.Monitor.Newline(); + } + + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="warning">The mod warning to match.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) + { + this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); + } + /// <summary>Load a mod's entry class.</summary> /// <param name="modAssembly">The mod assembly.</param> /// <param name="mod">The loaded instance.</param> @@ -1225,6 +1278,12 @@ namespace StardewModdingAPI.Framework } /// <summary>Reload translations for all mods.</summary> + private void ReloadTranslations() + { + this.ReloadTranslations(this.ModRegistry.GetAll()); + } + + /// <summary>Reload translations for the given mods.</summary> /// <param name="mods">The mods for which to reload translations.</param> private void ReloadTranslations(IEnumerable<IModMetadata> mods) { @@ -1309,48 +1368,6 @@ namespace StardewModdingAPI.Framework return translations; } - /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> - /// <param name="name">The command name.</param> - /// <param name="arguments">The command arguments.</param> - private void HandleCommand(string name, string[] arguments) - { - switch (name) - { - case "help": - if (arguments.Any()) - { - Command result = this.GameInstance.CommandManager.Get(arguments[0]); - if (result == null) - this.Monitor.Log("There's no command with that name.", LogLevel.Error); - else - this.Monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info); - } - else - { - string message = "The following commands are registered:\n"; - IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray(); - foreach (var group in groups) - { - string modName = group.Key ?? "SMAPI"; - string[] commandNames = group.ToArray(); - message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; - } - message += "For more information about a command, type 'help command_name'."; - - this.Monitor.Log(message, LogLevel.Info); - } - break; - - case "reload_i18n": - this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); - this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); - break; - - default: - throw new NotSupportedException($"Unrecognized core SMAPI command '{name}'."); - } - } - /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> /// <param name="gameMonitor">The monitor with which to log messages as the game.</param> /// <param name="message">The message to log.</param> diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 2a30b595..4d310185 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -240,7 +240,7 @@ namespace StardewModdingAPI.Framework modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender // raise events - this.Events.ModMessageReceived.RaiseForMods(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); + this.Events.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); } /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> @@ -1310,7 +1310,7 @@ namespace StardewModdingAPI.Framework } Game1.drawPlayerHeldObject(Game1.player); } - label_139: + label_139: if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) Game1.drawTool(Game1.player); if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 821c343f..8c444e45 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -231,7 +231,11 @@ namespace StardewModdingAPI.Framework this.AddPeer(peer, canBeHost: false); } + // let game handle connection resume(); + + // raise event + this.EventManager.PeerConnected.Raise(new PeerConnectedEventArgs(this.Peers[message.FarmerID])); break; // handle mod message diff --git a/src/SMAPI/Framework/Serialization/ColorConverter.cs b/src/SMAPI/Framework/Serialization/ColorConverter.cs index 19979981..7315f1a5 100644 --- a/src/SMAPI/Framework/Serialization/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialization/ColorConverter.cs @@ -35,7 +35,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 4) - throw new SParseException($"Can't parse {typeof(Color).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Color)} from invalid value '{str}' (path: {path})."); int r = Convert.ToInt32(parts[0]); int g = Convert.ToInt32(parts[1]); diff --git a/src/SMAPI/Framework/Serialization/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index 3481c9b2..6cf795dc 100644 --- a/src/SMAPI/Framework/Serialization/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 2) - throw new SParseException($"Can't parse {typeof(Point).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Point)} from invalid value '{str}' (path: {path})."); int x = Convert.ToInt32(parts[0]); int y = Convert.ToInt32(parts[1]); diff --git a/src/SMAPI/Framework/Serialization/RectangleConverter.cs b/src/SMAPI/Framework/Serialization/RectangleConverter.cs index fbb2e253..a5780d8a 100644 --- a/src/SMAPI/Framework/Serialization/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialization/RectangleConverter.cs @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework.Serialization var match = Regex.Match(str, @"^\{X:(?<x>\d+) Y:(?<y>\d+) Width:(?<width>\d+) Height:(?<height>\d+)\}$", RegexOptions.IgnoreCase); if (!match.Success) - throw new SParseException($"Can't parse {typeof(Rectangle).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Rectangle)} from invalid value '{str}' (path: {path})."); int x = Convert.ToInt32(match.Groups["x"].Value); int y = Convert.ToInt32(match.Groups["y"].Value); diff --git a/src/SMAPI/Framework/Serialization/Vector2Converter.cs b/src/SMAPI/Framework/Serialization/Vector2Converter.cs index 1d9b08e0..3e2ab776 100644 --- a/src/SMAPI/Framework/Serialization/Vector2Converter.cs +++ b/src/SMAPI/Framework/Serialization/Vector2Converter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 2) - throw new SParseException($"Can't parse {typeof(Vector2).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Vector2)} from invalid value '{str}' (path: {path})."); float x = Convert.ToSingle(parts[0]); float y = Convert.ToSingle(parts[1]); diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 0a14086b..fa6541cb 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -816,9 +816,18 @@ namespace StardewModdingAPI.Metadata where key != null && lookup.Contains(key) select new { Npc = npc, Key = key } ) - .ToArray(); - if (!characters.Any()) - return; + .ToList(); + + // special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait) + { + string gilKey = this.NormalizeAssetNameIgnoringEmpty("Portraits/Gil"); + if (lookup.Contains(gilKey)) + { + GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild"); + if (adventureGuild != null) + characters.Add(new { Npc = this.Reflection.GetField<NPC>(adventureGuild, "Gil").GetValue(), Key = gilKey }); + } + } // update portrait foreach (var target in characters) diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index eee5c235..79d7a7a8 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -3,8 +3,8 @@ using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.RewriteFacades; using StardewModdingAPI.Framework.ModLoading.Rewriters; -using StardewModdingAPI.Framework.RewriteFacades; using StardewValley; namespace StardewModdingAPI.Metadata @@ -25,17 +25,24 @@ namespace StardewModdingAPI.Metadata *********/ /// <summary>Get rewriters which detect or fix incompatible CIL instructions in mod assemblies.</summary> /// <param name="paranoidMode">Whether to detect paranoid mode issues.</param> - public IEnumerable<IInstructionHandler> GetHandlers(bool paranoidMode) + /// <param name="platformChanged">Whether the assembly was rewritten for crossplatform compatibility.</param> + public IEnumerable<IInstructionHandler> GetHandlers(bool paranoidMode, bool platformChanged) { /**** ** rewrite CIL to fix incompatible code ****/ // rewrite for crossplatform compatibility - yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchMethods), onlyIfPlatformChanged: true); + if (platformChanged) + yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade)); // rewrite for Stardew Valley 1.3 yield return new StaticFieldToConstantRewriter<int>(typeof(Game1), "tileSize", Game1.tileSize); +#if HARMONY_2 + // rewrite for SMAPI 3.6 (Harmony 1.x => 2.0 update) + yield return new Harmony1AssemblyRewriter(); +#endif + /**** ** detect mod issues ****/ @@ -46,7 +53,11 @@ namespace StardewModdingAPI.Metadata /**** ** detect code which may impact game stability ****/ - yield return new TypeFinder("Harmony.HarmonyInstance", InstructionHandleResult.DetectedGamePatch); +#if HARMONY_2 + yield return new TypeFinder(typeof(HarmonyLib.Harmony).FullName, InstructionHandleResult.DetectedGamePatch); +#else + yield return new TypeFinder(typeof(Harmony.HarmonyInstance).FullName, InstructionHandleResult.DetectedGamePatch); +#endif yield return new TypeFinder("System.Runtime.CompilerServices.CallSite", InstructionHandleResult.DetectedDynamic); yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerializer); yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerializer); diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs index 1e49826d..8043eda3 100644 --- a/src/SMAPI/Patches/DialogueErrorPatch.cs +++ b/src/SMAPI/Patches/DialogueErrorPatch.cs @@ -1,11 +1,16 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Harmony; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; using StardewValley; +#if HARMONY_2 +using HarmonyLib; +using StardewModdingAPI.Framework; +#else +using System.Reflection; +using Harmony; +#endif namespace StardewModdingAPI.Patches { @@ -47,6 +52,19 @@ namespace StardewModdingAPI.Patches /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + public void Apply(Harmony harmony) + { + harmony.Patch( + original: AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }), + finalizer: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Finalize_Dialogue_Constructor)) + ); + harmony.Patch( + original: AccessTools.Property(typeof(NPC), nameof(NPC.CurrentDialogue)).GetMethod, + finalizer: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Finalize_NPC_CurrentDialogue)) + ); + } +#else public void Apply(HarmonyInstance harmony) { harmony.Patch( @@ -58,11 +76,53 @@ namespace StardewModdingAPI.Patches prefix: new HarmonyMethod(this.GetType(), nameof(DialogueErrorPatch.Before_NPC_CurrentDialogue)) ); } - +#endif /********* ** Private methods *********/ +#if HARMONY_2 + /// <summary>The method to call after the Dialogue constructor.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="masterDialogue">The dialogue being parsed.</param> + /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_Dialogue_Constructor(Dialogue __instance, string masterDialogue, NPC speaker, Exception __exception) + { + if (__exception != null) + { + // log message + string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; + DialogueErrorPatch.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error); + + // set default dialogue + IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString"); + IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes"); + parseDialogueString.Invoke("..."); + checkForSpecialDialogueAttributes.Invoke(); + } + + return null; + } + + /// <summary>The method to call after <see cref="NPC.CurrentDialogue"/>.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The return value of the original method.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception __exception) + { + if (__exception == null) + return null; + + DialogueErrorPatch.MonitorForGame.Log($"Failed loading current dialogue for NPC {__instance.Name}:\n{__exception.GetLogSummary()}", LogLevel.Error); + __result = new Stack<Dialogue>(); + + return null; + } +#else + /// <summary>The method to call instead of the Dialogue constructor.</summary> /// <param name="__instance">The instance being patched.</param> /// <param name="masterDialogue">The dialogue being parsed.</param> @@ -129,5 +189,6 @@ namespace StardewModdingAPI.Patches PatchHelper.StopIntercept(key); } } +#endif } } diff --git a/src/SMAPI/Patches/EventErrorPatch.cs b/src/SMAPI/Patches/EventErrorPatch.cs index 504d1d2e..4dbb25f3 100644 --- a/src/SMAPI/Patches/EventErrorPatch.cs +++ b/src/SMAPI/Patches/EventErrorPatch.cs @@ -1,6 +1,11 @@ using System.Diagnostics.CodeAnalysis; +#if HARMONY_2 +using System; +using HarmonyLib; +#else using System.Reflection; using Harmony; +#endif using StardewModdingAPI.Framework.Patching; using StardewValley; @@ -38,6 +43,15 @@ namespace StardewModdingAPI.Patches /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + public void Apply(Harmony harmony) + { + harmony.Patch( + original: AccessTools.Method(typeof(GameLocation), "checkEventPrecondition"), + finalizer: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Finalize_GameLocation_CheckEventPrecondition)) + ); + } +#else public void Apply(HarmonyInstance harmony) { harmony.Patch( @@ -45,11 +59,29 @@ namespace StardewModdingAPI.Patches prefix: new HarmonyMethod(this.GetType(), nameof(EventErrorPatch.Before_GameLocation_CheckEventPrecondition)) ); } +#endif /********* ** Private methods *********/ +#if HARMONY_2 + /// <summary>The method to call instead of the GameLocation.CheckEventPrecondition.</summary> + /// <param name="__result">The return value of the original method.</param> + /// <param name="precondition">The precondition to be parsed.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_GameLocation_CheckEventPrecondition(ref int __result, string precondition, Exception __exception) + { + if (__exception != null) + { + __result = -1; + EventErrorPatch.MonitorForGame.Log($"Failed parsing event precondition ({precondition}):\n{__exception.InnerException}", LogLevel.Error); + } + + return null; + } +#else /// <summary>The method to call instead of the GameLocation.CheckEventPrecondition.</summary> /// <param name="__instance">The instance being patched.</param> /// <param name="__result">The return value of the original method.</param> @@ -78,5 +110,6 @@ namespace StardewModdingAPI.Patches PatchHelper.StopIntercept(key); } } +#endif } } diff --git a/src/SMAPI/Patches/LoadContextPatch.cs b/src/SMAPI/Patches/LoadContextPatch.cs index 0cc8c8eb..768ddd6b 100644 --- a/src/SMAPI/Patches/LoadContextPatch.cs +++ b/src/SMAPI/Patches/LoadContextPatch.cs @@ -1,6 +1,10 @@ using System; using System.Diagnostics.CodeAnalysis; +#if HARMONY_2 +using HarmonyLib; +#else using Harmony; +#endif using StardewModdingAPI.Enums; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; @@ -47,7 +51,11 @@ namespace StardewModdingAPI.Patches /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + public void Apply(Harmony harmony) +#else public void Apply(HarmonyInstance harmony) +#endif { // detect CreatedBasicInfo harmony.Patch( diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs index 77415ff2..5e67b169 100644 --- a/src/SMAPI/Patches/LoadErrorPatch.cs +++ b/src/SMAPI/Patches/LoadErrorPatch.cs @@ -2,7 +2,11 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +#if HARMONY_2 +using HarmonyLib; +#else using Harmony; +#endif using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Patching; using StardewValley; @@ -49,7 +53,11 @@ namespace StardewModdingAPI.Patches /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + public void Apply(Harmony harmony) +#else public void Apply(HarmonyInstance harmony) +#endif { harmony.Patch( original: AccessTools.Method(typeof(SaveGame), nameof(SaveGame.loadDataToLocations)), diff --git a/src/SMAPI/Patches/ObjectErrorPatch.cs b/src/SMAPI/Patches/ObjectErrorPatch.cs index d3b8800a..4edcc64e 100644 --- a/src/SMAPI/Patches/ObjectErrorPatch.cs +++ b/src/SMAPI/Patches/ObjectErrorPatch.cs @@ -1,11 +1,16 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Harmony; using StardewModdingAPI.Framework.Patching; using StardewValley; using StardewValley.Menus; using SObject = StardewValley.Object; +#if HARMONY_2 +using System; +using HarmonyLib; +#else +using System.Reflection; +using Harmony; +#endif namespace StardewModdingAPI.Patches { @@ -27,7 +32,11 @@ namespace StardewModdingAPI.Patches *********/ /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + public void Apply(Harmony harmony) +#else public void Apply(HarmonyInstance harmony) +#endif { // object.getDescription harmony.Patch( @@ -38,7 +47,11 @@ namespace StardewModdingAPI.Patches // object.getDisplayName harmony.Patch( original: AccessTools.Method(typeof(SObject), "loadDisplayName"), +#if HARMONY_2 + finalizer: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Finalize_Object_loadDisplayName)) +#else prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_loadDisplayName)) +#endif ); // IClickableMenu.drawToolTip @@ -68,6 +81,22 @@ namespace StardewModdingAPI.Patches return true; } +#if HARMONY_2 + /// <summary>The method to call after <see cref="StardewValley.Object.loadDisplayName"/>.</summary> + /// <param name="__result">The patched method's return value.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_Object_loadDisplayName(ref string __result, Exception __exception) + { + if (__exception is KeyNotFoundException) + { + __result = "???"; + return null; + } + + return __exception; + } +#else /// <summary>The method to call instead of <see cref="StardewValley.Object.loadDisplayName"/>.</summary> /// <param name="__instance">The instance being patched.</param> /// <param name="__result">The patched method's return value.</param> @@ -98,12 +127,12 @@ namespace StardewModdingAPI.Patches PatchHelper.StopIntercept(key); } } +#endif /// <summary>The method to call instead of <see cref="IClickableMenu.drawToolTip"/>.</summary> - /// <param name="__instance">The instance being patched.</param> /// <param name="hoveredItem">The item for which to draw a tooltip.</param> /// <returns>Returns whether to execute the original method.</returns> - private static bool Before_IClickableMenu_DrawTooltip(IClickableMenu __instance, Item hoveredItem) + private static bool Before_IClickableMenu_DrawTooltip(Item hoveredItem) { // invalid edible item cause crash when drawing tooltips if (hoveredItem is SObject obj && obj.Edibility != -300 && !Game1.objectInformation.ContainsKey(obj.ParentSheetIndex)) diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs index 799fcb40..cc2238b0 100644 --- a/src/SMAPI/Patches/ScheduleErrorPatch.cs +++ b/src/SMAPI/Patches/ScheduleErrorPatch.cs @@ -1,9 +1,15 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Harmony; using StardewModdingAPI.Framework.Patching; using StardewValley; +#if HARMONY_2 +using System; +using HarmonyLib; +using StardewModdingAPI.Framework; +#else +using System.Reflection; +using Harmony; +#endif namespace StardewModdingAPI.Patches { @@ -39,11 +45,19 @@ namespace StardewModdingAPI.Patches /// <summary>Apply the Harmony patch.</summary> /// <param name="harmony">The Harmony instance.</param> +#if HARMONY_2 + public void Apply(Harmony harmony) +#else public void Apply(HarmonyInstance harmony) +#endif { harmony.Patch( original: AccessTools.Method(typeof(NPC), "parseMasterSchedule"), +#if HARMONY_2 + finalizer: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Finalize_NPC_parseMasterSchedule)) +#else prefix: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule)) +#endif ); } @@ -51,6 +65,24 @@ namespace StardewModdingAPI.Patches /********* ** Private methods *********/ +#if HARMONY_2 + /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary> + /// <param name="rawData">The raw schedule data to parse.</param> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The patched method's return value.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception __exception) + { + if (__exception != null) + { + ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{__exception.GetLogSummary()}", LogLevel.Error); + __result = new Dictionary<int, SchedulePathDescription>(); + } + + return null; + } +#else /// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary> /// <param name="rawData">The raw schedule data to parse.</param> /// <param name="__instance">The instance being patched.</param> @@ -79,5 +111,6 @@ namespace StardewModdingAPI.Patches PatchHelper.StopIntercept(key); } } +#endif } } diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 715c8553..9438f11e 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -41,7 +41,7 @@ namespace StardewModdingAPI Program.AssertGameVersion(); Program.Start(args); } - catch (BadImageFormatException ex) when (ex.FileName == "StardewValley") + catch (BadImageFormatException ex) when (ex.FileName == "StardewValley" || ex.FileName == "Stardew Valley") // NOTE: don't use StardewModdingAPI.Constants here, assembly resolution isn't hooked up at this point { string executableName = Program.GetExecutableAssemblyName(); Console.WriteLine($"SMAPI failed to initialize because your game's {executableName}.exe seems to be invalid.\nThis may be a pirated version which modified the executable in an incompatible way; if so, you can try a different download or buy a legitimate version.\n\nTechnical details:\n{ex}"); diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index a426b0ef..0a6d8372 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -34,17 +34,29 @@ copy all the settings, or you may cause bugs due to overridden changes in future "DeveloperMode": true, /** + * Whether to enable experimental parallel rewriting when SMAPI is loading mods. This can + * reduce startup time when you have many mods installed, but is experimental and may cause + * intermittent startup errors. + * + * When this is commented out, it'll be true for local debug builds and false otherwise. + */ + //"RewriteInParallel": false, + + /** * Whether to add a section to the 'mod issues' list for mods which directly use potentially * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as * part of their normal functionality, so these warnings are meaningless without further - * investigation. When this is commented out, it'll be true for local debug builds and false - * otherwise. + * investigation. + * + * When this is commented out, it'll be true for local debug builds and false otherwise. */ //"ParanoidWarnings": true, /** - * Whether SMAPI should show newer beta versions as an available update. When this is commented - * out, it'll be true if the current SMAPI version is beta, and false otherwise. + * Whether SMAPI should show newer beta versions as an available update. + * + * When this is commented out, it'll be true if the current SMAPI version is beta, and false + * otherwise. */ //"UseBetaChannel": true, diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 5f41387b..4af4527b 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -1,14 +1,12 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> <AssemblyName>StardewModdingAPI</AssemblyName> <RootNamespace>StardewModdingAPI</RootNamespace> <Description>The modding API for Stardew Valley.</Description> <TargetFramework>net45</TargetFramework> - <LangVersion>latest</LangVersion> <PlatformTarget>x86</PlatformTarget> <OutputType>Exe</OutputType> - <DocumentationFile>bin\$(Configuration)\StardewModdingAPI.xml</DocumentationFile> + <GenerateDocumentationFile>true</GenerateDocumentationFile> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware> <ApplicationIcon>icon.ico</ApplicationIcon> @@ -16,61 +14,31 @@ <ItemGroup> <PackageReference Include="LargeAddressAware" Version="1.0.4" /> - <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> <PackageReference Include="Mono.Cecil" Version="0.11.2" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> - <PackageReference Include="Platonymous.TMXTile" Version="1.3.4" /> + <PackageReference Include="Platonymous.TMXTile" Version="1.3.8" /> </ItemGroup> <ItemGroup> - <Reference Include="$(GameExecutableName)"> - <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="StardewValley.GameData"> - <HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="System.Numerics"> - <Private>True</Private> - </Reference> - <Reference Include="System.Runtime.Caching"> - <Private>True</Private> - </Reference> - <Reference Include="GalaxyCSharp"> - <HintPath>$(GamePath)\GalaxyCSharp.dll</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="Lidgren.Network"> - <HintPath>$(GamePath)\Lidgren.Network.dll</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="xTile"> - <HintPath>$(GamePath)\xTile.dll</HintPath> - <Private>False</Private> - </Reference> + <Reference Include="..\..\build\0Harmony.dll" Private="True" /> + <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" /> + <Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="False" /> + <Reference Include="System.Numerics" Private="True" /> + <Reference Include="System.Runtime.Caching" Private="True" /> + <Reference Include="GalaxyCSharp" HintPath="$(GamePath)\GalaxyCSharp.dll" Private="False" /> + <Reference Include="Lidgren.Network" HintPath="$(GamePath)\Lidgren.Network.dll" Private="False" /> + <Reference Include="xTile" HintPath="$(GamePath)\xTile.dll" Private="False" /> </ItemGroup> <Choose> <!-- Windows --> <When Condition="$(OS) == 'Windows_NT'"> <ItemGroup> - <Reference Include="Netcode"> - <HintPath>$(GamePath)\Netcode.dll</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> + <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" /> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> <Reference Include="System.Windows.Forms" /> </ItemGroup> </When> @@ -78,10 +46,7 @@ <!-- Linux/Mac --> <Otherwise> <ItemGroup> - <Reference Include="MonoGame.Framework"> - <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> - <Private>False</Private> - </Reference> + <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="False" /> </ItemGroup> </Otherwise> </Choose> @@ -92,22 +57,12 @@ </ItemGroup> <ItemGroup> - <Content Include="SMAPI.config.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Include="..\SMAPI.Web\wwwroot\SMAPI.metadata.json"> - <Link>SMAPI.metadata.json</Link> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <None Update="i18n\*"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - <None Update="steam_appid.txt"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> + <Content Include="SMAPI.config.json" CopyToOutputDirectory="PreserveNewest" /> + <Content Include="..\SMAPI.Web\wwwroot\SMAPI.metadata.json" Link="SMAPI.metadata.json" CopyToOutputDirectory="PreserveNewest" /> + <None Update="i18n\*" CopyToOutputDirectory="PreserveNewest" /> + <None Update="steam_appid.txt" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> - </Project> |