diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index ff3d3d2e..abb25d50 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -599,6 +599,7 @@ select option { } .manage-left, +.manage-center, .manage-right { flex: 1; display: flex; @@ -610,6 +611,7 @@ select option { } .manage-left p, +.manage-center p, .manage-right p { flex: 1; margin-bottom: 15px; @@ -617,6 +619,7 @@ select option { } .manage-left .slp-button, +.manage-center .slp-button, .manage-right .slp-button { width: 100%; margin-top: auto; diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js index f4588d19..6fb4ceac 100644 --- a/UIMod/onboard_bundled/assets/js/slp.js +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -110,6 +110,31 @@ function uninstallSLP() { }); } +function reinstallSLP() { + if (!confirm('Are you sure you want to reinstall SLP? This will re-download and reinstall the SLP plugin. Your mods and modconfig will be preserved.')) { + return; + } + setButtonLoading('reinstallSLPBtn', true); + showPopup('info', 'Reinstalling Stationeers Launch Pad...\n\nThis will re-download the latest version while keeping your mods intact.'); + + fetch('/api/v2/slp/reinstall') + .then(response => response.json()) + .then(data => { + if (data.success) { + showPopup('success', 'Stationeers Launch Pad reinstalled successfully!' + (data.version ? ' (Version: ' + data.version + ')' : '') + ' The page will refresh automatically.'); + setButtonLoading('reinstallSLPBtn', false); + setTimeout(() => window.location.reload(), 3000); + } else { + showPopup('error', 'Failed to reinstall SLP:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('reinstallSLPBtn', false); + } + }) + .catch(error => { + showPopup('error', 'Failed to reinstall SLP:\n\n' + (error.message || 'Network error')); + setButtonLoading('reinstallSLPBtn', false); + }); +} + function updateWorkshopMods() { setButtonLoading('updateWorkshopModsBtn', true); showPopup('info', 'Updating workshop mods...\n\nThis may take some time depending on the number of mods. Please wait.'); diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 329daeb3..ae6c2f08 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -630,6 +630,10 @@

{{.UIText_SLP_ManageInstallation}}

⚠️ {{.UIText_SLP_UninstallWarning}}

+
+

Re-download and reinstall SLP without removing your mods.

+ +

{{.UIText_SLP_UpdateWorkshopModsDesc}}

diff --git a/src/config/config.go b/src/config/config.go index 8f448602..533bf32d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -11,7 +11,7 @@ import ( var ( // All configuration variables can be found in vars.go - Version = "5.13.1" + Version = "5.13.2" Branch = "release" ) diff --git a/src/config/getters.go b/src/config/getters.go index b7ace57f..315a2434 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -358,6 +358,12 @@ func GetAutoRestartServerTimer() string { return AutoRestartServerTimer } +func GetNextAutoRestartTime() time.Time { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return NextAutoRestartTime +} + func GetAllowPrereleaseUpdates() bool { ConfigMu.RLock() defer ConfigMu.RUnlock() diff --git a/src/config/setters.go b/src/config/setters.go index 7839e082..39009092 100644 --- a/src/config/setters.go +++ b/src/config/setters.go @@ -413,6 +413,14 @@ func SetAutoRestartServerTimer(value string) error { return safeSaveConfig() } +func SetNextAutoRestartTime(value time.Time) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + NextAutoRestartTime = value + return nil +} + // Backup Settings func SetBackupKeepLastN(value int) error { ConfigMu.Lock() diff --git a/src/config/vars.go b/src/config/vars.go index 692fbaa6..05d2f745 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -72,12 +72,13 @@ var ( // Runtime only variables var ( - CurrentBranchBuildID string // ONLY RUNTIME - ExtractedGameVersion string // ONLY RUNTIME - SkipSteamCMD bool // ONLY RUNTIME - IsDockerContainer bool // ONLY RUNTIME - NoSanityCheck bool // ONLY RUNTIME - IsGameServerRunning bool // ONLY RUNTIME + CurrentBranchBuildID string // ONLY RUNTIME + ExtractedGameVersion string // ONLY RUNTIME + SkipSteamCMD bool // ONLY RUNTIME + IsDockerContainer bool // ONLY RUNTIME + NoSanityCheck bool // ONLY RUNTIME + IsGameServerRunning bool // ONLY RUNTIME + NextAutoRestartTime time.Time // ONLY RUNTIME ) // Discord integration diff --git a/src/discordbot/serverstatuspanel.go b/src/discordbot/serverstatuspanel.go index 7b364299..e5942249 100644 --- a/src/discordbot/serverstatuspanel.go +++ b/src/discordbot/serverstatuspanel.go @@ -14,6 +14,8 @@ import ( // Button custom IDs for the server & players panel const ( ButtonGetPassword = "ssui_get_password" + ButtonGetGameVersion = "ssui_get_game_version" + ButtonGetNextRestart = "ssui_get_next_restart" ButtonDownloadBackupPfx = "ssui_download_backup_" // Prefix for download backup button ) @@ -119,19 +121,37 @@ func buildStatusPanelEmbed(players map[string]string) *discordgo.MessageEmbed { // buildPanelComponents returns the action row with interactive buttons func buildPanelComponents() []discordgo.MessageComponent { + var buttons []discordgo.MessageComponent - if config.GetServerPassword() == "" { + if config.GetServerPassword() != "" { + buttons = append(buttons, discordgo.Button{ + Label: "🔑 Get Server Password", + Style: discordgo.PrimaryButton, + CustomID: ButtonGetPassword, + }) + } + + buttons = append(buttons, discordgo.Button{ + Label: "🎮 Get Game Version", + Style: discordgo.SecondaryButton, + CustomID: ButtonGetGameVersion, + }) + + if config.GetAutoRestartServerTimer() != "0" && config.GetAutoRestartServerTimer() != "" { + buttons = append(buttons, discordgo.Button{ + Label: "🔄 Next Auto Restart", + Style: discordgo.SecondaryButton, + CustomID: ButtonGetNextRestart, + }) + } + + if len(buttons) == 0 { return nil } + return []discordgo.MessageComponent{ discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: "🔑 Get Server Password", - Style: discordgo.PrimaryButton, - CustomID: ButtonGetPassword, - }, - }, + Components: buttons, }, } } @@ -191,6 +211,10 @@ func handlePanelButtonInteraction(s *discordgo.Session, i *discordgo.Interaction switch customID { case ButtonGetPassword: handleGetPasswordButton(s, i) + case ButtonGetGameVersion: + handleGetGameVersionButton(s, i) + case ButtonGetNextRestart: + handleGetNextRestartButton(s, i) default: return } @@ -249,3 +273,86 @@ func handleGetPasswordButton(s *discordgo.Session, i *discordgo.InteractionCreat } }() } + +// handleGetGameVersionButton sends the current game server version as an ephemeral message +func handleGetGameVersionButton(s *discordgo.Session, i *discordgo.InteractionCreate) { + version := config.GetExtractedGameVersion() + + var embed *discordgo.MessageEmbed + if version == "" { + embed = &discordgo.MessageEmbed{ + Title: "🎮 Game Version Unknown", + Description: "The game server version has not been detected yet.", + Color: 0xFFA500, + } + } else { + embed = &discordgo.MessageEmbed{ + Title: "🎮 Game Server Version", + Color: 0x5865F2, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Version", + Value: "```" + version + "```", + Inline: false, + }, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + } + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + logger.Discord.Error("Error responding to game version button: " + err.Error()) + } +} + +// handleGetNextRestartButton sends the next scheduled auto-restart time as an ephemeral message +func handleGetNextRestartButton(s *discordgo.Session, i *discordgo.InteractionCreate) { + nextRestart := config.GetNextAutoRestartTime() + + var embed *discordgo.MessageEmbed + if nextRestart.IsZero() { + embed = &discordgo.MessageEmbed{ + Title: "🔄 No Restart Scheduled", + Description: "No auto-restart is currently scheduled.", + Color: 0xFFA500, + } + } else { + unixTS := nextRestart.Unix() + embed = &discordgo.MessageEmbed{ + Title: "🔄 Next Auto Restart", + Description: "Times below are shown in your local (Discord) timezone.", + Color: 0x5865F2, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Scheduled Time", + Value: fmt.Sprintf("", unixTS), + Inline: true, + }, + { + Name: "Countdown", + Value: fmt.Sprintf("", unixTS), + Inline: true, + }, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + } + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + logger.Discord.Error("Error responding to next restart button: " + err.Error()) + } +} diff --git a/src/managers/gamemgr/autorestart.go b/src/managers/gamemgr/autorestart.go index 3bec7653..4f087006 100644 --- a/src/managers/gamemgr/autorestart.go +++ b/src/managers/gamemgr/autorestart.go @@ -20,6 +20,7 @@ func startAutoRestart(schedule string, done chan struct{}) { // Try parsing as a time in HH:MM format if t, err := time.Parse("15:04", schedule); err == nil { // Valid HH:MM format, schedule daily restart + setNextDailyRestartTime(t) go scheduleDailyRestart(t, done) return } @@ -27,6 +28,7 @@ func startAutoRestart(schedule string, done chan struct{}) { // Try parsing as a time in HH:MMAM/PM format if t, err := time.Parse("03:04PM", schedule); err == nil { // Valid HH:MMAM/PM format, schedule daily restart + setNextDailyRestartTime(t) go scheduleDailyRestart(t, done) return } @@ -42,6 +44,8 @@ func startAutoRestart(schedule string, done chan struct{}) { return } + config.SetNextAutoRestartTime(time.Now().Add(time.Duration(minutesInt) * time.Minute)) + ticker := time.NewTicker(time.Duration(minutesInt) * time.Minute) defer ticker.Stop() @@ -88,6 +92,7 @@ func startAutoRestart(schedule string, done chan struct{}) { return } case <-done: + config.SetNextAutoRestartTime(time.Time{}) return } } @@ -115,6 +120,8 @@ func scheduleDailyRestart(t time.Time, done chan struct{}) { if !internalIsServerRunningNoLock() { mu.Unlock() logger.Core.Info("Auto-restart skipped: server is not running") + // Schedule next day + setNextDailyRestartTime(t) continue } mu.Unlock() @@ -133,6 +140,8 @@ func scheduleDailyRestart(t time.Time, done chan struct{}) { logger.Core.Info("Daily auto-restart triggered: stopping server") if err := InternalStopServer(); err != nil { logger.Core.Error("Daily auto-restart failed to stop server: " + err.Error()) + // Schedule next day + setNextDailyRestartTime(t) continue } @@ -146,7 +155,19 @@ func scheduleDailyRestart(t time.Time, done chan struct{}) { } case <-done: timer.Stop() + config.SetNextAutoRestartTime(time.Time{}) return } } } + +// setNextDailyRestartTime calculates and stores the next daily restart time. +func setNextDailyRestartTime(t time.Time) { + hour, min := t.Hour(), t.Minute() + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, now.Location()) + if now.After(next) || now.Equal(next) { + next = next.Add(24 * time.Hour) + } + config.SetNextAutoRestartTime(next) +} diff --git a/src/modding/launchpad.go b/src/modding/launchpad.go index 4e946460..59579546 100644 --- a/src/modding/launchpad.go +++ b/src/modding/launchpad.go @@ -172,6 +172,27 @@ func UninstallSLP() (string, error) { return "success", nil } +// ReinstallSLP removes only the SLP plugin directory (preserving mods and modconfig.xml), +// then downloads and installs the latest version. +// Returns: (installed version tag or "", error) +func ReinstallSLP() (string, error) { + pluginsDir := "BepInEx/plugins" + slpDir := filepath.Join(pluginsDir, "StationeersLaunchPad") + + // Remove only the SLP plugin directory, keep mods & modconfig.xml + if _, err := os.Stat(slpDir); err == nil { + logger.Install.Info("🔄 Removing existing SLP installation for reinstall...") + if err := os.RemoveAll(slpDir); err != nil { + return "", fmt.Errorf("failed to remove SLP folder for reinstall: %w", err) + } + } else { + logger.Install.Info("SLP not currently installed; proceeding with fresh install") + } + + // Now install fresh + return InstallSLP() +} + func downloadFile(destPath, url string) error { resp, err := http.Get(url) if err != nil { diff --git a/src/setup/update/runandexit.go b/src/setup/update/runandexit.go index 51f9d88c..b6e1220e 100644 --- a/src/setup/update/runandexit.go +++ b/src/setup/update/runandexit.go @@ -11,6 +11,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" ) // runAndExit launches the new executable and terminates the current process @@ -61,7 +62,13 @@ func runAndExitLinux(newExe string) error { return nil } -func RestartMySelf() { +func RestartMySelf() { // Stop the game server before restarting to prevent detached processes + if config.GetIsGameServerRunning() { + logger.Install.Info("🛑 Stopping game server before restart...") + if err := gamemgr.InternalStopServer(); err != nil { + logger.Install.Warn(fmt.Sprintf("⚠️ Failed to stop game server before restart: %v. Proceeding anyway.", err)) + } + } currentExe, err := os.Executable() if err != nil { logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t get current executable path: %v. Keeping version %s.", err, config.GetVersion())) diff --git a/src/setup/update/updater.go b/src/setup/update/updater.go index 0c5f13fd..445b4d5b 100644 --- a/src/setup/update/updater.go +++ b/src/setup/update/updater.go @@ -9,6 +9,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" ) // githubRelease represents the structure of a GitHub release response @@ -112,6 +113,14 @@ func Update(isInUpdateableState bool) (err error, newVersion string) { } } + // Stop the game server before launching the new version to prevent detached processes + if config.GetIsGameServerRunning() { + logger.Install.Info("🛑 Stopping game server before applying update...") + if err := gamemgr.InternalStopServer(); err != nil { + logger.Install.Warn(fmt.Sprintf("⚠️ Failed to stop game server before update: %v. Proceeding anyway.", err)) + } + } + // Launch the new executable and exit logger.Install.Info("🚀 Launching the new version and retiring the old one...") if runtime.GOOS == "windows" { diff --git a/src/web/routes.go b/src/web/routes.go index 94ab1fed..23637cda 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -92,6 +92,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { // SLP & Modding protectedMux.HandleFunc("/api/v2/slp/install", InstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/uninstall", UninstallSLPHandler) + protectedMux.HandleFunc("/api/v2/slp/reinstall", ReinstallSLPHandler) protectedMux.HandleFunc("/api/v2/slp/upload", UploadModPackageHandler) protectedMux.HandleFunc("/api/v2/slp/mods", GetInstalledModDetailsHandler) protectedMux.HandleFunc("/api/v2/steamcmd/updatemods", UpdateWorkshopModsHandler) diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go index 416f5ec2..da313adc 100644 --- a/src/web/slp-launchpad.go +++ b/src/web/slp-launchpad.go @@ -39,6 +39,26 @@ func UninstallSLPHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"success": true}`)) } +func ReinstallSLPHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + version, err := modding.ReinstallSLP() + if 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, + "version": version, + "message": "SLP reinstalled successfully", + }) +} + func UploadModPackageHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")