From 5b00944bb19123fcbc6fe896a43707bfe36e753d Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 15 Jan 2026 20:15:12 +0100 Subject: [PATCH 01/21] add SLP install and uninstall functions with corresponding web handlers and UI. SLP defaults to false, but if SLP is enabled SLP auto updates default to true --- UIMod/onboard_bundled/assets/js/slp.js | 31 +++ UIMod/onboard_bundled/ui/config.html | 15 +- src/cli/runtimecommands.go | 5 + src/config/config.go | 142 +++++++------- src/config/getters.go | 12 ++ src/config/setters.go | 16 ++ src/config/vars.go | 38 ++-- src/core/loader/loader.go | 22 +++ src/setup/launchpad/launchpad-config.go | 86 +++++++++ src/setup/launchpad/launchpad.go | 242 ++++++++++++++++++++++++ src/setup/update/progressbar.go | 6 +- src/setup/update/updater.go | 2 +- src/web/configpage.go | 7 + src/web/routes.go | 4 + src/web/slp-launchpad.go | 32 ++++ src/web/templatevars.go | 2 + 16 files changed, 571 insertions(+), 91 deletions(-) create mode 100644 UIMod/onboard_bundled/assets/js/slp.js create mode 100644 src/setup/launchpad/launchpad-config.go create mode 100644 src/setup/launchpad/launchpad.go create mode 100644 src/web/slp-launchpad.go diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js new file mode 100644 index 00000000..85c55fad --- /dev/null +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -0,0 +1,31 @@ +function installSLP() { + fetch('/api/v2/slp/install') + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('SLP installed successfully!'); + window.location.reload(); + } else { + alert('Failed to install SLP: ' + data.error); + } + }) + .catch(error => { + alert('Failed to install SLP: ' + error); + }); +} + +function uninstallSLP() { + fetch('/api/v2/slp/uninstall') + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('SLP uninstalled successfully!'); + window.location.reload(); + } else { + alert('Failed to uninstall SLP: ' + data.error); + } + }) + .catch(error => { + alert('Failed to uninstall SLP: ' + error); + }); +} \ No newline at end of file diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 0d00d920..f8197eee 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -40,15 +40,10 @@

{{.UIText_ServerConfig}}

{{.UIText_DiscordIntegration}} - - + {{end}} + {{if eq .IsStationeersLaunchPadEnabled "true"}} +

To uninstall SLP and all Mods, simply click the button below. This will DELETE all Mods in the Mods folder!

+ + {{end}}
@@ -614,6 +618,7 @@

Regex Detection:

