C#:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using System.Security.Cryptography;
namespace Oxide.Plugins {
[Info("FeedMeUpdates", "frankie290651", "1.6.5")]
[Description("Highly configurable plugin for Oxide framework to orchestrate Server/Oxide/Plugins updates.")]
public class FeedMeUpdates : CovalencePlugin {
#region Configuration
private class ConfigData {
public string ServerDirectory {
get;
set;
}
= "";
public string SteamCmdPath {
get;
set;
}
= "";
public string UpdaterExecutablePath {
get;
set;
}
= "";
public bool ShowUpdaterConsole {
get;
set;
}
= false;
public string ServerStartScript {
get;
set;
}
= "";
public bool RunServerScriptHidden {
get;
set;
}
= false;
public string ServerTmuxSession {
get;
set;
}
= "";
public bool RustOnService {
get;
set;
}
= false;
public string ServiceName {
get;
set;
}
= "";
public string ServiceType {
get;
set;
}
= "";
public int HttpTimeoutMs {
get;
set;
}
= 3000;
public bool StartupScan {
get;
set;
}
= false;
public int MaxAttempts {
get;
set;
}
= 0;
public int CheckIntervalMinutes {
get;
set;
}
= 10;
public int CountdownMinutes {
get;
set;
}
= 5;
public bool UpdatePlugins {
get;
set;
}
= false;
public bool OnlyServerProtocolUpdate {
get;
set;
}
= false;
public bool UseScheme {
get;
set;
}
= false;
public string SchemeFile {
get;
set;
}
= "";
public string DailyRestartTime {
get;
set;
}
= "";
public int MinutesBeforeRestart {
get;
set;
}
= 60;
public bool DiscordNotificationsEnabled {
get;
set;
}
= false;
public string DiscordWebhookUrl {
get;
set;
}
= "";
public int BeforeForceWipeRange {
get;
set;
}
= 15;
public string CustomWipeDay {
get;
set;
}
= "";
public string CustomWipeTime {
get;
set;
}
= "";
public string ServerIdentity {
get;
set;
}
= "";
public string NextWipeServerName {
get;
set;
}
= "";
public string NextWipeServerDescription {
get;
set;
}
= "";
public string NextWipeMapUrl {
get;
set;
}
= "";
public string NextWipeLevel {
get;
set;
}
= "";
public string NextWipeSeed {
get;
set;
}
= "";
public bool NextWipeRandomSeed {
get;
set;
}
= false;
public string NextWipeMapsize {
get;
set;
}
= "";
public bool NextWipeKeepBps {
get;
set;
}
= true;
public bool NextWipeResetRustPlus {
get;
set;
}
= false;
public bool NextWipeDeletePlayerData {
get;
set;
}
= false;
public string NextWipeDeletePluginDatafiles {
get;
set;
}
= "";
public string UpdaterMarkerFileName {
get;
set;
}
= "updateresult.json";
public string UpdaterLockFileName {
get;
set;
}
= "updating.lock";
public string MarkersSubfolder {
get;
set;
}
= "markers";
}
private ConfigData configData;
// Writes a default configuration file when none is present or on reset.
protected override void LoadDefaultConfig() {
Config.WriteObject(new ConfigData(), true);
}
#endregion
#region Constants
private const string DataFileName = "FeedMeUpdatesData";
#endregion
#region Runtime State
private bool? lastOxideCompat = null;
private string lastOxideProto = null;
private string lastCompatNote = null;
private bool debugging = false;
private bool quitBeforeExecOnDebugging = true;
private bool enablePlugin = true;
private bool SystemUpdating = false;
private Timer DailyRestartCheckRepeater;
private bool pendingServerUpdate = false;
private bool pendingOxideUpdate = false;
private bool tempPendingServerUpdate = false;
private bool tempPendingOxideUpdate = false;
private string localOxideVersion = null;
private string localProtocol = null;
private string localBuildFromServerVersion = null;
private string LocalServerVersionString = "";
private string LocalSteamBuildID = "";
private string cachedRemoteBuild = null;
private string cachedRemoteOxide = null;
private Timer checkTimer;
private volatile bool isFetchingRemote = false;
private volatile bool countdownActive = false;
private string cachedPluginVersion = null;
private int trynumber = 0;
private bool tmuxAvailable = false;
private bool gnomeAvailable = false;
private bool DoRestartTimeCheck = false;
private int targetHour = 0;
private int targetMinute = 0;
Scheme scheme = new Scheme();
private bool lastNetworkFail = false;
private int lastOxideStatusCode = 0;
private const string UAValue = "FeedMeUpdates";
private bool updaterValidAtInit = true;
private int? minutesBeforeForceWipe = null;
private Timer fwTimer;
private bool nextUpdateIsForce = false;
private int? minutesBeforeCustomWipe = null;
private string pluginDatafilesToRemoveString = "";
#endregion
#region Resilient HTTP Wrapper
private enum HttpBackend {
Unknown, WebRequest, WebClient
}
private HttpBackend currentHttpBackend = HttpBackend.Unknown;
private volatile bool httpBackendTested = false;
private readonly object httpBackendLock = new object();
// Detects and selects the most reliable HTTP backend available on this server.
private void EnsureHttpBackendDetected() {
if (httpBackendTested) return;
lock (httpBackendLock) {
if (httpBackendTested) return;
httpBackendTested = true;
try {
webrequest.Enqueue("https://api.github.com/", null, (code, response) => {
timer.Once(0f, () => {
currentHttpBackend = HttpBackend.WebRequest;
}
);
}
, this);
Task.Run(async () => {
await Task.Delay(1500).ConfigureAwait(false);
if (currentHttpBackend == HttpBackend.Unknown) currentHttpBackend = HttpBackend.WebRequest;
}
);
}
catch {
currentHttpBackend = HttpBackend.WebClient;
}
}
}
// Performs a resilient HTTP GET with timeout and fallback between backends.
private (int statusCode, string body) ResilientHttpGetSync(string url, Dictionary<string, string> headers = null, int timeoutMs = 0) {
EnsureHttpBackendDetected();
int effectiveTimeout = timeoutMs > 0 ? timeoutMs : (configData?.HttpTimeoutMs > 0 ? configData.HttpTimeoutMs : 3000);
try {
var task = Task.Run(async () => {
if (currentHttpBackend == HttpBackend.WebRequest) {
try {
return await HttpGetViaWebRequestAsync(url).ConfigureAwait(false);
}
catch (Exception ex) {
Puts("WebRequest GET failed, fallback WebClient: " + ex.Message);
currentHttpBackend = HttpBackend.WebClient;
return await HttpGetViaWebClientAsync(url, headers).ConfigureAwait(false);
}
}
return await HttpGetViaWebClientAsync(url, headers).ConfigureAwait(false);
}
);
if (task.Wait(effectiveTimeout)) return task.Result;
return (-1, null);
}
catch (Exception ex) {
Puts("ResilientHttpGetSync error: " + ex.Message);
return (-1, null);
}
}
// Performs a resilient HTTP POST with timeout and backend fallback.
private (int statusCode, string body) ResilientHttpPostSync(string url, Dictionary<string, string> headers, string payload, int timeoutMs = 0) {
EnsureHttpBackendDetected();
int effectiveTimeout = timeoutMs > 0 ? timeoutMs : (configData?.HttpTimeoutMs > 0 ? configData.HttpTimeoutMs : 3000);
try {
var task = Task.Run(async () => {
if (currentHttpBackend == HttpBackend.WebRequest) {
try {
return await HttpPostViaWebRequestAsync(url, payload).ConfigureAwait(false);
}
catch (Exception ex) {
Puts("WebRequest POST failed, fallback WebClient: " + ex.Message);
currentHttpBackend = HttpBackend.WebClient;
return await HttpPostViaWebClientAsync(url, headers, payload).ConfigureAwait(false);
}
}
return await HttpPostViaWebClientAsync(url, headers, payload).ConfigureAwait(false);
}
);
if (task.Wait(effectiveTimeout)) return task.Result;
return (-1, null);
}
catch (Exception ex) {
Puts("ResilientHttpPostSync error: " + ex.Message);
return (-1, null);
}
}
// Executes an async HTTP GET using Oxide's webrequest API and returns status/body.
private Task<(int statusCode, string body)> HttpGetViaWebRequestAsync(string url) {
var tcs = new TaskCompletionSource<(int, string)>();
try {
webrequest.Enqueue(url, null, (code, response) => {
timer.Once(0f, () => tcs.TrySetResult((code, response)));
}
, this);
}
catch (Exception ex) {
tcs.TrySetException(ex);
}
return tcs.Task;
}
// Executes an async HTTP POST using Oxide's webrequest API and returns status/body.
private Task<(int statusCode, string body)> HttpPostViaWebRequestAsync(string url, string payload) {
var tcs = new TaskCompletionSource<(int, string)>();
try {
webrequest.Enqueue(url, payload ?? "", (code, response) => {
timer.Once(0f, () => tcs.TrySetResult((code, response)));
}
, this);
}
catch (Exception ex) {
tcs.TrySetException(ex);
}
return tcs.Task;
}
// Executes an async HTTP GET using WebClient and returns status/body.
private async Task<(int statusCode, string body)> HttpGetViaWebClientAsync(string url, Dictionary<string, string> headers = null) {
try {
using (var wc = new WebClient()) {
wc.Headers["User-Agent"] = UAValue;
if (headers != null) {
foreach (var kv in headers) {
if (kv.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase)) continue;
try {
wc.Headers[kv.Key] = kv.Value;
}
catch {
}
}
}
var body = await wc.DownloadStringTaskAsync(url).ConfigureAwait(false);
return (200, body);
}
}
catch (WebException wex) {
int code = -1;
string body = null;
try {
if (wex.Response is HttpWebResponse resp) {
code = (int)resp.StatusCode;
using (var rs = resp.GetResponseStream()) using (var sr = new StreamReader(rs)) body = sr.ReadToEnd();
}
}
catch {
}
return (code, body);
}
catch (Exception ex) {
return (-1, ex.Message);
}
}
// Executes an async HTTP POST using WebClient and returns status/body.
private async Task<(int statusCode, string body)> HttpPostViaWebClientAsync(string url, Dictionary<string, string> headers, string payload) {
try {
using (var wc = new WebClient()) {
wc.Headers["User-Agent"] = UAValue;
wc.Headers[HttpRequestHeader.ContentType] = "application/json";
if (headers != null) {
foreach (var kv in headers) {
if (kv.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase)) continue;
if (kv.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) continue;
try {
wc.Headers[kv.Key] = kv.Value;
}
catch {
}
}
}
var body = await wc.UploadStringTaskAsync(url, "POST", payload ?? "").ConfigureAwait(false);
return (200, body);
}
}
catch (WebException wex) {
int code = -1;
string body = null;
try {
if (wex.Response is HttpWebResponse resp) {
code = (int)resp.StatusCode;
using (var rs = resp.GetResponseStream()) using (var sr = new StreamReader(rs)) body = sr.ReadToEnd();
}
}
catch {
}
return (code, body);
}
catch (Exception ex) {
return (-1, ex.Message);
}
}
#endregion
#region Scheme Parsing
private class Scheme {
public bool isValid {
set;
get;
}
= false;
public string SchemePath {
get;
set;
}
= "";
public List<schemeInstruction> instructions {
get;
set;
}
= null;
}
public class schemeInstruction {
public bool isValid {
get;
set;
}
= false;
public int RowNum {
get;
set;
}
= 0;
public int ErrorColumn {
get;
set;
}
= 0;
public string ErrorText {
get;
set;
}
= "";
public bool OxideUpdateEvent {
get;
set;
}
= false;
public bool OxideProtocolMatchEvent {
get;
set;
}
= false;
public bool OxideProtocolMismatchEvent {
get;
set;
}
= false;
public bool OxideProtocolUnknownEvent {
get;
set;
}
= false;
public bool OxideProtocolErrorEvent {
get;
set;
}
= false;
public bool ServerEvent {
get;
set;
}
= false;
public bool UpdateServer {
get;
set;
}
= false;
public bool UpdateOxide {
get;
set;
}
= false;
}
// Parses a single scheme instruction row into a structured object with validation.
public schemeInstruction ReadSchemeInstruction(int row, string rowContent) {
var instr = new schemeInstruction();
instr.RowNum = row;
if (string.IsNullOrWhiteSpace(rowContent)) {
instr.ErrorText = "Empty instruction";
return instr;
}
try {
var parts = rowContent.Split(new[] {
'='
}
, 2);
if (parts.Length != 2) {
instr.ErrorText = "Invalid instruction format, expected 'events=action'";
return instr;
}
var left = parts[0].Trim();
var right = parts[1].Trim();
if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right)) {
instr.ErrorText = "Empty left or right side in instruction";
return instr;
}
if (right.Equals("oxide", StringComparison.OrdinalIgnoreCase)) {
instr.UpdateOxide = true;
instr.UpdateServer = false;
}
else if (right.Equals("server", StringComparison.OrdinalIgnoreCase)) {
instr.UpdateOxide = false;
instr.UpdateServer = true;
}
else if (right.Equals("both", StringComparison.OrdinalIgnoreCase)) {
instr.UpdateOxide = true;
instr.UpdateServer = true;
}
else {
instr.ErrorText = $"Action '{right}' is not valid (expected 'server', 'oxide' or 'both')";
return instr;
}
var leftParts = left.Split(new[] {
'+'
}
, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (leftParts.Length == 0) {
instr.ErrorText = "No events specified on left side";
return instr;
}
bool serverSet = false;
bool oxideSet = false;
foreach (var ev in leftParts) {
if (ev.IndexOf("server", StringComparison.OrdinalIgnoreCase) >= 0 && !serverSet) {
instr.ServerEvent = true;
serverSet = true;
continue;
}
if (ev.IndexOf("oxide", StringComparison.OrdinalIgnoreCase) >= 0 && !oxideSet) {
var m = Regex.Match(ev, @"oxide\s*\(([^)]*)\)", RegexOptions.IgnoreCase);
if (!m.Success) {
instr.OxideProtocolErrorEvent = true;
instr.OxideProtocolMatchEvent = true;
instr.OxideProtocolMismatchEvent = true;
instr.OxideProtocolUnknownEvent = true;
instr.OxideUpdateEvent = true;
oxideSet = true;
}
else {
var flags = m.Groups[1].Value;
if (string.IsNullOrEmpty(flags)) {
instr.ErrorText = "Oxide parentheses present but empty";
return instr;
}
foreach (char c in flags) {
if (c == 'e') {
instr.OxideProtocolErrorEvent = true;
instr.OxideUpdateEvent = true;
oxideSet = true;
}
else if (c == 's') {
instr.OxideProtocolMatchEvent = true;
instr.OxideUpdateEvent = true;
oxideSet = true;
}
else if (c == 'c') {
instr.OxideProtocolMismatchEvent = true;
instr.OxideUpdateEvent = true;
oxideSet = true;
}
else if (c == 'u') {
instr.OxideProtocolUnknownEvent = true;
instr.OxideUpdateEvent = true;
oxideSet = true;
}
else if (char.IsWhiteSpace(c)) {
}
else {
instr.ErrorText = $"Unknown oxide protocol event flag: '{c}'";
return instr;
}
}
}
continue;
}
instr.ErrorText = $"Unknown event specifier: '{ev}' (expected 'server' or 'oxide(...)')";
return instr;
}
if ((instr.ServerEvent || instr.OxideUpdateEvent) && string.IsNullOrEmpty(instr.ErrorText)) instr.isValid = true;
else instr.ErrorText = "No valid events parsed";
}
catch (Exception ex) {
instr.ErrorText = "Error parsing instruction: " + ex.Message;
}
return instr;
}
// Reads and validates the entire scheme file, enabling scheme-driven behavior if valid.
public void ReadSchemeFile() {
if (string.IsNullOrEmpty(configData.SchemeFile)) {
Puts("Scheme file path not configured.");
return;
}
if (!File.Exists(configData.SchemeFile)) {
Puts("No scheme file found at: \"" + configData.SchemeFile + "\"");
return;
}
string[] rows = File.ReadAllLines(configData.SchemeFile);
int x = 1;
scheme.SchemePath = configData.SchemeFile;
scheme.instructions = new List<schemeInstruction>();
scheme.isValid = true;
foreach (string rawRow in rows) {
var row = rawRow?.Trim() ?? "";
if (string.IsNullOrEmpty(row) || row.StartsWith("//")) {
x++;
continue;
}
schemeInstruction sIn = ReadSchemeInstruction(x, row);
if (!sIn.isValid) scheme.isValid = false;
scheme.instructions.Add(sIn);
x++;
}
if (scheme.isValid) Puts("Scheme correctly parsed. Now running on scheme behaviour.");
else {
Puts("Unable to parse scheme file. The following instructions are invalid:");
foreach (schemeInstruction si in scheme.instructions) {
if (!si.isValid) Puts("(" + si.RowNum.ToString() + ") " + si.ErrorText);
}
Puts("UseScheme has been disabled. Plugin is going back to default behaviour.");
configData.UseScheme = false;
}
}
#endregion
#region Discord Notifications
// Escapes a string for safe embedding in JSON payloads.
private string EscapeJsonString(string s) {
if (s == null) return "";
return s.Replace("\\", "\\\\") .Replace("\"", "\\\"") .Replace("\r", "") .Replace("\n", "\\n");
}
// Sends a message to Discord via webhook, handling network/timeout errors.
private async Task SendDiscordNotification(string message) {
if (configData?.DiscordNotificationsEnabled != true) return;
if (string.IsNullOrEmpty(configData?.DiscordWebhookUrl)) return;
try {
var payload = "{\"content\":\"" + EscapeJsonString(message) + "\"}";
var headers = new Dictionary<string, string> {
{
"Content-Type", "application/json"
}
}
;
var prevBackend = currentHttpBackend;
currentHttpBackend = HttpBackend.WebClient;
try {
var result = await Task.Run(() => ResilientHttpPostSync(configData.DiscordWebhookUrl, headers, payload, configData.HttpTimeoutMs)).ConfigureAwait(false);
timer.Once(0f, () => {
if (result.statusCode >= 200 && result.statusCode < 300) Puts("Discord notification sent successfully.");
else Puts($"Error sending Discord notification: HTTP {result.statusCode} - {result.body}");
}
);
}
finally {
currentHttpBackend = prevBackend;
}
}
catch (Exception ex) {
timer.Once(0f, () => Puts("Error sending Discord notification: " + ex.Message));
}
}
// Composes and sends a Discord notification at the start of an update/wipe.
private void NotifyDiscordUpdateStart(bool updateServer, bool updateOxide, string remoteOxideVersion, bool protocolChanged) {
if (configData?.DiscordNotificationsEnabled != true) return;
var updateType = (updateServer && updateOxide) ? "Server & Oxide" : (updateServer ? "Server" : (updateOxide ? "Oxide" : "No update"));
var protocolMsg = updateOxide ? (protocolChanged ? "Protocol NUMBER CHANGE" : "No protocol change") : "";
var oxideMsg = updateOxide ? $"Remote Oxide version: {remoteOxideVersion ?? "unknown"}" : "";
var msg = "";
if (updateType == "No update") {
msg = $"[FeedMeUpdates] Starting custom wipe.";
}
else {
if (nextUpdateIsForce) updateType += " (ForceWipe)";
msg = $"[FeedMeUpdates] Starting update: {updateType}\n" + (string.IsNullOrEmpty(oxideMsg) ? "" : oxideMsg + "\n") + (string.IsNullOrEmpty(protocolMsg) ? "" : protocolMsg);
}
_ = SendDiscordNotification(msg);
}
// Composes and sends a Discord notification after an update/wipe ends.
private void NotifyDiscordUpdateResult(string result, string updateId, bool pluginsUpdated, List<string> pluginsList, string failureReason, string wiped, string wipe_info) {
if (configData?.DiscordNotificationsEnabled != true) return;
var msg = "";
if (updateId == "wipe") {
msg = $"[FeedMeUpdates] wipe finished: {(string.IsNullOrEmpty(result) ? "UNKNOWN" : result.Trim().ToUpper())} (ID: {updateId ?? "unknown"})\n";
if (!string.IsNullOrEmpty(result) && result.ToLower().Trim() == "success") {
msg += "Wipe executed SUCCESSFULLY.";
}
else {
msg += "Wipe FAILED.";
if (!string.IsNullOrEmpty(failureReason)) {
msg += $"\nFailure reason: {failureReason}";
}
}
if (wiped == "yes") {
msg += $"\nServer wiped.";
}
else {
msg += $"\nServer not wiped";
}
if (wipe_info != "") {
msg += $"\nWipe ALERTS reported:";
string[] wipewarnings = wipe_info.Split("]", StringSplitOptions.RemoveEmptyEntries);
foreach (string warning in wipewarnings) {
var w = warning.TrimStart('[').Trim();
msg += $"\n - {w}";
}
}
}
else {
msg = $"[FeedMeUpdates] Update finished: {(string.IsNullOrEmpty(result) ? "UNKNOWN" : result.Trim().ToUpper())} (ID: {updateId ?? "unknown"})\n";
if (!string.IsNullOrEmpty(result) && result.ToLower().Trim() == "success") {
msg += "Update executed SUCCESSFULLY.";
if (pluginsUpdated && pluginsList != null && pluginsList.Count > 0) {
msg += $"\nUpdated plugins: {string.Join(", ", pluginsList)}";
}
}
else {
msg += "Update FAILED.";
if (!string.IsNullOrEmpty(failureReason)) {
msg += $"\nFailure reason: {failureReason}";
}
}
if(wiped == "yes") {
msg += $"\nServer wiped.";
}
else {
msg += $"\nServer not wiped";
}
if (wipe_info != "") {
msg += $"\nWipe ALERTS reported:";
string[] wipewarnings = wipe_info.Split("]", StringSplitOptions.RemoveEmptyEntries);
foreach (string warning in wipewarnings) {
var w = warning.TrimStart('[').Trim();
msg += $"\n - {w}";
}
}
}
_ = SendDiscordNotification(msg);
}
#endregion
#region Init / Unload
// Initializes plugin state, reads config, registers commands and schedules tasks.
private void Init() {
permission.RegisterPermission("feedme.run", this);
try {
cachedPluginVersion = Version.ToString();
}
catch {
cachedPluginVersion = "unknown";
}
AddCovalenceCommand("feedme.testrun", nameof(Cmd_TestRun));
AddCovalenceCommand("feedme.status", nameof(Cmd_Status));
AddCovalenceCommand("feedme.version", nameof(Cmd_Version));
AddCovalenceCommand("feedme.set", nameof(Cmd_SetConfig));
try {
configData = Config.ReadObject<ConfigData>() ?? new ConfigData();
if (configData.HttpTimeoutMs <= 0) configData.HttpTimeoutMs = 3000;
if (string.IsNullOrEmpty(configData.ServerStartScript) && !configData.RustOnService) {
configData.ServerStartScript = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\rust-server\start_server.bat" : "/home/rust/rust-server/start_server.sh";
Config.WriteObject(configData, true);
}
}
catch (Exception ex) {
Puts("Error loading config: " + ex.Message);
configData = new ConfigData();
}
try {
string resolved, reason;
if (!ValidateUpdaterExecutableAtInit(out resolved, out reason)) {
updaterValidAtInit = false;
Puts("Updater executable validation failed at init: " + (reason ?? "unknown reason"));
}
else {
updaterValidAtInit = true;
Puts("Updater executable validated at init: " + (resolved ?? "<unknown>"));
}
}
catch (Exception ex) {
updaterValidAtInit = false;
Puts("Updater executable validation error at init: " + ex.Message);
}
Puts($"FeedMeUpdates initialized (v{cachedPluginVersion}).");
try {
ProcessSteamTempAndDataFile();
}
catch (Exception ex) {
Puts("Error ProcessSteamTempAndDataFile: " + ex.Message);
}
try {
ProcessTryNumber();
}
catch (Exception ex) {
Puts("Error ProcessTryNumber: " + ex.Message);
}
try {
ProcessUpdaterMarkerIfPresent();
}
catch (Exception ex) {
Puts("Marker processing error: " + ex.Message);
}
try {
ProcessUpdaterLockIfPresent();
}
catch (Exception ex) {
Puts("Lock processing error: " + ex.Message);
}
AutoUpdateTask();
pluginDatafilesToRemoveString = BuildFileListSingleArg(ParseFileListStrictJsonArray(configData.NextWipeDeletePluginDatafiles));
if(!string.IsNullOrEmpty(configData.NextWipeDeletePluginDatafiles)) {
if(string.IsNullOrEmpty(pluginDatafilesToRemoveString)) Puts("List of plugin datafiles to delete at wipe in invalid. Plugin datafile removal disabled");
}
else {
configData.NextWipeDeletePluginDatafiles = "";
pluginDatafilesToRemoveString = "";
}
int sizeValue = 0;
if (!string.IsNullOrEmpty(configData.NextWipeMapsize)) {
if (int.TryParse(configData.NextWipeMapsize, out sizeValue)) {
if (sizeValue == 0 || sizeValue < 1000 || sizeValue > 6000) {
configData.NextWipeMapsize = "";
Puts("Map size specified for the next wipe is out of range (accepted range 1000-6000). In case of wipe map size won't be changed.");
}
}
else {
configData.NextWipeMapsize = "";
Puts("Map size specified for the next is invalid. In case of wipe map size won't be changed");
}
}
else {
configData.NextWipeMapsize = "";
}
if (configData.NextWipeRandomSeed) {
var rndui = new System.Random();
var _bytes = new byte[4];
rndui.NextBytes(_bytes);
uint _seed = BitConverter.ToUInt32(_bytes, 0);
configData.NextWipeSeed = _seed.ToString(CultureInfo.InvariantCulture);
}
else {
if (!string.IsNullOrEmpty(configData.NextWipeSeed)) {
if (!TryParseSeedDecimal(configData.NextWipeSeed)) {
configData.NextWipeSeed = "";
Puts("Seed value specified for the next is invalid. In case of wipe the seed won't be changed");
}
}
else {
configData.NextWipeSeed = "";
}
}
if (configData.MaxAttempts != 0 && trynumber >= configData.MaxAttempts) enablePlugin = false;
else enablePlugin = true;
try {
try {
var rp = server.Protocol;
var rb = server.Version;
localProtocol = rp?.ToString();
localBuildFromServerVersion = rb?.ToString();
}
catch (Exception exServer) {
Puts("Error obtaining server.Protocol / server.Version: " + exServer.Message);
}
localOxideVersion = GetOxideVersionFromDll();
LocalServerVersionString = localBuildFromServerVersion ?? "";
Puts($"Local detected: protocol={(localProtocol ?? "null")} | server.Version={(localBuildFromServerVersion ?? "null")} | LocalSteamBuildID={(string.IsNullOrEmpty(LocalSteamBuildID) ? "<empty>" : LocalSteamBuildID)} | Oxide={(localOxideVersion ?? "unknown")}");
}
catch (Exception ex) {
Puts("Error reading local values: " + ex.Message);
return;
}
if (configData.UseScheme) {
try {
ReadSchemeFile();
}
catch (Exception ex) {
Puts("Scheme reading error: " + ex.Message);
configData.UseScheme = false;
Puts("UseScheme has been disabled. Plugin is going back to default behaviour.");
}
}
bool[] availableUnixStuff = DetectGnomeAndTmux();
gnomeAvailable = availableUnixStuff[0];
tmuxAvailable = availableUnixStuff[1];
if (!string.IsNullOrEmpty(configData.DailyRestartTime) && configData.MinutesBeforeRestart > 0) {
if (ValidateRestartTime()) DoRestartTimeCheck = true;
if (DoRestartTimeCheck) {
int secondsToNextMinute = 60 - DateTime.Now.Second;
timer.Once(secondsToNextMinute, () => {
DailyRestartCheckerMethod();
DailyRestartCheckRepeater = timer.Every(60f, () => DailyRestartCheckerMethod());
}
);
}
}
fwTimer = timer.Every(60f, () => WipeTimeCheck());
}
// Cleans up repeating timers and scheduled tasks when the plugin unloads.
void Unload() {
if (checkTimer != null) {
checkTimer?.Destroy();
checkTimer = null;
}
if (DailyRestartCheckRepeater != null) {
DailyRestartCheckRepeater?.Destroy();
DailyRestartCheckRepeater = null;
}
if (fwTimer != null) {
fwTimer?.Destroy();
fwTimer = null;
}
}
#endregion
#region Datafile Management
// Ensures the try number is present in the datafile or initializes it.
private void ProcessTryNumber() {
if (!ReadTryNumber()) {
if (!WriteTryNumber()) {
Puts("Unable to define try number in datafile.");
}
}
}
// Reads the try number value from the datafile.
private bool ReadTryNumber() {
try {
var dict = ReadDataFileDict();
if (dict == null) {
return false;
}
if (dict.ContainsKey("TryNumber") && !string.IsNullOrEmpty(dict["TryNumber"])) {
trynumber = Int32.Parse(dict["TryNumber"]);
return true;
}
}
catch (Exception ex) {
Puts("Error reading datafile: " + ex.Message);
}
return false;
}
// Writes the current try number back to the datafile.
private bool WriteTryNumber() {
try {
var dict = ReadDataFileDict() ?? new Dictionary<string, string>();
dict["TryNumber"] = trynumber.ToString();
WriteDataFileDict(dict);
}
catch (Exception ex) {
Puts("Unexpected error when saving trynumber to datafile: " + ex.Message);
return false;
}
return true;
}
// Handles steambid.temp detection, promotion to datafile, and cleanup.
private void ProcessSteamTempAndDataFile() {
if (string.IsNullOrEmpty(configData.ServerDirectory)) {
Puts("ServerDirectory not configured; skipping steambid.temp handling.");
return;
}
var tempPath = Path.Combine(configData.ServerDirectory, "steambid.temp");
if (File.Exists(tempPath)) {
try {
var id = File.ReadAllText(tempPath).Trim();
if (!string.IsNullOrEmpty(id)) {
LocalSteamBuildID = id;
SaveLocalSteamToDataFile();
Puts($"steambid.temp found: LocalSteamBuildID set to '{LocalSteamBuildID}', saved in datafile.");
}
else {
Puts("steambid.temp present but empty.");
}
try {
File.Delete(tempPath);
Puts("steambid.temp deleted after reading.");
}
catch (Exception exDel) {
Puts("Unable to delete steambid.temp: " + exDel.Message);
}
return;
}
catch (Exception ex) {
Puts("Error reading steambid.temp: " + ex.Message);
}
}
var loaded = LoadLocalSteamFromDataFile();
if (loaded) Puts($"LocalSteamBuildID loaded from datafile: {(string.IsNullOrEmpty(LocalSteamBuildID) ? "<empty>" : LocalSteamBuildID)}");
}
// Loads LocalSteamBuildID from the persisted datafile storage.
private bool LoadLocalSteamFromDataFile() {
try {
var dict = ReadDataFileDict();
if (dict == null) return false;
if (dict.ContainsKey("LocalSteamBuildID") && !string.IsNullOrEmpty(dict["LocalSteamBuildID"])) {
LocalSteamBuildID = dict["LocalSteamBuildID"];
return true;
}
}
catch (Exception ex) {
Puts("Error reading datafile: " + ex.Message);
}
return false;
}
// Saves LocalSteamBuildID to the datafile storage for later use.
private void SaveLocalSteamToDataFile() {
try {
var dict = ReadDataFileDict() ?? new Dictionary<string, string>();
dict["LocalSteamBuildID"] = LocalSteamBuildID ?? "";
WriteDataFileDict(dict);
}
catch (Exception ex) {
Puts("Error writing datafile (LocalSteamBuildID): " + ex.Message);
}
}
// Persists a remote build ID to the datafile (to promote after success).
private void SaveRemoteSteamToDataFile(string remoteBuild) {
try {
var dict = ReadDataFileDict() ?? new Dictionary<string, string>();
dict["RemoteSteamBuildID"] = remoteBuild ?? "";
WriteDataFileDict(dict);
Puts($"RemoteSteamBuildID '{remoteBuild}' saved in datafile.");
}
catch (Exception ex) {
Puts("Error writing datafile (RemoteSteamBuildID): " + ex.Message);
}
}
// Removes the remote build ID entry from the datafile after promotion.
private void RemoveRemoteSteamFromDataFile() {
try {
var dict = ReadDataFileDict();
if (dict == null) return;
if (dict.ContainsKey("RemoteSteamBuildID")) {
dict.Remove("RemoteSteamBuildID");
WriteDataFileDict(dict);
Puts("RemoteSteamBuildID removed from datafile.");
}
}
catch (Exception ex) {
Puts("Error removing RemoteSteamBuildID: " + ex.Message);
}
}
// Reads the plugin's dictionary-shaped datafile into memory.
private Dictionary<string, string> ReadDataFileDict() {
try {
var dict = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, string>>(DataFileName);
return dict ?? new Dictionary<string, string>();
}
catch (Exception ex) {
Puts("Generic datafile read error: " + ex.Message);
return new Dictionary<string, string>();
}
}
// Writes a dictionary back into the plugin's datafile storage.
private void WriteDataFileDict(Dictionary<string, string> dict) {
try {
Interface.Oxide.DataFileSystem.WriteObject(DataFileName, dict);
}
catch (Exception ex) {
Puts("Generic datafile write error: " + ex.Message);
}
}
#endregion
#region Marker & Lock Handling
// Processes updater result marker file, promotes build IDs, archives marker, and notifies.
private void ProcessUpdaterMarkerIfPresent() {
try {
if (string.IsNullOrEmpty(configData.ServerDirec
// ... [Code preview truncated due to length. Full source in attached file] ...