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, + }) +}