+ \ No newline at end of file diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go index a9924f60..0953244d 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/runtimecommands.go @@ -166,6 +166,7 @@ func init() { RegisterCommand("printconfig", WrapNoReturn(printConfig), "pc") RegisterCommand("update", WrapNoReturn(triggerUpdateCheck), "u") RegisterCommand("applyupdate", WrapNoReturn(applyUpdate), "au") + RegisterCommand("installslp", WrapNoReturn(installSLP), "slp") } func startServer() { @@ -243,6 +244,10 @@ func applyUpdate() { } } +func installSLP() { + loader.InstallSLP() +} + func supportMode() { if isSupportMode { diff --git a/src/config/config.go b/src/config/config.go index 7d2fb51d..e41251b0 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -82,6 +82,10 @@ type JsonConfig struct { AllowMajorUpdates *bool `json:"AllowMajorUpdates"` AllowAutoGameServerUpdates *bool `json:"AllowAutoGameServerUpdates"` + // SLP Modding Settings + IsStationeersLaunchPadEnabled *bool `json:"IsStationeersLaunchPadEnabled"` + IsStationeersLaunchPadAutoUpdatesEnabled *bool `json:"IsStationeersLaunchPadAutoUpdatesEnabled"` + // Discord Settings DiscordToken string `json:"discordToken"` ControlChannelID string `json:"controlChannelID"` @@ -253,6 +257,14 @@ func applyConfig(cfg *JsonConfig) { AllowAutoGameServerUpdates = allowAutoGameServerUpdatesVal cfg.AllowAutoGameServerUpdates = &allowAutoGameServerUpdatesVal + isStationeersLaunchPadEnabledVal := getBool(cfg.IsStationeersLaunchPadEnabled, "IS_SLP_MODDING_ENABLED", false) + IsStationeersLaunchPadEnabled = isStationeersLaunchPadEnabledVal + cfg.IsStationeersLaunchPadEnabled = &isStationeersLaunchPadEnabledVal + + isStationeersLaunchPadAutoUpdatesEnabledVal := getBool(cfg.IsStationeersLaunchPadAutoUpdatesEnabled, "IS_SLP_MODDING_AUTO_UPDATES_ENABLED", true) + IsStationeersLaunchPadAutoUpdatesEnabled = isStationeersLaunchPadAutoUpdatesEnabledVal + cfg.IsStationeersLaunchPadAutoUpdatesEnabled = &isStationeersLaunchPadAutoUpdatesEnabledVal + SubsystemFilters = getStringSlice(cfg.SubsystemFilters, "SUBSYSTEM_FILTERS", []string{}) AutoRestartServerTimer = getString(cfg.AutoRestartServerTimer, "AUTO_RESTART_SERVER_TIMER", "0") isSSCMEnabledVal := getBool(cfg.IsSSCMEnabled, "IS_SSCM_ENABLED", true) @@ -326,70 +338,72 @@ func applyConfig(cfg *JsonConfig) { // M U S T be called while holding a lock on ConfigMu! func safeSaveConfig() error { cfg := JsonConfig{ - DiscordToken: DiscordToken, - ControlChannelID: ControlChannelID, - StatusChannelID: StatusChannelID, - ConnectionListChannelID: ConnectionListChannelID, - LogChannelID: LogChannelID, - SaveChannelID: SaveChannelID, - ControlPanelChannelID: ControlPanelChannelID, - DiscordCharBufferSize: DiscordCharBufferSize, - BlackListFilePath: BlackListFilePath, - IsDiscordEnabled: &IsDiscordEnabled, - ErrorChannelID: ErrorChannelID, - BackupKeepLastN: BackupKeepLastN, - IsCleanupEnabled: &IsCleanupEnabled, - BackupKeepDailyFor: int(BackupKeepDailyFor / time.Hour), // Convert to hours - BackupKeepWeeklyFor: int(BackupKeepWeeklyFor / time.Hour), // Convert to hours - BackupKeepMonthlyFor: int(BackupKeepMonthlyFor / time.Hour), // Convert to hours - BackupCleanupInterval: int(BackupCleanupInterval / time.Hour), // Convert to hours - BackupWaitTime: int(BackupWaitTime / time.Second), // Convert to seconds - IsNewTerrainAndSaveSystem: &IsNewTerrainAndSaveSystem, - GameBranch: GameBranch, - Difficulty: Difficulty, - StartCondition: StartCondition, - StartLocation: StartLocation, - ServerName: ServerName, - SaveName: SaveName, - WorldID: WorldID, - ServerMaxPlayers: ServerMaxPlayers, - ServerPassword: ServerPassword, - ServerAuthSecret: ServerAuthSecret, - AdminPassword: AdminPassword, - GamePort: GamePort, - UpdatePort: UpdatePort, - UPNPEnabled: &UPNPEnabled, - AutoSave: &AutoSave, - SaveInterval: SaveInterval, - AutoPauseServer: &AutoPauseServer, - LocalIpAddress: LocalIpAddress, - StartLocalHost: &StartLocalHost, - ServerVisible: &ServerVisible, - UseSteamP2P: &UseSteamP2P, - ExePath: ExePath, - AdditionalParams: AdditionalParams, - Users: Users, - AuthEnabled: &AuthEnabled, - JwtKey: JwtKey, - AuthTokenLifetime: AuthTokenLifetime, - Debug: &IsDebugMode, - CreateSSUILogFile: &CreateSSUILogFile, - CreateGameServerLogFile: &CreateGameServerLogFile, - LogLevel: LogLevel, - LogClutterToConsole: &LogClutterToConsole, - SubsystemFilters: SubsystemFilters, - IsUpdateEnabled: &IsUpdateEnabled, - IsSSCMEnabled: &IsSSCMEnabled, - AutoRestartServerTimer: AutoRestartServerTimer, - AllowPrereleaseUpdates: &AllowPrereleaseUpdates, - AllowMajorUpdates: &AllowMajorUpdates, - AllowAutoGameServerUpdates: &AllowAutoGameServerUpdates, - IsConsoleEnabled: &IsConsoleEnabled, - LanguageSetting: LanguageSetting, - AutoStartServerOnStartup: &AutoStartServerOnStartup, - SSUIIdentifier: SSUIIdentifier, - SSUIWebPort: SSUIWebPort, - AdvertiserOverride: AdvertiserOverride, + DiscordToken: DiscordToken, + ControlChannelID: ControlChannelID, + StatusChannelID: StatusChannelID, + ConnectionListChannelID: ConnectionListChannelID, + LogChannelID: LogChannelID, + SaveChannelID: SaveChannelID, + ControlPanelChannelID: ControlPanelChannelID, + DiscordCharBufferSize: DiscordCharBufferSize, + BlackListFilePath: BlackListFilePath, + IsDiscordEnabled: &IsDiscordEnabled, + ErrorChannelID: ErrorChannelID, + BackupKeepLastN: BackupKeepLastN, + IsCleanupEnabled: &IsCleanupEnabled, + BackupKeepDailyFor: int(BackupKeepDailyFor / time.Hour), // Convert to hours + BackupKeepWeeklyFor: int(BackupKeepWeeklyFor / time.Hour), // Convert to hours + BackupKeepMonthlyFor: int(BackupKeepMonthlyFor / time.Hour), // Convert to hours + BackupCleanupInterval: int(BackupCleanupInterval / time.Hour), // Convert to hours + BackupWaitTime: int(BackupWaitTime / time.Second), // Convert to seconds + IsNewTerrainAndSaveSystem: &IsNewTerrainAndSaveSystem, + GameBranch: GameBranch, + Difficulty: Difficulty, + StartCondition: StartCondition, + StartLocation: StartLocation, + ServerName: ServerName, + SaveName: SaveName, + WorldID: WorldID, + ServerMaxPlayers: ServerMaxPlayers, + ServerPassword: ServerPassword, + ServerAuthSecret: ServerAuthSecret, + AdminPassword: AdminPassword, + GamePort: GamePort, + UpdatePort: UpdatePort, + UPNPEnabled: &UPNPEnabled, + AutoSave: &AutoSave, + SaveInterval: SaveInterval, + AutoPauseServer: &AutoPauseServer, + LocalIpAddress: LocalIpAddress, + StartLocalHost: &StartLocalHost, + ServerVisible: &ServerVisible, + UseSteamP2P: &UseSteamP2P, + ExePath: ExePath, + AdditionalParams: AdditionalParams, + Users: Users, + AuthEnabled: &AuthEnabled, + JwtKey: JwtKey, + AuthTokenLifetime: AuthTokenLifetime, + Debug: &IsDebugMode, + CreateSSUILogFile: &CreateSSUILogFile, + CreateGameServerLogFile: &CreateGameServerLogFile, + LogLevel: LogLevel, + LogClutterToConsole: &LogClutterToConsole, + SubsystemFilters: SubsystemFilters, + IsUpdateEnabled: &IsUpdateEnabled, + IsSSCMEnabled: &IsSSCMEnabled, + AutoRestartServerTimer: AutoRestartServerTimer, + AllowPrereleaseUpdates: &AllowPrereleaseUpdates, + AllowMajorUpdates: &AllowMajorUpdates, + AllowAutoGameServerUpdates: &AllowAutoGameServerUpdates, + IsStationeersLaunchPadEnabled: &IsStationeersLaunchPadEnabled, + IsStationeersLaunchPadAutoUpdatesEnabled: &IsStationeersLaunchPadAutoUpdatesEnabled, + IsConsoleEnabled: &IsConsoleEnabled, + LanguageSetting: LanguageSetting, + AutoStartServerOnStartup: &AutoStartServerOnStartup, + SSUIIdentifier: SSUIIdentifier, + SSUIWebPort: SSUIWebPort, + AdvertiserOverride: AdvertiserOverride, } file, err := os.Create(ConfigPath) diff --git a/src/config/getters.go b/src/config/getters.go index cd637768..16a12724 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -536,3 +536,15 @@ func GetStationeersServerPingEndpoint() string { defer ConfigMu.RUnlock() return StationeersServerPingEndpoint } + +func GetIsStationeersLaunchPadEnabled() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsStationeersLaunchPadEnabled +} + +func GetIsStationeersLaunchPadAutoUpdatesEnabled() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsStationeersLaunchPadAutoUpdatesEnabled +} diff --git a/src/config/setters.go b/src/config/setters.go index 3f8b603b..3e838375 100644 --- a/src/config/setters.go +++ b/src/config/setters.go @@ -710,3 +710,19 @@ func SetAdvertiserOverride(value string) error { AdvertiserOverride = value return safeSaveConfig() } + +func SetIsStationeersLaunchPadEnabled(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsStationeersLaunchPadEnabled = value + return safeSaveConfig() +} + +func SetIsStationeersLaunchPadAutoUpdatesEnabled(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsStationeersLaunchPadAutoUpdatesEnabled = value + return safeSaveConfig() +} diff --git a/src/config/vars.go b/src/config/vars.go index 3aafbbff..e9f6f57b 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -45,24 +45,26 @@ var ( // Logging, debugging and misc var ( - IsDebugMode bool //only used for pprof server, keep it like this and check the log level instead. Debug = 10 - CreateSSUILogFile bool - CreateGameServerLogFile bool - LogLevel int - IsFirstTimeSetup bool - SSEMessageBufferSize = 2000 - MaxSSEConnections = 20 - GameServerAppID = "600760" - ExePath string - GameBranch string - SubsystemFilters []string - AutoRestartServerTimer string - IsConsoleEnabled bool - LogClutterToConsole bool // surpresses clutter mono logs from the gameserver - LanguageSetting string - AutoStartServerOnStartup bool - SSUIIdentifier string - AdvertiserOverride string + IsDebugMode bool //only used for pprof server, keep it like this and check the log level instead. Debug = 10 + CreateSSUILogFile bool + CreateGameServerLogFile bool + LogLevel int + IsFirstTimeSetup bool + SSEMessageBufferSize = 2000 + MaxSSEConnections = 20 + GameServerAppID = "600760" + ExePath string + GameBranch string + SubsystemFilters []string + AutoRestartServerTimer string + IsConsoleEnabled bool + LogClutterToConsole bool // surpresses clutter mono logs from the gameserver + LanguageSetting string + AutoStartServerOnStartup bool + SSUIIdentifier string + AdvertiserOverride string + IsStationeersLaunchPadEnabled bool + IsStationeersLaunchPadAutoUpdatesEnabled bool ) // Runtime only variables diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index 8d150485..219ad6a7 100644 --- a/src/core/loader/loader.go +++ b/src/core/loader/loader.go @@ -15,6 +15,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) @@ -27,6 +28,7 @@ func InitBackend() { ReloadLocalizer() ReloadAppInfoPoller() ReloadDiscordBot() + EnsureSLPAutoUpdates() InitDetector() StartIsGameServerRunningCheck() StartUpdateCheckLoop() @@ -119,6 +121,26 @@ func InitVirtFS(v1uiFS embed.FS) { config.SetV1UIFS(v1uiFS) } +func InstallSLP() { + version, err := launchpad.InstallSLP() + if err != nil { + logger.Install.Error("SLP installation failed: " + err.Error()) + return + } + logger.Install.Infof("SLP %s installed successfully", version) +} + +func EnsureSLPAutoUpdates() { + modified, err := launchpad.ToggleSLPAutoUpdates(config.GetIsStationeersLaunchPadAutoUpdatesEnabled()) + if err != nil { + logger.Install.Error("Failed to toggle SLP auto-updates: " + err.Error()) + return + } + if modified { + logger.Install.Infof("StationeersLaunchPad auto-updates toggled to %t", config.GetIsStationeersLaunchPadAutoUpdatesEnabled()) + } +} + func SanityCheck() { err := runSanityCheck() if err != nil { diff --git a/src/setup/launchpad/launchpad-config.go b/src/setup/launchpad/launchpad-config.go new file mode 100644 index 00000000..97d924e7 --- /dev/null +++ b/src/setup/launchpad/launchpad-config.go @@ -0,0 +1,86 @@ +package launchpad + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// ToggleSLPAutoUpdates enables or disables the SLP built-in auto-updater +// by setting CheckForUpdate and AutoUpdateOnStart to true/false in +// BepInEx/config/stationeers.launchpad.cfg +// +// If the file doesn't exist → does nothing (returns nil) +// If the file exists but keys are missing → adds them under [Startup] +// Returns true if the file was modified, false if no change/no file, error on failure +func ToggleSLPAutoUpdates(enable bool) (modified bool, err error) { + const configPath = "BepInEx/config/stationeers.launchpad.cfg" + + // Check if config exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + logger.Install.Debug("SLP config not found, skipping auto-update flag setup") + return false, nil + } + + // Read existing content + data, err := os.ReadFile(configPath) + if err != nil { + return false, fmt.Errorf("failed to read SLP config: %w", err) + } + + original := string(data) + content := original + + value := "true" + if !enable { + value = "false" + } + + // Regex to match lines like: CheckForUpdate = false (any whitespace, case insensitive) + reCheck := regexp.MustCompile(`(?mi)^\s*CheckForUpdate\s*=\s*(true|false)\s*(?:#.*)?$`) + reAuto := regexp.MustCompile(`(?mi)^\s*AutoUpdateOnStart\s*=\s*(true|false)\s*(?:#.*)?$`) + + // Replace existing values + newContent := reCheck.ReplaceAllString(content, fmt.Sprintf("CheckForUpdate = %s", value)) + newContent = reAuto.ReplaceAllString(newContent, fmt.Sprintf("AutoUpdateOnStart = %s", value)) + + modified = (newContent != content) + + // If neither key existed → append them under [Startup] section + if !modified || (!reCheck.MatchString(content) && !reAuto.MatchString(content)) { + // Look for [Startup] section + startupSectionRe := regexp.MustCompile(`(?m)^\[Startup\]\s*$`) + + if startupSectionRe.MatchString(newContent) { + // Append to existing [Startup] section + newContent = startupSectionRe.ReplaceAllStringFunc(newContent, func(match string) string { + return match + fmt.Sprintf("\nCheckForUpdate = %s\nAutoUpdateOnStart = %s", value, value) + }) + } else { + // No [Startup] section → add it at the end + newContent = strings.TrimRight(newContent, "\r\n") + fmt.Sprintf("\n\n[Startup]\nCheckForUpdate = %s\nAutoUpdateOnStart = %s\n", value, value) + } + modified = true + } + + if !modified { + logger.Install.Debug("SLP auto-update flags already set correctly, no change needed") + return false, nil + } + + // Write back + if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { + return false, fmt.Errorf("failed to write updated SLP config: %w", err) + } + + action := "enabled" + if !enable { + action = "disabled" + } + logger.Install.Info(fmt.Sprintf("SLP auto-updater %s (CheckForUpdate & AutoUpdateOnStart = %s)", action, value)) + + return true, nil +} diff --git a/src/setup/launchpad/launchpad.go b/src/setup/launchpad/launchpad.go new file mode 100644 index 00000000..bf6d94f7 --- /dev/null +++ b/src/setup/launchpad/launchpad.go @@ -0,0 +1,242 @@ +//go:build !js + +package launchpad + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" +) + +// InstallSLP downloads the latest StationeersLaunchPad-server zip from GitHub +// and extracts it into BepInEx/plugins/StationeersLaunchPad +// Returns: (installed version tag or "", error) +func InstallSLP() (string, error) { + const repoOwner = "StationeersLaunchPad" + const repoName = "StationeersLaunchPad" + const slpAssetPattern = "StationeersLaunchPad-server-" + + // Prepare target folder + pluginsDir := "BepInEx/plugins" + slpDir := filepath.Join(pluginsDir, "StationeersLaunchPad") + + baseURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", repoOwner, repoName) + logger.Install.Info("📡 Fetching latest Stationeers Launch Pad release...") + + resp, err := http.Get(baseURL) + if err != nil { + return "", fmt.Errorf("failed to query GitHub API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var releases []struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", fmt.Errorf("failed to parse GitHub releases: %w", err) + } + + if len(releases) == 0 { + return "", fmt.Errorf("no releases found in %s/%s", repoOwner, repoName) + } + + // Find newest non-prerelease release with server zip + var selectedRelease *struct { + TagName string + URL string + } + + for _, rel := range releases { + if rel.Prerelease { + continue // skip prereleases for now (you can make configurable later) + } + + for _, asset := range rel.Assets { + if strings.HasPrefix(asset.Name, slpAssetPattern) && + strings.HasSuffix(asset.Name, ".zip") { + selectedRelease = &struct { + TagName string + URL string + }{rel.TagName, asset.URL} + break + } + } + if selectedRelease != nil { + break + } + } + + if selectedRelease == nil { + return "", fmt.Errorf("no suitable StationeersLaunchPad-server-*.zip found in latest releases") + } + + zipName := fmt.Sprintf("StationeersLaunchPad-server-%s.zip", selectedRelease.TagName) + downloadURL := selectedRelease.URL + + logger.Install.Info(fmt.Sprintf("Found SLP %s → downloading %s...", selectedRelease.TagName, zipName)) + + // Download to temp file + tmpZip := zipName + ".tmp" + if err := downloadFile(tmpZip, downloadURL); err != nil { + return "", fmt.Errorf("failed to download SLP zip: %w", err) + } + defer os.Remove(tmpZip) + + // Clean previous installation if exists + if err := os.RemoveAll(slpDir); err != nil { + logger.Install.Warn(fmt.Sprintf("Could not clean old SLP folder: %v", err)) + } + + // Make sure parent exists + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + return "", fmt.Errorf("failed to create BepInEx/plugins directory: %w", err) + } + + // Extract + logger.Install.Info("📦 Extracting Stationeers Launch Pad...") + if err := unzipTo(tmpZip, slpDir, func(name string) bool { + // Only process files inside the StationeersLaunchPad folder + return strings.HasPrefix(name, "StationeersLaunchPad/") + }); err != nil { + return "", fmt.Errorf("failed to extract SLP: %w", err) + } + + logger.Install.Info(fmt.Sprintf("✅ Stationeers Launch Pad %s installed to %s", selectedRelease.TagName, slpDir)) + logger.Install.Info("💡 SLP contains its own auto-updater — future updates should happen automatically.") + config.SetIsStationeersLaunchPadEnabled(true) + + return selectedRelease.TagName, nil +} + +// UninstallSLP removes the SLP folder from BepInEx/plugins +// Returns: ("success" or "failed", error) +func UninstallSLP() (string, error) { + pluginsDir := "BepInEx/plugins" + slpDir := filepath.Join(pluginsDir, "StationeersLaunchPad") + + if err := os.RemoveAll(slpDir); err != nil { + logger.Install.Error("Failed to remove SLP folder: " + err.Error()) + return "failed", fmt.Errorf("failed to remove SLP folder: %w", err) + } + + // remove the ./mods folder and the modconfig.xml file too + if err := os.RemoveAll(filepath.Join(slpDir, "mods")); err != nil { + logger.Install.Error("Failed to remove the mods folder: " + err.Error()) + return "failed", fmt.Errorf("failed to remove SLP mods folder: %w", err) + } + + if err := os.Remove(filepath.Join(slpDir, "modconfig.xml")); err != nil { + logger.Install.Error("Failed to remove SLP modconfig.xml file: " + err.Error()) + return "failed", fmt.Errorf("failed to remove modconfig.xml file: %w", err) + } + + config.SetIsStationeersLaunchPadEnabled(false) + logger.Install.Info("SLP uninstalled successfully") + + return "success", nil +} + +func downloadFile(destPath, url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + out, err := os.Create(destPath) + if err != nil { + return err + } + defer out.Close() + + counter := &update.WriteCounter{Total: resp.ContentLength} + _, err = io.Copy(out, io.TeeReader(resp.Body, counter)) + if err != nil { + return err + } + + return nil +} + +// unzipTo extracts zip contents into destDir +// Only extracts files where shouldExtract returns true +func unzipTo(zipPath, destDir string, shouldExtract func(fileName string) bool) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + if !shouldExtract(f.Name) { + continue + } + + // Remove the leading "StationeersLaunchPad/" from the path + // so BepInEx/plugins/StationeersLaunchPad/core.dll etc. + relPath := strings.TrimPrefix(f.Name, "StationeersLaunchPad/") + if relPath == f.Name { // safety check + logger.Install.Warn("Unexpected file outside StationeersLaunchPad/: " + f.Name) + continue + } + + // Build full target path + fpath := filepath.Join(destDir, relPath) + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(fpath, f.Mode()); err != nil { + return err + } + continue + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + return err + } + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return err + } + + _, err = io.Copy(outFile, rc) + rc.Close() + outFile.Close() + + if err != nil { + return err + } + } + + return nil +} diff --git a/src/setup/update/progressbar.go b/src/setup/update/progressbar.go index 07a362c0..1431253c 100644 --- a/src/setup/update/progressbar.go +++ b/src/setup/update/progressbar.go @@ -8,19 +8,19 @@ import ( ) // writeCounter tracks download progress -type writeCounter struct { +type WriteCounter struct { Total int64 count int64 } -func (wc *writeCounter) Write(p []byte) (int, error) { +func (wc *WriteCounter) Write(p []byte) (int, error) { n := len(p) wc.count += int64(n) wc.printProgress() return n, nil } -func (wc *writeCounter) printProgress() { +func (wc *WriteCounter) printProgress() { // If we don't know the total size, just show downloaded bytes if wc.Total <= 0 { logger.Backup.Info(fmt.Sprintf("\r%s downloaded", bytesToHuman(wc.count))) diff --git a/src/setup/update/updater.go b/src/setup/update/updater.go index f5d6a826..0c5f13fd 100644 --- a/src/setup/update/updater.go +++ b/src/setup/update/updater.go @@ -154,7 +154,7 @@ func downloadNewExecutable(filename, url string) error { } // Show progress - counter := &writeCounter{Total: resp.ContentLength} + counter := &WriteCounter{Total: resp.ContentLength} _, err = io.Copy(out, io.TeeReader(resp.Body, counter)) if err != nil { out.Close() diff --git a/src/web/configpage.go b/src/web/configpage.go index 839f3fdd..7e34a73d 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -121,6 +121,11 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { createGameServerLogFileFalseSelected = "selected" } + isStationeersLaunchPadEnabled := "false" + if config.GetIsStationeersLaunchPadEnabled() { + isStationeersLaunchPadEnabled = "true" + } + data := ConfigTemplateData{ // Config values DiscordToken: config.GetDiscordToken(), @@ -293,6 +298,8 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_CopyrightConfig1: localization.GetString("UIText_Copyright1"), UIText_CopyrightConfig2: localization.GetString("UIText_Copyright2"), + + IsStationeersLaunchPadEnabled: isStationeersLaunchPadEnabled, } err = tmpl.Execute(w, data) diff --git a/src/web/routes.go b/src/web/routes.go index f3e6b3cd..d9590d7c 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -87,5 +87,9 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { // Monitoring protectedMux.HandleFunc("/api/v2/monitor/gameserver/status", HandleMonitorStatus) + // SLP + protectedMux.HandleFunc("/api/v2/slp/install", InstallSLPHandler) + protectedMux.HandleFunc("/api/v2/slp/uninstall", UninstallSLPHandler) + return mux, protectedMux } diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go new file mode 100644 index 00000000..65efd4ba --- /dev/null +++ b/src/web/slp-launchpad.go @@ -0,0 +1,32 @@ +package web + +import ( + "net/http" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" +) + +func InstallSLPHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := launchpad.InstallSLP(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) +} + +func UninstallSLPHandler(w http.ResponseWriter, r *http.Request) { + + w.Header().Set("Content-Type", "application/json") + if _, err := launchpad.UninstallSLP(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) +} diff --git a/src/web/templatevars.go b/src/web/templatevars.go index c61e94b8..4ce388d3 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -201,4 +201,6 @@ type ConfigTemplateData struct { UIText_Copyright string UIText_CopyrightConfig1 string UIText_CopyrightConfig2 string + + IsStationeersLaunchPadEnabled string } From 105467d6a3f61da563e07dce6e06a984ca303116 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 15 Jan 2026 20:33:01 +0100 Subject: [PATCH 02/21] add file input to config page for modpackage --- UIMod/onboard_bundled/ui/config.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index f8197eee..d79a1c09 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -448,6 +448,8 @@

{{.UIText_DiscordIntegrationBenefits}}

To uninstall SLP and all Mods, simply click the button below. This will DELETE all Mods in the Mods folder!

{{end}} +

To upload a mod package, simply select a file or drag and drop it into the box below.

+
From 5144d382c2864deb6afb6852ebb2647428e5e179 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 17:11:43 +0100 Subject: [PATCH 03/21] add modding subsystem and corresponding severity level to logger --- src/logger/logger.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/logger/logger.go b/src/logger/logger.go index a505e154..719c08c3 100644 --- a/src/logger/logger.go +++ b/src/logger/logger.go @@ -25,6 +25,7 @@ var ( Security = &Logger{suffix: SYS_SECURITY} Localization = &Logger{suffix: SYS_LOCALIZATION} Advertiser = &Logger{suffix: SYS_ADVERTISER} + Modding = &Logger{suffix: SYS_MODDING} ) // Severity Levels @@ -50,6 +51,7 @@ const ( SYS_SECURITY = "SECURITY" SYS_LOCALIZATION = "LOCALIZATION" SYS_ADVERTISER = "ADVERTISER" + SYS_MODDING = "MODDING" ) const ( @@ -76,6 +78,7 @@ var subsystemColors = map[string]string{ SYS_SECURITY: colorRed, // Screams "pay attention" SYS_LOCALIZATION: colorCyan, // Matches WEB, localization-related SYS_ADVERTISER: colorYellow, // Matches Config, advanced feature + SYS_MODDING: colorCyan, // } // Global channels and mutex for all loggers From 69a68a4a6943817207760c4e91f562dadd4388ac Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 18:12:21 +0100 Subject: [PATCH 04/21] remove supermaven devcontainer and switch to github copilot update .gitignore to include .agent-context and .github/copilot-instructions --- .devcontainer/devcontainer.json | 1 - .gitignore | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0ed4e03b..c1439a5d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,6 @@ "extensions": [ "golang.go", "svelte.svelte-vscode", - "supermaven.supermaven", "eamodio.gitlens" ], "settings": { diff --git a/.gitignore b/.gitignore index ca2aa0b9..5e61644a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,6 @@ frontend/node_modules frontend/dist frontend/build UIMod/onboard_bundled/v2 -UIMod/logs/* \ No newline at end of file +UIMod/logs/* +.agent-context/** +.github/copilot-instructions.md From 3a9d56e39d969d2f10c40fdd306af8d21225d929 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 18:18:54 +0100 Subject: [PATCH 05/21] added (SLP) mod package upload system & improve SLP install process: Implemented comprehensive mod package upload functionality (&UI) - Add functions to handle modpkg upload, extraction, and deletion - Add route POST /api/v2/slp/upload for mod package uploads - Add logger subsystem logger.Modding - Used universal popup system from Dashboard for UI feedback --- UIMod/onboard_bundled/assets/css/config.css | 230 ++++++++++++++++++ UIMod/onboard_bundled/assets/js/slp.js | 243 +++++++++++++++++++- UIMod/onboard_bundled/ui/config.html | 55 ++++- src/config/config.go | 2 +- src/setup/launchpad/launchpad.go | 33 ++- src/setup/launchpad/modpackages.go | 182 +++++++++++++++ src/web/routes.go | 1 + src/web/slp-launchpad.go | 30 ++- 8 files changed, 751 insertions(+), 25 deletions(-) create mode 100644 src/setup/launchpad/modpackages.go diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index 1c734b22..22449d6a 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -436,4 +436,234 @@ select option { @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } +} + +.slp-section { + padding: 20px; + border-radius: 8px; + background-color: var(--form-bg, #1a1a1a); + margin: 20px 0; +} + +.slp-install-section { + background: linear-gradient(135deg, rgba(0, 255, 150, 0.15), rgba(0, 150, 255, 0.15)); + border: 2px solid var(--primary); + box-shadow: 0 0 20px rgba(0, 255, 150, 0.2); +} + +.slp-install-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 15px; +} + +.slp-install-icon { + font-size: 48px; +} + +.slp-install-header h3 { + color: var(--text-bright); + font-family: 'Press Start 2P', cursive; + font-size: 1rem; + margin: 0; + padding: 0; + border: none; +} + +.slp-section h3 { + color: var(--text-bright); + font-family: 'Press Start 2P', cursive; + font-size: 0.9rem; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 2px solid var(--primary-dim); +} + +.slp-button { + padding: 12px 24px; + background-color: var(--primary); + border: 2px solid var(--primary); + color: var(--bg-dark); + border-radius: 6px; + font-weight: bold; + cursor: pointer; + transition: all var(--transition-normal); + font-family: 'Press Start 2P', cursive; + font-size: 0.8rem; + box-shadow: 0 0 10px rgba(0, 255, 150, 0.3); + margin: 10px 5px 10px 0; +} + +.slp-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 0 20px rgba(0, 255, 150, 0.6); +} + +.slp-button:active:not(:disabled) { + transform: translateY(0); +} + +.slp-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.slp-button.loading { + opacity: 0.8; + cursor: wait; +} + +.slp-button-large { + padding: 16px 32px; + font-size: 0.9rem; + width: 100%; + max-width: 500px; + box-shadow: 0 0 20px rgba(0, 255, 150, 0.4); +} + +.slp-button-large:hover:not(:disabled) { + box-shadow: 0 0 30px rgba(0, 255, 150, 0.7); +} + +.slp-button-small { + padding: 8px 16px; + font-size: 0.7rem; +} + +.slp-button.danger { + background-color: #ff4444; + border-color: #ff4444; + box-shadow: 0 0 10px rgba(255, 68, 68, 0.3); +} + +.slp-button.danger:hover:not(:disabled) { + box-shadow: 0 0 20px rgba(255, 68, 68, 0.6); +} + +.slp-uninstall-section { + opacity: 0.8; + border: 1px solid var(--primary-dim); +} + +#modPackageUploadZone { + border: 3px dashed var(--primary-dim); + border-radius: 10px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all var(--transition-normal); + background-color: rgba(0, 255, 150, 0.05); + position: relative; + margin: 20px 0; +} + +#modPackageUploadZone:hover:not(.upload-zone-disabled) { + border-color: var(--primary); + background-color: rgba(0, 255, 150, 0.1); +} + +#modPackageUploadZone.highlight { + border-color: var(--primary); + background-color: rgba(0, 255, 150, 0.15); + box-shadow: 0 0 20px rgba(0, 255, 150, 0.3); +} + +.upload-zone-disabled { + cursor: not-allowed !important; + opacity: 0.6; + border-color: var(--primary-dim) !important; + background-color: rgba(0, 150, 150, 0.05) !important; +} + +.upload-zone-disabled:hover { + border-color: var(--primary-dim) !important; + background-color: rgba(0, 150, 150, 0.05) !important; +} + +.upload-icon { + font-size: 48px; + margin-bottom: 15px; + display: block; +} + +.upload-text { + color: var(--text-bright); + font-family: 'Press Start 2P', cursive; + font-size: 0.9rem; + margin: 10px 0; +} + +.upload-subtext { + color: var(--text-dim); + font-family: 'Share Tech Mono', monospace; + font-size: 0.8rem; + margin-top: 10px; +} + +#modPackageUpload { + display: none; +} + +#modPackageUploadProgress { + display: none; + height: 4px; + background-color: var(--primary); + border-radius: 2px; + width: 0%; + transition: width 0.3s ease; + margin-top: 10px; + box-shadow: 0 0 10px var(--primary); +} + +#modPackageUploadProgress.active { + display: block; +} + +/* Notification Styling */ +.notification { + display: none; + padding: 16px 20px; + margin: 15px 0; + border-radius: 8px; + font-family: 'Share Tech Mono', monospace; + font-size: 0.9rem; + animation: slideIn 0.3s ease-out; + position: relative; + border-left: 5px solid transparent; +} + +.notification.success { + background-color: rgba(0, 255, 150, 0.1); + border-color: #00ff96; + color: #00ff96; +} + +.notification.error { + background-color: rgba(255, 68, 68, 0.1); + border-color: #ff4444; + color: #ff6666; +} + +.notification.info { + background-color: rgba(0, 200, 255, 0.1); + border-color: #00c8ff; + color: #00d4ff; +} + +.notification.warning { + background-color: rgba(255, 200, 0, 0.1); + border-color: #ffc800; + color: #ffd700; +} + +@keyframes slideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } } \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js index 85c55fad..1e6d8d00 100644 --- a/UIMod/onboard_bundled/assets/js/slp.js +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -1,31 +1,258 @@ +function showNotification(message, type = 'info') { + const notification = document.getElementById('notification'); + notification.textContent = message; + notification.className = 'notification ' + type; + notification.style.display = 'block'; + + // Auto-hide after 5 seconds for success, keep longer for errors + const duration = type === 'success' ? 5000 : 7000; + setTimeout(() => { + notification.style.display = 'none'; + }, duration); +} + +function setButtonLoading(buttonId, isLoading) { + const button = document.getElementById(buttonId); + if (!button) return; + + if (isLoading) { + button.disabled = true; + button.dataset.originalText = button.textContent; + button.textContent = '⏳ Please wait...'; + button.classList.add('loading'); + } else { + button.disabled = false; + button.textContent = button.dataset.originalText || button.textContent; + button.classList.remove('loading'); + } +} + function installSLP() { + setButtonLoading('installSLPBtn', true); + showPopup('info', 'Installing Stationeers Launch Pad...'); + fetch('/api/v2/slp/install') .then(response => response.json()) .then(data => { if (data.success) { - alert('SLP installed successfully!'); - window.location.reload(); + showPopup('success', 'Stationeers Launch Pad installed successfully! The page will refresh automatically.'); + setButtonLoading('installSLPBtn', false); + // Reload after 3 seconds to show the success message + setTimeout(() => window.location.reload(), 3000); } else { - alert('Failed to install SLP: ' + data.error); + showPopup('error', 'Failed to install SLP:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('installSLPBtn', false); } }) .catch(error => { - alert('Failed to install SLP: ' + error); + showPopup('error', 'Failed to install SLP:\n\n' + (error.message || 'Network error')); + setButtonLoading('installSLPBtn', false); }); } function uninstallSLP() { + if (!confirm('Are you sure you want to uninstall SLP? This will DELETE all mods too (you can always reinstall later).')) { + return; + } + setButtonLoading('uninstallSLPBtn', true); + showPopup('info', 'Uninstalling Stationeers Launch Pad...'); + fetch('/api/v2/slp/uninstall') .then(response => response.json()) .then(data => { if (data.success) { - alert('SLP uninstalled successfully!'); - window.location.reload(); + showPopup('success', 'Stationeers Launch Pad uninstalled successfully! The page will refresh automatically.'); + setButtonLoading('uninstallSLPBtn', false); + setTimeout(() => window.location.reload(), 3000); + } else { + showPopup('error', 'Failed to uninstall SLP:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('uninstallSLPBtn', false); + } + }) + .catch(error => { + showPopup('error', 'Failed to uninstall SLP:\n\n' + (error.message || 'Network error')); + setButtonLoading('uninstallSLPBtn', false); + }); +} + +let selectedModFile = null; + +function handleModPackageSelection(files) { + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + + // Validate file is a zip + if (!file.name.endsWith('.zip')) { + showPopup('error', 'Invalid file format\n\nPlease select a .zip file'); + document.getElementById('modPackageUpload').value = ''; + selectedModFile = null; + updateFileDisplay(); + return; + } + + // Validate filename starts with modpkg_ + if (!file.name.startsWith('modpkg_')) { + showPopup('error', 'Invalid mod package name: Mod package filename must start with "modpkg_" Example: modpkg_2026-01-09_12-33-01-670.zip'); + document.getElementById('modPackageUpload').value = ''; + selectedModFile = null; + updateFileDisplay(); + return; + } + + // Check file size (limit to 500MB) + const maxSize = 500 * 1024 * 1024; + if (file.size > maxSize) { + showPopup('error', 'File too large: Maximum file size is 500MB'); + document.getElementById('modPackageUpload').value = ''; + selectedModFile = null; + updateFileDisplay(); + return; + } + + // Store the file for later upload + selectedModFile = file; + updateFileDisplay(); + showNotification('✓ File selected: ' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + 'MB)', 'success'); +} + +function updateFileDisplay() { + const uploadZone = document.getElementById('modPackageUploadZone'); + const uploadBtn = document.getElementById('uploadModPackageBtn'); + + if (!uploadZone || !uploadBtn) return; + + if (selectedModFile) { + uploadZone.innerHTML = '' + + '
File Selected
' + + '
' + selectedModFile.name + '
' + + '
' + (selectedModFile.size / 1024 / 1024).toFixed(2) + ' MB
'; + uploadBtn.disabled = false; + uploadBtn.style.opacity = '1'; + } else { + uploadZone.innerHTML = '📦' + + '
Drag & Drop Mod Package Here
' + + '
or click to select a .zip file
'; + uploadBtn.disabled = true; + uploadBtn.style.opacity = '0.5'; + } +} + +function uploadModPackage() { + if (!selectedModFile) { + showPopup('error', 'No file selected'); + return; + } + + setButtonLoading('uploadModPackageBtn', true); + showPopup('info', 'Uploading mod package...\n\n' + selectedModFile.name + ' (' + (selectedModFile.size / 1024 / 1024).toFixed(2) + 'MB)'); + updateUploadProgress(0); + + const reader = new FileReader(); + reader.onload = function(e) { + const zipData = e.target.result; + + fetch('/api/v2/slp/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/zip' + }, + body: zipData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showPopup('success', 'Mod package uploaded successfully!\n\n' + (data.message || 'The mods have been extracted and are ready to use.')); + selectedModFile = null; + document.getElementById('modPackageUpload').value = ''; + updateFileDisplay(); + updateUploadProgress(100); + setTimeout(() => updateUploadProgress(0), 2000); + setButtonLoading('uploadModPackageBtn', false); } else { - alert('Failed to uninstall SLP: ' + data.error); + showPopup('error', 'Failed to upload mod package:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('uploadModPackageBtn', false); } }) .catch(error => { - alert('Failed to uninstall SLP: ' + error); + showPopup('error', 'Upload failed:\n\n' + (error.message || 'Network error')); + setButtonLoading('uploadModPackageBtn', false); }); + }; + + reader.onerror = function() { + showPopup('error', 'Failed to read file'); + setButtonLoading('uploadModPackageBtn', false); + }; + + reader.readAsArrayBuffer(selectedModFile); +} + +function updateUploadProgress(percent) { + const progressBar = document.getElementById('modPackageUploadProgress'); + if (progressBar) { + progressBar.style.width = percent + '%'; + if (percent > 0) { + progressBar.classList.add('active'); + } else { + progressBar.classList.remove('active'); + } + } +} + +// Drag and drop support +function initializeDragAndDrop() { + const uploadZone = document.getElementById('modPackageUploadZone'); + if (!uploadZone) return; + + // Click to open file selector + uploadZone.addEventListener('click', function() { + document.getElementById('modPackageUpload').click(); + }); + + // Drag and drop events + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadZone.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ['dragenter', 'dragover'].forEach(eventName => { + uploadZone.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadZone.addEventListener(eventName, unhighlight, false); + }); + + function highlight(e) { + uploadZone.classList.add('highlight'); + } + + function unhighlight(e) { + uploadZone.classList.remove('highlight'); + } + + uploadZone.addEventListener('drop', handleDrop, false); + + function handleDrop(e) { + const dt = e.dataTransfer; + const files = dt.files; + handleModPackageSelection(files); + } + + // Initialize file display on load + updateFileDisplay(); +} + +// Initialize on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDragAndDrop); +} else { + initializeDragAndDrop(); } \ No newline at end of file diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index d79a1c09..e580c543 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -14,11 +14,19 @@ +
+
@@ -439,17 +447,49 @@

