diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 3c8d5272..f665842e 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -24,8 +24,7 @@ RUN mkdir -p /go/bin && chown -R vscode:vscode /go
# Install Node.js 22.15.0
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
- apt-get install -y nodejs && \
- npm install -g npm@latest
+ apt-get install -y nodejs
# Set up non-root user and workspace
USER vscode
diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json
new file mode 100644
index 00000000..94b2b7fe
--- /dev/null
+++ b/.devcontainer/devcontainer-lock.json
@@ -0,0 +1,9 @@
+{
+ "features": {
+ "ghcr.io/devcontainers/features/go:1": {
+ "version": "1.3.4",
+ "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032",
+ "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032"
+ }
+ }
+}
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 44166e0b..74805d67 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -21,8 +21,7 @@
"vscode": {
"extensions": [
"golang.go",
- "svelte.svelte-vscode",
- "eamodio.gitlens"
+ "svelte.svelte-vscode"
],
"settings": {
"go.toolsManagement.autoUpdate": true,
@@ -30,5 +29,5 @@
}
}
},
- "postCreateCommand": "go mod tidy && cd frontend && npm install && npm install @sveltejs/vite-plugin-svelte"
+ "postCreateCommand": "go mod tidy && cd frontend && npm install && npm install @sveltejs/vite-plugin-svelte && npm audit fix"
}
\ No newline at end of file
diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css
index abb25d50..25963528 100644
--- a/UIMod/onboard_bundled/assets/css/config.css
+++ b/UIMod/onboard_bundled/assets/css/config.css
@@ -576,6 +576,39 @@ select option {
font-size: 0.75rem;
}
+.mod-update-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: 10px;
+ padding: 5px 12px;
+ background: transparent;
+ border: 1px solid var(--primary-dim);
+ color: var(--primary);
+ border-radius: 6px;
+ font-family: 'Share Tech Mono', monospace;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ letter-spacing: 0.3px;
+}
+
+.mod-update-button:hover:not(:disabled) {
+ border-color: var(--primary);
+ background: color-mix(in srgb, var(--primary) 10%, transparent);
+ box-shadow: 0 0 8px var(--button-glow-soft);
+}
+
+.mod-update-button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.mod-update-button.loading {
+ opacity: 0.6;
+ cursor: wait;
+}
+
.slp-button.danger {
background-color: var(--danger);
border-color: var(--danger);
diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js
index 6fb4ceac..c1a71936 100644
--- a/UIMod/onboard_bundled/assets/js/slp.js
+++ b/UIMod/onboard_bundled/assets/js/slp.js
@@ -53,7 +53,7 @@ function setButtonLoading(buttonId, isLoading) {
if (isLoading) {
button.disabled = true;
button.dataset.originalText = button.textContent;
- button.textContent = '⏳ Please wait...';
+ button.textContent = button.classList.contains('mod-update-button') ? '⏳' : '⏳ Please wait...';
button.classList.add('loading');
} else {
button.disabled = false;
@@ -135,6 +135,32 @@ function reinstallSLP() {
});
}
+function updateSingleMod(workshopHandle, index) {
+ const btnId = 'update-mod-btn-' + index;
+ setButtonLoading(btnId, true);
+ showPopup('info', 'Updating workshop mod ' + workshopHandle + '...\n\nPlease wait.');
+
+ fetch('/api/v2/steamcmd/updatemod', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workshopHandle: workshopHandle })
+ })
+ .then(response => response.json())
+ .then(data => {
+ setButtonLoading(btnId, false);
+ if (data.success) {
+ showPopup('success', 'Workshop mod updated successfully!\n\nReloading mod list...');
+ loadInstalledMods();
+ } else {
+ showPopup('error', 'Failed to update mod:\n\n' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ showPopup('error', 'Failed to update mod:\n\n' + (error.message || 'Network error'));
+ setButtonLoading(btnId, false);
+ });
+}
+
function updateWorkshopMods() {
setButtonLoading('updateWorkshopModsBtn', true);
showPopup('info', 'Updating workshop mods...\n\nThis may take some time depending on the number of mods. Please wait.');
@@ -424,12 +450,18 @@ function createModCard(mod, index) {
`;
}
+ let updateButtonHtml = '';
+ if (mod.WorkshopHandle) {
+ updateButtonHtml = ``;
+ }
+
card.innerHTML = `
${imageHtml}
${escapeHtml(mod.Name || 'Unknown Mod')}
${mod.Author ? `By ${escapeHtml(mod.Author)}
` : ''}
${mod.Version ? `v${escapeHtml(mod.Version)}
` : ''}
${descriptionHtml}
+ ${updateButtonHtml}
`;
return card;
diff --git a/src/cli/commands.go b/src/cli/commands.go
index f9a7a57c..2600f005 100644
--- a/src/cli/commands.go
+++ b/src/cli/commands.go
@@ -49,7 +49,7 @@ func init() {
RegisterCommand("printconfig", WrapNoReturn(printConfig), "Print the current SSUI configuration", true, "pc")
RegisterCommand("listmods", WrapNoReturn(listmods), "List installed SLP mods", true, "lm")
RegisterCommand("listworkshophandles", WrapNoReturn(listworkshophandles), "List workshop Mod handles", true, "lwh")
- RegisterCommand("downloadworkshopitemtest", WrapNoReturn(downloadWorkshopItemTest), "Test downloading a workshop item (ModularConsolesMod)", true, "dwmodcon")
+ RegisterCommand("downloadworkshopitem", downloadWorkshopItem, "Download a workshop item. When no arguments are provided, 3672138641/BlueprintMod is downloaded. Can be called like downloadworkshopitem 3505169479 or downloadworkshopitem 3505169479 3505115682 3505169479 ", true, "dwi")
RegisterCommand("dumpheapprofile", WrapNoReturn(dumpHeapProfile), "Dump a pprof heap profile for debugging", true, "dhp")
RegisterCommand("testserverstatuspaneldiscord", WrapNoReturn(testServerStatusPanelDiscord), "Send a fake player list to the Discord package to test the server status panel", true, "tsspd")
}
@@ -89,6 +89,10 @@ func stopServer() {
func exitfromcli() {
// send signal to the main process to exit
logger.Core.Info("I have to go...")
+ err := gamemgr.InternalStopServer()
+ if err != nil {
+ logger.Core.Error("Error stopping server:" + err.Error())
+ }
os.Exit(0)
}
diff --git a/src/cli/devcommands.go b/src/cli/devcommands.go
index 08a1a868..27ed3b73 100644
--- a/src/cli/devcommands.go
+++ b/src/cli/devcommands.go
@@ -17,12 +17,19 @@ import (
// COMMAND HANDLERS WITH COMMANDS USEFUL FOR DEVELOPMENT AND DEBUGGING
-func downloadWorkshopItemTest() {
- workshopHandles := []string{"3505169479"}
+func downloadWorkshopItem(args []string) error {
+ var workshopHandles []string
+ if len(args) == 0 {
+ workshopHandles = []string{"3672138641"} // blueprint mod
+ } else {
+ workshopHandles = args
+ }
+ logger.Core.Info(fmt.Sprintf("Downloading workshop items: %v", workshopHandles))
_, err := steamcmd.DownloadWorkshopItems(workshopHandles)
if err != nil {
logger.Core.Error("Error downloading workshop items: " + err.Error())
}
+ return nil
}
func listworkshophandles() {
diff --git a/src/config/config.go b/src/config/config.go
index 533bf32d..bb80a311 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -11,7 +11,7 @@ import (
var (
// All configuration variables can be found in vars.go
- Version = "5.13.2"
+ Version = "5.13.3"
Branch = "release"
)
@@ -176,7 +176,7 @@ func applyConfig(cfg *JsonConfig) {
IsNewTerrainAndSaveSystem = isNewTerrainAndSaveSystemVal
cfg.IsNewTerrainAndSaveSystem = &isNewTerrainAndSaveSystemVal
- GameBranch = getString(cfg.GameBranch, "GAME_BRANCH", "public")
+ GameBranch = getString(strings.ToLower(cfg.GameBranch), "GAME_BRANCH", "public")
Difficulty = getString(cfg.Difficulty, "DIFFICULTY", "")
StartCondition = getString(cfg.StartCondition, "START_CONDITION", "")
StartLocation = getString(cfg.StartLocation, "START_LOCATION", "")
diff --git a/src/steamcmd/steamcmd-helper.go b/src/steamcmd/steamcmd-helper.go
index 4caee4fe..c7dad485 100644
--- a/src/steamcmd/steamcmd-helper.go
+++ b/src/steamcmd/steamcmd-helper.go
@@ -14,6 +14,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "slices"
"strings"
"time"
@@ -293,9 +294,64 @@ func untarWrapper(r io.ReaderAt, _ int64, dest string) error {
return untar(dest, io.NewSectionReader(r, 0, 1<<63-1)) // Use a large size for the section reader
}
+type distroFamily int
+
+const (
+ distroUnknown distroFamily = iota
+ distroDebian
+ distroRHEL
+)
+
+// parseOSRelease parses a /etc/os-release file into a key-value map.
+func parseOSRelease(content string) map[string]string {
+ fields := make(map[string]string)
+ for _, line := range strings.Split(content, "\n") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ fields[parts[0]] = strings.Trim(parts[1], "\"'")
+ }
+ return fields
+}
+
+// detectDistroFamily reads /etc/os-release and returns the distro family.
+// ID is checked first (single value); ID_LIKE is the fallback (space-separated list).
+func detectDistroFamily() distroFamily {
+ data, err := os.ReadFile("/etc/os-release")
+ if err != nil {
+ return distroUnknown
+ }
+
+ debianIDs := []string{"ubuntu", "debian", "linuxmint", "pop", "elementary", "raspbian"}
+ rhelIDs := []string{"rhel", "centos", "fedora", "rocky", "almalinux", "ol"}
+
+ fields := parseOSRelease(string(data))
+
+ // Check ID first — it's a single value identifying the primary distro.
+ id := strings.ToLower(fields["ID"])
+ if slices.Contains(debianIDs, id) {
+ return distroDebian
+ }
+ if slices.Contains(rhelIDs, id) {
+ return distroRHEL
+ }
+
+ // Fall back to ID_LIKE — a space-separated list of closely related distros.
+ for _, like := range strings.Fields(strings.ToLower(fields["ID_LIKE"])) {
+ if slices.Contains(debianIDs, like) {
+ return distroDebian
+ }
+ if slices.Contains(rhelIDs, like) {
+ return distroRHEL
+ }
+ }
+
+ return distroUnknown
+}
+
// installRequiredLibraries installs the required libraries for SteamCMD if they are not already installed.
func installRequiredLibraries() error {
- // Check if the system is Debian-based
if runtime.GOOS != "linux" {
return nil // Only Linux systems need this
}
@@ -306,8 +362,19 @@ func installRequiredLibraries() error {
return nil
}
- // According to https://developer.valvesoftware.com/wiki/SteamCMD#Manually only lib32gcc-s1 is needed
- // List of required libraries
+ switch detectDistroFamily() {
+ case distroDebian:
+ return installRequiredLibrariesDebian()
+ case distroRHEL:
+ return installRequiredLibrariesRHEL()
+ default:
+ return fmt.Errorf("unsupported Linux distribution: only Ubuntu/Debian and RHEL-based distros are supported")
+ }
+}
+
+// installRequiredLibrariesDebian installs SteamCMD dependencies on Ubuntu/Debian using apt-get.
+// According to https://developer.valvesoftware.com/wiki/SteamCMD#Manually only lib32gcc-s1 is needed.
+func installRequiredLibrariesDebian() error {
requiredLibs := []string{
"lib32gcc-s1",
//"lib32stdc++6",
@@ -316,10 +383,9 @@ func installRequiredLibraries() error {
// Check and install each library
for _, lib := range requiredLibs {
// Check if the library is already installed
- cmd := exec.Command("dpkg", "-s", lib)
- if err := cmd.Run(); err == nil {
+ if err := exec.Command("dpkg", "-s", lib).Run(); err == nil {
logger.Install.Debug("✅ Library already installed: " + lib + "\n")
- continue // Library is already installed, skip to the next one
+ continue
}
// Library is not installed, attempt to install it
@@ -327,12 +393,39 @@ func installRequiredLibraries() error {
installCmd := exec.Command("sudo", "apt-get", "install", "-y", lib)
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
-
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install library %s: %w", lib, err)
}
logger.Install.Debug("✅ Installed library: " + lib + "\n")
}
+ return nil
+}
+
+// installRequiredLibrariesRHEL installs SteamCMD dependencies on RHEL-based distros using dnf.
+// libgcc.i686 is the RHEL equivalent of lib32gcc-s1 on Debian-based distros.
+func installRequiredLibrariesRHEL() error {
+ requiredLibs := []string{
+ "libgcc.i686",
+ "libstdc++.i686",
+ }
+
+ // Check and install each library
+ for _, lib := range requiredLibs {
+ // Check if the library is already installed
+ if err := exec.Command("rpm", "-q", lib).Run(); err == nil {
+ logger.Install.Debug("✅ Library already installed: " + lib + "\n")
+ continue
+ }
+ // Library is not installed, attempt to install it
+ logger.Install.Debug("🔄 Installing library: " + lib + "\n")
+ installCmd := exec.Command("sudo", "dnf", "install", "-y", lib)
+ installCmd.Stdout = os.Stdout
+ installCmd.Stderr = os.Stderr
+ if err := installCmd.Run(); err != nil {
+ return fmt.Errorf("failed to install library %s: %w", lib, err)
+ }
+ logger.Install.Debug("✅ Installed library: " + lib + "\n")
+ }
return nil
}
diff --git a/src/web/routes.go b/src/web/routes.go
index 23637cda..bfc1e452 100644
--- a/src/web/routes.go
+++ b/src/web/routes.go
@@ -96,6 +96,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) {
protectedMux.HandleFunc("/api/v2/slp/upload", UploadModPackageHandler)
protectedMux.HandleFunc("/api/v2/slp/mods", GetInstalledModDetailsHandler)
protectedMux.HandleFunc("/api/v2/steamcmd/updatemods", UpdateWorkshopModsHandler)
+ protectedMux.HandleFunc("/api/v2/steamcmd/updatemod", UpdateSingleWorkshopModHandler)
return mux, protectedMux
}
diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go
index da313adc..bc744604 100644
--- a/src/web/slp-launchpad.go
+++ b/src/web/slp-launchpad.go
@@ -111,3 +111,55 @@ func UpdateWorkshopModsHandler(w http.ResponseWriter, r *http.Request) {
"logs": logs,
})
}
+
+func UpdateSingleWorkshopModHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "error": "method not allowed",
+ })
+ return
+ }
+
+ var req struct {
+ WorkshopHandle string `json:"workshopHandle"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "error": "invalid request body",
+ })
+ return
+ }
+
+ if req.WorkshopHandle == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "error": "workshopHandle is required",
+ })
+ return
+ }
+
+ logs, err := steamcmd.DownloadWorkshopItems([]string{req.WorkshopHandle})
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "error": err.Error(),
+ "logs": logs,
+ })
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Workshop mod updated successfully",
+ "logs": logs,
+ })
+}