oni-priority-ux/mod/PLibAVC/PVersionCheck.cs

341 lines
12 KiB
C#

/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using HarmonyLib;
using PeterHan.PLib.Core;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace PeterHan.PLib.AVC {
/// <summary>
/// Implements a basic automatic version check, using either Steam or an external website.
///
/// The version of the current mod is taken from the mod version attribute of the provided
/// mod.
/// </summary>
public sealed class PVersionCheck : PForwardedComponent {
/// <summary>
/// The delegate type used when a background version check completes.
/// </summary>
/// <param name="result">The results of the check. If null, the check has failed,
/// and the next version should be tried.</param>
public delegate void OnVersionCheckComplete(ModVersionCheckResults result);
/// <summary>
/// The instantiated copy of this class.
/// </summary>
internal static PVersionCheck Instance { get; private set; }
/// <summary>
/// The version of this component. Uses the running PLib version.
/// </summary>
internal static readonly Version VERSION = new Version(PVersion.VERSION);
/// <summary>
/// Gets the reported version of the specified assembly.
/// </summary>
/// <param name="assembly">The assembly to check.</param>
/// <returns>The assembly's file version, or if that is unset, its assembly version.</returns>
private static string GetCurrentVersion(Assembly assembly) {
string version = null;
if (assembly != null) {
version = assembly.GetFileVersion();
if (string.IsNullOrEmpty(version))
version = assembly.GetName()?.Version?.ToString();
}
return version;
}
/// <summary>
/// Gets the current version of the mod. If the version is specified in mod_info.yaml,
/// that version is reported. Otherwise, the assembly file version (and failing that,
/// the assembly version) of the assembly defining the mod's first UserMod2 instance
/// is reported.
///
/// This method will only work after mods have loaded.
/// </summary>
/// <param name="mod">The mod to check.</param>
/// <returns>The current version of that mod.</returns>
public static string GetCurrentVersion(KMod.Mod mod) {
if (mod == null)
throw new ArgumentNullException(nameof(mod));
string version = mod.packagedModInfo?.version;
if (string.IsNullOrEmpty(version)) {
// Does it have UM2 instances?
var instances = mod.loaded_mod_data?.userMod2Instances;
var dlls = mod.loaded_mod_data?.dlls;
if (instances != null)
// Use first UserMod2
foreach (var um2 in instances) {
version = GetCurrentVersion(um2.Key);
if (!string.IsNullOrEmpty(version))
break;
}
else if (dlls != null && dlls.Count > 0)
// Use first DLL
foreach (var assembly in dlls) {
version = GetCurrentVersion(assembly);
if (!string.IsNullOrEmpty(version))
break;
}
else
// All methods of determining the version have failed
version = "";
}
return version;
}
private static void MainMenu_OnSpawn_Postfix() {
Instance?.RunVersionCheck();
}
private static void ModsScreen_BuildDisplay_Postfix(System.Collections.IEnumerable
___displayedMods) {
// Must cast the type because ModsScreen.DisplayedMod is private
if (Instance != null && ___displayedMods != null)
foreach (var modEntry in ___displayedMods)
Instance.AddWarningIfOutdated(modEntry);
}
/// <summary>
/// The mods whose version will be checked.
/// </summary>
private readonly IDictionary<string, VersionCheckMethods> checkVersions;
/// <summary>
/// The location where the outcome of mod version checking will be stored.
/// </summary>
private readonly ConcurrentDictionary<string, ModVersionCheckResults> results;
public override Version Version => VERSION;
public PVersionCheck() {
checkVersions = new Dictionary<string, VersionCheckMethods>(8);
results = new ConcurrentDictionary<string, ModVersionCheckResults>(2, 16);
InstanceData = results.Values;
}
/// <summary>
/// Adds a warning to the mods screen if a mod is outdated.
/// </summary>
/// <param name="modEntry">The mod entry to modify.</param>
private void AddWarningIfOutdated(object modEntry) {
int index = -1;
var type = modEntry.GetType();
var indexVal = type.GetFieldSafe("mod_index", false)?.GetValue(modEntry);
if (indexVal is int intVal)
index = intVal;
var rowInstance = type.GetFieldSafe("rect_transform", false)?.GetValue(
modEntry) as RectTransform;
var mods = Global.Instance.modManager?.mods;
string id;
if (rowInstance != null && mods != null && index >= 0 && index < mods.Count &&
!string.IsNullOrEmpty(id = mods[index]?.staticID) && rowInstance.
TryGetComponent(out HierarchyReferences hr) && results.TryGetValue(id,
out ModVersionCheckResults data) && data != null)
// Version text is thankfully known, even if other mods have added buttons
AddWarningIfOutdated(data, hr.GetReference<LocText>("Version"));
}
/// <summary>
/// Adds a warning to a mod version label if it is outdated.
/// </summary>
/// <param name="data">The updated mod version.</param>
/// <param name="versionText">The current mod version label.</param>
private void AddWarningIfOutdated(ModVersionCheckResults data, LocText versionText) {
GameObject go;
if (versionText != null && (go = versionText.gameObject) != null && !data.
IsUpToDate) {
string text = versionText.text;
if (string.IsNullOrEmpty(text))
text = PLibStrings.OUTDATED_WARNING;
else
text = text + " " + PLibStrings.OUTDATED_WARNING;
versionText.text = text;
go.AddOrGet<ToolTip>().toolTip = string.Format(PLibStrings.OUTDATED_TOOLTIP,
data.NewVersion ?? "");
}
}
public override void Initialize(Harmony plibInstance) {
Instance = this;
plibInstance.Patch(typeof(MainMenu), "OnSpawn", postfix: PatchMethod(nameof(
MainMenu_OnSpawn_Postfix)));
plibInstance.Patch(typeof(ModsScreen), "BuildDisplay", postfix: PatchMethod(
nameof(ModsScreen_BuildDisplay_Postfix)));
}
public override void Process(uint operation, object args) {
if (operation == 0 && args is System.Action runNext) {
VersionCheckTask first = null, previous = null;
results.Clear();
foreach (var pair in checkVersions) {
string staticID = pair.Key;
var mod = pair.Value;
#if DEBUG
PUtil.LogDebug("Checking version for mod {0}".F(staticID));
#endif
foreach (var checker in mod.Methods) {
var node = new VersionCheckTask(mod.ModToCheck, checker, results) {
Next = runNext
};
if (previous != null)
previous.Next = runNext;
if (first == null)
first = node;
}
}
first?.Run();
}
}
/// <summary>
/// Registers the specified mod for automatic version checking. Mods will be registered
/// using their static ID, so to avoid the default ID from being used instead, set this
/// attribute in mod.yaml.
///
/// The same mod can be registered multiple times with different methods to check the
/// mod versions. The methods will be attempted in order from first registered to last.
/// However, the same mod must not be registered multiple times in different instances
/// of PVersionCheck.
/// </summary>
/// <param name="mod">The mod instance to check.</param>
/// <param name="checker">The method to use for checking the mod version.</param>
public void Register(KMod.UserMod2 mod, IModVersionChecker checker) {
var kmod = mod?.mod;
if (kmod == null)
throw new ArgumentNullException(nameof(mod));
if (checker == null)
throw new ArgumentNullException(nameof(checker));
RegisterForForwarding();
string staticID = kmod.staticID;
if (!checkVersions.TryGetValue(staticID, out VersionCheckMethods checkers))
checkVersions.Add(staticID, checkers = new VersionCheckMethods(kmod));
checkers.Methods.Add(checker);
}
/// <summary>
/// Reports the results of the version check.
/// </summary>
private void ReportResults() {
var allMods = PRegistry.Instance.GetAllComponents(ID);
results.Clear();
if (allMods != null)
// Consolidate them through JSON roundtrip into results dictionary
foreach (var mod in allMods) {
var modResults = mod.GetInstanceDataSerialized<ICollection<
ModVersionCheckResults>>();
if (modResults != null)
foreach (var result in modResults)
results.TryAdd(result.ModChecked, result);
}
}
/// <summary>
/// Starts the automatic version check for all mods.
/// </summary>
internal void RunVersionCheck() {
var allMods = PRegistry.Instance.GetAllComponents(ID);
// See if Mod Updater triggered master disable
if (!PRegistry.GetData<bool>("PLib.VersionCheck.ModUpdaterActive") &&
allMods != null)
new AllVersionCheckTask(allMods, this).Run();
}
/// <summary>
/// Checks each mod's version one at a time to avoid saturating the network with
/// generally nonessential traffic (in the case of yaml/json checkers).
/// </summary>
private sealed class AllVersionCheckTask {
/// <summary>
/// A list of actions that will check each version in turn.
/// </summary>
private readonly IList<PForwardedComponent> checkAllVersions;
/// <summary>
/// The current location in the list.
/// </summary>
private int index;
/// <summary>
/// Handles version check result reporting when complete.
/// </summary>
private readonly PVersionCheck parent;
internal AllVersionCheckTask(IEnumerable<PForwardedComponent> allMods,
PVersionCheck parent) {
if (allMods == null)
throw new ArgumentNullException(nameof(allMods));
checkAllVersions = new List<PForwardedComponent>(allMods);
index = 0;
this.parent = parent ?? throw new ArgumentNullException(nameof(parent));
}
/// <summary>
/// Runs all checks and fires the callback when complete.
/// </summary>
internal void Run() {
int n = checkAllVersions.Count;
if (index >= n)
parent.ReportResults();
while (index < n) {
var doCheck = checkAllVersions[index++];
if (doCheck != null) {
doCheck.Process(0, new System.Action(Run));
break;
} else if (index >= n)
parent.ReportResults();
}
}
public override string ToString() {
return "AllVersionCheckTask for {0:D} mods".F(checkAllVersions.Count);
}
}
/// <summary>
/// A placeholder class which stores all methods used to check a single mod.
/// </summary>
private sealed class VersionCheckMethods {
/// <summary>
/// The methods which will be used to check.
/// </summary>
internal IList<IModVersionChecker> Methods { get; }
// <summary>
/// The mod whose version will be checked.
/// </summary>
internal KMod.Mod ModToCheck { get; }
internal VersionCheckMethods(KMod.Mod mod) {
Methods = new List<IModVersionChecker>(8);
ModToCheck = mod ?? throw new ArgumentNullException(nameof(mod));
PUtil.LogDebug("Registered mod ID {0} for automatic version checking".F(
ModToCheck.staticID));
}
public override string ToString() {
return ModToCheck.staticID;
}
}
}
}