Repository: StationeersLaunchPad/StationeersLaunchPad
Pull request #7: Add Auto Update Feature
Author: login: TerameTechYT
Created: 2025-05-09T23:14:16Z | Merged: 2025-05-10T16:04:28Z

This pull request adds fully OPTIONAL automatic updating (defaults to auto update)


Diff:
diff --git a/.gitignore b/.gitignore
index cbbd0b5..a9b29e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
+.vs/
 bin/
 obj/
\ No newline at end of file
diff --git a/README.md b/README.md
index b264515..0c956b1 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ A simple mod loader for Stationeers that allows you to edit mod configuration at
 - Install mods into `%HOME%/documents/my games/stationeers/mods` or download them from steam workshop.
 - Start game. Mods will automatically load
 - If you want to reorder or enable/disable mods, click the loading window at the bottom when the game first opens
+- Mod will auto be updated unless otherwise chosen in configuration.
 
 ## Dedicated Server
 
diff --git a/StationeersLaunchPad/LaunchPadConfig.cs b/StationeersLaunchPad/LaunchPadConfig.cs
index 83ef3ff..a6af214 100644
--- a/StationeersLaunchPad/LaunchPadConfig.cs
+++ b/StationeersLaunchPad/LaunchPadConfig.cs
@@ -23,13 +23,16 @@ namespace StationeersLaunchPad
   public enum LoadState
   {
     Initializing,
+    Updating,
     Configuring,
     ModsLoading,
     ModsLoaded,
     GameRunning
   }