{{.UIText_DiscordIntegrationBenefits}}

-

Stationeers Launch Pad is a simple mod loader for Stationeers. SLP Updates itself automatically.

{{if eq .IsStationeersLaunchPadEnabled "false"}} -

To install SLP, simply click the button below.

- +
+
+ 🚀 +

Stationeers Launch Pad

+
+

Stationeers Launch Pad is a simple mod loader for Stationeers. It automatically updates itself and makes mod management easy.

+

Ready to install? Click the button below to get started.

+ +
+ +
+

Upload Mod Package

+

Once SLP is installed, you'll be able to upload SLP packages here.

+
+ 🔒 +
Install SLP First
+
SLP must be installed to upload mods
+
+
{{end}} + {{if eq .IsStationeersLaunchPadEnabled "true"}} -

To uninstall SLP and all Mods, simply click the button below. This will DELETE all Mods in the Mods folder!

- +
+

Upload Mod Package

+

Select a mod package zip file or drag and drop it into the box below to upload and extract mods.

+ +
+ 📦 +
Drag & Drop Mod Package Here
+
or click to select a .zip file
+
+
+ + +
+ +
+

Manage Installation

+

⚠️ Uninstalling will DELETE all mods in the Mods folder.

+ +
{{end}} -

To upload a mod package, simply select a file or drag and drop it into the box below.

-
@@ -620,6 +660,7 @@

Regex Detection:

