#region using System; using System.IO; using Oxide.Core; using System.Linq; using System.Text; using UnityEngine; using UnityEngine.UI; using Newtonsoft.Json; using Oxide.Core.Plugins; using System.Collections; using Oxide.Game.Rust.Cui; using System.Globalization; using JetBrains.Annotations; using UnityEngine.Networking; using System.Collections.Generic; using System.Text.RegularExpressions; #endregion namespace Oxide.Plugins; [Info("UpdateChecker", "tofurahie", "4.6.4")] [Description("Update checker for all of your plugins")] internal class UpdateChecker : RustPlugin { #region GUI private class UI { private const string Layer = "UI_UpdateChecker"; public static void MainParent(ref CuiElementContainer container, string name = null, string aMin = "0.5 0.5", string aMax = "0.5 0.5", bool overAll = true, bool keyboardEnabled = true, bool cursorEnabled = true) => container.Add(new CuiPanel { KeyboardEnabled = keyboardEnabled, CursorEnabled = cursorEnabled, RectTransform = { AnchorMin = aMin, AnchorMax = aMax }, Image = { Color = "0 0 0 0" } }, overAll ? "Overlay" : "Hud", Layer + ".bg" + name, Layer + ".bg" + name); public static void Panel(ref CuiElementContainer container, string parent, string name = null, string destroy = null, string aMin = "0 0", string aMax = "1 1", string oMin = "0 0", string oMax = "0 0", string bgColor = "0.33 0.33 0.33 1", string material = null, string sprite = null, int itemID = 0, ulong skinID = 0) => container.Add(new CuiElement { Parent = Layer + parent, Name = name != null && name.Contains(".other.") ? name.Replace(".other.", "") : Layer + name, DestroyUi = destroy == null ? null : Layer + destroy, Components = { new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, new CuiImageComponent { Color = HexToRustFormat(bgColor), Material = material, Sprite = sprite, ItemId = itemID, SkinId = skinID }, }, }); public static void Icon(ref CuiElementContainer container, string parent, string name = null, string destroy = null, string aMin = "0 0", string aMax = "1 1", string oMin = "0 0", string oMax = "0 0", int itemID = 0, ulong skinID = 0) => container.Add(new CuiElement { Parent = Layer + parent, Name = Layer + name, DestroyUi = destroy == null ? null : Layer + destroy, Components = { new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, new CuiImageComponent { ItemId = itemID, SkinId = skinID }, }, }); public static void Image(ref CuiElementContainer container, string parent, string name = null, string destroy = null, string aMin = "0 0", string aMax = "1 1", string oMin = "0 0", string oMax = "0 0", string image = "", string color = "1 1 1 1") => container.Add(new CuiElement { Parent = Layer + parent, Name = Layer + name, DestroyUi = destroy == null ? null : Layer + destroy, Components = { new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, new CuiRawImageComponent { Png = !image.StartsWith("http") && !image.StartsWith("www") ? image : null, Url = image.StartsWith("http") || image.StartsWith("www") ? image : null, Color = HexToRustFormat(color), Sprite = "assets/content/textures/generic/fulltransparent.tga" }, }, }); public static void Label(ref CuiElementContainer container, string parent, string name = null, string destroy = null, string aMin = "0 0", string aMax = "1 1", string oMin = "0 0", string oMax = "0 0", string text = null, int fontSize = 16, string color = "1 1 1 1", TextAnchor align = TextAnchor.MiddleCenter, string outlineDistance = null, string outlineColor = "0 0 0 1", VerticalWrapMode wrapMode = VerticalWrapMode.Truncate, string font = "robotocondensed-regular.ttf") => container.Add(new CuiElement { Parent = Layer + parent, Name = Layer + name, DestroyUi = destroy == null ? null : Layer + destroy, Components = { new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, new CuiTextComponent { Text = text, FontSize = fontSize, Color = HexToRustFormat(color), Align = align, Font = font, VerticalOverflow = wrapMode }, outlineDistance == null ? new CuiOutlineComponent { Distance = "0 0", Color = "0 0 0 0" } : new CuiOutlineComponent { Distance = outlineDistance, Color = HexToRustFormat(outlineColor) }, }, }); public static void Button(ref CuiElementContainer container, string parent, string name = null, string destroy = null, string aMin = "0 0", string aMax = "1 1", string oMin = "0 0", string oMax = "0 0", string text = null, int fontSize = 16, string color = "1 1 1 1", string command = null, string bgColor = "0 0 0 0", VerticalWrapMode wrapMode = VerticalWrapMode.Truncate, TextAnchor align = TextAnchor.MiddleCenter, string font = "robotocondensed-regular.ttf", string material = null, string sprite = null) => container.Add(new CuiButton { RectTransform = { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, Text = { Text = text, FontSize = fontSize, Color = HexToRustFormat(color), Align = align, Font = font, VerticalOverflow = wrapMode }, Button = { Command = command, Close = command == null ? Layer + name : null, Color = HexToRustFormat(bgColor), Material = material, Sprite = sprite } }, Layer + parent, command == null ? null : Layer + name, destroy == null ? null : Layer + destroy); public static void Input(ref CuiElementContainer container, string parent, string name = null, string destroy = null, string aMin = "0 0", string aMax = "1 1", string oMin = "0 0", string oMax = "0 0", string text = null, int limit = 40, int fontSize = 16, string color = "1 1 1 1", string command = null, TextAnchor align = TextAnchor.MiddleCenter, bool autoFocus = false, bool hudMenuInput = false, bool readOnly = false, bool isPassword = false, bool needsKeyboard = false, bool singleLine = true, string font = "robotocondensed-regular.ttf") => container.Add( new CuiElement { Parent = Layer + parent, Name = Layer + name, DestroyUi = destroy == null ? null : Layer + destroy, Components = { new CuiRectTransformComponent { AnchorMin = aMin, AnchorMax = aMax, OffsetMin = oMin, OffsetMax = oMax }, new CuiInputFieldComponent { Text = text, Command = command, CharsLimit = limit, FontSize = fontSize, Color = HexToRustFormat(color), Align = align, Font = font, Autofocus = autoFocus, IsPassword = isPassword, ReadOnly = readOnly, HudMenuInput = hudMenuInput, NeedsKeyboard = needsKeyboard, LineType = singleLine ? InputField.LineType.SingleLine : InputField.LineType.MultiLineNewline }, } }); public static void Outline(ref CuiElementContainer container, string layer, string size = "1 1 1 1", string color = "0 0 0 1", bool external = false) { var borders = size.Split(' '); if (borders[0] != "0") Panel(ref container, layer, aMin: "0 1", aMax: "1 1", oMin: $"-{borders[0]} {(external ? "0" : "-" + borders[0])}", oMax: $"{borders[0]} {(external ? borders[0] : "0")}", bgColor: color); if (borders[1] != "0") Panel(ref container, layer, aMin: "1 0", aMax: "1 1", oMin: $"{(external ? "0" : "-" + borders[1])} -{borders[1]}", oMax: $"{(external ? borders[1] : "0")} {borders[1]}", bgColor: color); if (borders[2] != "0") Panel(ref container, layer, aMin: "0 0", aMax: "1 0", oMin: $"-{borders[2]} {(external ? "-" + borders[2] : "0")}", oMax: $"{borders[2]} {(external ? "0" : borders[2])}", bgColor: color); if (borders[3] != "0") Panel(ref container, layer, aMin: "0 0", aMax: "0 1", oMin: $"{(external ? "-" + borders[3] : "0")} -{borders[3]}", oMax: $"{(external ? "0" : borders[3])} {borders[3]}", bgColor: color); } public static string HexToRustFormat(string hex) { if (string.IsNullOrEmpty(hex)) return hex; Color color; if (hex.Contains(":")) return ColorUtility.TryParseHtmlString(hex.Substring(0, hex.IndexOf(":", StringComparison.Ordinal)), out color) ? $"{color.r:F2} {color.g:F2} {color.b:F2} {hex.Substring(hex.IndexOf(":", StringComparison.Ordinal) + 1, hex.Length - hex.IndexOf(":", StringComparison.Ordinal) - 1)}" : hex; return ColorUtility.TryParseHtmlString(hex, out color) ? $"{color.r:F2} {color.g:F2} {color.b:F2} {color.a:F2}" : hex; } public static void Create(BasePlayer player, CuiElementContainer container) { CuiHelper.AddUi(player, container); } public static void CreateToAll(CuiElementContainer container, string layer) { foreach (var player in BasePlayer.activePlayerList) CuiHelper.AddUi(player, container); } public static void Destroy(BasePlayer player, string layer) => CuiHelper.DestroyUi(player, Layer + layer); public static void DestroyToAll(string layer) { foreach (var player in BasePlayer.activePlayerList) Destroy(player, layer); } } #endregion #region Static private const int PLUGIN_SCAN_BATCH_SIZE = 20; private const string PERM = "updatechecker.setup"; private static readonly Regex PluginInfoRegex = new(@"Info\(\""(.*?)\"",\""(.*?)\"",\""(.*?)\""", RegexOptions.Compiled); private static readonly Regex CamelBoundaryRegex = new(@"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", RegexOptions.Compiled); private static readonly Regex LeadingFileIdRegex = new(@"^\d+-", RegexOptions.Compiled); private IEnumerator _checkNewVersionCoroutine, _sendDiscordMessageCoroutine, _updatePluginListCoroutine; private bool _isChecking; private DateTime _checkStarted; private readonly Dictionary _openInterfaces = new(); private readonly HashSet _configIgnoreList = new() { "RustEdit" }; #region Classes private enum CheckStatus { PENDING, FOUND, NOT_FOUND, FAILED } private class UiState { public string Search = ""; public int Page; public string SelectedPlugin; } [Serializable] private class Configuration { [JsonProperty("Command to open UI")] public string Command = "ucsetup"; [JsonProperty("Command to send the test message to your discord")] public string CommandDiscord = "uctest"; [JsonProperty("Console command to check orphaned config files")] public string CommandCheckOrphanedConfigFiles = "uc_check_config"; [JsonProperty("Console command to move orphaned config files")] public string CommandDeleteOrphanedConfigFiles = "uc_clean_config"; [JsonProperty("Discord WebHook")] public string DiscordWebHook = ""; [JsonProperty("Discord message ID")] public string MessageID = ""; [JsonProperty("Check updates on load [disable it if you have problem with the config]")] public bool CheckUpdateOnLoad = true; [JsonProperty("Embed side line color [hex]")] public string EmbedLineColor = "#ffffff"; [JsonProperty("Difference between UTC and your time [in minutes]")] public int UTC = 60; [JsonProperty("Use 24 time format")] public bool TimeFormat = true; [JsonProperty("Check Interval(In minutes)")] public int CheckMinutes = 60; [JsonProperty("Ignore 'All plugins have the latest version' discord message")] public bool IgnoreAllPluginsUpToDateMessage = false; [JsonProperty("Ignore not found plugins")] public bool IgnoreNotFound = false; [JsonProperty("Ignore not loaded plugins")] public bool IgnoreNotLoaded = true; [JsonProperty("Add a link to the plugin to be updated")] public bool UseURL = true; [JsonProperty("Remove missing plugins from config automatically")] public bool AutoRemoveMissingPlugins = true; [JsonProperty("List of plugins", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List ListOfPlugins = new(); [JsonProperty("Enable auto search")] public Dictionary AutoSearch = new() { ["uMod"] = true, ["Codefling"] = true, ["Lone.Design"] = true, ["Chaos"] = true, ["RustWorkshop"] = true, ["Github"] = true, ["ModPulse"] = true, ["RustPlugins"] = true, ["ServerArmour"] = true, ["ImperialPlugins"] = true, ["MyVector"] = true, ["SkyPlugins"] = true, ["Game4Freak"] = true, }; [JsonProperty("Links to version lists [json: plugin name -> Major, Minor, Patch, Author, Url]", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List VersionLists = new() { "https://www.robjmaps.com/downloads/Plugin_Versions.json" }; } [Serializable] private class PluginInfo { [JsonIgnore] public CheckStatus Status; [JsonIgnore] public bool IsFounded => Status == CheckStatus.FOUND; [JsonIgnore] public bool IsLoaded; [JsonIgnore] public string SearchUrl; [JsonIgnore] public string Title; [JsonProperty("Name")] public string Name; [JsonProperty("Author")] public string Author; [JsonProperty("Plugin version")] public string Version; [JsonProperty("Link to plugin")] public string Url; [JsonProperty("Marketplace")] public string Marketplace; [JsonProperty("Ignore")] public bool Ignore; public PluginInfo(string author, string name, string title, string version, string url = "", string marketplace = "", bool ignore = false, bool isLoaded = false) { Name = name; Author = author; Title = title; Version = version; Url = url; Marketplace = marketplace; Ignore = ignore; IsLoaded = isLoaded; } } [Serializable] private class RequestData { [JsonProperty("status")] public int Status { get; set; } [JsonProperty("data")] public PluginData[] Data { get; set; } } [Serializable] private class PluginData { [JsonProperty("name")] public string Name { get; set; } [JsonProperty("manualName")] public string ManualName { get; set; } [JsonProperty("author")] public string Author { get; set; } [JsonProperty("latestVersion")] public string LatestVersion { get; set; } [JsonProperty("url")] public string Url { get; set; } [JsonProperty("slug")] public string Slug { get; set; } [JsonProperty("marketplace")] public string Marketplace { get; set; } [JsonProperty("tags")] public string Tags { get; set; } } private class MarketplaceResponse { public PluginData[] Data; public bool Failed; public string Error; } [Serializable] private class ListedVersion { public int Major { get; set; } public int Minor { get; set; } public int Patch { get; set; } public string Author { get; set; } public string Url { get; set; } public string Marketplace { get; set; } public string Version => $"{Major}.{Minor}.{Patch}"; public bool IsWrittenBy(string author) { return string.IsNullOrWhiteSpace(Author) || string.Equals(Author.Trim(), author?.Trim(), StringComparison.OrdinalIgnoreCase); } } [Serializable] private class AboutPlugin { public string Name; public string Title; public string Author; public string Version; public bool IsLoaded; public AboutPlugin(string name, string title, string author, string version, bool isLoaded) { Name = name; Title = title; Author = author; Version = version; IsLoaded = isLoaded; } } #endregion #endregion #region OxideHooks [UsedImplicitly] [HookMethod(nameof(OnServerInitialized))] private void OnServerInitialized() { cmd.AddChatCommand(_config.Command, this, nameof(ChatSetupCommand)); cmd.AddChatCommand(_config.CommandDiscord, this, nameof(DiscordTestCommand)); cmd.AddConsoleCommand(_config.CommandCheckOrphanedConfigFiles, this, nameof(console_uc_check_config)); cmd.AddConsoleCommand(_config.CommandDeleteOrphanedConfigFiles, this, nameof(console_uc_clean_config)); permission.RegisterPermission(PERM, this); if (_config.CheckUpdateOnLoad) CheckPlugins(); timer.Every(60 * Math.Max(_config.CheckMinutes, 1), CheckPlugins); } [UsedImplicitly] [HookMethod(nameof(Unload))] private void Unload() { if (_isChecking) WriteCheckLog("ABORTED: plugin unloaded before the check completed."); _openInterfaces.Clear(); UI.DestroyToAll(".bg"); if (_updatePluginListCoroutine != null) ServerMgr.Instance.StopCoroutine(_updatePluginListCoroutine); if (_sendDiscordMessageCoroutine != null) ServerMgr.Instance.StopCoroutine(_sendDiscordMessageCoroutine); } private void OnPlayerDisconnected(BasePlayer player) { _openInterfaces.Remove(player.userID); } #endregion #region Commands [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] private void console_uc_check_config(ConsoleSystem.Arg arg) { if (arg.HasArgs() || arg.Player() != null) return; UpdatePluginList(); var configsNames = ""; foreach (var check in Directory.GetFiles(Interface.Oxide.ConfigDirectory, "*.json").Select(Path.GetFileNameWithoutExtension)) if (_config.ListOfPlugins.All(x => !string.Equals(x.Name, check, StringComparison.CurrentCultureIgnoreCase)) && !_configIgnoreList.Contains(check)) configsNames += check + ", "; if (configsNames == string.Empty) { PrintWarning($"All config files are in use"); return; } PrintWarning(configsNames.TrimEnd().TrimEnd(',') + $"\nYou can move orphaned config files to UpdateCheckerConfigsBackup via command \"{_config.CommandDeleteOrphanedConfigFiles}\""); } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] private void console_uc_clean_config(ConsoleSystem.Arg arg) { if (arg.HasArgs() || arg.Player() != null) return; UpdatePluginList(); Directory.CreateDirectory(Interface.Oxide.ConfigDirectory + "/UpdateCheckerConfigsBackup"); var count = 0; foreach (var check in Directory.GetFiles(Interface.Oxide.ConfigDirectory, "*.json")) if (_config.ListOfPlugins.All(x => !string.Equals(x.Name, Path.GetFileNameWithoutExtension(check), StringComparison.CurrentCultureIgnoreCase)) && !_configIgnoreList.Contains(Path.GetFileNameWithoutExtension(check))) { File.Move(check, Interface.Oxide.ConfigDirectory + $"/UpdateCheckerConfigsBackup/{Path.GetFileNameWithoutExtension(check)}.json"); count++; } if (count == 0) { PrintWarning("All config files are in use"); return; } PrintError($"{count} config files ${(count > 1 ? "were" : "was")} moved to {Interface.Oxide.ConfigDirectory + $"/UpdateCheckerConfigsBackup"}"); } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] private void ChatSetupCommand(BasePlayer player, string command, string[] args) { if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PERM)) { SendReply(player, "You don't have permissions to use this command"); return; } ShowUIBG(player); } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] private void DiscordTestCommand(BasePlayer player, string command, string[] args) { if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PERM)) { SendReply(player, "You don't have permissions to use this command"); return; } var updateList = _config.AutoSearch.ToDictionary(static check => check.Key, static _ => (new List(), new List { "Your discord webhook is working" })); _sendDiscordMessageCoroutine = SendMessageDiscord(updateList); ServerMgr.Instance.StartCoroutine(_sendDiscordMessageCoroutine); } [ConsoleCommand("checkupdates")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] private void ConsolecheckupdatesCommand(ConsoleSystem.Arg arg) { if (arg == null || arg.Player() != null) return; if (_isChecking) { PrintWarning("The plugin already scans other plugins for updates"); return; } CheckPlugins(); } [ConsoleCommand("UI_UC")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] private void UI_UC(ConsoleSystem.Arg arg) { if (!arg.HasArgs()) return; var player = arg.Player(); if (player == null) return; if (arg.GetString(0) == "CLOSE") { _openInterfaces.Remove(player.userID); UI.Destroy(player, ".bg"); return; } if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PERM)) return; switch (arg.GetString(0)) { case "SEARCH": ShowUIPlugins(player, string.Join(" ", arg.Args.Skip(1)).Replace(" ", "")); break; case "PAGE": ShowUIPlugins(player, string.Join(" ", arg.Args.Skip(2)), arg.GetInt(1)); break; case "CHECK": ShowUICurrentPluginInfo(player, _config.ListOfPlugins.FirstOrDefault(x => string.Equals(x.Name, string.Join(" ", arg.Args.Skip(1)), StringComparison.CurrentCultureIgnoreCase))); break; case "CHANGEURL": ChangePluginUrl(arg.GetString(1), arg.GetString(2)); break; case "CHANGEIGNORE": { var pluginInfo = _config.ListOfPlugins.FirstOrDefault(x => string.Equals(x.Name, arg.GetString(1))); if (pluginInfo == null) break; pluginInfo.Ignore = !pluginInfo.Ignore; SaveConfigKeepingManualEdits(pluginInfo.Name); RefreshOpenInterfaces(); break; } } } #endregion #region Functions private void ChangePluginUrl(string name, string url) { var pluginInfo = _config.ListOfPlugins.FirstOrDefault(x => string.Equals(x.Name, name)); if (pluginInfo == null || string.Equals(pluginInfo.Url, url, StringComparison.Ordinal)) return; pluginInfo.Url = url; pluginInfo.Status = CheckStatus.PENDING; SaveConfigKeepingManualEdits(pluginInfo.Name); RefreshOpenInterfaces(); } private IEnumerator SendMessageDiscord(Dictionary, List)> infoList, bool isAllUpToDate = false) { if (string.IsNullOrEmpty(_config.DiscordWebHook)) { yield break; } var updateListBuilder = new StringBuilder(); foreach (var checks in infoList) foreach (var check in checks.Value.Item2) updateListBuilder.Append(check); var updateList = updateListBuilder.ToString(); var message = new { content = "", embeds = new[] { new { title = "", description = updateList, color = isAllUpToDate ? 65280 : int.Parse(_config.EmbedLineColor.Substring(1), NumberStyles.HexNumber), } } }; var spaceBuilder = new StringBuilder(); if (updateList.Length < 4000) { using var request = string.IsNullOrEmpty(_config.MessageID) ? new UnityWebRequest(_config.DiscordWebHook, "POST") : new UnityWebRequest($"{_config.DiscordWebHook}/messages/{_config.MessageID}", "PATCH"); request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message))); request.SetRequestHeader("Content-Type", "application/json"); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) PrintError(request.responseCode == 404 ? "Failed to find the message with the specified ID in the config, please check it and try again." : "The discord hook is borken, please check it and try again."); yield break; } foreach (var check in infoList) foreach (var url in check.Value.Item2) { spaceBuilder.Append(url); if (spaceBuilder.Length + url.Length + "\nThe list of updates has reached its limit, please update your plugins.".Length <= 4000) continue; message = new { content = "", embeds = new[] { new { title = "", description = spaceBuilder.ToString(), color = int.Parse(_config.EmbedLineColor.Substring(1), NumberStyles.HexNumber), }, new { title = "", description = "The list of updates has reached its limit, please update your plugins.", color = 16711680, } } }; using var request = string.IsNullOrEmpty(_config.MessageID) ? new UnityWebRequest(_config.DiscordWebHook, "POST") : new UnityWebRequest($"{_config.DiscordWebHook}/messages/{_config.MessageID}", "PATCH"); request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message))); request.SetRequestHeader("Content-Type", "application/json"); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) PrintError(request.responseCode == 404 ? "Failed to find the message with the specified ID in the config, please check it and try again." : "The discord hook is borken, please check it and try again."); yield break; } } private void CheckPlugins() { if (_isChecking) return; if (!ReloadConfigFromDisk()) { WriteCheckLog("ABORTED: configuration could not be read."); return; } _isChecking = true; _checkStarted = DateTime.UtcNow; WriteCheckLog($"START: {_config.ListOfPlugins.Count} configured plugins."); _updatePluginListCoroutine = CheckPluginsRoutine(); ServerMgr.Instance.StartCoroutine(_updatePluginListCoroutine); } private IEnumerator CheckPluginsRoutine() { var completed = false; try { var allPlugins = new List(); var scanRoutine = GetAllPluginsRoutine(allPlugins); while (scanRoutine.MoveNext()) { yield return scanRoutine.Current; } UpdatePluginList(allPlugins); SaveConfigKeepingManualEdits(); _checkNewVersionCoroutine = CheckNewVersion(); while (_checkNewVersionCoroutine.MoveNext()) { yield return _checkNewVersionCoroutine.Current; } completed = true; } finally { _isChecking = false; if (!completed) WriteCheckLog("ABORTED: check did not reach its summary."); RefreshOpenInterfaces(); } } private List GetAllPlugins() { var allFoundedPlugins = new List(); var scanRoutine = GetAllPluginsRoutine(allFoundedPlugins); while (scanRoutine.MoveNext()) { } return allFoundedPlugins; } private IEnumerator GetAllPluginsRoutine(List allFoundedPlugins) { foreach (var check in plugins.GetAll().Where(static x => !x.IsCorePlugin)) allFoundedPlugins.Add(new AboutPlugin(check.Name, check.Title, check.Author, check.Version.ToString(), check.IsLoaded)); var foundedNames = new HashSet(allFoundedPlugins.Select(static x => x.Name), StringComparer.OrdinalIgnoreCase); var filesInBatch = 0; foreach (var fileSystemInfo in new DirectoryInfo(Interface.Oxide.PluginDirectory).GetFiles("*" + "cs") .Where(static f => (f.Attributes & FileAttributes.Hidden) != FileAttributes.Hidden)) { if (!foundedNames.Contains(Path.GetFileNameWithoutExtension(fileSystemInfo.Name)) && TryParseAboutPlugin(fileSystemInfo, out var aboutPlugin)) allFoundedPlugins.Add(aboutPlugin); filesInBatch++; if (filesInBatch < PLUGIN_SCAN_BATCH_SIZE) continue; filesInBatch = 0; yield return null; } } private static bool TryParseAboutPlugin(FileInfo fileSystemInfo, out AboutPlugin aboutPlugin) { aboutPlugin = null; var regexInfo = PluginInfoRegex.Match(File.ReadAllText(fileSystemInfo.FullName).Replace(" ", "")); if (!regexInfo.Success) return false; aboutPlugin = new AboutPlugin(Path.GetFileNameWithoutExtension(fileSystemInfo.Name), regexInfo.Groups[1].Value, regexInfo.Groups[2].Value, regexInfo.Groups[3].Value, false); return true; } private void UpdatePluginList() { UpdatePluginList(GetAllPlugins()); } private void UpdatePluginList(List allPlugins) { var listChanged = false; if (_config.AutoRemoveMissingPlugins) { var foundedNames = new HashSet(allPlugins.Select(static x => x.Name), StringComparer.OrdinalIgnoreCase); var removedNames = new List(); for (var index = _config.ListOfPlugins.Count - 1; index >= 0; index--) { var pluginInfo = _config.ListOfPlugins[index]; if (foundedNames.Contains(pluginInfo.Name) || File.Exists(Path.Combine(Interface.Oxide.PluginDirectory, pluginInfo.Name + ".cs"))) continue; removedNames.Add(pluginInfo.Name); _config.ListOfPlugins.RemoveAt(index); } if (removedNames.Count != 0) { PrintWarning($"Removed from the list, no such plugin on the server: {string.Join(", ", removedNames)}"); listChanged = true; } } var pluginsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pluginInfo in _config.ListOfPlugins) pluginsByName.TryAdd(pluginInfo.Name, pluginInfo); foreach (var entry in allPlugins) { if (pluginsByName.TryGetValue(entry.Name, out PluginInfo pluginInfo)) { pluginInfo.Author = entry.Author; pluginInfo.Title = entry.Title; pluginInfo.Version = entry.Version; pluginInfo.IsLoaded = entry.IsLoaded; } else { var newPluginInfo = new PluginInfo(entry.Author, entry.Name, entry.Title, entry.Version, "", "", false, entry.IsLoaded); _config.ListOfPlugins.Add(newPluginInfo); pluginsByName[newPluginInfo.Name] = newPluginInfo; listChanged = true; } } if (listChanged) _config.ListOfPlugins = _config.ListOfPlugins.OrderBy(static x => x.Name).ToList(); } private IEnumerator CheckNewVersion() { _isChecking = true; var listedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); var updateList = _config.AutoSearch.ToDictionary(static check => check.Key, static _ => (new List(), new List())); updateList["uMod"] = ( new List { $"{DateTime.UtcNow.AddMinutes(_config.UTC).ToString(_config.TimeFormat ? "d MMM yyyy HH:mm" : "d MMM yyyy hh:mm tt")}" }, new List { $"### {DateTime.UtcNow.AddMinutes(_config.UTC).ToString(_config.TimeFormat ? "d MMM yyyy HH:mm" : "d MMM yyyy hh:mm tt")}" }); updateList.Add("NotFound", (new List { "\n[NotFound]" }, new List { "\n[NotFound]" })); updateList.Add("NotLoaded", (new List { "\n[NotLoaded]" }, new List { "\n[NotLoaded]" })); updateList.Add("CheckFailed", (new List(), new List())); var listsRoutine = LoadVersionLists(listedVersions, updateList); while (listsRoutine.MoveNext()) { yield return listsRoutine.Current; } var isUpToDate = true; var searchedPlugins = new List(); var linkedPlugins = new List(); for (var index = 0; index < _config.ListOfPlugins.Count; index++) { if (!IsLoaded) yield break; var check = _config.ListOfPlugins[index]; if (check.Ignore) continue; if (!check.IsLoaded) { WriteCheckLog($"{check.Name}: NOT_LOADED; skipped={_config.IgnoreNotLoaded}."); if (_config.IgnoreNotLoaded) continue; updateList["NotLoaded"].Item1[0] += $" {check.Name},"; updateList["NotLoaded"].Item2[0] += $" {check.Name},"; } if (string.IsNullOrEmpty(check.Url) && listedVersions.TryGetValue(check.Name, out var listed) && listed.IsWrittenBy(check.Author)) { ApplyLatestVersion(check, listed.Marketplace, listed.Version, listed.Url, updateList, ref isUpToDate); continue; } check.SearchUrl = string.IsNullOrEmpty(check.Url) ? null : NormalizeUrl(check.Url); var response = new MarketplaceResponse(); var searchRoutine = RequestMarketplace(string.IsNullOrEmpty(check.SearchUrl) ? check.Name : check.SearchUrl, response, check.Name); while (searchRoutine.MoveNext()) yield return searchRoutine.Current; if (response.Failed) { if (IsSearchCurrent(check)) MarkCheckFailed(check, response.Error, updateList); continue; } string searchError = null; var foundedPlugins = response.Data; var hasExactUrl = !string.IsNullOrEmpty(check.SearchUrl) && foundedPlugins != null && foundedPlugins.Any(x => x != null && IsSameUrl(check.SearchUrl, x.Url, false)); if (!string.IsNullOrEmpty(check.SearchUrl) && !hasExactUrl) { var nameResponse = new MarketplaceResponse(); var nameRoutine = RequestMarketplace(check.Name, nameResponse, check.Name); while (nameRoutine.MoveNext()) yield return nameRoutine.Current; if (nameResponse.Failed) searchError = nameResponse.Error; if (nameResponse.Data != null) { foundedPlugins = foundedPlugins == null ? nameResponse.Data : foundedPlugins.Concat(nameResponse.Data).ToArray(); hasExactUrl = foundedPlugins.Any(x => x != null && IsSameUrl(check.SearchUrl, x.Url, false)); } } var hadUrl = !string.IsNullOrEmpty(check.Url); searchedPlugins.Clear(); if (foundedPlugins != null) searchedPlugins.AddRange(foundedPlugins); if (!IsSearchCurrent(check)) continue; check.Status = CheckStatus.PENDING; TryResolvePlugin(check, foundedPlugins, hasExactUrl, allowWeakMatch: false, updateList, ref isUpToDate); if (check.Status == CheckStatus.PENDING) { foreach (var rescueQuery in GetRescueQueries(check)) { var rescueResponse = new MarketplaceResponse(); var rescueRoutine = RequestMarketplace(rescueQuery, rescueResponse, check.Name); while (rescueRoutine.MoveNext()) yield return rescueRoutine.Current; if (rescueResponse.Failed) { searchError = rescueResponse.Error; continue; } if (rescueResponse.Data == null) continue; if (!IsSearchCurrent(check)) break; searchedPlugins.AddRange(rescueResponse.Data); if (TryResolvePlugin(check, rescueResponse.Data, hasExactUrl: false, allowWeakMatch: false, updateList, ref isUpToDate)) break; } if (!IsSearchCurrent(check)) continue; if (check.Status == CheckStatus.PENDING) TryResolvePlugin(check, searchedPlugins.ToArray(), hasExactUrl, allowWeakMatch: true, updateList, ref isUpToDate); } if (!hadUrl && !string.IsNullOrEmpty(check.Url)) linkedPlugins.Add($"{check.Name} -> {check.Url}"); if (check.Status != CheckStatus.PENDING) continue; if (searchError != null) { MarkCheckFailed(check, searchError, updateList); continue; } check.Status = CheckStatus.NOT_FOUND; WriteCheckLog($"{check.Name}: NOT_FOUND; query={GetLogQuery(check.SearchUrl ?? check.Name)}; API returned no matching plugin."); updateList["NotFound"].Item1[0] += $" {check.Name},"; updateList["NotFound"].Item2[0] += $" {check.Name},"; } if (linkedPlugins.Count != 0) PrintWarning($"The link was empty and has been filled in from the search:\n{string.Join("\n", linkedPlugins)}"); updateList["NotFound"].Item1[0] = updateList["NotFound"].Item1[0].TrimEnd(','); updateList["NotFound"].Item2[0] = updateList["NotFound"].Item2[0].TrimEnd(','); updateList["NotLoaded"].Item1[0] = updateList["NotLoaded"].Item1[0].TrimEnd(','); updateList["NotLoaded"].Item2[0] = updateList["NotLoaded"].Item2[0].TrimEnd(','); SaveConfigKeepingManualEdits(); var notFound = _config.ListOfPlugins.Count(x => !x.Ignore && (x.IsLoaded || !_config.IgnoreNotLoaded) && x.Status == CheckStatus.NOT_FOUND); var failed = updateList["CheckFailed"].Item1.Count; var pending = _config.ListOfPlugins.Count(x => !x.Ignore && (x.IsLoaded || !_config.IgnoreNotLoaded) && x.Status == CheckStatus.PENDING); var notLoaded = _config.ListOfPlugins.Count(x => !x.Ignore && !x.IsLoaded); var incomplete = failed != 0 || pending != 0; var allCheckedUpToDate = isUpToDate && !incomplete && (_config.IgnoreNotFound || notFound == 0); if (_config.IgnoreNotFound || !updateList["NotFound"].Item1[0].Contains(" ")) updateList.Remove("NotFound"); if (!updateList["NotLoaded"].Item1[0].Contains(" ")) updateList.Remove("NotLoaded"); if (incomplete) { updateList["uMod"].Item1.Add("\nUpdate check incomplete. See the check log for details."); updateList["uMod"].Item2.Add("\n**Update check incomplete.** See the check log for details."); } else if (allCheckedUpToDate) { updateList["uMod"].Item1.Add("\nAll checked plugins are up to date"); updateList["uMod"].Item2.Add("\n**All checked plugins are up to date**"); } if (updateList.Any(static x => x.Value.Item2.Count != 0)) { var printWarningBuilder = new StringBuilder(); foreach (var check in updateList) foreach (var item in check.Value.Item1) printWarningBuilder.Append(item); PrintWarning(printWarningBuilder.ToString()); if (incomplete || !isUpToDate || !_config.IgnoreAllPluginsUpToDateMessage) { _sendDiscordMessageCoroutine = SendMessageDiscord(updateList, allCheckedUpToDate); ServerMgr.Instance.StartCoroutine(_sendDiscordMessageCoroutine); } } WriteCheckLog($"END: duration={(DateTime.UtcNow - _checkStarted).TotalSeconds:F1}s; " + $"found={_config.ListOfPlugins.Count(x => !x.Ignore && x.IsFounded)}; notFound={notFound}; " + $"failed={failed}; pending={pending}; notLoaded={notLoaded}; " + $"ignored={_config.ListOfPlugins.Count(x => x.Ignore)}; updatesFound={!isUpToDate}; incomplete={incomplete}."); } private IEnumerator LoadVersionLists(Dictionary listedVersions, Dictionary, List)> updateList) { foreach (var listLink in _config.VersionLists) { if (!Uri.TryCreate(NormalizeUrl(listLink), UriKind.Absolute, out var listUri)) { AddCheckFailure("Version list", $"Invalid URL: {GetLogQuery(listLink)}", updateList); continue; } var marketplace = listUri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? listUri.Host.Substring("www.".Length) : listUri.Host; _config.AutoSearch.TryAdd(marketplace, true); if (!_config.AutoSearch[marketplace]) continue; using var request = UnityWebRequest.Get(listUri); request.timeout = 30; request.SetRequestHeader("User-Agent", $"Update Checker/{Version}"); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { AddCheckFailure("Version list", GetRequestError(request, listLink), updateList); continue; } Dictionary versions; try { versions = JsonConvert.DeserializeObject>(request.downloadHandler.text); } catch (Exception exception) { AddCheckFailure("Version list", $"{GetLogQuery(listLink)}; invalid JSON ({exception.GetType().Name})", updateList); continue; } if (versions == null) { AddCheckFailure("Version list", $"{GetLogQuery(listLink)}; empty response", updateList); continue; } foreach (var version in versions) { if (version.Value == null) continue; version.Value.Marketplace = marketplace; listedVersions.TryAdd(version.Key, version.Value); } } } private bool TryResolvePlugin(PluginInfo check, PluginData[] foundedPlugins, bool hasExactUrl, bool allowWeakMatch, Dictionary, List)> updateList, ref bool isUpToDate) { if (foundedPlugins == null || foundedPlugins.Length == 0) return false; var validPlugins = foundedPlugins.Where(static x => x != null && !string.IsNullOrEmpty(x.Name) && !string.IsNullOrEmpty(x.Marketplace)).ToList(); if (validPlugins.Count == 0) return false; foreach (var pluginData in validPlugins) RegisterMarketplace(pluginData.Marketplace, updateList); var marketplaceOrder = new Dictionary(StringComparer.Ordinal); foreach (var marketplace in _config.AutoSearch.Keys) marketplaceOrder[marketplace] = marketplaceOrder.Count; var orderedFoundedPlugins = validPlugins.OrderBy(x => marketplaceOrder.TryGetValue(x.Marketplace, out var order) ? order : -1).ToList(); List candidates; if (!string.IsNullOrEmpty(check.SearchUrl)) { candidates = orderedFoundedPlugins.Where(x => IsSameUrl(check.SearchUrl, x.Url, !hasExactUrl)).ToList(); } else { var enabledPlugins = orderedFoundedPlugins .Where(x => !_config.AutoSearch.TryGetValue(x.Marketplace, out var isEnabled) || isEnabled).ToList(); var checkNameLower = check.Name.ToLower(); var checkTitleLower = string.IsNullOrEmpty(check.Title) ? null : check.Title.Replace(" ", "").ToLower(); var checkAuthorLower = check.Author.ToLower(); candidates = enabledPlugins.Where(x => x.Author != null && x.Author.ToLower().Contains(checkAuthorLower) && (IsNameMatch(x, checkNameLower) || checkTitleLower != null && IsNameMatch(x, checkTitleLower))).ToList(); if (candidates.Count == 0) candidates = enabledPlugins.Where(x => IsExactNameMatch(x, checkNameLower) || checkTitleLower != null && IsExactNameMatch(x, checkTitleLower)).ToList(); if (candidates.Count == 0 && allowWeakMatch) candidates = enabledPlugins.Where(x => IsNameMatch(x, checkNameLower) || checkTitleLower != null && IsNameMatch(x, checkTitleLower)).ToList(); if (candidates.Count == 0 && allowWeakMatch && enabledPlugins.Count != 0) { var firstNameLower = enabledPlugins[0].Name.ToLower(); candidates = enabledPlugins.Where(x => x.Tags != null && x.Tags.Contains(firstNameLower)).ToList(); } } if (candidates.Count == 0) return false; var newPluginData = candidates[0]; foreach (var candidate in candidates) { if (string.IsNullOrWhiteSpace(candidate.LatestVersion)) continue; if (string.IsNullOrWhiteSpace(newPluginData.LatestVersion) || ParseVersion(candidate.LatestVersion) > ParseVersion(newPluginData.LatestVersion)) newPluginData = candidate; } if (string.IsNullOrEmpty(check.Url)) { check.Url = newPluginData.Url; check.SearchUrl = string.IsNullOrEmpty(check.Url) ? null : NormalizeUrl(check.Url); } ApplyLatestVersion(check, newPluginData.Marketplace, newPluginData.LatestVersion, check.Url, updateList, ref isUpToDate); return true; } private void ApplyLatestVersion(PluginInfo check, string marketplace, string latestVersion, string url, Dictionary, List)> updateList, ref bool isUpToDate) { if (string.IsNullOrWhiteSpace(latestVersion)) { MarkCheckFailed(check, "Matching plugin has no latest version", updateList); return; } check.Status = CheckStatus.FOUND; check.Marketplace = marketplace; RegisterMarketplace(marketplace, updateList); if (ParseVersion(latestVersion) <= ParseVersion(check.Version)) return; isUpToDate = false; Interface.Oxide.CallHook("OnUpdateCheckerUpdateFound", check.Name, check.Version, latestVersion, url, marketplace); var link = _config.UseURL && !string.IsNullOrEmpty(url) ? url : null; var (consoleLines, discordLines) = updateList[marketplace]; consoleLines.Add($"\n[{marketplace}] {check.Name} {check.Version} -> {latestVersion}{(link == null ? "" : $" {link}")}"); discordLines.Add($"\n[{marketplace}] {check.Name} {check.Version} -> {(link == null ? latestVersion : $"[{latestVersion}](<{link}>)")}"); } private static bool IsExactNameMatch(PluginData pluginData, string nameKey) { return string.Equals(pluginData.Name.Replace(" ", ""), nameKey, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(pluginData.ManualName) && string.Equals(pluginData.ManualName.Replace(" ", ""), nameKey, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(pluginData.Slug) && string.Equals(pluginData.Slug.Replace("-", ""), nameKey, StringComparison.OrdinalIgnoreCase); } private static bool IsNameMatch(PluginData pluginData, string nameKey) { return pluginData.Name.ToLower().Replace(" ", "").Contains(nameKey) || !string.IsNullOrEmpty(pluginData.ManualName) && pluginData.ManualName.ToLower().Contains(nameKey) || !string.IsNullOrEmpty(pluginData.Slug) && pluginData.Slug.ToLower().Replace("-", "").Contains(nameKey); } private List GetRescueQueries(PluginInfo check) { var queries = new List(); var spacedName = SplitCamelCase(check.Name); if (!string.Equals(spacedName, check.Name, StringComparison.Ordinal)) queries.Add(spacedName); if (!string.IsNullOrEmpty(check.Title) && !string.Equals(check.Title.Replace(" ", ""), check.Name, StringComparison.CurrentCultureIgnoreCase) && !queries.Contains(check.Title)) queries.Add(check.Title); if (!string.IsNullOrEmpty(check.SearchUrl) && Uri.TryCreate(check.SearchUrl, UriKind.Absolute, out var uri)) { var slug = GetUrlSlug(uri); if (!string.IsNullOrEmpty(slug) && !queries.Contains(slug)) queries.Add(slug); } return queries; } private static string SplitCamelCase(string value) { return CamelBoundaryRegex.Replace(value, " "); } private static string NormalizeUrl(string url) { var trimmed = url.Trim(); if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) return "https://" + trimmed.Substring("http://".Length); if (!trimmed.Contains("://")) return "https://" + trimmed; return trimmed; } private void RegisterMarketplace(string marketplace, Dictionary, List)> updateList) { if (string.IsNullOrEmpty(marketplace)) return; _config.AutoSearch.TryAdd(marketplace, true); if (!updateList.ContainsKey(marketplace)) updateList[marketplace] = (new List(), new List()); } private void WriteCheckLog(string message) { LogToFile("checks", $"[{DateTime.UtcNow:O}] {message.Replace('\r', ' ').Replace('\n', ' ')}", this); } private static string GetLogQuery(string query) { if (Uri.TryCreate(query, UriKind.Absolute, out var uri)) return uri.GetLeftPart(UriPartial.Path).Replace(uri.UserInfo + "@", ""); return query?.Split('?', '#')[0]; } private static string GetRequestError(UnityWebRequest request, string query) { var safeQuery = GetLogQuery(query); var error = request.error?.Replace(query, safeQuery).Replace(Uri.EscapeDataString(query), safeQuery); return $"query={safeQuery}; HTTP {request.responseCode}; {request.result}; {error}"; } private bool IsSearchCurrent(PluginInfo check) { var currentUrl = string.IsNullOrEmpty(check.Url) ? null : NormalizeUrl(check.Url); if (string.Equals(check.SearchUrl, currentUrl, StringComparison.Ordinal) && !check.Ignore) return true; check.Status = CheckStatus.PENDING; WriteCheckLog($"{check.Name}: PENDING; settings changed during the check; result discarded."); return false; } private void AddCheckFailure(string name, string reason, Dictionary, List)> updateList) { var message = $"\n[CheckFailed] {name}: {reason}"; updateList["CheckFailed"].Item1.Add(message); updateList["CheckFailed"].Item2.Add(message); WriteCheckLog($"{name}: CHECK_FAILED; {reason}"); } private void MarkCheckFailed(PluginInfo check, string reason, Dictionary, List)> updateList) { check.Status = CheckStatus.FAILED; AddCheckFailure(check.Name, reason, updateList); } private IEnumerator RequestMarketplace(string query, MarketplaceResponse response, string pluginName) { for (var attempt = 1; attempt <= 3; attempt++) { using var request = UnityWebRequest.Get($"https://serverarmour.com/api/v3/marketplace/search?plugin={Uri.EscapeDataString(query)}"); request.timeout = 30; request.SetRequestHeader("User-Agent", $"Update Checker/{Version}"); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { response.Error = GetRequestError(request, query); WriteCheckLog($"{pluginName}: REQUEST_FAILED; attempt={attempt}/3; {response.Error}"); if (request.responseCode == 429 && attempt < 3) { yield return new WaitForSeconds(30f); continue; } response.Failed = true; yield break; } RequestData json = null; try { json = JsonConvert.DeserializeObject(request.downloadHandler.text); } catch (JsonException exception) { response.Error = $"query={GetLogQuery(query)}; HTTP {request.responseCode}; invalid JSON ({exception.GetType().Name})"; } if (json?.Data == null || json.Status != 200) { response.Failed = true; response.Error ??= $"query={GetLogQuery(query)}; HTTP {request.responseCode}; invalid API response (status={json?.Status})"; WriteCheckLog($"{pluginName}: REQUEST_FAILED; {response.Error}"); yield break; } response.Data = json.Data; response.Error = null; yield break; } } private static bool IsSameUrl(string configUrl, string marketplaceUrl, bool allowSlugMatch) { if (string.IsNullOrEmpty(configUrl) || string.IsNullOrEmpty(marketplaceUrl)) return false; if (!Uri.TryCreate(configUrl, UriKind.Absolute, out var configUri) || !Uri.TryCreate(marketplaceUrl, UriKind.Absolute, out var marketplaceUri)) return allowSlugMatch && (configUrl.Contains(marketplaceUrl) || marketplaceUrl.Contains(configUrl)); if (!string.Equals(configUri.Host, marketplaceUri.Host, StringComparison.OrdinalIgnoreCase)) return false; if (string.Equals(configUri.AbsolutePath.TrimEnd('/'), marketplaceUri.AbsolutePath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) return true; if (!allowSlugMatch) return false; var configSlug = GetUrlSlug(configUri); return !string.IsNullOrEmpty(configSlug) && string.Equals(configSlug, GetUrlSlug(marketplaceUri), StringComparison.OrdinalIgnoreCase); } private static string GetUrlSlug(Uri uri) { var segments = uri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); if (segments.Length == 0) return string.Empty; return LeadingFileIdRegex.Replace(segments[segments.Length - 1], ""); } private VersionNumber ParseVersion(string v) { if (string.IsNullOrEmpty(v)) return new VersionNumber(1, 0, 0); var parts = v.Split('.'); if (!int.TryParse(parts[0], out var major)) { var majorPart = parts[0]; var majorString = string.Empty; for (var i = majorPart.Length - 1; i >= 0; i--) { if (!char.IsDigit(majorPart[i])) break; majorString = majorPart[i] + majorString; } if (string.IsNullOrEmpty(majorString)) return new VersionNumber(1, 0, 0); major = int.Parse(majorString); } if (parts.Length < 2) return new VersionNumber(major, 0, 0); if (!int.TryParse(parts[1], out var minor)) minor = 0; if (parts.Length < 3) return new VersionNumber(major, minor, 0); if (!int.TryParse(parts[2], out var patch)) { var patchPart = parts[2]; var patchString = string.Empty; for (var i = 0; i < patchPart.Length; i++) { if (!char.IsDigit(patchPart[i])) break; patchString += patchPart[i]; } if (string.IsNullOrEmpty(patchString)) return new VersionNumber(major, minor, 0); patch = int.Parse(patchString); } return new VersionNumber(major, minor, patch); } #endregion #region UI private static string GetStatusText(PluginInfo pluginInfo) { if (pluginInfo.Ignore) return "Ignored"; if (!pluginInfo.IsLoaded) return "Not loaded"; switch (pluginInfo.Status) { case CheckStatus.FOUND: return "Found"; case CheckStatus.NOT_FOUND: return "Not found"; case CheckStatus.FAILED: return "Check failed"; default: return "Not checked yet"; } } private static string GetStatusColor(PluginInfo pluginInfo) { if (pluginInfo.Ignore) return "FFA500"; if (!pluginInfo.IsLoaded) return "B0B0B0"; switch (pluginInfo.Status) { case CheckStatus.FOUND: return "06c258"; case CheckStatus.NOT_FOUND: return "FF5555"; case CheckStatus.FAILED: return "FFCC66"; default: return "B0B0B0"; } } private void RefreshOpenInterfaces() { foreach (var entry in _openInterfaces) { var player = BasePlayer.FindByID(entry.Key); if (player == null || !player.IsConnected) continue; ShowUIPlugins(player, entry.Value.Search, entry.Value.Page); var selected = _config.ListOfPlugins.FirstOrDefault(x => x.Name == entry.Value.SelectedPlugin); ShowUICurrentPluginInfo(player, selected ?? _config.ListOfPlugins.FirstOrDefault()); } } private void ShowUICurrentPluginInfo(BasePlayer player, PluginInfo pluginInfo) { if (pluginInfo == null) { foreach (var field in new[] { "name.input", "author.input", "version.input", "url.input", "marketplace.input", "ignore", "status" }) { UI.Destroy(player, ".current.plugin." + field); } return; } if (_openInterfaces.TryGetValue(player.userID, out var state)) state.SelectedPlugin = pluginInfo.Name; var container = new CuiElementContainer(); UI.Input(ref container, ".current.plugin.name", ".current.plugin.name.input", ".current.plugin.name.input", oMin: "5 0", oMax: "-5 0", text: $"{pluginInfo.Name}", fontSize: 18, color: "1 1 1 1", align: TextAnchor.MiddleLeft, readOnly: true); UI.Input(ref container, ".current.plugin.author", ".current.plugin.author.input", ".current.plugin.author.input", oMin: "5 0", oMax: "-5 0", text: $"{pluginInfo.Author}", fontSize: 18, color: "1 1 1 1", align: TextAnchor.MiddleLeft, readOnly: true); UI.Input(ref container, ".current.plugin.version", ".current.plugin.version.input", ".current.plugin.version.input", oMin: "5 0", oMax: "-5 0", text: $"{pluginInfo.Version}", fontSize: 18, color: "1 1 1 1", align: TextAnchor.MiddleLeft, readOnly: true); UI.Input(ref container, ".current.plugin.url", ".current.plugin.url.input", ".current.plugin.url.input", oMin: "5 0", oMax: "-5 0", text: $"{pluginInfo.Url}", fontSize: 18, color: "1 1 1 1", align: TextAnchor.MiddleLeft, command: $"UI_UC CHANGEURL {pluginInfo.Name}", limit: 200); UI.Input(ref container, ".current.plugin.marketplace", ".current.plugin.marketplace.input", ".current.plugin.marketplace.input", oMin: "5 0", oMax: "-5 0", text: $"{pluginInfo.Marketplace}", fontSize: 18, color: "1 1 1 1", align: TextAnchor.MiddleLeft, readOnly: true); UI.Button(ref container, ".current.plugin.bg", ".current.plugin.ignore", ".current.plugin.ignore", aMin: "0 1", aMax: "0.5 1", oMin: "5 -175", oMax: "-5 -155", command: $"UI_UC CHANGEIGNORE {pluginInfo.Name}", text: $"Ignore the plugin {pluginInfo.Ignore}", align: TextAnchor.MiddleLeft); UI.Label(ref container, ".current.plugin.bg", ".current.plugin.status", ".current.plugin.status", aMin: "0.5 1", aMax: "1 1", oMin: "0 -175", oMax: "-5 -155", text: $"{GetStatusText(pluginInfo)}", align: TextAnchor.MiddleRight); UI.Create(player, container); } private void ShowUIPlugins(BasePlayer player, string search = "", int page = 0) { var container = new CuiElementContainer(); UI.Panel(ref container, ".plugins.bg", ".plugins", ".plugins", bgColor: "0 0 0 0"); var searchLower = search.ToLower(); var searchSort = (!string.IsNullOrEmpty(search) ? _config.ListOfPlugins.Where(x => x.Name.ToLower().Contains(searchLower) || x.Author.ToLower().Contains(searchLower)) : _config.ListOfPlugins).OrderByDescending(x => x.IsFounded).ThenByDescending(x => x.Ignore).ToList(); page = Math.Max(0, Math.Min(page, (Math.Max(1, searchSort.Count) - 1) / 63)); if (_openInterfaces.TryGetValue(player.userID, out var state)) { state.Search = search; state.Page = page; } var posX = 8; var posY = -70; foreach (var check in searchSort.Skip(page * 63).Take(63)) { UI.Button(ref container, ".plugins", ".plugin" + posX + posY, ".plugin" + posX + posY, "0 1", "0 1", $"{posX} {posY}", $"{posX + 108} {posY + 45}", $"{check.Name}\n[{check.Author}]\n{GetStatusText(check)}", 12, command: $"UI_UC CHECK {check.Name}"); posX += 113; if (posX < 690) continue; posX = 8; posY -= 45; } if (page > 0) UI.Button(ref container, ".plugins", ".previous", ".previous", "0.5 0", "0.5 0", "-25 5", "-5 25", "<", command: $"UI_UC PAGE {page - 1} {search}"); UI.Label(ref container, ".plugins", ".page", ".page", "0.5 0", "0.5 0", "-5 5", "5 25", $"{page + 1}"); if (page + 1 < searchSort.Count / 63f) UI.Button(ref container, ".plugins", ".next", ".next", "0.5 0", "0.5 0", "5 5", "25 25", ">", command: $"UI_UC PAGE {page + 1} {search}"); UI.Create(player, container); } private void ShowUIBG(BasePlayer player) { _openInterfaces[player.userID] = new UiState(); var container = new CuiElementContainer(); UI.MainParent(ref container); UI.Button(ref container, ".bg", ".close", ".close", oMin: "-640 -360", oMax: "640 360", material: "assets/content/ui/uibackgroundblur-ingamemenu.mat", bgColor: "0.13 0.13 0.13 0.85", command: "UI_UC CLOSE"); UI.Panel(ref container, ".bg", ".plugins.bg", oMin: "-400 -325", oMax: "400 125", material: "assets/content/ui/binocular_overlay.mat", bgColor: "0.25 0.25 0.25 0.95"); UI.Panel(ref container, ".bg", ".search.bg", oMin: "-250 105", oMax: "250 145", material: "assets/content/ui/binocular_overlay.mat", bgColor: "0.15 0.15 0.15 0.95"); UI.Label(ref container, ".search.bg", oMin: "10 0", text: "Search:", color: "0.85 0.85 0.85 0.95", align: TextAnchor.MiddleLeft); UI.Panel(ref container, ".search.bg", ".search.input", oMin: "65 5", oMax: "-10 -5", material: "assets/content/ui/uibackgroundblur-notice.mat", bgColor: "0.75 0.75 0.75 0.6"); UI.Input(ref container, ".search.input", oMin: "5 0", oMax: "-5 0", text: "", fontSize: 18, color: "1 1 1 1", autoFocus: true, command: "UI_UC SEARCH"); UI.Panel(ref container, ".bg", ".current.plugin.bg", oMin: "-250 160", oMax: "250 339", material: "assets/content/ui/binocular_overlay.mat", bgColor: "0.25 0.25 0.25 0.95"); UI.Label(ref container, ".current.plugin.bg", aMin: "0 1", aMax: "1 1", oMin: "5 -30", oMax: "0 -5", text: "Name:", align: TextAnchor.MiddleLeft); UI.Panel(ref container, ".current.plugin.bg", ".current.plugin.name", aMin: "0 1", aMax: "1 1", oMin: "95 -30", oMax: "-5 -5", bgColor: "0.85 0.85 0.85 0.25"); UI.Label(ref container, ".current.plugin.bg", aMin: "0 1", aMax: "1 1", oMin: "5 -60", oMax: "0 -35", text: "Author:", align: TextAnchor.MiddleLeft); UI.Panel(ref container, ".current.plugin.bg", ".current.plugin.author", aMin: "0 1", aMax: "1 1", oMin: "95 -60", oMax: "-5 -35", bgColor: "0.85 0.85 0.85 0.25"); UI.Label(ref container, ".current.plugin.bg", aMin: "0 1", aMax: "1 1", oMin: "5 -90", oMax: "0 -65", text: "Version:", align: TextAnchor.MiddleLeft); UI.Panel(ref container, ".current.plugin.bg", ".current.plugin.version", aMin: "0 1", aMax: "1 1", oMin: "95 -90", oMax: "-5 -65", bgColor: "0.85 0.85 0.85 0.25"); UI.Label(ref container, ".current.plugin.bg", aMin: "0 1", aMax: "1 1", oMin: "5 -120", oMax: "0 -95", text: "URL*:", align: TextAnchor.MiddleLeft); UI.Panel(ref container, ".current.plugin.bg", ".current.plugin.url", aMin: "0 1", aMax: "1 1", oMin: "95 -120", oMax: "-5 -95", bgColor: "0.85 0.85 0.85 0.65"); UI.Label(ref container, ".current.plugin.bg", aMin: "0 1", aMax: "1 1", oMin: "5 -150", oMax: "0 -125", text: "Marketplace:", align: TextAnchor.MiddleLeft); UI.Panel(ref container, ".current.plugin.bg", ".current.plugin.marketplace", aMin: "0 1", aMax: "1 1", oMin: "95 -150", oMax: "-5 -125", bgColor: "0.85 0.85 0.85 0.25"); UI.Create(player, container); ShowUIPlugins(player); ShowUICurrentPluginInfo(player, _config.ListOfPlugins.OrderByDescending(x => x.IsFounded).ThenByDescending(x => x.Ignore).FirstOrDefault()); } #endregion #region Config private Configuration _config; protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception(); SaveConfig(); } catch { PrintError("Your configuration file contains an error. Using default configuration values."); LoadDefaultConfig(); } } private bool TryReadConfigFromDisk(out Configuration configOnDisk) { try { configOnDisk = Config.ReadObject(); if (configOnDisk == null) throw new Exception("configuration file is empty"); return true; } catch (Exception exception) { PrintError($"Failed to read the configuration file, the changes made in it are ignored: {exception.Message}"); configOnDisk = null; return false; } } private bool ReloadConfigFromDisk() { if (!TryReadConfigFromDisk(out Configuration configOnDisk)) return false; var previousPlugins = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pluginInfo in _config.ListOfPlugins) { previousPlugins.TryAdd(pluginInfo.Name, pluginInfo); } foreach (var pluginInfo in configOnDisk.ListOfPlugins) { if (!previousPlugins.TryGetValue(pluginInfo.Name, out var previous)) continue; pluginInfo.IsLoaded = previous.IsLoaded; pluginInfo.Title = previous.Title; if (string.Equals(pluginInfo.Url, previous.Url, StringComparison.Ordinal) && string.Equals(pluginInfo.Author, previous.Author, StringComparison.Ordinal) && string.Equals(pluginInfo.Version, previous.Version, StringComparison.Ordinal)) { pluginInfo.Status = previous.Status; } } _config = configOnDisk; return true; } private void SaveConfigKeepingManualEdits(string changedPluginName = null) { if (!TryReadConfigFromDisk(out Configuration configOnDisk)) { PrintError("The configuration file is left as it is, fix the error above or the changes of this check will be lost"); return; } var pluginsOnDisk = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pluginInfo in configOnDisk.ListOfPlugins) pluginsOnDisk.TryAdd(pluginInfo.Name, pluginInfo); foreach (var pluginInfo in _config.ListOfPlugins) { if (string.Equals(pluginInfo.Name, changedPluginName, StringComparison.OrdinalIgnoreCase)) continue; if (!pluginsOnDisk.TryGetValue(pluginInfo.Name, out PluginInfo pluginOnDisk)) continue; if (!string.IsNullOrEmpty(pluginOnDisk.Url)) { if (!string.Equals(pluginInfo.Url, pluginOnDisk.Url, StringComparison.Ordinal)) pluginInfo.Status = CheckStatus.PENDING; pluginInfo.Url = pluginOnDisk.Url; } pluginInfo.Ignore = pluginOnDisk.Ignore; } SaveConfig(); } protected override void SaveConfig() { Config.WriteObject(_config); } protected override void LoadDefaultConfig() => _config = new Configuration(); #endregion }