+
   public static class LaunchPadConfig
   {
+    public static ConfigEntry<bool> AutoUpdateOnStart;
     public static ConfigEntry<bool> AutoLoadOnStart;
     public static ConfigEntry<int> AutoLoadWaitTime;
     public static ConfigEntry<bool> AutoSort;
@@ -39,18 +42,26 @@ public static class LaunchPadConfig
     public static List<ModInfo> Mods = new();
     public static HashSet<string> GameAssemblies = new();
     public static LoadState LoadState = LoadState.Initializing;
+    public static bool AutoUpdate = false;
     public static bool AutoLoad = true;
+    public static bool HasUpdated = false;
     public static Stopwatch AutoStopwatch = new();
 
     public static async void Run()
     {
+      AutoUpdate = AutoUpdateOnStart.Value;
       AutoLoad = AutoLoadOnStart.Value;
-
+      
       await Load();
 
-      while (LoadState < LoadState.Configuring)
+      while (LoadState < LoadState.Updating)
         await UniTask.Yield();
 
+      if (HasUpdated && !GameManager.IsBatchMode)
+      {
+        AutoLoad = false;
+      }
+
       AutoStopwatch.Restart();
 
       while (LoadState == LoadState.Configuring && (!AutoLoad || AutoStopwatch.Elapsed.TotalSeconds < AutoLoadWaitTime.Value))
@@ -76,6 +87,7 @@ private static async UniTask Load()
       {
         LoadState = LoadState.Initializing;
 
+        Logger.Global.Log("Initializing...");
         await UniTask.Run(() => Initialize());
 
         Logger.Global.Log("Listing Local Mods");
@@ -100,15 +112,41 @@ private static async UniTask Load()
 
         Logger.Global.Log("Mod Config Initialized");
 
+        if (AutoUpdate)
+        {
+          LoadState = LoadState.Updating;
+
+          Logger.Global.Log("Checking Version");
+          try
+          {
+            await UniTask.Run(() => LaunchPadUpdater.CheckVersion());
+          }
+          catch (Exception ex)
+          {
+            Logger.Global.LogError("Error occurred during updating.");
+            Logger.Global.LogException(ex);
+            await UniTask.Run(() => LaunchPadUpdater.RevertUpdate());
+          }
+        }
+
         LoadState = LoadState.Configuring;
       }
       catch (Exception ex)
       {
-        Logger.Global.LogError("Error occurred during initialization. Mods will not be loaded");
-        Logger.Global.LogException(ex);
-        Mods = new();
-        LoadState = LoadState.ModsLoaded;
-        AutoLoad = false;
+        if (!GameManager.IsBatchMode)
+        {
+         Logger.Global.LogError("Error occurred during initialization. Mods will not be loaded.");
+         Logger.Global.LogException(ex);
+
+         Mods = new();
+         LoadState = LoadState.ModsLoaded;
+         AutoLoad = false;
+        }
+        else
+        {
+          Logger.Global.LogError("Error occurred during initialization.");
+          Logger.Global.LogException(ex);
+        }
       }
     }
 
diff --git a/StationeersLaunchPad/LaunchPadGUI.cs b/StationeersLaunchPad/LaunchPadGUI.cs
index 039402f..0749f41 100644
--- a/StationeersLaunchPad/LaunchPadGUI.cs
+++ b/StationeersLaunchPad/LaunchPadGUI.cs
@@ -65,6 +65,7 @@ private static void DrawAutoLoad()
       Text("LaunchPad " + (LaunchPadConfig.LoadState switch
       {
         LoadState.Initializing => "Initializing",
+        LoadState.Updating => "Updating",
         LoadState.Configuring => $"Loading mods in {autoTime}",
         LoadState.ModsLoading => "Loading mods",
         LoadState.ModsLoaded => $"Starting game in {autoTime}",
@@ -129,6 +130,7 @@ private static void DrawManualLoad()
       switch (LaunchPadConfig.LoadState)
       {
         case LoadState.Initializing:
+        case LoadState.Updating:
         case LoadState.Configuring:
           {
             ConfigChanged = false;
diff --git a/StationeersLaunchPad/LaunchPadPlugin.cs b/StationeersLaunchPad/LaunchPadPlugin.cs
index 5b2a112..827c82f 100644
--- a/StationeersLaunchPad/LaunchPadPlugin.cs
+++ b/StationeersLaunchPad/LaunchPadPlugin.cs
@@ -1,4 +1,3 @@
-
 using System.Reflection;
 using System.Threading.Tasks;
 using Assets.Scripts;
@@ -19,7 +18,7 @@ public class LaunchPadPlugin : BaseUnityPlugin
   {
     public const string pluginGuid = "stationeers.launchpad";
     public const string pluginName = "StationeersLaunchPad";
-    public const string pluginVersion = "0.1.1";
+    public const string pluginVersion = "0.1.5";
 
     void Awake()
     {
@@ -32,7 +31,14 @@ void Awake()
         configDescription: new ConfigDescription(
           "Automatically load after the configured wait time on startup. Can be stopped by clicking the loading window at the bottom"
         )
-      );
+       );
+       LaunchPadConfig.AutoUpdateOnStart = this.Config.Bind<bool>(
+         new ConfigDefinition("Startup", "AutoUpdateOnStart"),
+         defaultValue: !GameManager.IsBatchMode, // Default to false on DS
+         configDescription: new ConfigDescription(
+           "Automatically update mod loader on startup."
+         )
+       );
       LaunchPadConfig.AutoLoadWaitTime = this.Config.Bind<int>(
         new ConfigDefinition("Startup", "AutoLoadWaitTime"),
         defaultValue: 3,
diff --git a/StationeersLaunchPad/LaunchPadUpdater.cs b/StationeersLaunchPad/LaunchPadUpdater.cs
new file mode 100644
index 0000000..c5c55ef
--- /dev/null
+++ b/StationeersLaunchPad/LaunchPadUpdater.cs
@@ -0,0 +1,161 @@
+﻿using Assets.Scripts;
+using BepInEx;
+using BepInEx.Bootstrap;
+using Cysharp.Threading.Tasks;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Text.RegularExpressions;
+using UnityEngine.Networking;
+
+namespace StationeersLaunchPad {
+    // similar to jixxed's updater
+    public static class LaunchPadUpdater {
+        public static List<string> Assemblies = new List<string>()
+        {
+            "RG.ImGui",
+            "StationeersMods.Interface",
+            "StationeersMods.Shared",
+            "StationeersLaunchPad",
+        };
+
+        private static Regex versionRegex = new Regex(@"""tag_name""\:\s""([V\d.]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+        private static Regex downloadRegexClient = new Regex(@"""browser_download_url""\:\s""([^""]*StationeersLaunchPad-v.+\.zip)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+        private static Regex downloadRegexServer = new Regex(@"""browser_download_url""\:\s""([^""]*StationeersLaunchPad-server-v.+\.zip)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+        public static async UniTask CheckVersion() {
+            using (var request = UnityWebRequest.Get("https://api.github.com/repos/StationeersLaunchPad/StationeersLaunchPad/releases/latest")) {
+                request.timeout = 10; // 10 seconds because we are only fetching a json file
+
+                Logger.Global.Log($"Requesting version...");
+                var result = await request.SendWebRequest();
+
+                if (result.result != UnityWebRequest.Result.Success) {
+                    Logger.Global.LogError($"Failed to send web request! result: {result.result}, error: {result.error}");
+                    return;
+                }
+
+                var text = result.downloadHandler.text;
+                var matches = versionRegex.Matches(text);
+                if (matches.Count == 0) {
+                    Logger.Global.LogError($"Failed to find version regex matches.");
+                    return;
+                }
+
+                var latestVersion = new Version(matches[0].Groups[1].Value.TrimStart('V', 'v'));
+                var currentVersion = new Version(LaunchPadPlugin.pluginVersion.TrimStart('V', 'v'));
+
+                if (latestVersion <= currentVersion) {
+                    Logger.Global.Log($"Plugin is up-to-date.");
+                    return;
+                }
+
+                Logger.Global.LogWarning($"Plugin is NOT up-to-date.");
+                var downloadMatches = GameManager.IsBatchMode ? downloadRegexServer.Matches(text) : downloadRegexClient.Matches(text);
+                if (downloadMatches.Count == 0) {
+                    Logger.Global.LogError($"Failed to find download regex matches.");
+                    return;
+                }
+
+                using (var downloadRequest = UnityWebRequest.Get(downloadMatches[0].Groups[1].Value)) {
+                    downloadRequest.timeout = 45; // max of 45 seconds to download the zip file
+
+                    Logger.Global.Log($"Requesting download file...");
+                    var downloadResult = await downloadRequest.SendWebRequest();
+
+                    if (downloadResult.result != UnityWebRequest.Result.Success) {
+                        Logger.Global.LogError($"Failed to send web request to download! result: {result.result}, error: {result.error}");
+                        return;
+                    }
+
+                    var tempPath = Path.GetTempPath();
+                    var extractionPath = Path.Combine(tempPath, "StationeersLaunchPad");
+                    if (Directory.Exists(extractionPath)) {
+                        foreach (var file in Directory.GetFiles(extractionPath)) {
+                            var path = Path.Combine(extractionPath, file);
+
+                            if (File.Exists(path)) {
+                                File.Delete(path);
+                            }
+                        }
+                        Directory.Delete(extractionPath);
+                    }
+
+                    var zipFilePath = Path.Combine(tempPath, "SLP.zip");
+                    if (File.Exists(zipFilePath)) {
+                        File.Delete(zipFilePath);
+                    }
+
+                    Logger.Global.Log($"Writing file to {zipFilePath}...");
+                    File.WriteAllBytes(zipFilePath, downloadResult.downloadHandler.data);
+
+                    using (var zipFile = ZipFile.Open(zipFilePath, ZipArchiveMode.Read)) {
+                        Logger.Global.Log($"Extracting file contents to {extractionPath}...");
+                        zipFile.ExtractToDirectory(tempPath);
+                    }
+                    File.Delete(zipFilePath);
+
+                    Logger.Global.Log($"Extracted file contents to {extractionPath}!");
+                    if (!Directory.Exists(extractionPath)) {
+                        Logger.Global.LogError($"Failed to exteract zip file");
+                        return;
+                    }
+
+                    var pluginPath = Path.Combine(Paths.PluginPath, "StationeersLaunchPad");
+                    foreach (var file in LaunchPadUpdater.Assemblies) {
+                        var fileName = $"{file}.dll";
+                        var backupFileName = $"{file}.dll.bak";
+                        var newPath = Path.Combine(extractionPath, fileName);
+                        if (!File.Exists(newPath))
+                            continue;
+
+                        var path = Path.Combine(pluginPath, fileName);
+                        if (!File.Exists(path))
+                            continue;
+
+                        var backupPath = Path.Combine(pluginPath, backupFileName);
+                        if (File.Exists(backupPath))
+                            File.Delete(backupPath);
+
+                        Logger.Global.Log($"Backing up DLL to {backupPath}!");
+                        File.Move(path, backupPath);
+                        Logger.Global.Log($"Deleting DLL at {path}!");
+                        File.Delete(path);
+
+                        Logger.Global.Log($"Moving new DLL to {newPath}!");
+                        File.Move(newPath, path);
+                        //File.Delete(newPath);
+                    }
+                    Directory.Delete(extractionPath);
+
+                    LaunchPadConfig.HasUpdated = true;
+                    Logger.Global.LogError($"Mod loader has been updated to version {latestVersion}, please restart your game!");
+                }
+            }
+        }
+
+        public static async UniTask RevertUpdate() {
+            var pluginPath = Path.Combine(Paths.PluginPath, "StationeersLaunchPad");
+            foreach (var file in LaunchPadUpdater.Assemblies) {
+                var fileName = $"{file}.dll";
+                var backupFileName = $"{file}.dll.bak";
+
+                var backupPath = Path.Combine(pluginPath, backupFileName);
+                if (!File.Exists(backupPath))
+                    continue;
+
+                var path = Path.Combine(pluginPath, fileName);
+                if (File.Exists(path))
+                    File.Delete(path);
+
+                Logger.Global.Log($"Moving backup DLL to {path}!");
+                File.Move(backupPath, path);
+                //Logger.Global.Log($"Deleting DLL at {backupPath}!");
+                //File.Delete(backupPath);
+            }
+
+            Logger.Global.LogWarning($"Mod loader has reverted update changes due to an error.");
+        }
+    }
+}
diff --git a/StationeersLaunchPad/README.md b/StationeersLaunchPad/README.md
index f1ea4fe..72b3c0a 100644
--- a/StationeersLaunchPad/README.md
+++ b/StationeersLaunchPad/README.md
@@ -13,13 +13,16 @@ The loading is currently done one mod at a time (`LinearLoadStrategy` in `ModLoa
 ## Code Organization
 
 ### [LaunchPadPlugin.cs](LaunchPadPlugin.cs)
-Bepinex plugin entrypoint to the mod loader
+Bepinex plugin entrypoint to the mod loader.
 
 ### [LaunchPadConfig.cs](LaunchPadConfig.cs)
-Main workflow logic of loading mod configuration and mods
+Main workflow logic of loading mod configuration and mods.
 
 ### [LaunchPadGUI.cs](LaunchPadGUI.cs)
-IMGui mod configuration window
+IMGui mod configuration window.
+
+### [LaunchPadUpdater.cs](LaunchPadUpdater.cs)
+Main class for handling automatic plugin updating.
 
 ### [ModLoader.cs](ModLoader.cs)
 Contains the workflow for loading enabled mods into the game, currently just a simple linear load.


Reviews:
user: login: tsholmes
body: Thanks for taking this on! Just a few things around dedicated servers, and some questions we gotta figure out the answers to
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-10T02:05:10Z

Review comments:
diff_hunk: @@ -32,7 +31,14 @@ void Awake()
         configDescription: new ConfigDescription(
           "Automatically load after the configured wait time on startup. Can be stopped by clicking the loading window at the bottom"
         )
-      );
+       );
+       LaunchPadConfig.AutoUpdateOnStart = this.Config.Bind<bool>(
+         new ConfigDefinition("Startup", "AutoUpdateOnStart"),
+         defaultValue: true,
path: StationeersLaunchPad/LaunchPadPlugin.cs
user: login: tsholmes
body: could we have this default to false on dedicated server (`GameManager.IsBatchMode`). A lot of people build their servers in docker containers and will probably want to hold a stable version unless they explicitly opt in
created_at: 2025-05-10T01:51:27Z
author_association: COLLABORATOR
diff_hunk: @@ -100,6 +115,14 @@ private static async UniTask Load()
 
         Logger.Global.Log("Mod Config Initialized");
 
+        if (AutoUpdate)
path: StationeersLaunchPad/LaunchPadConfig.cs
user: login: tsholmes
body: I think we want to handle this in a separate try-catch block than the initialization. if this fails for whatever reason, they still should be able to load the installed mods
created_at: 2025-05-10T01:52:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,138 @@
+﻿using Assets.Scripts;
+using BepInEx;
+using BepInEx.Bootstrap;
+using Cysharp.Threading.Tasks;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Text.RegularExpressions;
+using UnityEngine.Networking;
+
+namespace StationeersLaunchPad
+{
+    // similar to jixxed's updater
+    public static class LaunchPadUpdater
+    {
+        public static List<string> Assemblies = new List<string>()
+        {
+            "RG.ImGui",
+            "StationeersMods.Interface",
+            "StationeersMods.Shared",
+            "StationeersLaunchPad",
+        };
+
+        private static Regex versionRegex = new Regex(@"""tag_name""\:\s""([V\d.]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+        private static Regex downloadRegexClient = new Regex(@"""browser_download_url""\:\s""([^""]*StationeersLaunchPad-v.+\.zip)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+        private static Regex downloadRegexServer = new Regex(@"""browser_download_url""\:\s""([^""]*StationeersLaunchPad-server-v.+\.zip)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+        public static async UniTask CheckVersion()
+        {
+            var request = UnityWebRequest.Get("https://api.github.com/repos/StationeersLaunchPad/StationeersLaunchPad/releases/latest");
path: StationeersLaunchPad/LaunchPadUpdater.cs
user: login: tsholmes
body: could we move these to `using` blocks? there's a lot of exit paths here, its nice to have the compiler handle the cleanup for you, so we don't have to keep track of what needs disposing
created_at: 2025-05-10T01:54:13Z
author_association: COLLABORATOR
diff_hunk: @@ -39,18 +42,29 @@ public static class LaunchPadConfig
     public static List<ModInfo> Mods = new();
     public static HashSet<string> GameAssemblies = new();
     public static LoadState LoadState = LoadState.Initializing;
+    public static bool AutoUpdate = false;
     public static bool AutoLoad = true;
+    public static bool HasUpdated = false;
     public static Stopwatch AutoStopwatch = new();
 
     public static async void Run()
     {
+      AutoUpdate = AutoUpdateOnStart.Value;
       AutoLoad = AutoLoadOnStart.Value;
-
+      
       await Load();
 
-      while (LoadState < LoadState.Configuring)
+      while (LoadState < LoadState.Updating)
         await UniTask.Yield();
 
+      if (HasUpdated)
path: StationeersLaunchPad/LaunchPadConfig.cs
user: login: tsholmes
body: do we want to force a restart here in order to use mods, or just encourage it? I'm not sure one way or the other, unless it definitely wouldn't work
created_at: 2025-05-10T02:00:18Z
author_association: COLLABORATOR
diff_hunk: @@ -39,18 +42,29 @@ public static class LaunchPadConfig
     public static List<ModInfo> Mods = new();
     public static HashSet<string> GameAssemblies = new();
     public static LoadState LoadState = LoadState.Initializing;
+    public static bool AutoUpdate = false;
     public static bool AutoLoad = true;
+    public static bool HasUpdated = false;
     public static Stopwatch AutoStopwatch = new();
 
     public static async void Run()
     {
+      AutoUpdate = AutoUpdateOnStart.Value;
       AutoLoad = AutoLoadOnStart.Value;
-
+      
       await Load();
 
-      while (LoadState < LoadState.Configuring)
+      while (LoadState < LoadState.Updating)
         await UniTask.Yield();
 
+      if (HasUpdated)
+      {
+        Mods = new();
+        LoadState = LoadState.ModsLoaded;
+        AutoLoad = false;
path: StationeersLaunchPad/LaunchPadConfig.cs
user: login: tsholmes
body: If we are on dedicated server, this will just hang forever, since there is no way to trigger a load manually (this is definitely a problem everywhere else we stop autoload as well). do you think on dedicated server we should just exit immediately here?
created_at: 2025-05-10T02:01:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,138 @@
+﻿using Assets.Scripts;
+using BepInEx;
+using BepInEx.Bootstrap;
+using Cysharp.Threading.Tasks;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Text.RegularExpressions;
+using UnityEngine.Networking;
+
+namespace StationeersLaunchPad
+{
+    // similar to jixxed's updater
+    public static class LaunchPadUpdater
+    {
+        public static List<string> Assemblies = new List<string>()
+        {
+            "RG.ImGui",
+            "StationeersMods.Interface",
+            "StationeersMods.Shared",
+            "StationeersLaunchPad",
+        };
+
+        private static Regex versionRegex = new Regex(@"""tag_name""\:\s""([V\d.]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+        private static Regex downloadRegexClient = new Regex(@"""browser_download_url""\:\s""([^""]*StationeersLaunchPad-v.+\.zip)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+        private static Regex downloadRegexServer = new Regex(@"""browser_download_url""\:\s""([^""]*StationeersLaunchPad-server-v.+\.zip)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+        public static async UniTask CheckVersion()
+        {
+            var request = UnityWebRequest.Get("https://api.github.com/repos/StationeersLaunchPad/StationeersLaunchPad/releases/latest");
+            Logger.Global.Log($"Requesting version...");
+            var result = await request.SendWebRequest();
path: StationeersLaunchPad/LaunchPadUpdater.cs
user: login: tsholmes
body: is there a timeout set by default here? if not we probably should set a reasonable one so it doesn't just hang if they are on a questionable connection
created_at: 2025-05-10T02:04:00Z
author_association: COLLABORATOR

PR comments:
user: login: ilodev
created_at: 2025-05-09T23:20:05Z
body: looks ok to me, haven´t checked the update class itself, assuming a copy from a working version,
author_association: COLLABORATOR
user: login: TerameTechYT
created_at: 2025-05-09T23:32:51Z
body: not really, its mainly based on what jixxed did but i rewrote it myself
author_association: CONTRIBUTOR
user: login: TerameTechYT
created_at: 2025-05-09T23:44:10Z
body: also it uses UniTask instead of IEnumerators which fill memory with junk according to the devs
author_association: CONTRIBUTOR