+ diff --git a/src/config/config.go b/src/config/config.go index e41251b0..edce2ecf 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.10.0" + Version = "5.11.0" Branch = "release" ) diff --git a/src/setup/launchpad/launchpad.go b/src/setup/launchpad/launchpad.go index bf6d94f7..02f2ba70 100644 --- a/src/setup/launchpad/launchpad.go +++ b/src/setup/launchpad/launchpad.go @@ -133,20 +133,39 @@ func UninstallSLP() (string, error) { pluginsDir := "BepInEx/plugins" slpDir := filepath.Join(pluginsDir, "StationeersLaunchPad") + // stat the folder to see if it exists, if not skip removal + if _, err := os.Stat(slpDir); os.IsNotExist(err) { + logger.Install.Info("SLP is not installed; nothing to uninstall") + config.SetIsStationeersLaunchPadEnabled(false) + return "not_installed", nil + } + if err := os.RemoveAll(slpDir); err != nil { logger.Install.Error("Failed to remove SLP folder: " + err.Error()) return "failed", fmt.Errorf("failed to remove SLP folder: %w", err) } - // remove the ./mods folder and the modconfig.xml file too - if err := os.RemoveAll(filepath.Join(slpDir, "mods")); err != nil { - logger.Install.Error("Failed to remove the mods folder: " + err.Error()) - return "failed", fmt.Errorf("failed to remove SLP mods folder: %w", err) + // stat the current directory to see if mod files exist, if not skip removal + if _, err := os.Stat(filepath.Join(".", "mods")); os.IsNotExist(err) { + logger.Install.Info("No mods folder found; skipping mods removal") + } else { + + // remove the ./mods folder and the modconfig.xml file too + if err := os.RemoveAll(filepath.Join(".", "mods")); err != nil { + logger.Install.Error("Failed to remove the mods folder: " + err.Error()) + return "failed", fmt.Errorf("failed to remove mods folder: %w", err) + } } - if err := os.Remove(filepath.Join(slpDir, "modconfig.xml")); err != nil { - logger.Install.Error("Failed to remove SLP modconfig.xml file: " + err.Error()) - return "failed", fmt.Errorf("failed to remove modconfig.xml file: %w", err) + // stat the modconfig.xml file to see if it exists, if not skip removal + if _, err := os.Stat(filepath.Join(".", "modconfig.xml")); os.IsNotExist(err) { + logger.Install.Info("No modconfig.xml file found; skipping its removal") + } else { + + if err := os.Remove(filepath.Join(".", "modconfig.xml")); err != nil { + logger.Install.Error("Failed to remove modconfig.xml file: " + err.Error()) + return "failed", fmt.Errorf("failed to remove modconfig.xml file: %w", err) + } } config.SetIsStationeersLaunchPadEnabled(false) diff --git a/src/setup/launchpad/modpackages.go b/src/setup/launchpad/modpackages.go new file mode 100644 index 00000000..1bcef5bf --- /dev/null +++ b/src/setup/launchpad/modpackages.go @@ -0,0 +1,182 @@ +package launchpad + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// ProcessModPackageUpload handles the upload and extraction of a mod package zip file +func ProcessModPackageUpload(r io.Reader) error { + logger.Modding.Info("Starting mod package upload process") + + const maxZipSize = 500 * 1024 * 1024 // 500 MB + sizeLimitedReader := io.LimitReader(r, maxZipSize+1) + + // Read the entire zip file into memory + zipBytes, err := io.ReadAll(sizeLimitedReader) + if err != nil { + logger.Modding.Errorf("Failed to read mod package zip file, filesize might exceed 500mb: %v", err) + return fmt.Errorf("failed to read mod package zip file, filesize might exceed 500mb: %w", err) + } + + if len(zipBytes) == 0 { + logger.Modding.Error("Received empty mod package") + return fmt.Errorf("mod package is empty") + } + + logger.Modding.Debugf("Received Modpackage: %d bytes", len(zipBytes)) + + // Create temporary file with timestamp + timestamp := time.Now().Unix() + tempFilename := fmt.Sprintf("tmp-uploaded-modpackage-%d.zip", timestamp) + tempFilepath := filepath.Join(".", tempFilename) + + logger.Modding.Debugf("Saving temporary mod package: %s", tempFilename) + if err := os.WriteFile(tempFilepath, zipBytes, 0644); err != nil { + logger.Modding.Errorf("Failed to write temporary mod package: %v", err) + return fmt.Errorf("failed to save temporary mod package: %w", err) + } + defer func() { + if err := os.Remove(tempFilepath); err != nil && !os.IsNotExist(err) { + logger.Modding.Warnf("Failed to clean up temporary mod package: %v", err) + } + }() + + // Clear ./mods directory if it exists + modsDir := filepath.Join(".", "mods") + if err := clearDirectory(modsDir); err != nil { + logger.Modding.Warnf("Ran into an issue while clearing mods directory: %v", err) + } + + // Remove modconfig.xml if it exists + modconfigPath := filepath.Join(".", "modconfig.xml") + if err := os.Remove(modconfigPath); err != nil && !os.IsNotExist(err) { + logger.Modding.Warnf("Failed to remove existing modconfig.xml: %v", err) + } + if !os.IsNotExist(err) { + logger.Modding.Debug("Removed existing modconfig.xml") + } + + // Extract the zip file to current working directory + if err := extractZip(tempFilepath, "."); err != nil { + logger.Modding.Errorf("Failed to extract mod package: %v", err) + return fmt.Errorf("failed to extract mod package: %w", err) + } + + // Call ImportModPackage with the zip bytes + if err := ImportModPackage(zipBytes); err != nil { + logger.Modding.Errorf("ImportModPackage failed: %v", err) + return fmt.Errorf("import mod package failed: %w", err) + } + + logger.Modding.Info("Mod package upload process completed successfully") + return nil +} + +// clearDirectory removes all files and subdirectories in a directory +func clearDirectory(dirPath string) error { + if _, err := os.Stat(dirPath); err != nil { + if os.IsNotExist(err) { + return nil // Directory doesn't exist, nothing to clear + } + return err + } + + logger.Modding.Debugf("Clearing directory: %s", dirPath) + + entries, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("failed to read directory: %w", err) + } + + for _, entry := range entries { + path := filepath.Join(dirPath, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + } + + logger.Modding.Debug("Directory cleared successfully") + return nil +} + +// extractZip extracts all files from a zip archive to the destination directory +func extractZip(zipPath string, destDir string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer reader.Close() + + logger.Modding.Debugf("Extracting %d files from zip", len(reader.File)) + + for i, file := range reader.File { + filePath := filepath.Join(destDir, file.Name) + + // Prevent path traversal attacks + if !filepath.IsLocal(filepath.Join(filepath.Dir(filePath), filepath.Base(filePath))) { + return fmt.Errorf("invalid file path in archive: %s", file.Name) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(filePath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } else { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + rc, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open file in archive: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + rc.Close() + return fmt.Errorf("failed to create directory for file: %w", err) + } + + outFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + rc.Close() + return fmt.Errorf("failed to create output file: %w", err) + } + + if _, err := io.Copy(outFile, rc); err != nil { + outFile.Close() + rc.Close() + return fmt.Errorf("failed to write file: %w", err) + } + + outFile.Close() + rc.Close() + + if (i+1)%10 == 0 || i == len(reader.File)-1 { + logger.Modding.Debugf("Extracted %d/%d files", i+1, len(reader.File)) + } + } + } + + return nil +} + +func ImportModPackage(zipData []byte) error { + if len(zipData) == 0 { + return fmt.Errorf("empty zip data") + } + + // Validate it's a valid zip file + if _, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))); err != nil { + return fmt.Errorf("invalid zip file: %w", err) + } + + return nil +} diff --git a/src/web/routes.go b/src/web/routes.go index d9590d7c..8147595f 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -90,6 +90,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { // SLP protectedMux.HandleFunc("/api/v2/slp/install", InstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/uninstall", UninstallSLPHandler) + protectedMux.HandleFunc("/api/v2/slp/upload", UploadModPackageHandler) return mux, protectedMux } diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go index 65efd4ba..0de977ea 100644 --- a/src/web/slp-launchpad.go +++ b/src/web/slp-launchpad.go @@ -1,6 +1,7 @@ package web import ( + "encoding/json" "net/http" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" @@ -10,7 +11,10 @@ func InstallSLPHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if _, err := launchpad.InstallSLP(); err != nil { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) return } @@ -23,10 +27,32 @@ func UninstallSLPHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if _, err := launchpad.UninstallSLP(); err != nil { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"success": true}`)) } + +func UploadModPackageHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if err := launchpad.ProcessModPackageUpload(r.Body); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Mod package uploaded and extracted successfully", + }) +} From ec2743463d2cf8c1d722565a22234cbdfc971c59 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 18:49:16 +0100 Subject: [PATCH 06/21] add mod listing and workshop handle list functionality and API endpoint to get installed mod details --- src/cli/runtimecommands.go | 32 +++++++++++ src/setup/launchpad/modlist.go | 100 +++++++++++++++++++++++++++++++++ src/web/routes.go | 1 + src/web/slp-launchpad.go | 12 ++++ 4 files changed, 145 insertions(+) create mode 100644 src/setup/launchpad/modlist.go diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go index 0953244d..850fc796 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/runtimecommands.go @@ -23,6 +23,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) @@ -167,6 +168,37 @@ func init() { RegisterCommand("update", WrapNoReturn(triggerUpdateCheck), "u") RegisterCommand("applyupdate", WrapNoReturn(applyUpdate), "au") RegisterCommand("installslp", WrapNoReturn(installSLP), "slp") + RegisterCommand("listmods", WrapNoReturn(listmods), "lm") + RegisterCommand("listworkshophandles", WrapNoReturn(listworkshophandles), "lwh") +} + +func listmods() { + mods := launchpad.GetModList() + if len(mods) == 0 { + logger.Core.Info("No mods installed.") + return + } + logger.Core.Info(fmt.Sprintf("Installed Mods (%d):", len(mods))) + for _, mod := range mods { + + // print mod details in one logger call but with /n for new lines + logger.Modding.Info("Mod Details:\n" + + fmt.Sprintf("Modname: %s\n", mod.Name) + + fmt.Sprintf(" Version: %s\n", mod.Version) + + fmt.Sprintf(" Author: %s\n", mod.Author) + + fmt.Sprintf(" Workshop Handle: %s\n", mod.WorkshopHandle)) + } +} + +func listworkshophandles() { + handles := launchpad.GetModWorkshopHandles() + if len(handles) == 0 { + logger.Core.Info("No mods with Workshop handles found.") + return + } + logger.Core.Info(fmt.Sprintf("Installed Mod Workshop Handles: (%d):", len(handles))) + logger.Modding.Info(fmt.Sprintf("%v", handles)) + } func startServer() { diff --git a/src/setup/launchpad/modlist.go b/src/setup/launchpad/modlist.go new file mode 100644 index 00000000..f605d8e2 --- /dev/null +++ b/src/setup/launchpad/modlist.go @@ -0,0 +1,100 @@ +package launchpad + +import ( + "encoding/xml" + "os" + "path/filepath" +) + +// ModMetadata represents a parsed mod from About.xml +type ModMetadata struct { + Name string + Author string + Version string + Description string + WorkshopHandle string +} + +// aboutXML is the structure for parsing About.xml files +type aboutXML struct { + Name string `xml:"Name"` + Author string `xml:"Author"` + Version string `xml:"Version"` + Description string `xml:"Description"` + WorkshopHandle string `xml:"WorkshopHandle"` +} + +// GetModList returns an array of installed mods and their details +func GetModList() []ModMetadata { + var mods []ModMetadata + + // Check if ./mods folder exists + modsPath := "./mods" + info, err := os.Stat(modsPath) + if err != nil || !info.IsDir() { + return mods // Return empty slice if folder doesn't exist or isn't a directory + } + + // Read all entries in the mods folder + entries, err := os.ReadDir(modsPath) + if err != nil { + return mods // Return empty slice if we can't read the directory + } + + // Iterate over each entry in the mods folder + for _, entry := range entries { + if !entry.IsDir() { + continue // Skip if not a directory + } + + dirName := entry.Name() + aboutXMLPath := filepath.Join(modsPath, dirName, "About", "About.xml") + + // Check if About.xml exists + if _, err := os.Stat(aboutXMLPath); os.IsNotExist(err) { + continue // Skip if About.xml doesn't exist + } + + // Try to parse the XML file + data, err := os.ReadFile(aboutXMLPath) + if err != nil { + continue // Skip if we can't read the file + } + + var xmlData aboutXML + err = xml.Unmarshal(data, &xmlData) + if err != nil { + continue // Skip if we can't parse the XML + } + + // Use WorkshopHandle from XML if available + workshopHandle := xmlData.WorkshopHandle + + // Build the ModMetadata struct + mod := ModMetadata{ + Name: xmlData.Name, + Author: xmlData.Author, + Version: xmlData.Version, + Description: xmlData.Description, + WorkshopHandle: workshopHandle, + } + + mods = append(mods, mod) + } + + return mods +} + +// GetModWorkshopHandles returns an array of workshop handles for installed mods that have one +func GetModWorkshopHandles() []string { + var handles []string + mods := GetModList() + + for _, mod := range mods { + if mod.WorkshopHandle != "" { + handles = append(handles, mod.WorkshopHandle) + } + } + + return handles +} diff --git a/src/web/routes.go b/src/web/routes.go index 8147595f..086e368c 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -91,6 +91,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { protectedMux.HandleFunc("/api/v2/slp/install", InstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/uninstall", UninstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/upload", UploadModPackageHandler) + protectedMux.HandleFunc("/api/v2/slp/mods", GetInstalledModDetailsHandler) return mux, protectedMux } diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go index 0de977ea..65141bf8 100644 --- a/src/web/slp-launchpad.go +++ b/src/web/slp-launchpad.go @@ -56,3 +56,15 @@ func UploadModPackageHandler(w http.ResponseWriter, r *http.Request) { "message": "Mod package uploaded and extracted successfully", }) } + +func GetInstalledModDetailsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + mods := launchpad.GetModList() + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "mods": mods, + }) +} From 62204bb54609bcdf517e74865faed256c37be840 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 19:20:12 +0100 Subject: [PATCH 07/21] add image parsing functionality to modlist metadata --- src/setup/launchpad/modlist.go | 57 +++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/setup/launchpad/modlist.go b/src/setup/launchpad/modlist.go index f605d8e2..247326af 100644 --- a/src/setup/launchpad/modlist.go +++ b/src/setup/launchpad/modlist.go @@ -1,9 +1,11 @@ package launchpad import ( + "encoding/base64" "encoding/xml" "os" "path/filepath" + "strings" ) // ModMetadata represents a parsed mod from About.xml @@ -13,6 +15,7 @@ type ModMetadata struct { Version string Description string WorkshopHandle string + Images map[string]string // filename -> base64 encoded image data } // aboutXML is the structure for parsing About.xml files @@ -24,6 +27,53 @@ type aboutXML struct { WorkshopHandle string `xml:"WorkshopHandle"` } +// loadModImages loads all images from the About folder and converts them to base64 +func loadModImages(aboutPath string) map[string]string { + images := make(map[string]string) + + // List all files in the About folder + entries, err := os.ReadDir(aboutPath) + if err != nil { + return images // Return empty map if we can't read the directory + } + + // Common image extensions (case-insensitive) + imageExtensions := map[string]bool{ + ".png": true, + ".jpg": true, + ".jpeg": true, + ".webp": true, + } + + // Iterate over files and look for images + for _, entry := range entries { + if entry.IsDir() { + continue // Skip directories + } + + filename := entry.Name() + ext := strings.ToLower(filepath.Ext(filename)) + + // Check if file has an image extension + if !imageExtensions[ext] { + continue + } + + // Read the image file + imagePath := filepath.Join(aboutPath, filename) + data, err := os.ReadFile(imagePath) + if err != nil { + continue // Skip if we can't read the file + } + + // Convert to base64 + encoded := base64.StdEncoding.EncodeToString(data) + images[filename] = encoded + } + + return images +} + // GetModList returns an array of installed mods and their details func GetModList() []ModMetadata { var mods []ModMetadata @@ -48,7 +98,8 @@ func GetModList() []ModMetadata { } dirName := entry.Name() - aboutXMLPath := filepath.Join(modsPath, dirName, "About", "About.xml") + aboutPath := filepath.Join(modsPath, dirName, "About") + aboutXMLPath := filepath.Join(aboutPath, "About.xml") // Check if About.xml exists if _, err := os.Stat(aboutXMLPath); os.IsNotExist(err) { @@ -67,6 +118,9 @@ func GetModList() []ModMetadata { continue // Skip if we can't parse the XML } + // Load images from the About folder + images := loadModImages(aboutPath) + // Use WorkshopHandle from XML if available workshopHandle := xmlData.WorkshopHandle @@ -77,6 +131,7 @@ func GetModList() []ModMetadata { Version: xmlData.Version, Description: xmlData.Description, WorkshopHandle: workshopHandle, + Images: images, } mods = append(mods, mod) From 26c02bfc7f434bbf855e437288d16a482bbe745b Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 19:20:52 +0100 Subject: [PATCH 08/21] add mod list view to SLP UI --- .gitignore | 1 + UIMod/onboard_bundled/assets/css/config.css | 200 ++++++++++++++++++++ UIMod/onboard_bundled/assets/js/slp.js | 171 +++++++++++++++++ UIMod/onboard_bundled/ui/config.html | 8 + 4 files changed, 380 insertions(+) diff --git a/.gitignore b/.gitignore index 5e61644a..59e620c0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ UIMod/onboard_bundled/v2 UIMod/logs/* .agent-context/** .github/copilot-instructions.md +mods/** \ 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 22449d6a..e16396a8 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -666,4 +666,204 @@ select option { transform: translateY(0); opacity: 1; } +} + +/* Mods List Section */ +.mods-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.mod-card { + background: linear-gradient(135deg, var(--card-bg-start, #1a1a2e), var(--card-bg-end, #16213e)); + border: 2px solid var(--primary-dim); + border-radius: 12px; + padding: 20px; + transition: all var(--transition-normal); + box-shadow: 0 0 15px var(--button-glow-soft); +} + +.mod-card:hover { + border-color: var(--primary); + box-shadow: 0 0 25px var(--button-glow); + transform: translateY(-5px); +} + +.mod-image-container { + width: 100%; + height: 250px; + background: var(--input-bg, #0f0f1e); + border: 2px solid var(--primary-dim); + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + position: relative; +} + +.mod-image-container img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + padding: 5px; +} + +.mod-image-container.no-image { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim, #888); + font-size: 3rem; +} + + + +.mod-title { + font-family: 'Press Start 2P', cursive; + font-size: 0.95rem; + color: var(--text-bright); + margin-bottom: 5px; + word-break: break-word; +} + +.mod-author { + color: var(--primary); + font-size: 0.85rem; + margin-bottom: 3px; + font-family: 'Share Tech Mono', monospace; +} + +.mod-version { + color: var(--text-dim, #888); + font-size: 0.8rem; + margin-bottom: 10px; + font-family: 'Share Tech Mono', monospace; +} + +.mod-description { + color: var(--text-normal, #ccc); + font-size: 0.85rem; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; + max-height: 250px; + overflow: hidden; + position: relative; + transition: max-height 0.3s ease; +} + +.mod-description.expanded { + max-height: none; + overflow: visible; +} + +.mod-description::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(transparent, var(--card-bg-start, #1a1a2e)); + pointer-events: none; + transition: opacity 0.3s ease; +} + +.mod-description.expanded::after { + opacity: 0; + pointer-events: none; +} + +.mod-description h1 { + font-family: 'Press Start 2P', cursive; + font-size: 1rem; + color: var(--primary); + margin: 12px 0 8px 0; +} + +.mod-description h2 { + font-family: 'Press Start 2P', cursive; + font-size: 0.95rem; + color: var(--primary); + margin: 10px 0 6px 0; +} + +.mod-description h3, +.mod-description h4 { + font-family: 'Press Start 2P', cursive; + font-size: 0.9rem; + color: var(--primary); + margin: 8px 0 5px 0; +} + +.mod-description b { + color: var(--primary); + font-weight: bold; +} + +.mod-description i { + font-style: italic; + color: var(--primary-glow); +} + +.mod-description u { + text-decoration: underline; +} + +.mod-description ul { + margin: 8px 0; + padding-left: 20px; +} + +.mod-description li { + margin: 4px 0; +} + +.mod-description hr { + border: none; + border-top: 1px solid var(--primary-dim); + margin: 12px 0; +} + +.mod-description a { + color: var(--primary); + text-decoration: underline; + cursor: pointer; +} + +.mod-description a:hover { + color: var(--primary-glow); +} + +.mod-expand-button { + box-shadow: none !important; + display: inline-block; + margin-top: 10px; + background: none; + border: none; + color: var(--primary); + cursor: pointer; + font-size: 1.2rem; + padding: 0; + transition: transform 0.3s ease; + font-weight: bold; +} + +.mod-expand-button:hover { + color: var(--primary-glow); +} + +.mod-expand-button.expanded { + transform: rotate(180deg); +} + +.mods-empty { + text-align: center; + color: var(--text-dim, #888); + padding: 40px 20px; + font-family: 'Share Tech Mono', monospace; } \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js index 1e6d8d00..3825e177 100644 --- a/UIMod/onboard_bundled/assets/js/slp.js +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -255,4 +255,175 @@ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeDragAndDrop); } else { initializeDragAndDrop(); +} + +// Mods List Management +let modsData = []; + +function loadInstalledMods() { + const container = document.getElementById('mods-list-container'); + const loader = document.getElementById('mods-loader'); + const modsList = document.getElementById('mods-list'); + + if (!container) return; + + // Show loader + if (loader) loader.style.display = 'block'; + if (modsList) modsList.innerHTML = ''; + + fetch('/api/v2/slp/mods') + .then(response => response.json()) + .then(data => { + if (loader) loader.style.display = 'none'; + + if (data.success && data.mods && data.mods.length > 0) { + modsData = data.mods; + renderModsList(data.mods); + } else { + if (modsList) modsList.innerHTML = '
No mods installed yet. Upload a mod package to get started!
'; + } + }) + .catch(error => { + if (loader) loader.style.display = 'none'; + console.error('Failed to load mods:', error); + if (modsList) modsList.innerHTML = '
Failed to load mods. Check console for details.
'; + }); +} + +function renderModsList(mods) { + const modsList = document.getElementById('mods-list'); + if (!modsList) return; + + modsList.innerHTML = ''; + + if (!mods || mods.length === 0) { + modsList.innerHTML = '
No mods installed yet. Upload a mod package to get started!
'; + return; + } + + mods.forEach((mod, index) => { + const modCard = createModCard(mod, index); + modsList.appendChild(modCard); + }); +} + +function createModCard(mod, index) { + const card = document.createElement('div'); + card.className = 'mod-card'; + + const images = mod.Images || {}; + const imageArray = Object.entries(images); + + let imageHtml = ''; + if (imageArray.length > 0) { + const firstImageData = imageArray[0][1]; + imageHtml = ` +
+ Mod image +
+ `; + } else { + imageHtml = ` +
+ 📷 +
+ `; + } + + const parsedDescription = mod.Description ? parseSteamMarkup(mod.Description) : ''; + + let descriptionHtml = ''; + if (parsedDescription) { + descriptionHtml = ` +
+ ${parsedDescription} +
+ + `; + } + + card.innerHTML = ` + ${imageHtml} +
${escapeHtml(mod.Name || 'Unknown Mod')}
+ ${mod.Author ? `
By ${escapeHtml(mod.Author)}
` : ''} + ${mod.Version ? `
v${escapeHtml(mod.Version)}
` : ''} + ${descriptionHtml} + `; + + return card; +} + +function parseSteamMarkup(text) { + if (!text) return ''; + + // Escape HTML first + let html = text + .replace(/&/g, '&') + .replace(//g, '>'); + + // Replace Steam markup tags + // Headers + html = html.replace(/\[h1\](.*?)\[\/h1\]/g, '

$1

'); + html = html.replace(/\[h2\](.*?)\[\/h2\]/g, '

$1

'); + html = html.replace(/\[h3\](.*?)\[\/h3\]/g, '

$1

'); + html = html.replace(/\[h4\](.*?)\[\/h4\]/g, '

$1

'); + + // Bold, Italic, Underline + html = html.replace(/\[b\](.*?)\[\/b\]/g, '$1'); + html = html.replace(/\[i\](.*?)\[\/i\]/g, '$1'); + html = html.replace(/\[u\](.*?)\[\/u\]/g, '$1'); + + // Horizontal rule + html = html.replace(/\[hr\]/g, '
'); + + // Links + html = html.replace(/\[url=(.*?)\](.*?)\[\/url\]/g, '$2'); + + // Lists - handle [list] ... [/list] with [*] items + html = html.replace(/\[list\]([\s\S]*?)\[\/list\]/g, function(match, content) { + const items = content.split(/\[\*\]/).filter(item => item.trim()); + const listItems = items.map(item => '
  • ' + item.trim() + '
  • ').join(''); + return '
      ' + listItems + '
    '; + }); + + // Remove image tags (we don't need external images) + html = html.replace(/\[img\].*?\[\/img\]/g, ''); + + // Handle line breaks - replace multiple spaces/newlines with actual line breaks + html = html.replace(/\n\s*\n/g, '

    '); + html = html.replace(/\n/g, '
    '); + + return html; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function toggleModDescription(index) { + const descElement = document.getElementById(`desc-${index}`); + const btnElement = document.getElementById(`expand-btn-${index}`); + + if (descElement && btnElement) { + descElement.classList.toggle('expanded'); + btnElement.classList.toggle('expanded'); + } +} + +// Initialize mods list when mods section is visible +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + const modsContainer = document.getElementById('mods-list-container'); + if (modsContainer) { + loadInstalledMods(); + } + }); +} else { + const modsContainer = document.getElementById('mods-list-container'); + if (modsContainer) { + loadInstalledMods(); + } } \ No newline at end of file diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index e580c543..3a4a185f 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -489,6 +489,14 @@

    Manage Installation

    ⚠️ Uninstalling will DELETE all mods in the Mods folder.

    + +
    +

    Installed Mods

    +
    +
    +
    +
    +
    {{end}}
    From d4323a5c0281dbf16d302548ceb2d7e5b6eaaf7d Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 22:11:56 +0100 Subject: [PATCH 09/21] rename mod functionality package to from "launchpad" to "modding" package --- src/cli/runtimecommands.go | 6 +++--- src/core/loader/loader.go | 6 +++--- src/{setup/launchpad => modding}/launchpad-config.go | 2 +- src/{setup/launchpad => modding}/launchpad.go | 4 +--- src/{setup/launchpad => modding}/modlist.go | 2 +- src/{setup/launchpad => modding}/modpackages.go | 2 +- src/web/slp-launchpad.go | 10 +++++----- 7 files changed, 15 insertions(+), 17 deletions(-) rename src/{setup/launchpad => modding}/launchpad-config.go (99%) rename src/{setup/launchpad => modding}/launchpad.go (99%) rename src/{setup/launchpad => modding}/modlist.go (99%) rename src/{setup/launchpad => modding}/modpackages.go (99%) diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go index 850fc796..a86731ac 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/runtimecommands.go @@ -23,7 +23,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) @@ -173,7 +173,7 @@ func init() { } func listmods() { - mods := launchpad.GetModList() + mods := modding.GetModList() if len(mods) == 0 { logger.Core.Info("No mods installed.") return @@ -191,7 +191,7 @@ func listmods() { } func listworkshophandles() { - handles := launchpad.GetModWorkshopHandles() + handles := modding.GetModWorkshopHandles() if len(handles) == 0 { logger.Core.Info("No mods with Workshop handles found.") return diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index 219ad6a7..0fa3253f 100644 --- a/src/core/loader/loader.go +++ b/src/core/loader/loader.go @@ -14,8 +14,8 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/backupmgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) @@ -122,7 +122,7 @@ func InitVirtFS(v1uiFS embed.FS) { } func InstallSLP() { - version, err := launchpad.InstallSLP() + version, err := modding.InstallSLP() if err != nil { logger.Install.Error("SLP installation failed: " + err.Error()) return @@ -131,7 +131,7 @@ func InstallSLP() { } func EnsureSLPAutoUpdates() { - modified, err := launchpad.ToggleSLPAutoUpdates(config.GetIsStationeersLaunchPadAutoUpdatesEnabled()) + modified, err := modding.ToggleSLPAutoUpdates(config.GetIsStationeersLaunchPadAutoUpdatesEnabled()) if err != nil { logger.Install.Error("Failed to toggle SLP auto-updates: " + err.Error()) return diff --git a/src/setup/launchpad/launchpad-config.go b/src/modding/launchpad-config.go similarity index 99% rename from src/setup/launchpad/launchpad-config.go rename to src/modding/launchpad-config.go index 97d924e7..96597126 100644 --- a/src/setup/launchpad/launchpad-config.go +++ b/src/modding/launchpad-config.go @@ -1,4 +1,4 @@ -package launchpad +package modding import ( "fmt" diff --git a/src/setup/launchpad/launchpad.go b/src/modding/launchpad.go similarity index 99% rename from src/setup/launchpad/launchpad.go rename to src/modding/launchpad.go index 02f2ba70..9a2e624c 100644 --- a/src/setup/launchpad/launchpad.go +++ b/src/modding/launchpad.go @@ -1,6 +1,4 @@ -//go:build !js - -package launchpad +package modding import ( "archive/zip" diff --git a/src/setup/launchpad/modlist.go b/src/modding/modlist.go similarity index 99% rename from src/setup/launchpad/modlist.go rename to src/modding/modlist.go index 247326af..c13bfa9a 100644 --- a/src/setup/launchpad/modlist.go +++ b/src/modding/modlist.go @@ -1,4 +1,4 @@ -package launchpad +package modding import ( "encoding/base64" diff --git a/src/setup/launchpad/modpackages.go b/src/modding/modpackages.go similarity index 99% rename from src/setup/launchpad/modpackages.go rename to src/modding/modpackages.go index 1bcef5bf..374c4c4b 100644 --- a/src/setup/launchpad/modpackages.go +++ b/src/modding/modpackages.go @@ -1,4 +1,4 @@ -package launchpad +package modding import ( "archive/zip" diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go index 65141bf8..268204fc 100644 --- a/src/web/slp-launchpad.go +++ b/src/web/slp-launchpad.go @@ -4,12 +4,12 @@ import ( "encoding/json" "net/http" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/launchpad" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" ) func InstallSLPHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if _, err := launchpad.InstallSLP(); err != nil { + if _, err := modding.InstallSLP(); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, @@ -25,7 +25,7 @@ func InstallSLPHandler(w http.ResponseWriter, r *http.Request) { func UninstallSLPHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if _, err := launchpad.UninstallSLP(); err != nil { + if _, err := modding.UninstallSLP(); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, @@ -41,7 +41,7 @@ func UninstallSLPHandler(w http.ResponseWriter, r *http.Request) { func UploadModPackageHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if err := launchpad.ProcessModPackageUpload(r.Body); err != nil { + if err := modding.ProcessModPackageUpload(r.Body); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]interface{}{ "success": false, @@ -60,7 +60,7 @@ func UploadModPackageHandler(w http.ResponseWriter, r *http.Request) { func GetInstalledModDetailsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - mods := launchpad.GetModList() + mods := modding.GetModList() w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ From fdbe59833ff1da7d19aa859728c8094012aa40ec Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 23:35:33 +0100 Subject: [PATCH 10/21] add downloading of workshop mods (from cli command for now) can be called to update the installed mods or to download new mods --- src/cli/runtimecommands.go | 8 ++ src/steamcmd/workshop.go | 222 +++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 src/steamcmd/workshop.go diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go index a86731ac..3c433d7b 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/runtimecommands.go @@ -170,6 +170,14 @@ func init() { RegisterCommand("installslp", WrapNoReturn(installSLP), "slp") RegisterCommand("listmods", WrapNoReturn(listmods), "lm") RegisterCommand("listworkshophandles", WrapNoReturn(listworkshophandles), "lwh") + RegisterCommand("downloadworkshopupdates", WrapNoReturn(downloadWorkshopUpdates), "dwu") +} + +func downloadWorkshopUpdates() { + err := steamcmd.DownloadWorkshopItems() + if err != nil { + logger.Core.Error("Error downloading workshop updates: " + err.Error()) + } } func listmods() { diff --git a/src/steamcmd/workshop.go b/src/steamcmd/workshop.go new file mode 100644 index 00000000..e7f8b613 --- /dev/null +++ b/src/steamcmd/workshop.go @@ -0,0 +1,222 @@ +package steamcmd + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" +) + +// DownloadWorkshopItems downloads all installed workshop mods using SteamCMD +func DownloadWorkshopItems() error { + workshopHandles := modding.GetModWorkshopHandles() + if len(workshopHandles) == 0 { + logger.Install.Debug("ℹ️ No workshop items to download") + return nil + } + + logger.Install.Infof("🔄 Downloading %d workshop items...", len(workshopHandles)) + + currentDir, err := os.Getwd() + if err != nil { + logger.Install.Error("❌ Error getting current working directory: " + err.Error()) + return err + } + + // Acquire lock for SteamCMD access + if steamMu.TryLock() { + logger.Core.Debug("🔄 Locking SteamMu for SteamCMD Workshop Downloads...") + } else { + logger.Core.Warn("🔄 SteamMu is currently locked, waiting for it to be unlocked and then continuing...") + steamMu.Lock() + logger.Core.Debug("🔄 Locking SteamMu for SteamCMD Workshop Downloads...") + } + defer func() { + steamMu.Unlock() + logger.Core.Debug("🔄 Unlocking SteamMu after SteamCMD Workshop Downloads...") + }() + + steamcmddir := SteamCMDLinuxDir + executable := "steamcmd.sh" + + if runtime.GOOS == "windows" { + executable = "steamcmd.exe" + steamcmddir = SteamCMDWindowsDir + } + + // Download each workshop item + for i, appID := range workshopHandles { + logger.Install.Infof("📦 Downloading workshop item %d/%d: %s", i+1, len(workshopHandles), appID) + + // Build SteamCMD command + cmd := exec.Command( + filepath.Join(steamcmddir, executable), + "+force_install_dir", "../", + "+login", "anonymous", + "+workshop_download_item", "544550", appID, + "validate", + "+quit", + ) + + // Capture output + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Set up environment for Linux + if runtime.GOOS == "linux" { + env := os.Environ() + newEnv := make([]string, 0, len(env)+1) + foundHome := false + for _, e := range env { + if !strings.HasPrefix(e, "HOME=") { + newEnv = append(newEnv, e) + } else { + newEnv = append(newEnv, "HOME="+currentDir) + foundHome = true + } + } + if !foundHome { + newEnv = append(newEnv, "HOME="+currentDir) + } + cmd.Env = newEnv + } + + // Run the command + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + logger.Install.Warnf("⚠️ SteamCMD workshop download failed for %s (code %d): %s", appID, exitErr.ExitCode(), stderr.String()) + } else { + logger.Install.Warnf("⚠️ Error running SteamCMD for workshop item %s: %s", appID, err.Error()) + } + continue // Continue with next workshop item even if this one fails + } + + logger.Install.Debugf("✅ Successfully downloaded workshop item: %s", appID) + } + + logger.Install.Info("✅ Workshop items download complete") + + // Copy downloaded items to mods directory + err = copyDownloadedItemsToMods(workshopHandles) + if err != nil { + logger.Install.Error("❌ Error copying workshop items to mods directory: " + err.Error()) + return err + } + + return nil +} + +// copyDownloadedItemsToMods copies downloaded workshop items from the Steam directory to ./mods +func copyDownloadedItemsToMods(workshopHandles []string) error { + // Determine the steam content directory based on OS + var steamContentDir string + if runtime.GOOS == "windows" { + steamContentDir = SteamCMDWindowsDir + // Windows SteamCMD dir is C:\SteamCMD, so workshop content is at C:\SteamCMD\steamapps\workshop\content\544550 + steamContentDir = filepath.Join(steamContentDir, "steamapps", "workshop", "content", "544550") + } else { + // Linux: ./steamapps/workshop/content/544550 + steamContentDir = filepath.Join(".", "steamapps", "workshop", "content", "544550") + } + + // Ensure mods directory exists + modsDir := "./mods" + if err := os.MkdirAll(modsDir, 0755); err != nil { + return fmt.Errorf("failed to create mods directory: %w", err) + } + + logger.Install.Infof("📂 Copying %d workshop items to mods directory...", len(workshopHandles)) + + // Copy each workshop item + for i, appID := range workshopHandles { + logger.Install.Infof("📋 Processing workshop item %d/%d: %s", i+1, len(workshopHandles), appID) + + // Source path: steamapps/workshop/content/544550/{appID} + srcPath := filepath.Join(steamContentDir, appID) + + // Check if source directory exists + srcInfo, err := os.Stat(srcPath) + if err != nil || !srcInfo.IsDir() { + logger.Install.Errorf("❌ Workshop item not found at expected path: %s (skipping)", srcPath) + continue + } + + // Destination path: ./mods/Workshop_{appID} + destPath := filepath.Join(modsDir, fmt.Sprintf("Workshop_%s", appID)) + + // Remove existing destination directory if it exists + if _, err := os.Stat(destPath); err == nil { + logger.Install.Debugf("🗑️ Removing existing directory: %s", destPath) + if err := os.RemoveAll(destPath); err != nil { + logger.Install.Warnf("⚠️ Failed to remove existing directory %s: %s (continuing anyway)", destPath, err.Error()) + } + } + + // Copy the entire directory + if err := copyDir(srcPath, destPath); err != nil { + logger.Install.Warnf("⚠️ Failed to copy workshop item %s: %s (skipping)", appID, err.Error()) + continue + } + + logger.Install.Debugf("✅ Successfully copied workshop item to: %s", destPath) + } + + logger.Install.Info("✅ Workshop items copy complete") + return nil +} + +// copyDir recursively copies a directory from src to dst +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} + +// copyFile copies a single file from src to dst +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} From 1b47ae28f5de720fbef695559e129ecef149235d Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 19 Jan 2026 23:36:00 +0100 Subject: [PATCH 11/21] add command and function to download a specific workshop item for testing --- src/cli/runtimecommands.go | 8 +++++++- src/steamcmd/workshop.go | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go index 3c433d7b..db422ab7 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/runtimecommands.go @@ -171,10 +171,16 @@ func init() { RegisterCommand("listmods", WrapNoReturn(listmods), "lm") RegisterCommand("listworkshophandles", WrapNoReturn(listworkshophandles), "lwh") RegisterCommand("downloadworkshopupdates", WrapNoReturn(downloadWorkshopUpdates), "dwu") + RegisterCommand("downloadworkshopitemtest", WrapNoReturn(downloadWorkshopItemTest), "dwmodcon") +} + +func downloadWorkshopItemTest() { + workshopHandles := []string{"3505169479"} + steamcmd.DownloadWorkshopItems(workshopHandles) } func downloadWorkshopUpdates() { - err := steamcmd.DownloadWorkshopItems() + err := steamcmd.UpdateWorkshopItems() if err != nil { logger.Core.Error("Error downloading workshop updates: " + err.Error()) } diff --git a/src/steamcmd/workshop.go b/src/steamcmd/workshop.go index e7f8b613..d600eb49 100644 --- a/src/steamcmd/workshop.go +++ b/src/steamcmd/workshop.go @@ -15,13 +15,18 @@ import ( ) // DownloadWorkshopItems downloads all installed workshop mods using SteamCMD -func DownloadWorkshopItems() error { +func UpdateWorkshopItems() error { workshopHandles := modding.GetModWorkshopHandles() if len(workshopHandles) == 0 { logger.Install.Debug("ℹ️ No workshop items to download") return nil } + fmt.Println(workshopHandles) + return DownloadWorkshopItems(workshopHandles) +} + +func DownloadWorkshopItems(workshopHandles []string) error { logger.Install.Infof("🔄 Downloading %d workshop items...", len(workshopHandles)) currentDir, err := os.Getwd() From b6a7358a0709f72d9028da4272a0109ca3702da1 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 22 Jan 2026 20:35:17 +0100 Subject: [PATCH 12/21] refactor cli package to be more structured, no functional changes. Removed "slp" command --- src/cli/{runtimecommands.go => commands.go} | 178 +------------------- src/cli/devcommands.go | 58 +++++++ src/cli/ssuicli.go | 137 +++++++++++++++ 3 files changed, 196 insertions(+), 177 deletions(-) rename src/cli/{runtimecommands.go => commands.go} (56%) create mode 100644 src/cli/devcommands.go create mode 100644 src/cli/ssuicli.go diff --git a/src/cli/runtimecommands.go b/src/cli/commands.go similarity index 56% rename from src/cli/runtimecommands.go rename to src/cli/commands.go index db422ab7..b5df8e95 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/commands.go @@ -1,153 +1,25 @@ -// Package misc provides a non-blocking command-line interface for entering commands -// while allowing the application to continue its operations normally. package cli import ( "archive/zip" - "bufio" "encoding/json" - "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" - "sort" "strings" - "sync" "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) -// ANSI escape codes for green text and reset -const ( - cliPrompt = "\033[32m" + "SSUICLI" + " » " + "\033[0m" -) - -var isSupportMode bool - -// CommandFunc defines the signature for command handler functions. -type CommandFunc func(args []string) error - -// commandRegistry holds the map of command names to their handler functions. -var commandRegistry = make(map[string]CommandFunc) -var mu sync.Mutex - -var commandAliases = make(map[string][]string) - -// RegisterCommand adds a new command and its handler to the registry. -func RegisterCommand(name string, handler CommandFunc, aliases ...string) { - mu.Lock() - defer mu.Unlock() - commandRegistry[name] = handler - if len(aliases) > 0 { - commandAliases[name] = append(commandAliases[name], aliases...) - for _, alias := range aliases { - commandRegistry[alias] = handler - } - } -} - -// StartConsole starts a non-blocking console input loop in a separate goroutine. -func StartConsole(wg *sync.WaitGroup) { - if !config.GetIsConsoleEnabled() { - logger.Core.Info("SSUICLI runtime console is disabled in config, skipping...") - return - } - wg.Add(1) - go func() { - defer wg.Done() - scanner := bufio.NewScanner(os.Stdin) - logger.Core.Info("SSUICLI runtime console started. Type 'help' for commands.") - time.Sleep(10 * time.Millisecond) - - for { - fmt.Print(cliPrompt) - os.Stdout.Sync() // Force flush the output buffer - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - continue - } - ProcessCommand(input) - } - - if err := scanner.Err(); err != nil { - logger.Core.Error("SSUICLI input error:" + err.Error()) - } - logger.Core.Info("SSUICLI runtime console stopped.") - }() -} - -// ProcessCommand parses and executes a command from the input string. -func ProcessCommand(input string) { - args := strings.Fields(input) - if len(args) == 0 { - return - } - - commandName := strings.ToLower(args[0]) - args = args[1:] // Remove command name from args - - mu.Lock() - handler, exists := commandRegistry[commandName] - mu.Unlock() - - if !exists { - logger.Core.Error("Unknown command:" + commandName + ". Type 'help' for available commands.") - return - } - - if err := handler(args); err != nil { - logger.Core.Error("Command " + commandName + " failed:" + err.Error()) - } -} - -// WrapNoReturn wraps a function with no return value to match CommandFunc. -func WrapNoReturn(fn func()) CommandFunc { - return func(args []string) error { - if len(args) > 0 { - return errors.New("command does not accept arguments") - } - fn() - logger.Core.Info("Runtime CLI Command executed successfully") - return nil - } -} - -// helpCommand displays available commands along with their aliases. -func helpCommand(args []string) error { - mu.Lock() - defer mu.Unlock() - logger.Core.Info("Available commands:") - // Collect primary commands (those in commandAliases keys) - primaryCommands := make([]string, 0, len(commandAliases)) - for cmd := range commandAliases { - primaryCommands = append(primaryCommands, cmd) - } - sort.Strings(primaryCommands) - for _, cmd := range primaryCommands { - aliases := commandAliases[cmd] - if len(aliases) > 0 { - logger.Core.Info("- " + cmd + " (aliases: " + strings.Join(aliases, ", ") + ")") - } else { - logger.Core.Info("- %s" + cmd) - } - } - return nil -} - // init registers default cli commands and their aliases. func init() { RegisterCommand("help", helpCommand, "h") @@ -167,17 +39,13 @@ func init() { RegisterCommand("printconfig", WrapNoReturn(printConfig), "pc") RegisterCommand("update", WrapNoReturn(triggerUpdateCheck), "u") RegisterCommand("applyupdate", WrapNoReturn(applyUpdate), "au") - RegisterCommand("installslp", WrapNoReturn(installSLP), "slp") RegisterCommand("listmods", WrapNoReturn(listmods), "lm") RegisterCommand("listworkshophandles", WrapNoReturn(listworkshophandles), "lwh") RegisterCommand("downloadworkshopupdates", WrapNoReturn(downloadWorkshopUpdates), "dwu") RegisterCommand("downloadworkshopitemtest", WrapNoReturn(downloadWorkshopItemTest), "dwmodcon") } -func downloadWorkshopItemTest() { - workshopHandles := []string{"3505169479"} - steamcmd.DownloadWorkshopItems(workshopHandles) -} +// COMMAND HANDLERS WITH COMMANDS USEFUL FOR USERS func downloadWorkshopUpdates() { err := steamcmd.UpdateWorkshopItems() @@ -186,35 +54,6 @@ func downloadWorkshopUpdates() { } } -func listmods() { - mods := modding.GetModList() - if len(mods) == 0 { - logger.Core.Info("No mods installed.") - return - } - logger.Core.Info(fmt.Sprintf("Installed Mods (%d):", len(mods))) - for _, mod := range mods { - - // print mod details in one logger call but with /n for new lines - logger.Modding.Info("Mod Details:\n" + - fmt.Sprintf("Modname: %s\n", mod.Name) + - fmt.Sprintf(" Version: %s\n", mod.Version) + - fmt.Sprintf(" Author: %s\n", mod.Author) + - fmt.Sprintf(" Workshop Handle: %s\n", mod.WorkshopHandle)) - } -} - -func listworkshophandles() { - handles := modding.GetModWorkshopHandles() - if len(handles) == 0 { - logger.Core.Info("No mods with Workshop handles found.") - return - } - logger.Core.Info(fmt.Sprintf("Installed Mod Workshop Handles: (%d):", len(handles))) - logger.Modding.Info(fmt.Sprintf("%v", handles)) - -} - func startServer() { err := gamemgr.InternalStartServer() if err != nil { @@ -260,17 +99,6 @@ func getBuildID() { logger.Core.Info("Build ID: " + buildID) } -func setDummyBuildID() { - config.SetCurrentBranchBuildID("dummy") - logger.Core.Info("Dummy build ID set") -} - -func testLocalization() { - currentLanguageSetting := config.GetLanguageSetting() - s := localization.GetString("UIText_StartButton") - logger.Core.Info("Start Server Button text (current language: " + currentLanguageSetting + "): " + s) -} - func triggerUpdateCheck() { err, newVersion := update.Update(false) if err != nil { @@ -290,10 +118,6 @@ func applyUpdate() { } } -func installSLP() { - loader.InstallSLP() -} - func supportMode() { if isSupportMode { diff --git a/src/cli/devcommands.go b/src/cli/devcommands.go new file mode 100644 index 00000000..7d6019be --- /dev/null +++ b/src/cli/devcommands.go @@ -0,0 +1,58 @@ +package cli + +import ( + "fmt" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" +) + +// COMMAND HANDLERS WITH COMMANDS USEFUL FOR DEVELOPMENT AND DEBUGGING + +func downloadWorkshopItemTest() { + workshopHandles := []string{"3505169479"} + steamcmd.DownloadWorkshopItems(workshopHandles) +} + +func listworkshophandles() { + handles := modding.GetModWorkshopHandles() + if len(handles) == 0 { + logger.Core.Info("No mods with Workshop handles found.") + return + } + logger.Core.Info(fmt.Sprintf("Installed Mod Workshop Handles: (%d):", len(handles))) + logger.Modding.Info(fmt.Sprintf("%v", handles)) + +} + +func listmods() { + mods := modding.GetModList() + if len(mods) == 0 { + logger.Core.Info("No mods installed.") + return + } + logger.Core.Info(fmt.Sprintf("Installed Mods (%d):", len(mods))) + for _, mod := range mods { + + // print mod details in one logger call but with /n for new lines + logger.Modding.Info("Mod Details:\n" + + fmt.Sprintf("Modname: %s\n", mod.Name) + + fmt.Sprintf(" Version: %s\n", mod.Version) + + fmt.Sprintf(" Author: %s\n", mod.Author) + + fmt.Sprintf(" Workshop Handle: %s\n", mod.WorkshopHandle)) + } +} + +func setDummyBuildID() { + config.SetCurrentBranchBuildID("dummy") + logger.Core.Info("Dummy build ID set") +} + +func testLocalization() { + currentLanguageSetting := config.GetLanguageSetting() + s := localization.GetString("UIText_StartButton") + logger.Core.Info("Start Server Button text (current language: " + currentLanguageSetting + "): " + s) +} diff --git a/src/cli/ssuicli.go b/src/cli/ssuicli.go new file mode 100644 index 00000000..f343f5fe --- /dev/null +++ b/src/cli/ssuicli.go @@ -0,0 +1,137 @@ +// Package misc provides a non-blocking command-line interface for entering commands +// while allowing the application to continue its operations normally. +package cli + +import ( + "bufio" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// ANSI escape codes for green text and reset +const ( + cliPrompt = "\033[32m" + "SSUICLI" + " » " + "\033[0m" +) + +var isSupportMode bool + +// CommandFunc defines the signature for command handler functions. +type CommandFunc func(args []string) error + +// commandRegistry holds the map of command names to their handler functions. +var commandRegistry = make(map[string]CommandFunc) +var mu sync.Mutex + +var commandAliases = make(map[string][]string) + +// RegisterCommand adds a new command and its handler to the registry. +func RegisterCommand(name string, handler CommandFunc, aliases ...string) { + mu.Lock() + defer mu.Unlock() + commandRegistry[name] = handler + if len(aliases) > 0 { + commandAliases[name] = append(commandAliases[name], aliases...) + for _, alias := range aliases { + commandRegistry[alias] = handler + } + } +} + +// StartConsole starts a non-blocking console input loop in a separate goroutine. +func StartConsole(wg *sync.WaitGroup) { + if !config.GetIsConsoleEnabled() { + logger.Core.Info("SSUICLI runtime console is disabled in config, skipping...") + return + } + wg.Add(1) + go func() { + defer wg.Done() + scanner := bufio.NewScanner(os.Stdin) + logger.Core.Info("SSUICLI runtime console started. Type 'help' for commands.") + time.Sleep(10 * time.Millisecond) + + for { + fmt.Print(cliPrompt) + os.Stdout.Sync() // Force flush the output buffer + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + ProcessCommand(input) + } + + if err := scanner.Err(); err != nil { + logger.Core.Error("SSUICLI input error:" + err.Error()) + } + logger.Core.Info("SSUICLI runtime console stopped.") + }() +} + +// ProcessCommand parses and executes a command from the input string. +func ProcessCommand(input string) { + args := strings.Fields(input) + if len(args) == 0 { + return + } + + commandName := strings.ToLower(args[0]) + args = args[1:] // Remove command name from args + + mu.Lock() + handler, exists := commandRegistry[commandName] + mu.Unlock() + + if !exists { + logger.Core.Error("Unknown command:" + commandName + ". Type 'help' for available commands.") + return + } + + if err := handler(args); err != nil { + logger.Core.Error("Command " + commandName + " failed:" + err.Error()) + } +} + +// WrapNoReturn wraps a function with no return value to match CommandFunc. +func WrapNoReturn(fn func()) CommandFunc { + return func(args []string) error { + if len(args) > 0 { + return errors.New("command does not accept arguments") + } + fn() + logger.Core.Info("Runtime CLI Command executed successfully") + return nil + } +} + +// helpCommand displays available commands along with their aliases. +func helpCommand(args []string) error { + mu.Lock() + defer mu.Unlock() + logger.Core.Info("Available commands:") + // Collect primary commands (those in commandAliases keys) + primaryCommands := make([]string, 0, len(commandAliases)) + for cmd := range commandAliases { + primaryCommands = append(primaryCommands, cmd) + } + sort.Strings(primaryCommands) + for _, cmd := range primaryCommands { + aliases := commandAliases[cmd] + if len(aliases) > 0 { + logger.Core.Info("- " + cmd + " (aliases: " + strings.Join(aliases, ", ") + ")") + } else { + logger.Core.Info("- %s" + cmd) + } + } + return nil +} From 2c7f44fda57da263b46036645ec74fbf2dd4d33d Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 22 Jan 2026 21:09:39 +0100 Subject: [PATCH 13/21] add workshop mods update functionality to API and improve action response with an array of "log" that is returned --- src/cli/commands.go | 2 +- src/cli/devcommands.go | 5 ++++- src/steamcmd/workshop.go | 42 +++++++++++++++++++++++++++++----------- src/web/routes.go | 4 +++- src/web/slp-launchpad.go | 23 ++++++++++++++++++++++ 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/cli/commands.go b/src/cli/commands.go index b5df8e95..81f996eb 100644 --- a/src/cli/commands.go +++ b/src/cli/commands.go @@ -48,7 +48,7 @@ func init() { // COMMAND HANDLERS WITH COMMANDS USEFUL FOR USERS func downloadWorkshopUpdates() { - err := steamcmd.UpdateWorkshopItems() + _, err := steamcmd.UpdateWorkshopItems() if err != nil { logger.Core.Error("Error downloading workshop updates: " + err.Error()) } diff --git a/src/cli/devcommands.go b/src/cli/devcommands.go index 7d6019be..867c5898 100644 --- a/src/cli/devcommands.go +++ b/src/cli/devcommands.go @@ -14,7 +14,10 @@ import ( func downloadWorkshopItemTest() { workshopHandles := []string{"3505169479"} - steamcmd.DownloadWorkshopItems(workshopHandles) + _, err := steamcmd.DownloadWorkshopItems(workshopHandles) + if err != nil { + logger.Core.Error("Error downloading workshop items: " + err.Error()) + } } func listworkshophandles() { diff --git a/src/steamcmd/workshop.go b/src/steamcmd/workshop.go index d600eb49..11de909b 100644 --- a/src/steamcmd/workshop.go +++ b/src/steamcmd/workshop.go @@ -15,24 +15,31 @@ import ( ) // DownloadWorkshopItems downloads all installed workshop mods using SteamCMD -func UpdateWorkshopItems() error { +func UpdateWorkshopItems() ([]string, error) { + var logs []string workshopHandles := modding.GetModWorkshopHandles() if len(workshopHandles) == 0 { logger.Install.Debug("ℹ️ No workshop items to download") - return nil + logs = append(logs, "No workshop items to download") + return logs, nil } fmt.Println(workshopHandles) - return DownloadWorkshopItems(workshopHandles) + logs2, err := DownloadWorkshopItems(workshopHandles) + logs = append(logs, logs2...) + return logs, err } -func DownloadWorkshopItems(workshopHandles []string) error { +func DownloadWorkshopItems(workshopHandles []string) ([]string, error) { + var logs []string logger.Install.Infof("🔄 Downloading %d workshop items...", len(workshopHandles)) + logs = append(logs, fmt.Sprintf("Downloading %d workshop items...", len(workshopHandles))) currentDir, err := os.Getwd() if err != nil { logger.Install.Error("❌ Error getting current working directory: " + err.Error()) - return err + logs = append(logs, "Error getting current working directory: "+err.Error()) + return logs, err } // Acquire lock for SteamCMD access @@ -99,29 +106,36 @@ func DownloadWorkshopItems(workshopHandles []string) error { if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { logger.Install.Warnf("⚠️ SteamCMD workshop download failed for %s (code %d): %s", appID, exitErr.ExitCode(), stderr.String()) + logs = append(logs, fmt.Sprintf("SteamCMD workshop download failed for %s (code %d): %s", appID, exitErr.ExitCode(), stderr.String())) } else { logger.Install.Warnf("⚠️ Error running SteamCMD for workshop item %s: %s", appID, err.Error()) + logs = append(logs, fmt.Sprintf("Error running SteamCMD for workshop item %s: %s", appID, err.Error())) } continue // Continue with next workshop item even if this one fails } logger.Install.Debugf("✅ Successfully downloaded workshop item: %s", appID) + logs = append(logs, fmt.Sprintf("Successfully downloaded workshop item: %s", appID)) } logger.Install.Info("✅ Workshop items download complete") + logs = append(logs, "Workshop items download complete") // Copy downloaded items to mods directory - err = copyDownloadedItemsToMods(workshopHandles) + logs2, err := copyDownloadedItemsToMods(workshopHandles) + logs = append(logs, logs2...) if err != nil { logger.Install.Error("❌ Error copying workshop items to mods directory: " + err.Error()) - return err + logs = append(logs, "Error copying workshop items to mods directory: "+err.Error()) + return logs, err } - return nil + return logs, nil } // copyDownloadedItemsToMods copies downloaded workshop items from the Steam directory to ./mods -func copyDownloadedItemsToMods(workshopHandles []string) error { +func copyDownloadedItemsToMods(workshopHandles []string) ([]string, error) { + var logs []string // Determine the steam content directory based on OS var steamContentDir string if runtime.GOOS == "windows" { @@ -136,10 +150,11 @@ func copyDownloadedItemsToMods(workshopHandles []string) error { // Ensure mods directory exists modsDir := "./mods" if err := os.MkdirAll(modsDir, 0755); err != nil { - return fmt.Errorf("failed to create mods directory: %w", err) + return logs, fmt.Errorf("failed to create mods directory: %w", err) } logger.Install.Infof("📂 Copying %d workshop items to mods directory...", len(workshopHandles)) + logs = append(logs, fmt.Sprintf("Copying %d workshop items to mods directory...", len(workshopHandles))) // Copy each workshop item for i, appID := range workshopHandles { @@ -152,6 +167,7 @@ func copyDownloadedItemsToMods(workshopHandles []string) error { srcInfo, err := os.Stat(srcPath) if err != nil || !srcInfo.IsDir() { logger.Install.Errorf("❌ Workshop item not found at expected path: %s (skipping)", srcPath) + logs = append(logs, fmt.Sprintf("Workshop item not found at expected path: %s (skipping)", srcPath)) continue } @@ -163,20 +179,24 @@ func copyDownloadedItemsToMods(workshopHandles []string) error { logger.Install.Debugf("🗑️ Removing existing directory: %s", destPath) if err := os.RemoveAll(destPath); err != nil { logger.Install.Warnf("⚠️ Failed to remove existing directory %s: %s (continuing anyway)", destPath, err.Error()) + logs = append(logs, fmt.Sprintf("Failed to remove existing directory %s: %s (continuing anyway)", destPath, err.Error())) } } // Copy the entire directory if err := copyDir(srcPath, destPath); err != nil { logger.Install.Warnf("⚠️ Failed to copy workshop item %s: %s (skipping)", appID, err.Error()) + logs = append(logs, fmt.Sprintf("Failed to copy workshop item %s: %s (skipping)", appID, err.Error())) continue } logger.Install.Debugf("✅ Successfully copied workshop item to: %s", destPath) + logs = append(logs, fmt.Sprintf("Successfully copied workshop item to: %s", destPath)) } logger.Install.Info("✅ Workshop items copy complete") - return nil + logs = append(logs, "Workshop items copy complete") + return logs, nil } // copyDir recursively copies a directory from src to dst diff --git a/src/web/routes.go b/src/web/routes.go index 086e368c..0011c0ea 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -65,6 +65,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { protectedMux.HandleFunc("/api/v2/SSCM/run", HandleCommand) // Command execution via SSCM (needs to be enable, config.IsSSCMEnabled) protectedMux.HandleFunc("/api/v2/SSCM/enabled", HandleIsSSCMEnabled) // Check if SSCM is enabled protectedMux.HandleFunc("/api/v2/steamcmd/run", HandleRunSteamCMD) // Run SteamCMD + // /api/v2/steamcmd/updatemods is defined in the SLP & Modding section below // Custom Detections protectedMux.HandleFunc("/api/v2/custom-detections", detectionmgr.HandleCustomDetection) @@ -87,11 +88,12 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { // Monitoring protectedMux.HandleFunc("/api/v2/monitor/gameserver/status", HandleMonitorStatus) - // SLP + // SLP & Modding protectedMux.HandleFunc("/api/v2/slp/install", InstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/uninstall", UninstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/upload", UploadModPackageHandler) protectedMux.HandleFunc("/api/v2/slp/mods", GetInstalledModDetailsHandler) + protectedMux.HandleFunc("/api/v2/steamcmd/updatemods", UpdateWorkshopModsHandler) return mux, protectedMux } diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go index 268204fc..416f5ec2 100644 --- a/src/web/slp-launchpad.go +++ b/src/web/slp-launchpad.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) func InstallSLPHandler(w http.ResponseWriter, r *http.Request) { @@ -68,3 +69,25 @@ func GetInstalledModDetailsHandler(w http.ResponseWriter, r *http.Request) { "mods": mods, }) } + +func UpdateWorkshopModsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + logs, err := steamcmd.UpdateWorkshopItems() + 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 mods updated successfully", + "logs": logs, + }) +} From 667950912676e68941aa41283699340206e4223b Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 22 Jan 2026 21:38:54 +0100 Subject: [PATCH 14/21] enhance UI for managing workshop mods: update styles, add update button, and improve popup functionality --- UIMod/onboard_bundled/assets/css/config.css | 221 ++++++++++++++------ UIMod/onboard_bundled/assets/css/popup.css | 7 +- UIMod/onboard_bundled/assets/js/popup.js | 2 +- UIMod/onboard_bundled/assets/js/slp.js | 22 ++ UIMod/onboard_bundled/ui/config.html | 22 +- 5 files changed, 196 insertions(+), 78 deletions(-) diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index e16396a8..a5fedcfe 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -439,166 +439,248 @@ select option { } .slp-section { - padding: 20px; - border-radius: 8px; - background-color: var(--form-bg, #1a1a1a); - margin: 20px 0; + padding: 25px; + border-radius: 12px; + margin: 15px 0; + border: 1px solid var(--primary-dim); + transition: all var(--transition-normal); +} + +.slp-section:hover { + border-color: var(--primary); + box-shadow: 0 0 15px rgba(0, 255, 150, 0.1); +} + +.slp-section p { + color: var(--text-normal, #aaa); + font-family: 'Share Tech Mono', monospace; + font-size: 0.9rem; + line-height: 1.6; + margin: 0 0 15px 0; +} + +.slp-section h3 { + color: var(--text-bright); + font-family: 'Press Start 2P', cursive; + font-size: 0.85rem; + margin: 0 0 20px 0; + padding-bottom: 12px; + border-bottom: 2px solid var(--primary-dim); + letter-spacing: 1px; } .slp-install-section { - background: linear-gradient(135deg, rgba(0, 255, 150, 0.15), rgba(0, 150, 255, 0.15)); + background: linear-gradient(135deg, rgba(0, 255, 150, 0.08), rgba(0, 150, 255, 0.08)); border: 2px solid var(--primary); - box-shadow: 0 0 20px rgba(0, 255, 150, 0.2); + box-shadow: 0 0 25px rgba(0, 255, 150, 0.15); + text-align: center; + padding: 40px; +} + +.slp-install-section:hover { + box-shadow: 0 0 35px rgba(0, 255, 150, 0.25); } .slp-install-header { display: flex; align-items: center; + justify-content: center; gap: 15px; - margin-bottom: 15px; + margin-bottom: 20px; } .slp-install-icon { - font-size: 48px; + font-size: 52px; + filter: drop-shadow(0 0 10px rgba(0, 255, 150, 0.5)); } .slp-install-header h3 { color: var(--text-bright); font-family: 'Press Start 2P', cursive; - font-size: 1rem; + font-size: 1.1rem; margin: 0; padding: 0; border: none; -} - -.slp-section h3 { - color: var(--text-bright); - font-family: 'Press Start 2P', cursive; - font-size: 0.9rem; - margin-bottom: 15px; - padding-bottom: 10px; - border-bottom: 2px solid var(--primary-dim); + text-shadow: 0 0 10px rgba(0, 255, 150, 0.3); } .slp-button { - padding: 12px 24px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 200px; + padding: 14px 28px; background-color: var(--primary); border: 2px solid var(--primary); color: var(--bg-dark); - border-radius: 6px; + border-radius: 8px; font-weight: bold; cursor: pointer; - transition: all var(--transition-normal); - font-family: 'Press Start 2P', cursive; - font-size: 0.8rem; - box-shadow: 0 0 10px rgba(0, 255, 150, 0.3); - margin: 10px 5px 10px 0; + transition: all 0.2s ease; + font-family: 'Share Tech Mono', monospace; + font-size: 0.85rem; + box-shadow: 0 4px 15px rgba(0, 255, 150, 0.25); + margin: 8px 0; + text-transform: uppercase; + letter-spacing: 0.5px; } .slp-button:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 0 20px rgba(0, 255, 150, 0.6); + transform: translateY(-3px); + box-shadow: 0 6px 25px rgba(0, 255, 150, 0.4); } .slp-button:active:not(:disabled) { - transform: translateY(0); + transform: translateY(-1px); + box-shadow: 0 3px 15px rgba(0, 255, 150, 0.3); } .slp-button:disabled { opacity: 0.5; cursor: not-allowed; + transform: none; } .slp-button.loading { - opacity: 0.8; + opacity: 0.7; cursor: wait; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 0.9; } } .slp-button-large { - padding: 16px 32px; + min-width: 320px; + padding: 18px 36px; font-size: 0.9rem; - width: 100%; - max-width: 500px; - box-shadow: 0 0 20px rgba(0, 255, 150, 0.4); + box-shadow: 0 4px 20px rgba(0, 255, 150, 0.35); } .slp-button-large:hover:not(:disabled) { - box-shadow: 0 0 30px rgba(0, 255, 150, 0.7); + box-shadow: 0 8px 35px rgba(0, 255, 150, 0.5); } .slp-button-small { - padding: 8px 16px; - font-size: 0.7rem; + min-width: 160px; + padding: 10px 20px; + font-size: 0.75rem; } .slp-button.danger { - background-color: #ff4444; - border-color: #ff4444; - box-shadow: 0 0 10px rgba(255, 68, 68, 0.3); + background-color: #e53935; + border-color: #e53935; + box-shadow: 0 4px 15px rgba(229, 57, 53, 0.25); } .slp-button.danger:hover:not(:disabled) { - box-shadow: 0 0 20px rgba(255, 68, 68, 0.6); + background-color: #ff5252; + border-color: #ff5252; + box-shadow: 0 6px 25px rgba(255, 82, 82, 0.4); } -.slp-uninstall-section { - opacity: 0.8; - border: 1px solid var(--primary-dim); +.slp-manage-section { + border: 1px solid rgba(0, 255, 150, 0.2); +} + +.manage-buttons { + display: flex; + gap: 30px; + align-items: stretch; +} + +.manage-left, +.manage-right { + flex: 1; + display: flex; + flex-direction: column; + padding: 15px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.manage-left p, +.manage-right p { + flex: 1; + margin-bottom: 15px; + font-size: 0.85rem; +} + +.manage-left .slp-button, +.manage-right .slp-button { + width: 100%; + margin-top: auto; +} + +@media (max-width: 768px) { + .manage-buttons { + flex-direction: column; + } } #modPackageUploadZone { - border: 3px dashed var(--primary-dim); - border-radius: 10px; - padding: 40px; + border: 2px dashed var(--primary-dim); + border-radius: 12px; + padding: 50px 40px; text-align: center; cursor: pointer; - transition: all var(--transition-normal); - background-color: rgba(0, 255, 150, 0.05); + transition: all 0.25s ease; + background: linear-gradient(145deg, rgba(0, 255, 150, 0.03), rgba(0, 150, 255, 0.03)); position: relative; margin: 20px 0; } #modPackageUploadZone:hover:not(.upload-zone-disabled) { border-color: var(--primary); - background-color: rgba(0, 255, 150, 0.1); + background: linear-gradient(145deg, rgba(0, 255, 150, 0.08), rgba(0, 150, 255, 0.08)); + box-shadow: 0 0 25px rgba(0, 255, 150, 0.15); + transform: translateY(-2px); } #modPackageUploadZone.highlight { border-color: var(--primary); - background-color: rgba(0, 255, 150, 0.15); - box-shadow: 0 0 20px rgba(0, 255, 150, 0.3); + border-style: solid; + background: linear-gradient(145deg, rgba(0, 255, 150, 0.12), rgba(0, 150, 255, 0.12)); + box-shadow: 0 0 30px rgba(0, 255, 150, 0.25); } .upload-zone-disabled { cursor: not-allowed !important; - opacity: 0.6; - border-color: var(--primary-dim) !important; - background-color: rgba(0, 150, 150, 0.05) !important; + opacity: 0.5; + border-color: rgba(150, 150, 150, 0.3) !important; + background: rgba(50, 50, 50, 0.2) !important; } .upload-zone-disabled:hover { - border-color: var(--primary-dim) !important; - background-color: rgba(0, 150, 150, 0.05) !important; + transform: none !important; + box-shadow: none !important; } .upload-icon { - font-size: 48px; - margin-bottom: 15px; + font-size: 56px; + margin-bottom: 18px; display: block; + filter: drop-shadow(0 0 8px rgba(0, 255, 150, 0.3)); } .upload-text { color: var(--text-bright); - font-family: 'Press Start 2P', cursive; - font-size: 0.9rem; - margin: 10px 0; + font-family: 'Share Tech Mono', monospace; + font-size: 1rem; + font-weight: bold; + margin: 12px 0; + letter-spacing: 0.5px; } .upload-subtext { color: var(--text-dim); font-family: 'Share Tech Mono', monospace; - font-size: 0.8rem; - margin-top: 10px; + font-size: 0.85rem; + margin-top: 8px; + opacity: 0.8; } #modPackageUpload { @@ -620,7 +702,6 @@ select option { display: block; } -/* Notification Styling */ .notification { display: none; padding: 16px 20px; @@ -668,7 +749,6 @@ select option { } } -/* Mods List Section */ .mods-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); @@ -677,7 +757,7 @@ select option { } .mod-card { - background: linear-gradient(135deg, var(--card-bg-start, #1a1a2e), var(--card-bg-end, #16213e)); + background: #11111142; border: 2px solid var(--primary-dim); border-radius: 12px; padding: 20px; @@ -768,7 +848,6 @@ select option { left: 0; right: 0; height: 40px; - background: linear-gradient(transparent, var(--card-bg-start, #1a1a2e)); pointer-events: none; transition: opacity 0.3s ease; } @@ -783,6 +862,7 @@ select option { font-size: 1rem; color: var(--primary); margin: 12px 0 8px 0; + padding-left: 20px; } .mod-description h2 { @@ -864,6 +944,11 @@ select option { .mods-empty { text-align: center; color: var(--text-dim, #888); - padding: 40px 20px; + padding: 60px 30px; font-family: 'Share Tech Mono', monospace; + font-size: 0.95rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + border: 2px dashed var(--primary-dim); + margin: 10px 0; } \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/css/popup.css b/UIMod/onboard_bundled/assets/css/popup.css index 6b304c2a..cdf64cb2 100644 --- a/UIMod/onboard_bundled/assets/css/popup.css +++ b/UIMod/onboard_bundled/assets/css/popup.css @@ -19,9 +19,10 @@ padding: 20px; border-radius: 10px; text-align: center; - max-width: 30vw; + max-width: 60vw; + max-height: 60vh; + overflow-y: auto; box-shadow: 0 5px 30px var(--primary-glow); - overflow: hidden; } .popup-content h2 { margin: 10px 0; @@ -30,6 +31,8 @@ .popup-content p { margin: 10px 0; + text-align: left; + white-space: pre-wrap; } .popup-content button { diff --git a/UIMod/onboard_bundled/assets/js/popup.js b/UIMod/onboard_bundled/assets/js/popup.js index 9b018c86..a64ac7e0 100644 --- a/UIMod/onboard_bundled/assets/js/popup.js +++ b/UIMod/onboard_bundled/assets/js/popup.js @@ -5,7 +5,7 @@ function showPopup(status, message) { popup.className = 'popup'; popupTitle.textContent = ''; - popupMessage.textContent = message; + popupMessage.innerHTML = message.replace(/\n/g, '
    '); switch(status.toLowerCase()) { case 'error': diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js index 3825e177..eefaaaf1 100644 --- a/UIMod/onboard_bundled/assets/js/slp.js +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -75,6 +75,28 @@ function uninstallSLP() { }); } +function updateWorkshopMods() { + setButtonLoading('updateWorkshopModsBtn', true); + showPopup('info', 'Updating workshop mods...\n\nThis may take some time depending on the number of mods. Please wait.'); + + fetch('/api/v2/steamcmd/updatemods') + .then(response => response.json()) + .then(data => { + setButtonLoading('updateWorkshopModsBtn', false); + if (data.success) { + const logsText = data.logs && data.logs.length > 0 ? '\n\n' + data.logs.join('\n') : ''; + showPopup('success', 'Workshop mods updated successfully!' + logsText); + } else { + const logsText = data.logs && data.logs.length > 0 ? '\n\n' + data.logs.join('\n') : ''; + showPopup('error', 'Failed to update workshop mods:\n\n' + (data.error || 'Unknown error') + logsText); + } + }) + .catch(error => { + showPopup('error', 'Failed to update workshop mods:\n\n' + (error.message || 'Network error')); + setButtonLoading('updateWorkshopModsBtn', false); + }); +} + let selectedModFile = null; function handleModPackageSelection(files) { diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 3a4a185f..ddf8db79 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -460,11 +460,10 @@

    Stationeers Launch Pad

    Upload Mod Package

    -

    Once SLP is installed, you'll be able to upload SLP packages here.

    🔒
    Install SLP First
    -
    SLP must be installed to upload mods
    +
    SLP must be installed to upload mod packages
    {{end}} @@ -472,22 +471,31 @@

    Upload Mod Package

    {{if eq .IsStationeersLaunchPadEnabled "true"}}

    Upload Mod Package

    -

    Select a mod package zip file or drag and drop it into the box below to upload and extract mods.

    +

    Select a mod package (modpkg) zip file or drag and drop it into the box below to upload and extract mods.

    +

    Mod packages need to be exported from your game client. See the wiki for more information.

    📦
    Drag & Drop Mod Package Here
    -
    or click to select a .zip file
    +
    or click to select a mod package file
    -
    +

    Manage Installation

    -

    ⚠️ Uninstalling will DELETE all mods in the Mods folder.

    - +
    +
    +

    ⚠️ Uninstalling will DELETE all mods in the Mods folder.

    + +
    +
    +

    Update all installed workshop mods to their latest versions. This process may take some time depending on the number of mods.

    + +
    +
    From c61334a49d9c3a57fdad183bb38c1bf5ac64aa2e Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 22 Jan 2026 21:46:00 +0100 Subject: [PATCH 15/21] add localization support for Stationeers Launch Pad / mod management config section --- UIMod/onboard_bundled/localization/de-DE.json | 21 ++++++++++ UIMod/onboard_bundled/localization/en-US.json | 21 ++++++++++ UIMod/onboard_bundled/localization/sv-SE.json | 21 ++++++++++ UIMod/onboard_bundled/ui/config.html | 38 +++++++++---------- src/web/configpage.go | 21 ++++++++++ src/web/templatevars.go | 20 ++++++++++ 6 files changed, 123 insertions(+), 19 deletions(-) diff --git a/UIMod/onboard_bundled/localization/de-DE.json b/UIMod/onboard_bundled/localization/de-DE.json index a79e9f24..bdca48cf 100644 --- a/UIMod/onboard_bundled/localization/de-DE.json +++ b/UIMod/onboard_bundled/localization/de-DE.json @@ -137,6 +137,27 @@ "UIText_DiscordBenefit5": "Echtzeit Fehlerbenachrichtigungen", "UIText_DiscordSetupInstructions": "Für Setup-Anweisungen besuche die" }, + "slp": { + "UIText_SLP_Title": "Stationeers Launch Pad", + "UIText_SLP_Description": "Stationeers Launch Pad ist ein einfacher Mod-Loader für Stationeers. Es aktualisiert sich automatisch und macht Mod-Verwaltung einfach.", + "UIText_SLP_ReadyToInstall": "Bereit zur Installation? Klicke auf den Button unten, um zu starten.", + "UIText_SLP_InstallButton": "Stationeers Launch Pad installieren", + "UIText_SLP_UploadModPackage": "Mod-Paket hochladen", + "UIText_SLP_UploadDescription": "Wähle eine Mod-Paket (modpkg) ZIP-Datei oder ziehe sie per Drag & Drop in das Feld unten, um Mods hochzuladen und zu extrahieren.", + "UIText_SLP_UploadDescriptionLink": "Mod-Pakete müssen von deinem Spiel-Client exportiert werden. Siehe ", + "UIText_SLP_InstallFirst": "Zuerst SLP installieren", + "UIText_SLP_InstallFirstSubtext": "SLP muss installiert sein, um Mod-Pakete hochzuladen", + "UIText_SLP_DragDropHere": "Mod-Paket hierher ziehen", + "UIText_SLP_OrClickToSelect": "oder klicken, um eine Mod-Paket Datei auszuwählen", + "UIText_SLP_UploadButton": "Mod-Paket hochladen", + "UIText_SLP_ManageInstallation": "Installation verwalten", + "UIText_SLP_UninstallWarning": "Deinstallation LÖSCHT alle Mods im Mods-Ordner.", + "UIText_SLP_UninstallButton": "SLP deinstallieren", + "UIText_SLP_UpdateWorkshopMods": "Workshop-Mods aktualisieren", + "UIText_SLP_UpdateWorkshopModsDesc": "Aktualisiert alle installierten Workshop-Mods auf die neueste Version. Dieser Vorgang kann je nach Anzahl der Mods einige Zeit dauern.", + "UIText_SLP_UpdateButton": "Workshop-Mods aktualisieren", + "UIText_SLP_InstalledMods": "Installierte Mods" + }, "UIText_TerrainSettings": "Weltgenerierung" }, "setup": { diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json index 8e1e6090..7cf978d3 100644 --- a/UIMod/onboard_bundled/localization/en-US.json +++ b/UIMod/onboard_bundled/localization/en-US.json @@ -136,6 +136,27 @@ "UIText_DiscordBenefit4": "Community management options", "UIText_DiscordBenefit5": "Real-time error notifications", "UIText_DiscordSetupInstructions": "For setup instructions, visit the" + }, + "slp": { + "UIText_SLP_Title": "Stationeers Launch Pad", + "UIText_SLP_Description": "Stationeers Launch Pad is a simple mod loader for Stationeers. It automatically updates itself and makes mod management easy.", + "UIText_SLP_ReadyToInstall": "Ready to install? Click the button below to get started.", + "UIText_SLP_InstallButton": "Install Stationeers Launch Pad", + "UIText_SLP_UploadModPackage": "Upload Mod Package", + "UIText_SLP_UploadDescription": "Select a mod package (modpkg) zip file or drag and drop it into the box below to upload and extract mods.", + "UIText_SLP_UploadDescriptionLink": "Mod packages need to be exported from your game client. See the", + "UIText_SLP_InstallFirst": "Install SLP First", + "UIText_SLP_InstallFirstSubtext": "SLP must be installed to upload mod packages", + "UIText_SLP_DragDropHere": "Drag & Drop Mod Package Here", + "UIText_SLP_OrClickToSelect": "or click to select a mod package file", + "UIText_SLP_UploadButton": "Upload Mod Package", + "UIText_SLP_ManageInstallation": "Manage Installation", + "UIText_SLP_UninstallWarning": "Uninstalling will DELETE all mods in the Mods folder.", + "UIText_SLP_UninstallButton": "Uninstall SLP", + "UIText_SLP_UpdateWorkshopMods": "Update Workshop Mods", + "UIText_SLP_UpdateWorkshopModsDesc": "Update all installed workshop mods to their latest versions. This process may take some time depending on the number of mods.", + "UIText_SLP_UpdateButton": "Update Workshop Mods", + "UIText_SLP_InstalledMods": "Installed Mods" } }, "setup": { diff --git a/UIMod/onboard_bundled/localization/sv-SE.json b/UIMod/onboard_bundled/localization/sv-SE.json index 36177311..63865092 100644 --- a/UIMod/onboard_bundled/localization/sv-SE.json +++ b/UIMod/onboard_bundled/localization/sv-SE.json @@ -135,6 +135,27 @@ "UIText_DiscordBenefit5": "Felnotiser i realtid", "UIText_DiscordSetupInstructions": "För installationsinstruktioner, besök" }, + "slp": { + "UIText_SLP_Title": "Stationeers Launch Pad", + "UIText_SLP_Description": "Stationeers Launch Pad är en enkel mod-loader för Stationeers. Den uppdateras automatiskt och gör mod-hantering enkel.", + "UIText_SLP_ReadyToInstall": "Redo att installera? Klicka på knappen nedan för att börja.", + "UIText_SLP_InstallButton": "Installera Stationeers Launch Pad", + "UIText_SLP_UploadModPackage": "Ladda upp Mod-paket", + "UIText_SLP_UploadDescription": "Välj en mod-paket (modpkg) zip-fil eller dra och släpp den i rutan nedan för att ladda upp och extrahera mods.", + "UIText_SLP_UploadDescriptionLink": "Mod-paket måste exporteras från din spelklient. Se", + "UIText_SLP_InstallFirst": "Installera SLP först", + "UIText_SLP_InstallFirstSubtext": "SLP måste installeras för att ladda upp mod-paket", + "UIText_SLP_DragDropHere": "Dra & Släpp Mod-paket här", + "UIText_SLP_OrClickToSelect": "eller klicka för att välja en mod-paketfil", + "UIText_SLP_UploadButton": "Ladda upp Mod-paket", + "UIText_SLP_ManageInstallation": "Hantera Installation", + "UIText_SLP_UninstallWarning": "Avinstallation kommer att RADERA alla mods i Mods-mappen.", + "UIText_SLP_UninstallButton": "Avinstallera SLP", + "UIText_SLP_UpdateWorkshopMods": "Uppdatera Workshop-mods", + "UIText_SLP_UpdateWorkshopModsDesc": "Uppdatera alla installerade workshop-mods till deras senaste versioner. Denna process kan ta lite tid beroende på antalet mods.", + "UIText_SLP_UpdateButton": "Uppdatera Workshop-mods", + "UIText_SLP_InstalledMods": "Installerade Mods" + }, "UIText_TerrainSettings": "Världsgenerering" }, "setup": { diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index ddf8db79..a70ac33d 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -451,55 +451,55 @@

    {{.UIText_DiscordIntegrationBenefits}}

    🚀 -

    Stationeers Launch Pad

    +

    {{.UIText_SLP_Title}}

    -

    Stationeers Launch Pad is a simple mod loader for Stationeers. It automatically updates itself and makes mod management easy.

    -

    Ready to install? Click the button below to get started.

    - +

    {{.UIText_SLP_Description}}

    +

    {{.UIText_SLP_ReadyToInstall}}

    +
    -

    Upload Mod Package

    +

    {{.UIText_SLP_UploadModPackage}}

    🔒 -
    Install SLP First
    -
    SLP must be installed to upload mod packages
    +
    {{.UIText_SLP_InstallFirst}}
    +
    {{.UIText_SLP_InstallFirstSubtext}}
    {{end}} {{if eq .IsStationeersLaunchPadEnabled "true"}}
    -

    Upload Mod Package

    -

    Select a mod package (modpkg) zip file or drag and drop it into the box below to upload and extract mods.

    -

    Mod packages need to be exported from your game client. See the wiki for more information.

    +

    {{.UIText_SLP_UploadModPackage}}

    +

    {{.UIText_SLP_UploadDescription}}

    +

    {{.UIText_SLP_UploadDescriptionLink}} wiki.

    📦 -
    Drag & Drop Mod Package Here
    -
    or click to select a mod package file
    +
    {{.UIText_SLP_DragDropHere}}
    +
    {{.UIText_SLP_OrClickToSelect}}
    - +
    -

    Manage Installation

    +

    {{.UIText_SLP_ManageInstallation}}

    -

    ⚠️ Uninstalling will DELETE all mods in the Mods folder.

    - +

    ⚠️ {{.UIText_SLP_UninstallWarning}}

    +
    -

    Update all installed workshop mods to their latest versions. This process may take some time depending on the number of mods.

    - +

    {{.UIText_SLP_UpdateWorkshopModsDesc}}

    +
    -

    Installed Mods

    +

    {{.UIText_SLP_InstalledMods}}

    diff --git a/src/web/configpage.go b/src/web/configpage.go index 7e34a73d..c39755bb 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -299,6 +299,27 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_CopyrightConfig1: localization.GetString("UIText_Copyright1"), UIText_CopyrightConfig2: localization.GetString("UIText_Copyright2"), + // SLP Section + UIText_SLP_Title: localization.GetString("UIText_SLP_Title"), + UIText_SLP_Description: localization.GetString("UIText_SLP_Description"), + UIText_SLP_ReadyToInstall: localization.GetString("UIText_SLP_ReadyToInstall"), + UIText_SLP_InstallButton: localization.GetString("UIText_SLP_InstallButton"), + UIText_SLP_UploadModPackage: localization.GetString("UIText_SLP_UploadModPackage"), + UIText_SLP_UploadDescription: localization.GetString("UIText_SLP_UploadDescription"), + UIText_SLP_UploadDescriptionLink: localization.GetString("UIText_SLP_UploadDescriptionLink"), + UIText_SLP_InstallFirst: localization.GetString("UIText_SLP_InstallFirst"), + UIText_SLP_InstallFirstSubtext: localization.GetString("UIText_SLP_InstallFirstSubtext"), + UIText_SLP_DragDropHere: localization.GetString("UIText_SLP_DragDropHere"), + UIText_SLP_OrClickToSelect: localization.GetString("UIText_SLP_OrClickToSelect"), + UIText_SLP_UploadButton: localization.GetString("UIText_SLP_UploadButton"), + UIText_SLP_ManageInstallation: localization.GetString("UIText_SLP_ManageInstallation"), + UIText_SLP_UninstallWarning: localization.GetString("UIText_SLP_UninstallWarning"), + UIText_SLP_UninstallButton: localization.GetString("UIText_SLP_UninstallButton"), + UIText_SLP_UpdateWorkshopMods: localization.GetString("UIText_SLP_UpdateWorkshopMods"), + UIText_SLP_UpdateWorkshopModsDesc: localization.GetString("UIText_SLP_UpdateWorkshopModsDesc"), + UIText_SLP_UpdateButton: localization.GetString("UIText_SLP_UpdateButton"), + UIText_SLP_InstalledMods: localization.GetString("UIText_SLP_InstalledMods"), + IsStationeersLaunchPadEnabled: isStationeersLaunchPadEnabled, } diff --git a/src/web/templatevars.go b/src/web/templatevars.go index 4ce388d3..dc1a9ea5 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -202,5 +202,25 @@ type ConfigTemplateData struct { UIText_CopyrightConfig1 string UIText_CopyrightConfig2 string + UIText_SLP_Title string + UIText_SLP_Description string + UIText_SLP_ReadyToInstall string + UIText_SLP_InstallButton string + UIText_SLP_UploadModPackage string + UIText_SLP_UploadDescription string + UIText_SLP_UploadDescriptionLink string + UIText_SLP_InstallFirst string + UIText_SLP_InstallFirstSubtext string + UIText_SLP_DragDropHere string + UIText_SLP_OrClickToSelect string + UIText_SLP_UploadButton string + UIText_SLP_ManageInstallation string + UIText_SLP_UninstallWarning string + UIText_SLP_UninstallButton string + UIText_SLP_UpdateWorkshopMods string + UIText_SLP_UpdateWorkshopModsDesc string + UIText_SLP_UpdateButton string + UIText_SLP_InstalledMods string + IsStationeersLaunchPadEnabled string } From eeb19732769864241c9d521bcc680def715c9c64 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Wed, 28 Jan 2026 12:25:52 +0100 Subject: [PATCH 16/21] add SLP Beta disclaimer to UI with acknowledgment functionality that saves to local storage --- UIMod/onboard_bundled/assets/css/config.css | 54 +++++++++++++++++++++ UIMod/onboard_bundled/assets/js/slp.js | 35 +++++++++++++ UIMod/onboard_bundled/ui/config.html | 14 ++++++ 3 files changed, 103 insertions(+) diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index a5fedcfe..13de4173 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -702,6 +702,60 @@ select option { display: block; } +/* SLP Beta Disclaimer */ +.slp-disclaimer { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + padding: 40px; +} + +.slp-disclaimer-content { + text-align: center; + max-width: 500px; + padding: 50px; + background: linear-gradient(135deg, rgba(255, 170, 0, 0.08), rgba(255, 100, 0, 0.08)); + border: 2px solid rgba(255, 170, 0, 0.5); + border-radius: 16px; + box-shadow: 0 0 30px rgba(255, 170, 0, 0.15); +} + +.slp-disclaimer-icon { + font-size: 64px; + display: block; + margin-bottom: 20px; + filter: drop-shadow(0 0 15px rgba(255, 170, 0, 0.5)); +} + +.slp-disclaimer-content h2 { + color: #ffaa00; + font-family: 'Press Start 2P', cursive; + font-size: 1.2rem; + margin: 0 0 25px 0; + text-shadow: 0 0 15px rgba(255, 170, 0, 0.4); +} + +.slp-disclaimer-content p { + color: var(--text-normal, #ccc); + font-family: 'Share Tech Mono', monospace; + font-size: 1rem; + line-height: 1.8; + margin: 0 0 15px 0; +} + +.slp-disclaimer-content .slp-button { + margin-top: 25px; +} + +.slp-content-hidden { + display: none; +} + +.slp-disclaimer-hidden { + display: none; +} + .notification { display: none; padding: 16px 20px; diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js index eefaaaf1..8f9ea091 100644 --- a/UIMod/onboard_bundled/assets/js/slp.js +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -1,3 +1,38 @@ +// SLP Beta Disclaimer Handler +function acknowledgeSLPDisclaimer() { + const disclaimer = document.getElementById('slp-disclaimer'); + const content = document.getElementById('slp-content'); + + if (disclaimer && content) { + disclaimer.classList.add('slp-disclaimer-hidden'); + content.classList.remove('slp-content-hidden'); + + // Store acknowledgment in session storage (resets on browser close) + sessionStorage.setItem('slp-disclaimer-acknowledged', 'true'); + } +} + +// Check if disclaimer was already acknowledged this session +function checkSLPDisclaimerState() { + const acknowledged = sessionStorage.getItem('slp-disclaimer-acknowledged'); + if (acknowledged === 'true') { + const disclaimer = document.getElementById('slp-disclaimer'); + const content = document.getElementById('slp-content'); + + if (disclaimer && content) { + disclaimer.classList.add('slp-disclaimer-hidden'); + content.classList.remove('slp-content-hidden'); + } + } +} + +// Initialize disclaimer state on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', checkSLPDisclaimerState); +} else { + checkSLPDisclaimerState(); +} + function showNotification(message, type = 'info') { const notification = document.getElementById('notification'); notification.textContent = message; diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index a70ac33d..e55d9a82 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -447,6 +447,19 @@

    {{.UIText_DiscordIntegrationBenefits}}

    + +
    +
    + ⚠️ +

    Beta Feature

    +

    The SLP (Stationeers LaunchPad) Integration is currently in Beta and is subject to change.

    +

    Handle with care and report any issues you encounter.

    + +
    +
    + + +
    {{if eq .IsStationeersLaunchPadEnabled "false"}}
    @@ -506,6 +519,7 @@

    {{.UIText_SLP_InstalledMods}}

    {{end}} +
    From fa99a02eeff6428ab110b54154344832d93a4257 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Wed, 28 Jan 2026 12:29:28 +0100 Subject: [PATCH 17/21] added "multiplayersafe" branch to dropdown in wizard --- src/web/TwoBoxForm.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/TwoBoxForm.go b/src/web/TwoBoxForm.go index 17bfe149..80339d0f 100644 --- a/src/web/TwoBoxForm.go +++ b/src/web/TwoBoxForm.go @@ -85,6 +85,7 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) { {Display: "Pre-terrain rework update", Value: "preterrain"}, {Display: "Pre-rocket refactor update", Value: "prerocket"}, {Display: "Version before the latest update", Value: "previous"}, + {Display: "A slightly rolled back Multiplayer-Safe version", Value: "multiplayersafe"}, } var worldOptions = []struct{ Display, Value string }{ From dcec61cfa0e6db69627b4b9e00046f64fff2f04e Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Wed, 28 Jan 2026 12:38:57 +0100 Subject: [PATCH 18/21] code-review: - remove setDummyBuildID() - fix SLP-related auto update logic config file writer - fix typo in comment - fix remove redundant os.MkdirlAll call in modpackages.go - remove debug statement UpdateWorkshopItems as its not needed --- src/cli/devcommands.go | 5 ----- src/modding/launchpad-config.go | 4 ++-- src/modding/launchpad.go | 2 +- src/modding/modpackages.go | 5 ----- src/steamcmd/workshop.go | 2 +- 5 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/cli/devcommands.go b/src/cli/devcommands.go index 867c5898..f0aa5f7e 100644 --- a/src/cli/devcommands.go +++ b/src/cli/devcommands.go @@ -49,11 +49,6 @@ func listmods() { } } -func setDummyBuildID() { - config.SetCurrentBranchBuildID("dummy") - logger.Core.Info("Dummy build ID set") -} - func testLocalization() { currentLanguageSetting := config.GetLanguageSetting() s := localization.GetString("UIText_StartButton") diff --git a/src/modding/launchpad-config.go b/src/modding/launchpad-config.go index 96597126..0daccd55 100644 --- a/src/modding/launchpad-config.go +++ b/src/modding/launchpad-config.go @@ -49,8 +49,8 @@ func ToggleSLPAutoUpdates(enable bool) (modified bool, err error) { modified = (newContent != content) - // If neither key existed → append them under [Startup] section - if !modified || (!reCheck.MatchString(content) && !reAuto.MatchString(content)) { + // If any key is missing → append them under [Startup] section + if !reCheck.MatchString(content) || !reAuto.MatchString(content) { // Look for [Startup] section startupSectionRe := regexp.MustCompile(`(?m)^\[Startup\]\s*$`) diff --git a/src/modding/launchpad.go b/src/modding/launchpad.go index 9a2e624c..4e946460 100644 --- a/src/modding/launchpad.go +++ b/src/modding/launchpad.go @@ -65,7 +65,7 @@ func InstallSLP() (string, error) { for _, rel := range releases { if rel.Prerelease { - continue // skip prereleases for now (you can make configurable later) + continue // skip prereleases for now (can be made configurable later) } for _, asset := range rel.Assets { diff --git a/src/modding/modpackages.go b/src/modding/modpackages.go index 374c4c4b..65e9dabd 100644 --- a/src/modding/modpackages.go +++ b/src/modding/modpackages.go @@ -139,11 +139,6 @@ func extractZip(zipPath string, destDir string) error { return fmt.Errorf("failed to open file in archive: %w", err) } - if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { - rc.Close() - return fmt.Errorf("failed to create directory for file: %w", err) - } - outFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) if err != nil { rc.Close() diff --git a/src/steamcmd/workshop.go b/src/steamcmd/workshop.go index 11de909b..c28d1dec 100644 --- a/src/steamcmd/workshop.go +++ b/src/steamcmd/workshop.go @@ -24,7 +24,7 @@ func UpdateWorkshopItems() ([]string, error) { return logs, nil } - fmt.Println(workshopHandles) + //logger.Install.Debugf("Workshop handles to update: %v", workshopHandles) logs2, err := DownloadWorkshopItems(workshopHandles) logs = append(logs, logs2...) return logs, err From 16c18430f269bbcfc10013b77662b3fbdb36b3f3 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Wed, 28 Jan 2026 12:40:56 +0100 Subject: [PATCH 19/21] fix remove setdummybuildid command from CLI --- src/cli/commands.go | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cli/commands.go b/src/cli/commands.go index 81f996eb..c00b426f 100644 --- a/src/cli/commands.go +++ b/src/cli/commands.go @@ -35,7 +35,6 @@ func init() { RegisterCommand("supportmode", WrapNoReturn(supportMode), "sm") RegisterCommand("supportpackage", WrapNoReturn(supportPackage), "sp") RegisterCommand("getbuildid", WrapNoReturn(getBuildID), "gbid") - RegisterCommand("setdummybuildid", WrapNoReturn(setDummyBuildID), "sdbid") RegisterCommand("printconfig", WrapNoReturn(printConfig), "pc") RegisterCommand("update", WrapNoReturn(triggerUpdateCheck), "u") RegisterCommand("applyupdate", WrapNoReturn(applyUpdate), "au") From 4bf7cf1047435895c311fd0506d086c3bd56ae17 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Wed, 28 Jan 2026 12:44:55 +0100 Subject: [PATCH 20/21] add Stealthbob & Sumisukyo to donors --- UIMod/onboard_bundled/assets/credits.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UIMod/onboard_bundled/assets/credits.html b/UIMod/onboard_bundled/assets/credits.html index 631f27f1..c06dfa82 100644 --- a/UIMod/onboard_bundled/assets/credits.html +++ b/UIMod/onboard_bundled/assets/credits.html @@ -252,6 +252,8 @@
    Donors
    Musashi
    +
    Stealthbob
    +
    Sumisukyo
    From 00effedb71063d63d276349ad4d4203fb59deb23 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Wed, 28 Jan 2026 12:48:07 +0100 Subject: [PATCH 21/21] improve visual style of credits page slightly --- UIMod/onboard_bundled/assets/credits.html | 362 ++++++++++++++++++++-- 1 file changed, 344 insertions(+), 18 deletions(-) diff --git a/UIMod/onboard_bundled/assets/credits.html b/UIMod/onboard_bundled/assets/credits.html index c06dfa82..7113d2a9 100644 --- a/UIMod/onboard_bundled/assets/credits.html +++ b/UIMod/onboard_bundled/assets/credits.html @@ -18,6 +18,24 @@ overflow: hidden; position: relative; } + .nebula { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + background: + radial-gradient(ellipse at 20% 80%, rgba(102, 126, 234, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(118, 75, 162, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 50% 50%, rgba(240, 147, 251, 0.08) 0%, transparent 60%); + animation: nebula-drift 30s ease-in-out infinite alternate; + } + + @keyframes nebula-drift { + 0% { transform: scale(1) rotate(0deg); } + 100% { transform: scale(1.1) rotate(5deg); } + } .stars { position: fixed; @@ -35,6 +53,26 @@ animation: twinkle 3s infinite; } + .star.bright { + box-shadow: 0 0 6px 2px rgba(255, 255, 255, 0.6); + } + + .shooting-star { + position: absolute; + width: 100px; + height: 2px; + background: linear-gradient(90deg, rgba(255,255,255,0.8), transparent); + opacity: 0; + animation: shoot 4s linear infinite; + } + + @keyframes shoot { + 0% { transform: translateX(0) translateY(0); opacity: 0; } + 5% { opacity: 1; } + 20% { transform: translateX(300px) translateY(150px); opacity: 0; } + 100% { opacity: 0; } + } + @keyframes twinkle { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } @@ -53,15 +91,27 @@ width: 80%; max-width: 900px; left: 50%; - bottom: 0; - transform: translateX(-50%) translateY(100%); - animation: scroll-up 90s linear forwards; + top: 100%; + transform: translateX(-50%); text-align: center; + will-change: transform; + transition: none; + } + + .credits-scroll.animating { + animation: scroll-up 90s linear forwards; + } + + .credits-scroll.paused { + animation-play-state: paused; } @keyframes scroll-up { + from { + top: 100%; + } to { - transform: translateX(-50%) translateY(-100%); + top: -100%; } } @@ -69,15 +119,28 @@ font-size: 3.5em; font-weight: bold; margin-bottom: 2em; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + background-size: 200% 200%; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; - text-shadow: 0 0 30px rgba(102, 126, 234, 0.5); + animation: gradient-shift 5s ease infinite; + filter: drop-shadow(0 0 30px rgba(102, 126, 234, 0.5)); + } + + @keyframes gradient-shift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } } .section { margin: 4em 0; + padding: 2em; + transition: all 0.4s ease; + } + + .section:hover { + transform: scale(1.02); } .role { @@ -87,6 +150,17 @@ letter-spacing: 3px; margin-bottom: 0.5em; text-shadow: 0 0 20px rgba(255, 215, 0, 0.6); + position: relative; + } + + .role::after { + content: ''; + display: block; + width: 60px; + height: 3px; + background: linear-gradient(90deg, transparent, #ffd700, transparent); + margin: 0.5em auto; + border-radius: 2px; } .name { @@ -110,11 +184,16 @@ color: #60a5fa; text-decoration: none; font-size: 1em; - transition: color 0.3s; + transition: all 0.3s; + padding: 0.3em 1em; + border-radius: 20px; + display: inline-block; } .link:hover { color: #93c5fd; + background: rgba(96, 165, 250, 0.1); + box-shadow: 0 0 20px rgba(96, 165, 250, 0.3); } .description { @@ -137,7 +216,7 @@ font-size: 2em; color: #008cff; margin: 3em 0 1em 0; - text-shadow: 0 0 20px rgba(72, 187, 120, 0.6); + text-shadow: 0 0 20px rgba(0, 140, 255, 0.6); } .tech-stack { @@ -167,13 +246,101 @@ .glow { text-shadow: 0 0 10px currentColor; } + + /* Scroll hint indicator */ + .scroll-hint { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.5); + font-size: 0.9em; + text-align: center; + z-index: 100; + transition: opacity 0.5s; + pointer-events: none; + } + + .scroll-hint .mouse-icon { + width: 26px; + height: 40px; + border: 2px solid rgba(255, 255, 255, 0.5); + border-radius: 13px; + margin: 0 auto 10px; + position: relative; + } + + .scroll-hint .wheel { + width: 4px; + height: 8px; + background: rgba(255, 255, 255, 0.5); + border-radius: 2px; + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + animation: scroll-wheel 2s infinite; + } + + @keyframes scroll-wheel { + 0%, 100% { transform: translateX(-50%) translateY(0); opacity: 1; } + 50% { transform: translateX(-50%) translateY(10px); opacity: 0.3; } + } + + /* Control buttons */ + .controls { + position: fixed; + top: 20px; + right: 20px; + z-index: 100; + display: flex; + gap: 10px; + } + + .control-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 10px 20px; + border-radius: 25px; + cursor: pointer; + font-size: 0.9em; + transition: all 0.3s; + backdrop-filter: blur(10px); + } + + .control-btn:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); + transform: scale(1.05); + } + + .control-btn.active { + background: rgba(102, 126, 234, 0.3); + border-color: rgba(102, 126, 234, 0.6); + } +
    -
    -
    + +
    + + +
    + + +
    +
    +
    +
    + Scroll to navigate +
    + +
    +
    StationeersServerUI
    @@ -283,11 +450,11 @@ \ No newline at end of file