diff options
| author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-05-23 21:55:11 -0400 |
|---|---|---|
| committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-05-23 21:55:11 -0400 |
| commit | 786077340f2cea37d82455fc413535ae82a912ee (patch) | |
| tree | 588d6755b1001bd7eb218dcf9b332feb933e180b | |
| parent | d7add894419543667e60569bfeb439e8e797a4d1 (diff) | |
| download | SMAPI-786077340f2cea37d82455fc413535ae82a912ee.tar.gz SMAPI-786077340f2cea37d82455fc413535ae82a912ee.tar.bz2 SMAPI-786077340f2cea37d82455fc413535ae82a912ee.zip | |
refactor update check API
This simplifies the logic for individual clients, centralises common logic, and prepares for upcoming features.
37 files changed, 685 insertions, 834 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md index f3f2efa4..894fd562 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -24,6 +24,7 @@ * For SMAPI developers: * Eliminated MongoDB storage in the web services, which complicated the code unnecessarily. The app still uses an abstract interface for storage, so we can wrap a distributed cache in the future if needed. + * Overhauled update checks to simplify individual clients, centralize common logic, and enable upcoming features. * Merged the separate legacy redirects app on AWS into the main app on Azure. ## 3.5 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..f6044148 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -11,8 +11,8 @@ 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; } @@ -26,53 +26,56 @@ 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> + public UpdateKey(string rawText, ModSiteKey site, string id) { - this.RawText = rawText; - this.Repository = repository; - this.ID = id; + this.RawText = rawText?.Trim(); + this.Site = site; + this.ID = id?.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> + public UpdateKey(ModSiteKey site, string id) + : this(UpdateKey.GetString(site, id), site, id) { } /// <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); + + rawSite = parts[0].Trim(); + id = parts[1].Trim(); + } if (string.IsNullOrWhiteSpace(id)) id = null; // 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); if (id == null) - return new UpdateKey(raw, repository, null); + return new UpdateKey(raw, site, null); - return new UpdateKey(raw, repository, id); + return new UpdateKey(raw, site, id); } /// <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.RawText; } @@ -82,7 +85,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData { return other != null - && this.Repository == other.Repository + && this.Site == other.Site && string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase); } @@ -97,7 +100,15 @@ 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.Site}:{this.ID}".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> + public static string GetString(ModSiteKey site, string id) + { + return $"{site}:{id}".Trim(); } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index b9d7c32d..14be520d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -15,13 +15,13 @@ 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 { @@ -33,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; @@ -69,16 +69,7 @@ namespace StardewModdingAPI.Web.Controllers 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> @@ -149,40 +140,18 @@ namespace StardewModdingAPI.Web.Controllers } // 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 @@ -222,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 @@ -282,32 +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 from cache - if (this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out Cached<ModInfoModel> cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) - return cachedMod.Data; - - // fetch from mod site + // 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 mod = await repository.GetModInfoAsync(updateKey.ID); - if (mod.Error == null) + if (isCached) + page = cachedMod.Data; + else { - if (mod.Version == null) - mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!SemanticVersion.TryParse(mod.Version, allowNonStandardVersions, out _)) - mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{mod.Version}'."); + page = await this.ModSites.GetModPageAsync(updateKey); + this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); } - - // cache mod - this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, mod); - return mod; } + + // get version info + return this.ModSites.GetPageVersions(page, allowNonStandardVersions, mapRemoteVersions); } /// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary> @@ -334,13 +298,13 @@ namespace StardewModdingAPI.Web.Controllers if (entry != null) { if (entry.NexusID.HasValue) - yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}"; + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID?.ToString()); if (entry.ModDropID.HasValue) - yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}"; + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID?.ToString()); if (entry.CurseForgeID.HasValue) - yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}"; + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID?.ToString()); if (entry.ChucklefishID.HasValue) - yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}"; + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID?.ToString()); } } @@ -355,51 +319,5 @@ namespace StardewModdingAPI.Web.Controllers yield return key; } } - - /// <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; - } - - /// <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; - } } } diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 004202f9..0d912c7b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -1,6 +1,6 @@ using System; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -15,13 +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 Cached<ModInfoModel> 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> - void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod); + 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 index 62461116..6b0ec1ec 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods ** Fields *********/ /// <summary>The cached mod data indexed by <c>{site key}:{ID}</c>.</summary> - private readonly IDictionary<string, Cached<ModInfoModel>> Mods = new Dictionary<string, Cached<ModInfoModel>>(StringComparer.InvariantCultureIgnoreCase); + private readonly IDictionary<string, Cached<IModPage>> Mods = new Dictionary<string, Cached<IModPage>>(StringComparer.InvariantCultureIgnoreCase); /********* @@ -24,7 +24,7 @@ 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> - public bool TryGetMod(ModRepositoryKey site, string id, out Cached<ModInfoModel> mod, bool markRequested = true) + 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)) @@ -45,10 +45,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <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(ModRepositoryKey site, string id, ModInfoModel mod) + public void SaveMod(ModSiteKey site, string id, IModPage mod) { string key = this.GetKey(site, id); - this.Mods[key] = new Cached<ModInfoModel>(mod); + this.Mods[key] = new Cached<IModPage>(mod); } /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <summary>Get a cache key.</summary> /// <param name="site">The mod site.</param> /// <param name="id">The mod ID.</param> - private string GetKey(ModRepositoryKey site, string id) + private string GetKey(ModSiteKey site, string id) { return $"{site}:{id.Trim()}".ToLower(); } 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/C |
