From 7d08455f03453c27d8cf10b6779bac3b6b9760eb Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Fri, 6 Mar 2026 01:54:35 +0100 Subject: [PATCH 1/6] feat: Add functionality to update individual workshop mods with a new API endpoint, a UI botton and a cli command (dev) --- UIMod/onboard_bundled/assets/css/config.css | 33 +++++++++++++ UIMod/onboard_bundled/assets/js/slp.js | 34 +++++++++++++- src/cli/commands.go | 2 +- src/cli/devcommands.go | 11 ++++- src/config/config.go | 2 +- src/web/routes.go | 1 + src/web/slp-launchpad.go | 52 +++++++++++++++++++++ 7 files changed, 130 insertions(+), 5 deletions(-) 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..16c1d8f1 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") } 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..56292ab9 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" ) 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, + }) +} From 8fd183273df9d14fa805f7766e5cac3975cf88c7 Mon Sep 17 00:00:00 2001 From: akirilov <***REDACTED***> Date: Sun, 31 May 2026 09:04:51 +0000 Subject: [PATCH 2/6] Adding support for fedora --- .devcontainer/Dockerfile | 3 +- .devcontainer/devcontainer.json | 5 +- src/steamcmd/steamcmd-helper.go | 107 +++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 12 deletions(-) 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.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/src/steamcmd/steamcmd-helper.go b/src/steamcmd/steamcmd-helper.go index 4caee4fe..e8330fc1 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 } From aca9ef1e1874e2079c5b5f22f25b827349e0de6c Mon Sep 17 00:00:00 2001 From: akirilov Date: Sun, 31 May 2026 10:19:37 +0000 Subject: [PATCH 3/6] add devcontainer lock file --- .devcontainer/devcontainer-lock.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .devcontainer/devcontainer-lock.json 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" + } + } +} From 9b2148b18e181094a73b6e212b2de43fada7569e Mon Sep 17 00:00:00 2001 From: akirilov <***REDACTED***> Date: Mon, 1 Jun 2026 01:39:40 +0000 Subject: [PATCH 4/6] trim both single and double quotes when parsing OS type --- src/steamcmd/steamcmd-helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/steamcmd/steamcmd-helper.go b/src/steamcmd/steamcmd-helper.go index e8330fc1..c7dad485 100644 --- a/src/steamcmd/steamcmd-helper.go +++ b/src/steamcmd/steamcmd-helper.go @@ -310,7 +310,7 @@ func parseOSRelease(content string) map[string]string { if len(parts) != 2 { continue } - fields[parts[0]] = strings.Trim(parts[1], `"`) + fields[parts[0]] = strings.Trim(parts[1], "\"'") } return fields } From 21c431657aac5846e5ab954838c0b122d0ee5d5b Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 22 Jun 2026 20:47:15 +0200 Subject: [PATCH 5/6] fix(config): normalize GameBranch to lowercase (thx windsinger) first step / immediate reaction for #172 [StationeersSUI] ]Remove legacy terrain system checks and enforce branch compatibility GameBranch is now forced to lowercase when loading the config. This prevents mismatches with branch naming checks and ensures consistent behavior regardless of how the value was saved. --- src/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/config.go b/src/config/config.go index 56292ab9..bb80a311 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -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", "") From a2e35ef18c1c66bd1eac95f0f4343051c6747cac Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Tue, 23 Jun 2026 01:37:08 +0200 Subject: [PATCH 6/6] fixed server wouldnt stop when exiting from SSUICLI --- src/cli/commands.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cli/commands.go b/src/cli/commands.go index 16c1d8f1..2600f005 100644 --- a/src/cli/commands.go +++ b/src/cli/commands.go @@ -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) }