Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions UIMod/onboard_bundled/assets/css/config.css
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ select option {
}

.manage-left,
.manage-center,
.manage-right {
flex: 1;
display: flex;
Expand All @@ -610,13 +611,15 @@ select option {
}

.manage-left p,
.manage-center p,
.manage-right p {
flex: 1;
margin-bottom: 15px;
font-size: 0.85rem;
}

.manage-left .slp-button,
.manage-center .slp-button,
.manage-right .slp-button {
width: 100%;
margin-top: auto;
Expand Down
25 changes: 25 additions & 0 deletions UIMod/onboard_bundled/assets/js/slp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment on lines +120 to +121
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI calls /api/v2/slp/reinstall with a default GET. The release notes (and typical HTTP semantics) suggest this should be a POST since it changes server state. Consider changing this fetch to method: 'POST' (and handling non-2xx responses), in sync with the backend method enforcement.

Suggested change
fetch('/api/v2/slp/reinstall')
.then(response => response.json())
fetch('/api/v2/slp/reinstall', { method: 'POST' })
.then(async response => {
let data = null;
try {
data = await response.json();
} catch (e) {
// Ignore JSON parse errors; will fall back to a generic message below if needed.
}
if (!response.ok) {
const message =
(data && data.error) ||
('Request failed with status ' + response.status + ' ' + (response.statusText || ''));
throw new Error(message);
}
return data || {};
})

Copilot uses AI. Check for mistakes.
.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.');
Expand Down
4 changes: 4 additions & 0 deletions UIMod/onboard_bundled/ui/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,10 @@ <h3>{{.UIText_SLP_ManageInstallation}}</h3>
<p style="color: var(--danger);">⚠️ {{.UIText_SLP_UninstallWarning}}</p>
<button id="uninstallSLPBtn" class="slp-button slp-button-small danger" onclick="uninstallSLP()">🗑️ {{.UIText_SLP_UninstallButton}}</button>
</div>
<div class="manage-center">
<p>Re-download and reinstall SLP without removing your mods.</p>
<button id="reinstallSLPBtn" class="slp-button slp-button-small" onclick="reinstallSLP()">🔄 Reinstall SLP</button>
</div>
<div class="manage-right">
<p>{{.UIText_SLP_UpdateWorkshopModsDesc}}</p>
<button id="updateWorkshopModsBtn" class="slp-button" onclick="updateWorkshopMods()">🔄 {{.UIText_SLP_UpdateButton}}</button>
Expand Down
2 changes: 1 addition & 1 deletion src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
6 changes: 6 additions & 0 deletions src/config/getters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions src/config/setters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 7 additions & 6 deletions src/config/vars.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 115 additions & 8 deletions src/discordbot/serverstatuspanel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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,
})
Comment on lines +134 to +138
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the 🎮 Game Version button "only appears once a version is detected", but the button is currently always added. Either gate this button on config.GetExtractedGameVersion() != "" (and/or a dedicated "version detected" flag) or update the release notes/description to match the actual behavior.

Copilot uses AI. Check for mistakes.

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,
},
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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("<t:%d>", unixTS),
Inline: true,
},
{
Name: "Countdown",
Value: fmt.Sprintf("<t:%d:R>", 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())
}
}
21 changes: 21 additions & 0 deletions src/managers/gamemgr/autorestart.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ 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
}

// 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
}
Expand All @@ -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()

Expand Down Expand Up @@ -88,6 +92,7 @@ func startAutoRestart(schedule string, done chan struct{}) {
return
}
case <-done:
config.SetNextAutoRestartTime(time.Time{})
return
}
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
}

Expand All @@ -146,7 +155,19 @@ func scheduleDailyRestart(t time.Time, done chan struct{}) {
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the daily schedule, NextAutoRestartTime is updated when skipping/not-stopping, but it isn't updated after a successful restart. That means the stored "next restart" can become stale (in the past) until the next loop iteration recalculates it. Consider calling setNextDailyRestartTime(t) immediately after a successful restart (or before starting the timer each loop) so the Discord panel always shows the upcoming restart time.

Suggested change
}
}
// Schedule next day after a successful restart
setNextDailyRestartTime(t)

Copilot uses AI. Check for mistakes.
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)
}
21 changes: 21 additions & 0 deletions src/modding/launchpad.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +188 to +189
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.Stat errors other than "not exists" (e.g., permission issues) are currently treated as "SLP not installed" and the reinstall proceeds. That can mask real filesystem problems and lead to confusing failures later. Consider handling os.IsNotExist(err) explicitly and returning an error for other Stat failures.

Suggested change
} else {
logger.Install.Info("SLP not currently installed; proceeding with fresh install")
} else if os.IsNotExist(err) {
logger.Install.Info("SLP not currently installed; proceeding with fresh install")
} else {
return "", fmt.Errorf("failed to check existing SLP installation: %w", err)

Copilot uses AI. Check for mistakes.
}

// Now install fresh
return InstallSLP()
}

func downloadFile(destPath, url string) error {
resp, err := http.Get(url)
if err != nil {
Expand Down
9 changes: 8 additions & 1 deletion src/setup/update/runandexit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Comment on lines +66 to +69
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the update flow: this restart path logs and proceeds even if InternalStopServer() fails. If the stop fails while the server is still running, restarting SSUI can leave the game server detached and/or running alongside a new instance. Consider aborting the restart on stop failure (except when the server is already stopped), or performing a stronger termination before proceeding.

Suggested change
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))
wasRunning := config.GetIsGameServerRunning()
if wasRunning {
logger.Install.Info("🛑 Stopping game server before restart...")
if err := gamemgr.InternalStopServer(); err != nil {
// Re-check running state: if still running, abort restart to avoid detached/duplicate server
if config.GetIsGameServerRunning() {
logger.Install.Error(fmt.Sprintf("⚠️ Failed to stop game server before restart and server still appears to be running: %v. Aborting restart.", err))
return
}
logger.Install.Warn(fmt.Sprintf("⚠️ Failed to stop game server before restart: %v, but server no longer appears to be running. Proceeding with restart.", err))

Copilot uses AI. Check for mistakes.
}
}
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()))
Expand Down
Loading
Loading