using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Newtonsoft.Json; using Oxide.Core; using Oxide.Game.Rust.Cui; using UnityEngine; namespace Oxide.Plugins { [Info("OnlyPlugins Store", "OnlyPlugins", "1.0.0")] [Description("In-game CUI storefront for browsing, installing and updating Oxide plugins from your website's Developer API.")] public class OnlyPluginsStore : RustPlugin { #region Fields private const string ApiBaseUrl = "https://onlyplugins.lol/index.php"; private const string UiRoot = "PluginStore.Root"; private const string UiDetail = "PluginStore.Detail"; private const string UiUpdates = "PluginStore.Updates"; private const int ColumnsPerPage = 3; private const int RowsPerPage = 4; private const int CardsPerPage = ColumnsPerPage * RowsPerPage; private StoredData _data; private readonly Dictionary _state = new Dictionary(); private int _dataVersion; private readonly Dictionary _shellJsonCache = new Dictionary(); private readonly Dictionary _detailJsonCache = new Dictionary(); private const int MaxCacheEntries = 300; #endregion #region Theme private static class Theme { public const string Bg = "0.039 0.047 0.063 1"; public const string Bg2 = "0.059 0.071 0.094 1"; public const string Surface = "0.078 0.090 0.122 1"; public const string Surface2 = "0.110 0.125 0.161 1"; public const string Surface3 = "0.137 0.153 0.200 1"; public const string Border = "0.165 0.180 0.220 1"; public const string Text = "0.898 0.906 0.922 1"; public const string TextMuted = "0.796 0.816 0.847 1"; public const string Muted = "0.541 0.561 0.604 1"; public const string Primary = "0.808 0.259 0.169 1"; public const string PrimaryHi = "0.910 0.333 0.208 1"; public const string PrimaryLo = "0.659 0.208 0.122 1"; public const string Accent = "0.220 0.741 0.973 1"; public const string Orange = "0.976 0.451 0.086 1"; public const string Success = "0.133 0.773 0.369 1"; public const string Warning = "0.980 0.800 0.082 1"; public const string Danger = "0.937 0.267 0.267 1"; public static string Alpha(string rgb1, double a) { // rgb1 is "r g b 1" — swap the trailing alpha. int lastSpace = rgb1.LastIndexOf(' '); return rgb1.Substring(0, lastSpace + 1) + a.ToString("0.###"); } } #endregion #region Config private PluginConfig _config; private class PluginConfig { [JsonProperty("Request timeout (seconds)")] public int TimeoutSeconds = 20; [JsonProperty("Automatically reload/load the plugin after install or update")] public bool AutoReload = true; [JsonProperty("Chat command to open the store")] public string ChatCommand = "pluginstore"; } protected override void LoadDefaultConfig() { _config = new PluginConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("null config"); } catch { PrintWarning("Config file was invalid — creating a new one with default values."); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() { Config.WriteObject(_config); } #endregion #region Data private class StoredData { public Dictionary Installed = new Dictionary(); } private class InstalledRecord { public string Name; public string Author; public string FileName; public string Version; public string InstalledAtUtc; } private void SaveData() { Interface.Oxide.DataFileSystem.WriteObject("PluginStore/installed", _data); } #endregion #region API response models private class ApiPluginsResponse { public string status; public int count; public int total; public int page; public int per_page; public int total_pages; public Dictionary categories; public List plugins; public string message; } private class ApiPlugin { public string id; public string slug; public string name; public string author; public string version; public string category; public string category_label; public string summary; public List tags; public double rating; public int rating_count; public int downloads; public long file_size; public string icon_url; public string banner_url; public bool has_staging_build; public string download_url; public string page_url; public string created_at; public string updated_at; public string description; public string changelog; } #endregion #region Per-player UI state private class PlayerState { public string Tab = "browse"; // "browse" or "installed" public string Category = ""; public string Search = ""; public string Sort = "name"; // name | updated | downloads public int Page = 1; public int TotalPages = 1; public Dictionary Categories = new Dictionary(); public List CurrentList = new List(); public string ViewingId; public ApiPlugin ViewingPlugin; // cached response for the detail overlay, so tab switches are free public string DetailTab = "description"; // "description" or "changelog" public bool Loading; public string LastError; public bool ViewingUpdates; public bool CheckingUpdates; public List UpdateResults = new List(); } private class UpdateCheckResult { public string Id; public string Name; public string Author; public string FileName; public string InstalledVersion; public string LatestVersion; public bool Pending = true; public bool LookupFailed; public bool UpdateAvailable { get { return !Pending && !LookupFailed && !string.IsNullOrEmpty(LatestVersion) && LatestVersion != InstalledVersion; } } } private PlayerState GetState(BasePlayer player) { PlayerState state; if (!_state.TryGetValue(player.userID, out state)) { state = new PlayerState(); _state[player.userID] = state; } return state; } #endregion #region Hooks private void Init() { cmd.AddChatCommand(_config.ChatCommand, this, nameof(CmdOpenStore)); cmd.AddConsoleCommand("pluginstore.close", this, nameof(CcmdClose)); cmd.AddConsoleCommand("pluginstore.tab", this, nameof(CcmdTab)); cmd.AddConsoleCommand("pluginstore.category", this, nameof(CcmdCategory)); cmd.AddConsoleCommand("pluginstore.search", this, nameof(CcmdSearch)); cmd.AddConsoleCommand("pluginstore.sort", this, nameof(CcmdSort)); cmd.AddConsoleCommand("pluginstore.page", this, nameof(CcmdPage)); cmd.AddConsoleCommand("pluginstore.view", this, nameof(CcmdView)); cmd.AddConsoleCommand("pluginstore.back", this, nameof(CcmdBack)); cmd.AddConsoleCommand("pluginstore.detailtab", this, nameof(CcmdDetailTab)); cmd.AddConsoleCommand("pluginstore.install", this, nameof(CcmdInstall)); cmd.AddConsoleCommand("pluginstore.uninstall", this, nameof(CcmdUninstall)); cmd.AddConsoleCommand("pluginstore.checkupdates", this, nameof(CcmdCheckUpdates)); cmd.AddConsoleCommand("pluginstore.closeupdates", this, nameof(CcmdCloseUpdates)); cmd.AddConsoleCommand("pluginstore.updateall", this, nameof(CcmdUpdateAll)); } private void OnServerInitialized() { _data = Interface.Oxide.DataFileSystem.ReadObject("PluginStore/installed") ?? new StoredData(); if (_data.Installed == null) _data.Installed = new Dictionary(); } private void Unload() { foreach (var player in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(player, UiRoot); CuiHelper.DestroyUi(player, UiDetail); CuiHelper.DestroyUi(player, UiUpdates); } } private void OnPlayerDisconnected(BasePlayer player, string reason) { _state.Remove(player.userID); } #endregion #region Permission private bool CanUse(BasePlayer player) { return player != null && player.IsAdmin; } #endregion #region Commands private void CmdOpenStore(BasePlayer player, string command, string[] args) { if (!CanUse(player)) { SendReply(player, "The plugin store is admin-only."); return; } OpenBrowse(player); } private void CcmdClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; var state = GetState(player); state.ViewingUpdates = false; CuiHelper.DestroyUi(player, UiRoot); CuiHelper.DestroyUi(player, UiDetail); CuiHelper.DestroyUi(player, UiUpdates); } private void CcmdTab(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); state.Tab = arg.GetString(0, "browse"); state.Page = 1; state.ViewingId = null; CuiHelper.DestroyUi(player, UiDetail); if (state.Tab == "installed") RefreshInstalled(player); else RefreshBrowse(player); } private void CcmdCategory(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); state.Category = arg.GetString(0, ""); state.Page = 1; RefreshBrowse(player); } private void CcmdSearch(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); state.Search = string.Join(" ", arg.Args ?? new Facepunch.StringView[0]).Trim(); state.Page = 1; RefreshBrowse(player); } private void CcmdSort(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); string[] order = { "name", "updated", "downloads" }; int idx = Array.IndexOf(order, state.Sort); state.Sort = order[(idx + 1) % order.Length]; state.Page = 1; RefreshBrowse(player); } private void CcmdPage(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); int delta = arg.GetInt(0, 0); int target = state.Page + delta; if (target < 1) target = 1; if (target > state.TotalPages) target = state.TotalPages; state.Page = target; if (state.Tab == "installed") RefreshInstalled(player); else RefreshBrowse(player); } private void CcmdView(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) return; var state = GetState(player); if (state.ViewingId != id) state.DetailTab = "description"; state.ViewingId = id; OpenDetailFromApi(player, id); } private void CcmdBack(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); state.ViewingId = null; state.ViewingPlugin = null; CuiHelper.DestroyUi(player, UiDetail); } private void CcmdDetailTab(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); if (state.ViewingPlugin == null) return; state.DetailTab = arg.GetString(0, "description") == "changelog" ? "changelog" : "description"; RenderDetail(player, state.ViewingPlugin); } private void CcmdInstall(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) return; InstallOrUpdate(player, id); } private void CcmdUninstall(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; string id = arg.GetString(0, ""); if (string.IsNullOrEmpty(id)) return; UninstallPlugin(player, id); } private void CcmdCheckUpdates(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; OpenUpdatesPanel(player); } private void CcmdCloseUpdates(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; var state = GetState(player); state.ViewingUpdates = false; CuiHelper.DestroyUi(player, UiUpdates); } private void CcmdUpdateAll(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null || !CanUse(player)) return; var state = GetState(player); var toUpdate = state.UpdateResults.Where(r => r.UpdateAvailable).Select(r => r.Id).ToList(); if (toUpdate.Count == 0) { SendReply(player, "Everything is already up to date."); return; } SendReply(player, "Updating " + toUpdate.Count + " plugin(s)..."); foreach (var id in toUpdate) InstallOrUpdate(player, id); } #endregion #region API calls private string BuildUrl(string action, Dictionary query = null) { var url = ApiBaseUrl.TrimEnd('?') + "?action=" + Uri.EscapeDataString(action); if (query != null) { foreach (var kv in query) { if (string.IsNullOrEmpty(kv.Value)) continue; url += "&" + Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value); } } return url; } private void ApiGetList(PlayerState state, Action onSuccess, Action onError) { var query = new Dictionary { { "category", state.Category }, { "name", state.Search }, { "sort", state.Sort }, { "page", state.Page.ToString() }, { "per_page", CardsPerPage.ToString() } }; var url = BuildUrl("api_plugins", query); webrequest.Enqueue(url, null, (code, response) => { if (code != 200 || string.IsNullOrEmpty(response)) { onError?.Invoke("The store couldn't be reached (HTTP " + code + "). Check the API base URL in the config."); return; } try { var parsed = JsonConvert.DeserializeObject(response); if (parsed == null || parsed.status != "ok") { onError?.Invoke(parsed?.message ?? "The website returned an unexpected response."); return; } onSuccess?.Invoke(parsed); } catch (Exception ex) { PrintError("Failed to parse api_plugins response: " + ex.Message); onError?.Invoke("Failed to read the response from the website."); } }, this, Core.Libraries.RequestMethod.GET, null, _config.TimeoutSeconds); } private void ApiGetById(string id, Action onSuccess, Action onError) { var url = BuildUrl("api_plugin", new Dictionary { { "id", id } }); webrequest.Enqueue(url, null, (code, response) => { if (code != 200 || string.IsNullOrEmpty(response)) { onError?.Invoke("The store couldn't be reached (HTTP " + code + ")."); return; } try { var parsed = JsonConvert.DeserializeObject(response); if (parsed == null || parsed.status != "ok" || parsed.plugins == null || parsed.plugins.Count == 0) { onError?.Invoke(parsed?.message ?? "That plugin is no longer available."); return; } onSuccess?.Invoke(parsed.plugins[0]); } catch (Exception ex) { PrintError("Failed to parse api_plugin response: " + ex.Message); onError?.Invoke("Failed to read the response from the website."); } }, this, Core.Libraries.RequestMethod.GET, null, _config.TimeoutSeconds); } #endregion #region Install / update private void InstallOrUpdate(BasePlayer player, string id) { SendReply(player, "Downloading plugin..."); ApiGetById(id, plugin => { webrequest.Enqueue(plugin.download_url, null, (code, body) => { if (code != 200 || string.IsNullOrEmpty(body)) { SendReply(player, "Download failed (HTTP " + code + ") for " + plugin.name + "."); return; } string className = ExtractClassName(body); string fileName = (className ?? SanitizeFileName(plugin.name)) + ".cs"; string path = Path.Combine(Interface.Oxide.PluginDirectory, fileName); try { File.WriteAllText(path, body); } catch (Exception ex) { PrintError("Failed to write " + fileName + ": " + ex.Message); SendReply(player, "Could not save " + fileName + " to the plugins folder — check the console for details."); return; } _data.Installed[plugin.id] = new InstalledRecord { Name = plugin.name, Author = plugin.author, FileName = fileName, Version = plugin.version, InstalledAtUtc = DateTime.UtcNow.ToString("o") }; SaveData(); _dataVersion++; string loadedName = className ?? Path.GetFileNameWithoutExtension(fileName); if (_config.AutoReload) { ConsoleSystem.Run(ConsoleSystem.Option.Server, "oxide.reload " + loadedName); } SendReply(player, "Installed " + plugin.name + " v" + plugin.version + " (" + fileName + ")" + (_config.AutoReload ? " and reloaded it." : ". Reload it with: oxide.reload " + loadedName)); // Refresh whatever the player is currently looking at. var state = GetState(player); if (state.ViewingId == id) OpenDetailFromApi(player, id); if (state.Tab == "installed") RefreshInstalled(player); else if (state.Tab == "browse") RefreshBrowse(player); if (state.ViewingUpdates) { var row = state.UpdateResults.FirstOrDefault(r => r.Id == plugin.id); if (row == null) { row = new UpdateCheckResult { Id = plugin.id }; state.UpdateResults.Add(row); } row.Name = plugin.name; row.Author = plugin.author; row.FileName = fileName; row.InstalledVersion = plugin.version; row.LatestVersion = plugin.version; row.Pending = false; row.LookupFailed = false; RenderUpdatesPanel(player); } }, this, Core.Libraries.RequestMethod.GET, null, _config.TimeoutSeconds); }, error => SendReply(player, error)); } private void UninstallPlugin(BasePlayer player, string id) { InstalledRecord rec; if (!_data.Installed.TryGetValue(id, out rec)) { SendReply(player, "That plugin isn't installed."); return; } string loadedName = Path.GetFileNameWithoutExtension(rec.FileName); ConsoleSystem.Run(ConsoleSystem.Option.Server, "oxide.unload " + loadedName); string path = Path.Combine(Interface.Oxide.PluginDirectory, rec.FileName); try { if (File.Exists(path)) File.Delete(path); } catch (Exception ex) { PrintError("Failed to delete " + rec.FileName + ": " + ex.Message); SendReply(player, "Unloaded " + rec.Name + " but could not delete " + rec.FileName + " — check the console for details."); } _data.Installed.Remove(id); SaveData(); _dataVersion++; SendReply(player, "Uninstalled " + rec.Name + " (" + rec.FileName + ")."); var state = GetState(player); if (state.ViewingId == id) OpenDetailFromApi(player, id); if (state.Tab == "installed") RefreshInstalled(player); else if (state.Tab == "browse") RefreshBrowse(player); if (state.ViewingUpdates) { state.UpdateResults.RemoveAll(r => r.Id == id); RenderUpdatesPanel(player); } } private static string ExtractClassName(string source) { // Matches: public class MyPlugin : RustPlugin (also CovalencePlugin etc.) var match = Regex.Match(source, @"class\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\w*Plugin\b"); if (match.Success) return match.Groups[1].Value; // Fallback: any class declaration. match = Regex.Match(source, @"class\s+([A-Za-z_][A-Za-z0-9_]*)"); return match.Success ? match.Groups[1].Value : null; } private static string SanitizeFileName(string name) { var cleaned = Regex.Replace(name ?? "Plugin", @"[^A-Za-z0-9]", ""); return string.IsNullOrEmpty(cleaned) ? "Plugin" : cleaned; } private void OpenUpdatesPanel(BasePlayer player) { var state = GetState(player); state.ViewingUpdates = true; state.CheckingUpdates = _data.Installed.Count > 0; state.UpdateResults = _data.Installed.Select(kv => new UpdateCheckResult { Id = kv.Key, Name = kv.Value.Name, Author = kv.Value.Author, FileName = kv.Value.FileName, InstalledVersion = kv.Value.Version }).ToList(); RenderUpdatesPanel(player); if (_data.Installed.Count == 0) return; foreach (var kv in _data.Installed.ToList()) { string id = kv.Key; ApiGetById(id, plugin => { ApplyUpdateResult(player, id, plugin.author, plugin.version, false); }, error => { // Plugin may have been removed from the site — surface that in the row instead of failing silently. ApplyUpdateResult(player, id, null, null, true); }); } } private void ApplyUpdateResult(BasePlayer player, string id, string author, string latestVersion, bool lookupFailed) { var state = GetState(player); var row = state.UpdateResults.FirstOrDefault(r => r.Id == id); if (row == null) return; row.Pending = false; row.LookupFailed = lookupFailed; if (!lookupFailed) { row.Author = author; row.LatestVersion = latestVersion; } if (state.UpdateResults.All(r => !r.Pending)) state.CheckingUpdates = false; if (state.ViewingUpdates) RenderUpdatesPanel(player); } #endregion #region UI — shared private static CuiElementContainer NewContainer() { var container = new CuiElementContainer(); container.Add(new CuiPanel { Image = { Color = "0.02 0.024 0.031 0.75" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiRoot); return container; } private static void AddLabel(CuiElementContainer c, string parent, string text, string anchorMin, string anchorMax, int size = 14, TextAnchor align = TextAnchor.MiddleLeft, string color = Theme.Text) { c.Add(new CuiLabel { Text = { Text = text, FontSize = size, Align = align, Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }, parent); } private static void AddButton(CuiElementContainer c, string parent, string text, string command, string anchorMin, string anchorMax, string color = Theme.Surface3, string textColor = Theme.Text, int size = 12) { c.Add(new CuiButton { Button = { Command = command, Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax }, Text = { Text = text, FontSize = size, Align = TextAnchor.MiddleCenter, Color = textColor } }, parent); } private static void AddPanel(CuiElementContainer c, string parent, string name, string anchorMin, string anchorMax, string color, string offsetMin = null, string offsetMax = null) { var panel = new CuiPanel { Image = { Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }; if (offsetMin != null) panel.RectTransform.OffsetMin = offsetMin; if (offsetMax != null) panel.RectTransform.OffsetMax = offsetMax; c.Add(panel, parent, name); } private static void AddImage(CuiElementContainer c, string parent, string url, string anchorMin, string anchorMax) { if (string.IsNullOrEmpty(url)) return; c.Add(new CuiElement { Parent = parent, Components = { new CuiRawImageComponent { Url = url }, new CuiRectTransformComponent { AnchorMin = anchorMin, AnchorMax = anchorMax } } }); } #endregion #region UI — browse / installed shell private void OpenBrowse(BasePlayer player) { var state = GetState(player); state.Tab = "browse"; state.ViewingId = null; RefreshBrowse(player); } private void RefreshBrowse(BasePlayer player) { var state = GetState(player); state.Loading = true; RenderShell(player); // show shell + loading state immediately ApiGetList(state, resp => { state.CurrentList = resp.plugins ?? new List(); state.Categories = resp.categories ?? new Dictionary(); state.TotalPages = Math.Max(1, resp.total_pages); state.Loading = false; state.LastError = null; RenderShell(player); }, error => { state.Loading = false; state.LastError = error; RenderShell(player); }); } private void RefreshInstalled(BasePlayer player) { var state = GetState(player); state.Loading = false; RenderShell(player); } private string BuildShellCacheKey(PlayerState state) { var sb = new System.Text.StringBuilder(64); sb.Append(state.Tab).Append('|') .Append(state.Category).Append('|') .Append(state.Search).Append('|') .Append(state.Sort).Append('|') .Append(state.Page).Append('|') .Append(state.Loading ? 1 : 0).Append('|') .Append(state.LastError ?? "").Append('|') .Append(state.TotalPages).Append('|') .Append(_dataVersion); if (state.Tab == "browse" && state.CurrentList != null) { foreach (var p in state.CurrentList) sb.Append('|').Append(p.id).Append(':').Append(p.version); } return sb.ToString(); } private void RenderShell(BasePlayer player) { var state = GetState(player); CuiHelper.DestroyUi(player, UiRoot); string cacheKey = BuildShellCacheKey(state); string json; if (!_shellJsonCache.TryGetValue(cacheKey, out json)) { var c = NewContainer(); // Fixed-size window, centered: anchors pinned to the screen midpoint, then // sized out via pixel offsets on the same element so children actually inherit it. AddPanel(c, UiRoot, "Window", "0.5 0.5", "0.5 0.5", Theme.Alpha(Theme.Bg2, 0.98), "-460 -320", "460 320"); const string win = "Window"; // Header AddPanel(c, win, "Header", "0 0.90", "1 1", Theme.Surface); AddLabel(c, "Header", "Plugin Store", "0.02 0", "0.3 1", 20, TextAnchor.MiddleLeft, Theme.Text); AddButton(c, "Header", "Browse", "pluginstore.tab browse", "0.32 0.15", "0.42 0.85", state.Tab == "browse" ? Theme.Primary : Theme.Surface3); AddButton(c, "Header", "Installed (" + _data.Installed.Count + ")", "pluginstore.tab installed", "0.43 0.15", "0.58 0.85", state.Tab == "installed" ? Theme.Primary : Theme.Surface3); AddButton(c, "Header", "Sort: " + state.Sort, "pluginstore.sort", "0.59 0.15", "0.71 0.85", Theme.Surface3); AddButton(c, "Header", "✕", "pluginstore.close", "0.955 0.15", "0.99 0.85", Theme.PrimaryLo); // Search box (browse tab only) if (state.Tab == "browse") { AddPanel(c, "Header", "SearchBox", "0.72 0.20", "0.945 0.80", Theme.Bg); c.Add(new CuiElement { Name = "SearchInput", Parent = "SearchBox", Components = { new CuiInputFieldComponent { Text = state.Search, FontSize = 12, Align = TextAnchor.MiddleLeft, Command = "pluginstore.search", CharsLimit = 60, Color = Theme.Text }, new CuiRectTransformComponent { AnchorMin = "0.03 0", AnchorMax = "1 1" } } }); } // Body AddPanel(c, win, "Body", "0 0", "1 0.90", Theme.Bg2); if (state.Tab == "browse") { RenderSidebar(c, state); RenderBrowseGrid(c, state); } else { RenderInstalledList(c, state); } json = CuiHelper.ToJson(c); if (_shellJsonCache.Count >= MaxCacheEntries) _shellJsonCache.Clear(); _shellJsonCache[cacheKey] = json; } CuiHelper.AddUi(player, json); } private void RenderSidebar(CuiElementContainer c, PlayerState state) { AddPanel(c, "Body", "Sidebar", "0 0", "0.20 1", Theme.Surface); AddLabel(c, "Sidebar", "CATEGORIES", "0.08 0.955", "0.9 0.99", 11, TextAnchor.MiddleLeft, Theme.Muted); var rows = new List> { new KeyValuePair("", "All categories") }; if (state.Categories != null) rows.AddRange(state.Categories); double top = 0.93; double step = 0.055; int shown = 0; foreach (var kv in rows) { if (shown >= 16) break; // keep inside the sidebar height double y1 = top - step * (shown + 1); double y2 = top - step * shown; bool active = state.Category == kv.Key; AddButton(c, "Sidebar", kv.Value, "pluginstore.category " + kv.Key, "0.05 " + y1.ToString("0.000"), "0.95 " + y2.ToString("0.000"), active ? Theme.Primary : Theme.Surface2, Theme.Text, 11); shown++; } } private void RenderBrowseGrid(CuiElementContainer c, PlayerState state) { AddPanel(c, "Body", "Grid", "0.20 0", "1 1", Theme.Bg2); if (state.Loading) { AddLabel(c, "Grid", "Loading...", "0.4 0.48", "0.6 0.52", 14, TextAnchor.MiddleCenter); return; } if (!string.IsNullOrEmpty(state.LastError)) { AddLabel(c, "Grid", state.LastError, "0.08 0.48", "0.92 0.55", 13, TextAnchor.MiddleCenter, Theme.Danger); return; } if (state.CurrentList == null || state.CurrentList.Count == 0) { AddLabel(c, "Grid", "No plugins found.", "0.4 0.48", "0.6 0.52", 14, TextAnchor.MiddleCenter); return; } double marginX = 0.02, marginY = 0.02; double cellW = (1.0 - marginX * (ColumnsPerPage + 1)) / ColumnsPerPage; double cellH = (1.0 - marginY * (RowsPerPage + 1) - 0.08) / RowsPerPage; // reserve 0.08 for pager for (int i = 0; i < state.CurrentList.Count; i++) { var p = state.CurrentList[i]; int col = i % ColumnsPerPage; int row = i / ColumnsPerPage; double x1 = marginX + col * (cellW + marginX); double x2 = x1 + cellW; double y2 = 1 - marginY - row * (cellH + marginY); double y1 = y2 - cellH; string cardName = "Card_" + i; AddPanel(c, "Grid", cardName, x1.ToString("0.0000") + " " + y1.ToString("0.0000"), x2.ToString("0.0000") + " " + y2.ToString("0.0000"), Theme.Surface2); AddImage(c, cardName, p.icon_url, "0.04 0.42", "0.30 0.92"); AddLabel(c, cardName, Truncate(p.name, 26), "0.34 0.66", "0.98 0.92", 13, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, cardName, "by " + p.author, "0.34 0.44", "0.98 0.65", 10, TextAnchor.MiddleLeft, Theme.Muted); AddLabel(c, cardName, "v" + p.version + " ★" + p.rating.ToString("0.0") + " (" + p.rating_count + ")", "0.04 0.24", "0.98 0.42", 10, TextAnchor.MiddleLeft, Theme.Warning); AddLabel(c, cardName, Truncate(p.summary, 70), "0.04 0.02", "0.98 0.24", 9, TextAnchor.UpperLeft, Theme.TextMuted); bool installed = _data.Installed.ContainsKey(p.id); bool updateAvailable = installed && _data.Installed[p.id].Version != p.version; string label = updateAvailable ? "Update" : (installed ? "Installed" : "View"); string btnColor = updateAvailable ? Theme.Warning : (installed ? Theme.Surface3 : Theme.Primary); AddButton(c, cardName, label, "pluginstore.view " + p.id, "0 0", "1 0.10", btnColor, Theme.Text, 10); } RenderPager(c, state); } private void RenderInstalledList(CuiElementContainer c, PlayerState state) { AddPanel(c, "Body", "Grid", "0 0", "1 1", Theme.Bg2); AddButton(c, "Grid", "Check for updates", "pluginstore.checkupdates", "0.02 0.94", "0.22 0.99", Theme.Accent); if (_data.Installed.Count == 0) { AddLabel(c, "Grid", "You haven't installed any plugins from the store yet.", "0.1 0.48", "0.9 0.55", 13, TextAnchor.MiddleCenter); return; } var list = _data.Installed.ToList(); double rowH = 0.085; double top = 0.90; for (int i = 0; i < list.Count && i < 10; i++) { var rec = list[i].Value; string id = list[i].Key; double y2 = top - rowH * i; double y1 = y2 - rowH + 0.01; string rowName = "Row_" + i; AddPanel(c, "Grid", rowName, "0.02 " + y1.ToString("0.000"), "0.98 " + y2.ToString("0.000"), Theme.Surface2); AddLabel(c, rowName, rec.Name + " (" + rec.FileName + ")", "0.02 0.15", "0.65 0.85", 12, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, rowName, "Installed version: " + rec.Version, "0.02 0", "0.65 0.2", 10, TextAnchor.MiddleLeft, Theme.Muted); AddButton(c, rowName, "View / Update", "pluginstore.view " + id, "0.68 0.20", "0.85 0.80", Theme.Accent, Theme.Text, 10); AddButton(c, rowName, "Uninstall", "pluginstore.uninstall " + id, "0.86 0.20", "0.98 0.80", Theme.Danger, Theme.Text, 10); } } private void RenderPager(CuiElementContainer c, PlayerState state) { AddPanel(c, "Grid", "Pager", "0 0", "1 0.06", Theme.Bg2); AddLabel(c, "Pager", "Page " + state.Page + " / " + state.TotalPages, "0.4 0", "0.6 1", 11, TextAnchor.MiddleCenter, Theme.TextMuted); if (state.Page > 1) AddButton(c, "Pager", "◀ Prev", "pluginstore.page -1", "0.30 0.05", "0.40 0.95", Theme.Surface3); if (state.Page < state.TotalPages) AddButton(c, "Pager", "Next ▶", "pluginstore.page 1", "0.60 0.05", "0.70 0.95", Theme.Surface3); } #endregion #region UI — detail overlay private void OpenDetailFromApi(BasePlayer player, string id) { ApiGetById(id, plugin => { var state = GetState(player); state.ViewingPlugin = plugin; RenderDetail(player, plugin); }, error => { SendReply(player, error); }); } private string BuildDetailCacheKey(ApiPlugin p, string tab) { string installedVersion = _data.Installed.TryGetValue(p.id, out var rec) ? rec.Version : ""; return p.id + "|" + p.version + "|" + tab + "|" + installedVersion + "|" + _dataVersion; } private void RenderDetail(BasePlayer player, ApiPlugin p) { CuiHelper.DestroyUi(player, UiDetail); var state = GetState(player); string tab = state.DetailTab == "changelog" ? "changelog" : "description"; string cacheKey = BuildDetailCacheKey(p, tab); string json; if (!_detailJsonCache.TryGetValue(cacheKey, out json)) { var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = Theme.Alpha(Theme.Bg, 0.85) }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiDetail); // Bigger window than the shell — there's a lot more to show now. AddPanel(c, UiDetail, "DetailWindow", "0.5 0.5", "0.5 0.5", Theme.Alpha(Theme.Surface, 0.99), "-410 -280", "410 280"); const string win = "DetailWindow"; AddButton(c, win, "◀ Back", "pluginstore.back", "0.02 0.94", "0.14 0.985", Theme.Surface3); AddButton(c, win, "✕", "pluginstore.back", "0.94 0.94", "0.99 0.985", Theme.PrimaryLo); AddImage(c, win, p.icon_url, "0.03 0.66", "0.20 0.92"); AddLabel(c, win, p.name, "0.23 0.84", "0.96 0.92", 18, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, win, "by " + p.author + " · v" + p.version + " · " + p.category_label, "0.23 0.77", "0.96 0.84", 12, TextAnchor.MiddleLeft, Theme.TextMuted); AddLabel(c, win, "★ " + p.rating.ToString("0.0") + " (" + p.rating_count + " ratings) · " + p.downloads + " downloads · " + FormatSize(p.file_size), "0.23 0.70", "0.96 0.77", 11, TextAnchor.MiddleLeft, Theme.Warning); // Description / Changelog tabs AddButton(c, win, "Description", "pluginstore.detailtab description", "0.03 0.615", "0.19 0.66", tab == "description" ? Theme.Primary : Theme.Surface3, Theme.Text, 11); AddButton(c, win, "Changelog", "pluginstore.detailtab changelog", "0.20 0.615", "0.36 0.66", tab == "changelog" ? Theme.Primary : Theme.Surface3, Theme.Text, 11); string raw = tab == "changelog" ? (p.changelog ?? "") : (p.description ?? ""); string emptyMsg = tab == "changelog" ? "No changelog has been published for this plugin yet." : "No description provided."; bool truncated = raw.Length > MaxDetailTextChars; string shown = truncated ? raw.Substring(0, MaxDetailTextChars) : raw; string formatted = FormatRichText(shown, emptyMsg); if (truncated) formatted += "\n\n… truncated, see the full page on the website:\n" + p.page_url + ""; AddDetailContent(c, win, "0.03 0.19", "0.97 0.605", formatted); if (tab == "description" && p.tags != null && p.tags.Count > 0) AddLabel(c, win, "Tags: " + string.Join(", ", p.tags), "0.03 0.155", "0.97 0.19", 10, TextAnchor.UpperLeft, Theme.Muted); bool installed = _data.Installed.ContainsKey(p.id); bool updateAvailable = installed && _data.Installed[p.id].Version != p.version; string label = updateAvailable ? ("Update to v" + p.version) : (installed ? "Reinstall" : "Install"); AddButton(c, win, label, "pluginstore.install " + p.id, "0.03 0.03", "0.32 0.13", Theme.Primary, Theme.Text, 13); if (installed) { AddButton(c, win, "Uninstall", "pluginstore.uninstall " + p.id, "0.34 0.03", "0.50 0.13", Theme.Danger, Theme.Text, 13); AddLabel(c, win, "Installed version: " + _data.Installed[p.id].Version + " (" + _data.Installed[p.id].FileName + ")", "0.52 0.03", "0.97 0.13", 10, TextAnchor.MiddleLeft, Theme.Muted); } if (p.has_staging_build) AddLabel(c, win, "A staging build is also available on the website.", "0.03 -0.015", "0.97 0.02", 9, TextAnchor.MiddleLeft, Theme.Accent); json = CuiHelper.ToJson(c); if (_detailJsonCache.Count >= MaxCacheEntries) _detailJsonCache.Clear(); _detailJsonCache[cacheKey] = json; } CuiHelper.AddUi(player, json); } #endregion #region UI — updates panel private void RenderUpdatesPanel(BasePlayer player) { CuiHelper.DestroyUi(player, UiUpdates); var state = GetState(player); if (!state.ViewingUpdates) return; var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = Theme.Alpha(Theme.Bg, 0.85) }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiUpdates); AddPanel(c, UiUpdates, "UpdatesWindow", "0.5 0.5", "0.5 0.5", Theme.Alpha(Theme.Surface, 0.99), "-360 -260", "360 260"); const string win = "UpdatesWindow"; AddPanel(c, win, "Header", "0 0.90", "1 1", Theme.Surface2); string title = state.CheckingUpdates ? "Checking for Updates…" : "Check for Updates"; AddLabel(c, "Header", title, "0.03 0", "0.6 1", 16, TextAnchor.MiddleLeft, Theme.Text); AddButton(c, "Header", "✕", "pluginstore.closeupdates", "0.93 0.15", "0.98 0.85", Theme.PrimaryLo); if (state.UpdateResults.Any(r => r.UpdateAvailable)) AddButton(c, win, "Update All", "pluginstore.updateall", "0.03 0.855", "0.24 0.895", Theme.Orange, Theme.Text, 11); if (state.UpdateResults.Count == 0) { AddLabel(c, win, "You haven't installed any plugins from the store yet.", "0.1 0.45", "0.9 0.55", 13, TextAnchor.MiddleCenter, Theme.Muted); CuiHelper.AddUi(player, CuiHelper.ToJson(c)); return; } const int maxRows = 8; double rowH = 0.105; double top = 0.83; for (int i = 0; i < state.UpdateResults.Count && i < maxRows; i++) { var r = state.UpdateResults[i]; double y2 = top - rowH * i; double y1 = y2 - rowH + 0.012; string rowName = "URow_" + i; AddPanel(c, win, rowName, "0.03 " + y1.ToString("0.000"), "0.97 " + y2.ToString("0.000"), Theme.Surface3); AddLabel(c, rowName, r.Name ?? "(unknown plugin)", "0.02 0.52", "0.55 0.94", 13, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, rowName, "by " + (string.IsNullOrEmpty(r.Author) ? "?" : r.Author) + " · " + r.FileName, "0.02 0.08", "0.55 0.48", 10, TextAnchor.MiddleLeft, Theme.Muted); string statusText; string statusColor; if (r.Pending) { statusText = "Checking..."; statusColor = Theme.Muted; } else if (r.LookupFailed) { statusText = "v" + r.InstalledVersion + " · not found on website"; statusColor = Theme.Danger; } else if (r.UpdateAvailable) { statusText = "v" + r.InstalledVersion + " → v" + r.LatestVersion; statusColor = Theme.Warning; } else { statusText = "v" + r.InstalledVersion + " · up to date"; statusColor = Theme.Success; } AddLabel(c, rowName, statusText, "0.56 0", "0.82 1", 10, TextAnchor.MiddleLeft, statusColor); if (r.UpdateAvailable) AddButton(c, rowName, "Update", "pluginstore.install " + r.Id, "0.85 0.18", "0.98 0.82", Theme.Orange, Theme.Text, 10); } if (state.UpdateResults.Count > maxRows) AddLabel(c, win, "+ " + (state.UpdateResults.Count - maxRows) + " more installed plugin(s) not shown.", "0.03 0.02", "0.97 0.07", 9, TextAnchor.MiddleLeft, Theme.Muted); CuiHelper.AddUi(player, CuiHelper.ToJson(c)); } #endregion #region Helpers // Full text is always fetched now that there's real scrolling; this only guards against // a pathologically huge write-up chewing through CUI bandwidth/allocations. private const int MaxDetailTextChars = 8000; private const int DetailWindowWidthPx = 820; private const int DetailWindowHeightPx = 560; private const double AvgCharWidthPx = 6.2; // rough estimate for the proportional UI font at size 12 private const int ScrollLineHeightPx = 17; private const int ScrollPaddingPx = 16; private static string Truncate(string text, int max) { if (string.IsNullOrEmpty(text)) return ""; return text.Length <= max ? text : text.Substring(0, max - 1).TrimEnd() + "…"; } private static string FormatSize(long bytes) { if (bytes >= 1024 * 1024) return (bytes / (1024.0 * 1024.0)).ToString("0.0") + " MB"; if (bytes >= 1024) return (bytes / 1024.0).ToString("0.0") + " KB"; return bytes + " B"; } private static string FormatRichText(string raw, string emptyMessage = "No description provided.") { if (string.IsNullOrEmpty(raw)) return emptyMessage; string t = raw.Replace("\r\n", "\n"); t = t.Replace("<", "‹").Replace(">", "›"); t = Regex.Replace(t, "```[a-zA-Z0-9_+\\-]*\\n(.*?)\\n```", m => "" + m.Groups[1].Value + "", RegexOptions.Singleline); t = Regex.Replace(t, "`([^`\\n]+)`", "$1"); t = Regex.Replace(t, "(?m)^### (.+)$", "$1"); t = Regex.Replace(t, "(?m)^## (.+)$", "$1"); t = Regex.Replace(t, "\\*\\*([^*\\n]+)\\*\\*", "$1"); t = Regex.Replace(t, "(^|\\W)\\*([^*\\n]+)\\*", "$1$2"); t = Regex.Replace(t, @"\[([^\]]+)\]\((https?://[^\s)]+)\)", "$1 ($2)"); t = Regex.Replace(t, "(?m)^› (.+)$", "$1"); t = Regex.Replace(t, "(?m)^-{1,2} (.+)$", " • $1"); return t; } private static int EstimateTextHeightPx(string formatted, int viewportWidthPx) { string stripped = Regex.Replace(formatted, "<[^>]+>", ""); int charsPerLine = Math.Max(15, (int)(viewportWidthPx / AvgCharWidthPx)); int totalLines = 0; foreach (var line in stripped.Split('\n')) totalLines += Math.Max(1, (int)Math.Ceiling(line.Length / (double)charsPerLine)); return totalLines * ScrollLineHeightPx + ScrollPaddingPx; } private static void AddDetailContent(CuiElementContainer c, string parent, string anchorMin, string anchorMax, string formattedText) { var minParts = anchorMin.Split(' '); var maxParts = anchorMax.Split(' '); double x1 = double.Parse(minParts[0], System.Globalization.CultureInfo.InvariantCulture); double y1 = double.Parse(minParts[1], System.Globalization.CultureInfo.InvariantCulture); double x2 = double.Parse(maxParts[0], System.Globalization.CultureInfo.InvariantCulture); double y2 = double.Parse(maxParts[1], System.Globalization.CultureInfo.InvariantCulture); int viewportWidthPx = (int)((x2 - x1) * DetailWindowWidthPx); int viewportHeightPx = (int)((y2 - y1) * DetailWindowHeightPx); int contentHeightPx = Math.Max(EstimateTextHeightPx(formattedText, viewportWidthPx), viewportHeightPx); c.Add(new CuiElement { Name = "Content", Parent = parent, Components = { new CuiScrollViewComponent { MovementType = UnityEngine.UI.ScrollRect.MovementType.Clamped, Vertical = true, Horizontal = false, Inertia = true, Elasticity = 0.15f, DecelerationRate = 0.2f, ScrollSensitivity = 24f, ContentTransform = new CuiRectTransform { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 " + (-contentHeightPx), OffsetMax = "0 0" }, VerticalScrollbar = new CuiScrollbar { Size = 8f, AutoHide = true } }, new CuiImageComponent { Color = Theme.Bg2 }, new CuiRectTransformComponent { AnchorMin = anchorMin, AnchorMax = anchorMax } } }); c.Add(new CuiLabel { Text = { Text = formattedText, FontSize = 12, Align = TextAnchor.UpperLeft, Color = Theme.Text }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = "8 4", OffsetMax = "-8 -4" } }, "Content"); } #endregion } }