From 0f0a41f87cd34dda7eaa46b36e1bb21ebeaddd64 Mon Sep 17 00:00:00 2001 From: akirilov Date: Fri, 31 Oct 2025 03:54:52 +0100 Subject: [PATCH 01/27] Re-advertise if the IP or game version changes --- src/advertiser/advertiser.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/advertiser/advertiser.go b/src/advertiser/advertiser.go index 28782aae..dfdc1426 100644 --- a/src/advertiser/advertiser.go +++ b/src/advertiser/advertiser.go @@ -80,6 +80,8 @@ func StartAdvertiser() { } go func() { sessionId := -1 + oldVersion := "" + oldAddress := "" // Track accumulated transient errors and kill the advertiser if we exceed a threshold transientErrors := 0 for { @@ -126,6 +128,14 @@ func StartAdvertiser() { MaxPlayers: maxplayers, Type: platform, } + // Check if the version or address has changed since last advertisement + if (oldVersion != adMessage.Version) || (oldAddress != adMessage.Address) { + // Reset sessionId to force a new advertisement + adMessage.SessionId = -1 + } + // Update the saved values + oldVersion = adMessage.Version + oldAddress = adMessage.Address body, err := json.Marshal(adMessage) if err != nil { logger.Advertiser.Warnf("ServerAdvertiser failed to Serialize to JSON from native Go struct type: %v", err) From 9ee02f62e0dfdbfe809962a2a2d2c6c42561daca Mon Sep 17 00:00:00 2001 From: akirilov Date: Fri, 31 Oct 2025 04:24:19 +0100 Subject: [PATCH 02/27] Add a debug message for the re-advertising change --- src/advertiser/advertiser.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/advertiser/advertiser.go b/src/advertiser/advertiser.go index dfdc1426..147cfa52 100644 --- a/src/advertiser/advertiser.go +++ b/src/advertiser/advertiser.go @@ -131,6 +131,7 @@ func StartAdvertiser() { // Check if the version or address has changed since last advertisement if (oldVersion != adMessage.Version) || (oldAddress != adMessage.Address) { // Reset sessionId to force a new advertisement + logger.Advertiser.Debugf("ServerAdvertiser detected version or address change (old: %s @ %s, new: %s @ %s). Forcing re-advertisement...", oldVersion, oldAddress, adMessage.Version, adMessage.Address) adMessage.SessionId = -1 } // Update the saved values From c3c442d26a0f35e498ead23fd939f67deb2ff24b Mon Sep 17 00:00:00 2001 From: akirilov Date: Mon, 3 Nov 2025 12:23:09 +0100 Subject: [PATCH 03/27] Fix backup manager nested loops slowing down games with large numbers of saves --- UIMod/onboard_bundled/assets/js/server-api.js | 9 +- src/discordbot/handleSlashcommands.go | 4 +- src/managers/backupmgr/backuphttp.go | 6 +- src/managers/backupmgr/cleanup.go | 149 +++++----- src/managers/backupmgr/manager.go | 22 +- src/managers/backupmgr/restore.go | 281 ++---------------- src/managers/backupmgr/types.go | 10 +- src/managers/backupmgr/utils.go | 59 +--- 8 files changed, 128 insertions(+), 412 deletions(-) diff --git a/UIMod/onboard_bundled/assets/js/server-api.js b/UIMod/onboard_bundled/assets/js/server-api.js index b443b406..cb0ae2aa 100644 --- a/UIMod/onboard_bundled/assets/js/server-api.js +++ b/UIMod/onboard_bundled/assets/js/server-api.js @@ -69,13 +69,14 @@ function fetchBackups() { } let animationCount = 0; - data.forEach((backup) => { + for (i = 0; i < data.length; i++) { + const backup = data[i]; const li = document.createElement('li'); li.className = 'backup-item'; const backupType = getBackupType(backup); - const fileName = "Backup Index: " + backup.Index; - const formattedDate = "Created: " + new Date(backup.ModTime).toLocaleString(); + const fileName = "Backup Index: " + i; + const formattedDate = "Created: " + new Date(backup.SaveTime).toLocaleString(); li.innerHTML = `
@@ -96,7 +97,7 @@ function fetchBackups() { }, animationCount * 50); animationCount++; } - }); + } }) .catch(err => { console.error("Failed to fetch backups:", err); diff --git a/src/discordbot/handleSlashcommands.go b/src/discordbot/handleSlashcommands.go index 20d315aa..89e9d438 100644 --- a/src/discordbot/handleSlashcommands.go +++ b/src/discordbot/handleSlashcommands.go @@ -200,7 +200,7 @@ func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, data Embed return respond(s, i, data) } - sort.Slice(backups, func(i, j int) bool { return backups[i].ModTime.After(backups[j].ModTime) }) + sort.Slice(backups, func(i, j int) bool { return backups[i].SaveTime.After(backups[j].SaveTime) }) batchSize := 20 embeds := []*discordgo.MessageEmbed{} for start := 0; start < len(backups); start += batchSize { @@ -210,7 +210,7 @@ func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, data Embed } fields := make([]EmbedField, end-start) for j, b := range backups[start:end] { - fields[j] = EmbedField{Name: fmt.Sprintf("📂 Backup #%d", b.Index), Value: b.ModTime.Format("January 2, 2006, 3:04 PM")} + fields[j] = EmbedField{Name: fmt.Sprintf("📂 Backup #%d", j), Value: b.SaveTime.Format("January 2, 2006, 3:04 PM")} } embeds = append(embeds, generateEmbed(EmbedData{ Title: "📜 Backup Archives", Description: fmt.Sprintf("Showing %d-%d of %d backups", start+1, end, len(backups)), diff --git a/src/managers/backupmgr/backuphttp.go b/src/managers/backupmgr/backuphttp.go index 8ec322d4..b56a4e76 100644 --- a/src/managers/backupmgr/backuphttp.go +++ b/src/managers/backupmgr/backuphttp.go @@ -47,11 +47,11 @@ func (h *HTTPHandler) ListBackupsHandler(w http.ResponseWriter, r *http.Request) if mode == "classic" { // Format the response in the classic format classicResponses := make([]string, 0, len(backups)) - for _, backup := range backups { + for i, backup := range backups { // Format according to classic view: "BackupIndex: X, Created: DD.MM.YYYY HH:MM:SS" classicLine := fmt.Sprintf("BackupIndex: %d, Created: %s", - backup.Index, - backup.ModTime.Format("02.01.2006 15:04:05")) + i, + backup.SaveTime.Format("02.01.2006 15:04:05")) classicResponses = append(classicResponses, classicLine) } diff --git a/src/managers/backupmgr/cleanup.go b/src/managers/backupmgr/cleanup.go index 946b1b84..7bbb93a1 100644 --- a/src/managers/backupmgr/cleanup.go +++ b/src/managers/backupmgr/cleanup.go @@ -1,6 +1,8 @@ package backupmgr import ( + "archive/zip" + "encoding/xml" "fmt" "os" "path/filepath" @@ -11,6 +13,8 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) +const filetimeEpochOffset = 116444736000000000 // difference between 1601 and 1970 in 100-ns units + // Cleanup performs backup cleanup according to retention policy func (m *BackupManager) Cleanup() error { m.mu.Lock() @@ -62,14 +66,14 @@ func (m *BackupManager) cleanBackupDir() error { // cleanSafeBackupDir cleans the safe backup directory with retention policy func (m *BackupManager) cleanSafeBackupDir() error { - groups, err := m.getBackupGroups() + saves, err := m.getBackupSaveFiles() if err != nil { return err } // Sort newest first - sort.Slice(groups, func(i, j int) bool { - return groups[i].ModTime.After(groups[j].ModTime) + sort.Slice(saves, func(i, j int) bool { + return saves[i].SaveTime.After(saves[j].SaveTime) }) now := time.Now() @@ -79,8 +83,8 @@ func (m *BackupManager) cleanSafeBackupDir() error { lastKeptMonthly time.Time ) - for i, group := range groups { - age := now.Sub(group.ModTime) + for i, group := range saves { + age := now.Sub(group.SaveTime) // Always keep the most recent N backups if i < m.config.RetentionPolicy.KeepLastN { @@ -89,18 +93,18 @@ func (m *BackupManager) cleanSafeBackupDir() error { // Keep daily backups for specified duration if age < m.config.RetentionPolicy.KeepDailyFor { - if lastKeptDaily.IsZero() || group.ModTime.Day() != lastKeptDaily.Day() { - lastKeptDaily = group.ModTime + if lastKeptDaily.IsZero() || group.SaveTime.Day() != lastKeptDaily.Day() { + lastKeptDaily = group.SaveTime continue } } // Keep weekly backups for specified duration if age < m.config.RetentionPolicy.KeepWeeklyFor { - year1, week1 := group.ModTime.ISOWeek() + year1, week1 := group.SaveTime.ISOWeek() year2, week2 := lastKeptWeekly.ISOWeek() if lastKeptWeekly.IsZero() || year1 != year2 || week1 != week2 { - lastKeptWeekly = group.ModTime + lastKeptWeekly = group.SaveTime continue } } @@ -108,9 +112,9 @@ func (m *BackupManager) cleanSafeBackupDir() error { // Keep monthly backups for specified duration if age < m.config.RetentionPolicy.KeepMonthlyFor { if lastKeptMonthly.IsZero() || - group.ModTime.Month() != lastKeptMonthly.Month() || - group.ModTime.Year() != lastKeptMonthly.Year() { - lastKeptMonthly = group.ModTime + group.SaveTime.Month() != lastKeptMonthly.Month() || + group.SaveTime.Year() != lastKeptMonthly.Year() { + lastKeptMonthly = group.SaveTime continue } } @@ -122,84 +126,81 @@ func (m *BackupManager) cleanSafeBackupDir() error { return nil } -// getBackupGroups collects and groups backup files -func (m *BackupManager) getBackupGroups() ([]BackupGroup, error) { - var files []os.DirEntry - err := filepath.WalkDir(m.config.SafeBackupDir, func(path string, d os.DirEntry, err error) error { +// getBackupSaveFiles retrieves all backup save files from the safe backup directory +func (m *BackupManager) getBackupSaveFiles() ([]BackupSaveFile, error) { + var saves []BackupSaveFile + + err := filepath.WalkDir(m.config.SafeBackupDir, func(path string, de os.DirEntry, err error) error { if err != nil { return err } - if !d.IsDir() { - files = append(files, d) + if !de.IsDir() { + // Process the save file + filename := de.Name() + + // Skip invalid backup files + if !isValidBackupFile(filename) { + return nil + } + + // Get the full path + fullPath := filepath.Join(m.config.SafeBackupDir, filename) + + // Get the save time from the file + // Unzip the save file and open the world_meta.xml file inside + r, err := zip.OpenReader(fullPath) + if err != nil { + return err + } + defer r.Close() + worldMetadata, err := r.Open("world_meta.xml") + if err != nil { + return err + } + defer worldMetadata.Close() + // Read the world_meta.xml file content using the XML library + type WorldMeta struct { + SaveTime int64 `xml:"DateTime"` + } + var meta WorldMeta + decoder := xml.NewDecoder(worldMetadata) + if err := decoder.Decode(&meta); err != nil { + return err + } + + // Convert FILETIME (100-ns intervals) → Unix time (seconds + nanoseconds) + ns := (meta.SaveTime - filetimeEpochOffset) * 100 + saveTime := time.Unix(0, ns) + + // Add the backup save file info to the list + saves = append(saves, BackupSaveFile{ + SaveFile: fullPath, + SaveTime: saveTime, + }) } return nil }) + + // Handle errors if err != nil { // if the error contains no such file or directory, return nil but return a custom string intsted of the error if strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "The system cannot find the file specified") { return nil, fmt.Errorf("save dir doesn't seem to exist (yet). Try starting the gameserver and click ↻ once it's up. If the Save folder exists and you still get this error, verify the 'Use New Terrain and Save System' setting. Detailed Error: %w", err) } - return nil, fmt.Errorf("failed to walk safe backup dir: %w", err) + return nil, fmt.Errorf("failed to handle safe backup dir: %w", err) } - groups := make(map[int]BackupGroup) - - for _, file := range files { - filename := file.Name() - if !isValidBackupFile(filename) { - continue - } - - fullPath := filepath.Join(m.config.SafeBackupDir, filename) - info, err := file.Info() - if err != nil { - continue - } - - // Parse index or assign synthetic index for .save files - index := parseBackupIndex(filename, info.ModTime(), files) - if index == -1 { - continue - } - - group := groups[index] - group.Index = index - group.ModTime = info.ModTime() - - if strings.HasSuffix(filename, ".save") { - group.BinFile = fullPath - } else { - switch { - case strings.HasSuffix(filename, ".bin"): - group.BinFile = fullPath - case strings.Contains(filename, "world(") && strings.HasSuffix(filename, ".xml"): - group.XMLFile = fullPath - case strings.Contains(filename, "world_meta(") && strings.HasSuffix(filename, ".xml"): - group.MetaFile = fullPath - } - } - - groups[index] = group - } - - var result []BackupGroup - for _, group := range groups { - // Include both old-style groups (all three files) and .save-based groups (just BinFile) - if (group.BinFile != "" && group.XMLFile != "" && group.MetaFile != "") || (group.BinFile != "" && strings.HasSuffix(group.BinFile, ".save")) { - result = append(result, group) - } - } + // Sort saves by save time descending + sort.Slice(saves, func(i, j int) bool { + return saves[i].SaveTime.After(saves[j].SaveTime) + }) - return result, nil + return saves, nil } // deleteBackupGroup removes all files in a backup group -func (m *BackupManager) deleteBackupGroup(group BackupGroup) { - for _, file := range []string{group.BinFile, group.XMLFile, group.MetaFile} { - if file != "" { - if err := os.Remove(file); err != nil { - logger.Backup.Error("Failed to delete backup file " + file + ": " + err.Error()) - } - } +func (m *BackupManager) deleteBackupGroup(saveFile BackupSaveFile) { + if err := os.Remove(saveFile.SaveFile); err != nil { + logger.Backup.Error("Failed to delete backup file " + saveFile.SaveFile + ": " + err.Error()) } } diff --git a/src/managers/backupmgr/manager.go b/src/managers/backupmgr/manager.go index 69338718..fe500f10 100644 --- a/src/managers/backupmgr/manager.go +++ b/src/managers/backupmgr/manager.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" @@ -81,8 +80,12 @@ func (m *BackupManager) Initialize(identifier string) <-chan error { // Start begins the backup monitoring and cleanup routines func (m *BackupManager) Start(identifier string) error { - // Wait for initialization to complete + // Do not handle old terrain and save system backups + if !config.GetIsNewTerrainAndSaveSystem() { + return fmt.Errorf("The old terrain system and save format are no longer supported by backup manager. Please switch to the new system if you wish to continue to use new SSUI features") + } + // Wait for initialization to complete logger.Backup.Debugf("%s is waiting for save folder initialization...", identifier) initResult := <-m.Initialize(identifier) if initResult != nil { @@ -203,25 +206,20 @@ func (m *BackupManager) startCleanupRoutine() { // ListBackups returns information about available backups // limit: number of recent backups to return (0 for all) -func (m *BackupManager) ListBackups(limit int) ([]BackupGroup, error) { +func (m *BackupManager) ListBackups(limit int) ([]BackupSaveFile, error) { m.mu.Lock() defer m.mu.Unlock() - groups, err := m.getBackupGroups() + saves, err := m.getBackupSaveFiles() if err != nil { return nil, err } - // Sort by index (newest first) - sort.Slice(groups, func(i, j int) bool { - return groups[i].Index > groups[j].Index - }) - - if limit > 0 && limit < len(groups) { - groups = groups[:limit] + if limit > 0 && limit < len(saves) { + saves = saves[:limit] } - return groups, nil + return saves, nil } // Shutdown stops all backup operations diff --git a/src/managers/backupmgr/restore.go b/src/managers/backupmgr/restore.go index 8ce94533..fe26f0f7 100644 --- a/src/managers/backupmgr/restore.go +++ b/src/managers/backupmgr/restore.go @@ -1,14 +1,9 @@ package backupmgr import ( - "archive/zip" "fmt" - "io" "os" "path/filepath" - "regexp" - "strings" - "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) @@ -19,264 +14,46 @@ func (m *BackupManager) RestoreBackup(index int) error { defer m.mu.Unlock() logger.Backup.Infof("Restoring backup with index %d", index) - groups, err := m.getBackupGroups() + saves, err := m.getBackupSaveFiles() if err != nil { return fmt.Errorf("failed to get backup groups: %w", err) } - var targetGroup BackupGroup - for _, group := range groups { - if group.Index == index { - targetGroup = group - break - } - } - - if targetGroup.Index == 0 { - return fmt.Errorf("no backup found with index %d", index) + if index < 0 || index >= len(saves) { + return fmt.Errorf("invalid backup index %d", index) } + targetSave := saves[index] - restoredFiles := make(map[string]string) - - // Handle .save file or old-style trio - if targetGroup.BinFile != "" && strings.HasSuffix(targetGroup.BinFile, ".save") { - // .save file case - backupFile := targetGroup.BinFile - destFile := filepath.Join("./saves/"+m.config.WorldName, m.config.WorldName+".save") - - // This check was disabled since it was relatively unnecessary and didnt bring much benefit - - // Before restore, check if we have existing .save files in the root saves/WorldName dir - //saveDir := filepath.Join("./saves/", m.config.WorldName) - //files, err := os.ReadDir(saveDir) - //if err != nil { - // return fmt.Errorf("failed to read save directory %s: %w", saveDir, err) - //} - - //for _, file := range files { - // if file.IsDir() { - // continue - // } - // if strings.HasSuffix(file.Name(), ".save") { - // existingFile := filepath.Join(saveDir, file.Name()) - // // Move existing .save file to SafeBackupDir with timestamp to avoid overwrites - // timestamp := time.Now().Format("2006-01-02_15-04-05") - // savedPreviousHeadSaveFilePath := filepath.Join(m.config.SafeBackupDir, fmt.Sprintf("%s_%s_%s", "pre-restore-HEAD-", timestamp, file.Name())) - // if err := os.Rename(existingFile, savedPreviousHeadSaveFilePath); err != nil { - // return fmt.Errorf("failed to move existing HEAD .save file %s to %s: %w", existingFile, savedPreviousHeadSaveFilePath, err) - // } - // logger.Backup.Info("Moved previous HEAD .save file to: " + savedPreviousHeadSaveFilePath) - // } - //} - - // Create temp directory for mod time shenanigans (https://discordapp.com/channels/276525882049429515/392080751648178188/1407157281606336602) - tempDir := filepath.Join("./saves", m.config.WorldName, "tmp") - if err := os.MkdirAll(tempDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create temp directory %s: %w", tempDir, err) - } - defer os.RemoveAll(tempDir) - - // Extract .save (zip) file to tempDir - r, err := zip.OpenReader(backupFile) - if err != nil { - return fmt.Errorf("failed to open zip reader for %s: %w", backupFile, err) - } - defer r.Close() - - // --- Safe extraction ------------------------------------------------- - for _, f := range r.File { - // Sanitize the entry name – strip any leading / or .. components. - entryName := filepath.Clean(f.Name) - - // Skip empty names or names that contain '..' after cleaning. - if entryName == "." || entryName == ".." || strings.Contains(entryName, "..") { - // This entry would escape the target directory; reject it. - logger.Backup.Warn(fmt.Sprintf("Skipping potentially unsafe zip entry %q", f.Name)) - continue - } - - destPath := filepath.Join(tempDir, entryName) - // Ensure the destination is still inside tempDir. - if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(tempDir)+string(os.PathSeparator)) { - logger.Backup.Warn(fmt.Sprintf("Skipping zip entry that would escape extraction dir: %q", f.Name)) - continue - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(destPath, f.Mode()); err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create directory %s: %w", destPath, err) - } - continue - } - - // Create any missing parent directories. - if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create parent directory for %s: %w", destPath, err) - } - - outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create file %s: %w", destPath, err) - } - - rc, err := f.Open() - if err != nil { - outFile.Close() - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to open file in zip %s: %w", f.Name, err) - } - - if _, err := io.Copy(outFile, rc); err != nil { - rc.Close() - outFile.Close() - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to extract file %s: %w", destPath, err) - } - rc.Close() - outFile.Close() - } - // -------------------------------------------------------------------- - - // Update world_meta.xml DateTime with current Windows file time using regex - now := time.Now() - metaFilePath := filepath.Join(tempDir, "world_meta.xml") - if _, err := os.Stat(metaFilePath); err == nil { - // Read world_meta.xml - data, err := os.ReadFile(metaFilePath) - if err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to read world_meta.xml: %w", err) - } - - // Calculate Windows file time - const windowsEpochToUnixEpoch = 116444736000000000 // 100-ns intervals from 1601 to 1970 - windowsFileTime := now.UnixNano()/100 + windowsEpochToUnixEpoch + backupFile := targetSave.SaveFile + destFile := filepath.Join("./saves/"+m.config.WorldName, m.config.WorldName+".save") - re, err := regexp.Compile(`\d+`) - if err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to compile DateTime regex: %w", err) - } - newDateTime := fmt.Sprintf("%d", windowsFileTime) - updatedData := re.ReplaceAll(data, []byte(newDateTime)) - - if !re.Match(data) { - logger.Backup.Warn("Restore: DateTime element not found in world_meta.xml, proceeding without updating. Server might not load correct save.") - } else { - if err := os.WriteFile(metaFilePath, updatedData, 0644); err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to write updated world_meta.xml: %w", err) - } - } - } else { - logger.Backup.Warn("world_meta.xml not found in extracted files, proceeding without updating DateTime") - } - - // Modify timestamps of extracted files to current system time - if err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - return os.Chtimes(path, now, now) - }); err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to modify timestamps in %s: %w", tempDir, err) - } - - // Create new .save (zip) file at destFile with updated timestamps - dest, err := os.Create(destFile) - if err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create destination .save file %s: %w", destFile, err) - } - defer dest.Close() - - w := zip.NewWriter(dest) - defer w.Close() - - if err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - relPath, err := filepath.Rel(tempDir, path) - if err != nil { - return fmt.Errorf("failed to get relative path for %s: %w", path, err) - } - relPath = filepath.ToSlash(relPath) - - // Create zip entry with current system timestamp - fw, err := w.CreateHeader(&zip.FileHeader{ - Name: relPath, - Method: zip.Deflate, - Modified: now, - }) - if err != nil { - return fmt.Errorf("failed to create zip entry %s: %w", relPath, err) - } - - srcFile, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed to open file %s: %w", path, err) - } - defer srcFile.Close() - - if _, err := io.Copy(fw, srcFile); err != nil { - return fmt.Errorf("failed to write file %s to zip: %w", relPath, err) - } - return nil - }); err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to restore .save file %s: %w", backupFile, err) - } - restoredFiles[destFile] = backupFile - return nil // restore and mod time shenanigans successful, no need to return an error - } else { - // Old-style trio (world_meta.xml, world.xml, world.bin) - files := []struct { - backupName string - backupNameAlt string - destName string - }{ - {fmt.Sprintf("world_meta(%d).xml", index), fmt.Sprintf("world_meta(%d)_AutoSave.xml", index), "world_meta.xml"}, - {fmt.Sprintf("world(%d).xml", index), fmt.Sprintf("world(%d)_AutoSave.xml", index), "world.xml"}, - {fmt.Sprintf("world(%d).bin", index), fmt.Sprintf("world(%d)_AutoSave.bin", index), "world.bin"}, - } + // Backup current save before restoring + tmpfile, err := os.CreateTemp("", "ssui_restore_bak") + if err != nil { + return fmt.Errorf("failed to create temp backup file for current save: %w", err) + } + defer tmpfile.Close() + // Note we do not defer removal of tmpfile here, as we may need it on multiple failures - for _, file := range files { - backupFile := filepath.Join(m.config.SafeBackupDir, file.backupName) - destFile := filepath.Join("./saves/"+m.config.WorldName, file.destName) + // Copy current save to temp backup file + if err := copyFile(destFile, tmpfile.Name()); err != nil { + os.Remove(tmpfile.Name()) + return fmt.Errorf("failed to backup current save before restore: %w", err) + } - if err := copyFile(backupFile, destFile); err != nil { - // Try alternative name - backupFileAlt := filepath.Join(m.config.SafeBackupDir, file.backupNameAlt) - if err := copyFile(backupFileAlt, destFile); err != nil { - m.revertRestore(restoredFiles) - return fmt.Errorf("failed to restore %s: %w", file.backupName, err) - } - backupFile = backupFileAlt - } - restoredFiles[destFile] = backupFile + // Copy the backup file to the destination + if err := copyFile(backupFile, destFile); err != nil { + // Restore the original save from temp backup on failure + err2 := copyFile(tmpfile.Name(), destFile) + if err2 != nil { + return fmt.Errorf("failed to restore backup file: %w; additionally failed to restore original save: %w. Main save backup located at: %s", err, err2, tmpfile.Name()) } + os.Remove(tmpfile.Name()) + return fmt.Errorf("failed to restore backup file: %w", err) } - logger.Backup.Debug(fmt.Sprintf("%v", restoredFiles)) + os.Remove(tmpfile.Name()) - return nil -} + logger.Backup.Infof("Backup with index %d restored successfully", index) -// revertRestore undoes a failed restore operation -func (m *BackupManager) revertRestore(restoredFiles map[string]string) { - for destFile, backupFile := range restoredFiles { - if err := os.Remove(destFile); err == nil { - _ = copyFile(backupFile, destFile) - } - } + return nil } diff --git a/src/managers/backupmgr/types.go b/src/managers/backupmgr/types.go index b4b48b8b..e8e6f08c 100644 --- a/src/managers/backupmgr/types.go +++ b/src/managers/backupmgr/types.go @@ -29,13 +29,9 @@ type RetentionPolicy struct { CleanupInterval time.Duration // How often to run cleanup } -// BackupGroup represents a set of backup files -type BackupGroup struct { - Index int - BinFile string - XMLFile string - MetaFile string - ModTime time.Time +type BackupSaveFile struct { + SaveFile string + SaveTime time.Time } // BackupManager manages backup operations diff --git a/src/managers/backupmgr/utils.go b/src/managers/backupmgr/utils.go index 15c00eee..c98b9a32 100644 --- a/src/managers/backupmgr/utils.go +++ b/src/managers/backupmgr/utils.go @@ -3,11 +3,7 @@ package backupmgr import ( "io" "os" - "regexp" - "sort" - "strconv" "strings" - "time" ) // copyFile copies a file from src to dst @@ -31,60 +27,7 @@ func copyFile(src, dst string) error { return destination.Sync() } -// parseBackupIndex extracts the backup index from a filename or assigns a synthetic index -func parseBackupIndex(filename string, modTime time.Time, files []os.DirEntry) int { - // Try to extract index from old format (e.g., world(1).xml) - re := regexp.MustCompile(`\((\d+)\)`) - matches := re.FindStringSubmatch(filename) - if len(matches) >= 2 { - index, err := strconv.Atoi(matches[1]) - if err == nil { - return index - } - } - - // For .save files, assign synthetic index based on mod time (newest eq highest) - if strings.HasSuffix(filename, ".save") { - // Sort files by mod time to assign indexes - var sortedFiles []struct { - name string - modTime time.Time - } - for _, file := range files { - if !strings.HasSuffix(file.Name(), ".save") { - continue - } - info, err := file.Info() - if err != nil { - continue - } - sortedFiles = append(sortedFiles, struct { - name string - modTime time.Time - }{file.Name(), info.ModTime()}) - } - - // Sort newest first - sort.Slice(sortedFiles, func(i, j int) bool { - return sortedFiles[i].modTime.After(sortedFiles[j].modTime) - }) - - // Find the position of the current file - for i, f := range sortedFiles { - if f.name == filename { - // Assign index starting from max possible index downwards - return len(sortedFiles) - i - } - } - } - - return -1 -} - // isValidBackupFile checks if a filename is a valid backup file func isValidBackupFile(filename string) bool { - return (strings.Contains(filename, "world") && - (strings.HasSuffix(filename, ".bin") || - strings.HasSuffix(filename, ".xml"))) || - strings.HasSuffix(filename, ".save") + return strings.HasSuffix(filename, ".save") } From 58842f701ccfa591da16df3e53b17fad0f31475d Mon Sep 17 00:00:00 2001 From: akirilov Date: Mon, 3 Nov 2025 13:53:32 +0100 Subject: [PATCH 04/27] Revert the restore functionality since we need to update timestamps to let Stationeers load the save :( --- src/managers/backupmgr/restore.go | 230 +++++++++++++++++++++++++++--- 1 file changed, 207 insertions(+), 23 deletions(-) diff --git a/src/managers/backupmgr/restore.go b/src/managers/backupmgr/restore.go index fe26f0f7..d9c73373 100644 --- a/src/managers/backupmgr/restore.go +++ b/src/managers/backupmgr/restore.go @@ -1,9 +1,14 @@ package backupmgr import ( + "archive/zip" "fmt" + "io" "os" "path/filepath" + "regexp" + "strings" + "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) @@ -19,41 +24,220 @@ func (m *BackupManager) RestoreBackup(index int) error { return fmt.Errorf("failed to get backup groups: %w", err) } - if index < 0 || index >= len(saves) { - return fmt.Errorf("invalid backup index %d", index) - } - targetSave := saves[index] + var targetSave = saves[index] + + restoredFiles := make(map[string]string) + // .save file case backupFile := targetSave.SaveFile destFile := filepath.Join("./saves/"+m.config.WorldName, m.config.WorldName+".save") - // Backup current save before restoring - tmpfile, err := os.CreateTemp("", "ssui_restore_bak") + // This check was disabled since it was relatively unnecessary and didnt bring much benefit + + // Before restore, check if we have existing .save files in the root saves/WorldName dir + //saveDir := filepath.Join("./saves/", m.config.WorldName) + //files, err := os.ReadDir(saveDir) + //if err != nil { + // return fmt.Errorf("failed to read save directory %s: %w", saveDir, err) + //} + + //for _, file := range files { + // if file.IsDir() { + // continue + // } + // if strings.HasSuffix(file.Name(), ".save") { + // existingFile := filepath.Join(saveDir, file.Name()) + // // Move existing .save file to SafeBackupDir with timestamp to avoid overwrites + // timestamp := time.Now().Format("2006-01-02_15-04-05") + // savedPreviousHeadSaveFilePath := filepath.Join(m.config.SafeBackupDir, fmt.Sprintf("%s_%s_%s", "pre-restore-HEAD-", timestamp, file.Name())) + // if err := os.Rename(existingFile, savedPreviousHeadSaveFilePath); err != nil { + // return fmt.Errorf("failed to move existing HEAD .save file %s to %s: %w", existingFile, savedPreviousHeadSaveFilePath, err) + // } + // logger.Backup.Info("Moved previous HEAD .save file to: " + savedPreviousHeadSaveFilePath) + // } + //} + + // Create temp directory for mod time shenanigans (https://discordapp.com/channels/276525882049429515/392080751648178188/1407157281606336602) + tempDir := filepath.Join("./saves", m.config.WorldName, "tmp") + if err := os.MkdirAll(tempDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create temp directory %s: %w", tempDir, err) + } + defer os.RemoveAll(tempDir) + + // Extract .save (zip) file to tempDir + r, err := zip.OpenReader(backupFile) if err != nil { - return fmt.Errorf("failed to create temp backup file for current save: %w", err) + return fmt.Errorf("failed to open zip reader for %s: %w", backupFile, err) } - defer tmpfile.Close() - // Note we do not defer removal of tmpfile here, as we may need it on multiple failures + defer r.Close() - // Copy current save to temp backup file - if err := copyFile(destFile, tmpfile.Name()); err != nil { - os.Remove(tmpfile.Name()) - return fmt.Errorf("failed to backup current save before restore: %w", err) + // --- Safe extraction ------------------------------------------------- + for _, f := range r.File { + // Sanitize the entry name – strip any leading / or .. components. + entryName := filepath.Clean(f.Name) + + // Skip empty names or names that contain '..' after cleaning. + if entryName == "." || entryName == ".." || strings.Contains(entryName, "..") { + // This entry would escape the target directory; reject it. + logger.Backup.Warn(fmt.Sprintf("Skipping potentially unsafe zip entry %q", f.Name)) + continue + } + + destPath := filepath.Join(tempDir, entryName) + // Ensure the destination is still inside tempDir. + if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(tempDir)+string(os.PathSeparator)) { + logger.Backup.Warn(fmt.Sprintf("Skipping zip entry that would escape extraction dir: %q", f.Name)) + continue + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, f.Mode()); err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to create directory %s: %w", destPath, err) + } + continue + } + + // Create any missing parent directories. + if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to create parent directory for %s: %w", destPath, err) + } + + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to open file in zip %s: %w", f.Name, err) + } + + if _, err := io.Copy(outFile, rc); err != nil { + rc.Close() + outFile.Close() + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to extract file %s: %w", destPath, err) + } + rc.Close() + outFile.Close() } + // -------------------------------------------------------------------- + + // Update world_meta.xml DateTime with current Windows file time using regex + now := time.Now() + metaFilePath := filepath.Join(tempDir, "world_meta.xml") + if _, err := os.Stat(metaFilePath); err == nil { + // Read world_meta.xml + data, err := os.ReadFile(metaFilePath) + if err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to read world_meta.xml: %w", err) + } + + // Calculate Windows file time + const windowsEpochToUnixEpoch = 116444736000000000 // 100-ns intervals from 1601 to 1970 + windowsFileTime := now.UnixNano()/100 + windowsEpochToUnixEpoch - // Copy the backup file to the destination - if err := copyFile(backupFile, destFile); err != nil { - // Restore the original save from temp backup on failure - err2 := copyFile(tmpfile.Name(), destFile) - if err2 != nil { - return fmt.Errorf("failed to restore backup file: %w; additionally failed to restore original save: %w. Main save backup located at: %s", err, err2, tmpfile.Name()) + re, err := regexp.Compile(`\d+`) + if err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to compile DateTime regex: %w", err) } - os.Remove(tmpfile.Name()) - return fmt.Errorf("failed to restore backup file: %w", err) + newDateTime := fmt.Sprintf("%d", windowsFileTime) + updatedData := re.ReplaceAll(data, []byte(newDateTime)) + + if !re.Match(data) { + logger.Backup.Warn("Restore: DateTime element not found in world_meta.xml, proceeding without updating. Server might not load correct save.") + } else { + if err := os.WriteFile(metaFilePath, updatedData, 0644); err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to write updated world_meta.xml: %w", err) + } + } + } else { + logger.Backup.Warn("world_meta.xml not found in extracted files, proceeding without updating DateTime") + } + + // Modify timestamps of extracted files to current system time + if err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + return os.Chtimes(path, now, now) + }); err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to modify timestamps in %s: %w", tempDir, err) + } + + // Create new .save (zip) file at destFile with updated timestamps + dest, err := os.Create(destFile) + if err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to create destination .save file %s: %w", destFile, err) + } + defer dest.Close() + + w := zip.NewWriter(dest) + defer w.Close() + + if err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relPath, err := filepath.Rel(tempDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + relPath = filepath.ToSlash(relPath) + + // Create zip entry with current system timestamp + fw, err := w.CreateHeader(&zip.FileHeader{ + Name: relPath, + Method: zip.Deflate, + Modified: now, + }) + if err != nil { + return fmt.Errorf("failed to create zip entry %s: %w", relPath, err) + } + + srcFile, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", path, err) + } + defer srcFile.Close() + + if _, err := io.Copy(fw, srcFile); err != nil { + return fmt.Errorf("failed to write file %s to zip: %w", relPath, err) + } + return nil + }); err != nil { + m.revertRestore(restoredFiles) + return fmt.Errorf("failed to restore .save file %s: %w", backupFile, err) } - os.Remove(tmpfile.Name()) + restoredFiles[destFile] = backupFile + return nil // restore and mod time shenanigans successful, no need to return an error - logger.Backup.Infof("Backup with index %d restored successfully", index) + logger.Backup.Debug(fmt.Sprintf("%v", restoredFiles)) return nil } + +// revertRestore undoes a failed restore operation +func (m *BackupManager) revertRestore(restoredFiles map[string]string) { + for destFile, backupFile := range restoredFiles { + if err := os.Remove(destFile); err == nil { + _ = copyFile(backupFile, destFile) + } + } +} From e9012aca8187afd7dd66570d2161c582a8d7d1b7 Mon Sep 17 00:00:00 2001 From: akirilov Date: Mon, 3 Nov 2025 14:07:55 +0100 Subject: [PATCH 05/27] Restore index counting because we need to be able to display in reverse on the web ui --- UIMod/onboard_bundled/assets/js/server-api.js | 7 +++---- src/discordbot/handleSlashcommands.go | 2 +- src/managers/backupmgr/backuphttp.go | 2 +- src/managers/backupmgr/cleanup.go | 12 ++++++++++-- src/managers/backupmgr/types.go | 1 + 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/UIMod/onboard_bundled/assets/js/server-api.js b/UIMod/onboard_bundled/assets/js/server-api.js index cb0ae2aa..2a3d9421 100644 --- a/UIMod/onboard_bundled/assets/js/server-api.js +++ b/UIMod/onboard_bundled/assets/js/server-api.js @@ -69,13 +69,12 @@ function fetchBackups() { } let animationCount = 0; - for (i = 0; i < data.length; i++) { - const backup = data[i]; + data.forEach((backup) => { const li = document.createElement('li'); li.className = 'backup-item'; const backupType = getBackupType(backup); - const fileName = "Backup Index: " + i; + const fileName = "Backup Index: " + backup.Index; const formattedDate = "Created: " + new Date(backup.SaveTime).toLocaleString(); li.innerHTML = ` @@ -97,7 +96,7 @@ function fetchBackups() { }, animationCount * 50); animationCount++; } - } + }); }) .catch(err => { console.error("Failed to fetch backups:", err); diff --git a/src/discordbot/handleSlashcommands.go b/src/discordbot/handleSlashcommands.go index 89e9d438..d2b0b9ff 100644 --- a/src/discordbot/handleSlashcommands.go +++ b/src/discordbot/handleSlashcommands.go @@ -210,7 +210,7 @@ func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, data Embed } fields := make([]EmbedField, end-start) for j, b := range backups[start:end] { - fields[j] = EmbedField{Name: fmt.Sprintf("📂 Backup #%d", j), Value: b.SaveTime.Format("January 2, 2006, 3:04 PM")} + fields[j] = EmbedField{Name: fmt.Sprintf("📂 Backup #%d", b.Index), Value: b.SaveTime.Format("January 2, 2006, 3:04 PM")} } embeds = append(embeds, generateEmbed(EmbedData{ Title: "📜 Backup Archives", Description: fmt.Sprintf("Showing %d-%d of %d backups", start+1, end, len(backups)), diff --git a/src/managers/backupmgr/backuphttp.go b/src/managers/backupmgr/backuphttp.go index b56a4e76..dd55c027 100644 --- a/src/managers/backupmgr/backuphttp.go +++ b/src/managers/backupmgr/backuphttp.go @@ -50,7 +50,7 @@ func (h *HTTPHandler) ListBackupsHandler(w http.ResponseWriter, r *http.Request) for i, backup := range backups { // Format according to classic view: "BackupIndex: X, Created: DD.MM.YYYY HH:MM:SS" classicLine := fmt.Sprintf("BackupIndex: %d, Created: %s", - i, + backup.Index, backup.SaveTime.Format("02.01.2006 15:04:05")) classicResponses = append(classicResponses, classicLine) } diff --git a/src/managers/backupmgr/cleanup.go b/src/managers/backupmgr/cleanup.go index 7bbb93a1..46052966 100644 --- a/src/managers/backupmgr/cleanup.go +++ b/src/managers/backupmgr/cleanup.go @@ -190,10 +190,18 @@ func (m *BackupManager) getBackupSaveFiles() ([]BackupSaveFile, error) { return nil, fmt.Errorf("failed to handle safe backup dir: %w", err) } - // Sort saves by save time descending + // Sort saves by save time ascending sort.Slice(saves, func(i, j int) bool { - return saves[i].SaveTime.After(saves[j].SaveTime) + return saves[i].SaveTime.Before(saves[j].SaveTime) }) + // Add the index to each save + for i := range saves { + saves[i].Index = uint(i) + } + // Reverse the saves to have newest first + for i, j := 0, len(saves)-1; i < j; i, j = i+1, j-1 { + saves[i], saves[j] = saves[j], saves[i] + } return saves, nil } diff --git a/src/managers/backupmgr/types.go b/src/managers/backupmgr/types.go index e8e6f08c..d719ce1c 100644 --- a/src/managers/backupmgr/types.go +++ b/src/managers/backupmgr/types.go @@ -30,6 +30,7 @@ type RetentionPolicy struct { } type BackupSaveFile struct { + Index uint SaveFile string SaveTime time.Time } From 247cbd889bcd5117d20010e8ff5e74ad3b09c019 Mon Sep 17 00:00:00 2001 From: akirilov Date: Mon, 3 Nov 2025 14:12:30 +0100 Subject: [PATCH 06/27] Un-reverse the array and reverse it only when sending to the UI --- src/managers/backupmgr/backuphttp.go | 2 +- src/managers/backupmgr/cleanup.go | 4 ---- src/managers/backupmgr/manager.go | 6 ++++++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/managers/backupmgr/backuphttp.go b/src/managers/backupmgr/backuphttp.go index dd55c027..9fbc7cf9 100644 --- a/src/managers/backupmgr/backuphttp.go +++ b/src/managers/backupmgr/backuphttp.go @@ -47,7 +47,7 @@ func (h *HTTPHandler) ListBackupsHandler(w http.ResponseWriter, r *http.Request) if mode == "classic" { // Format the response in the classic format classicResponses := make([]string, 0, len(backups)) - for i, backup := range backups { + for _, backup := range backups { // Format according to classic view: "BackupIndex: X, Created: DD.MM.YYYY HH:MM:SS" classicLine := fmt.Sprintf("BackupIndex: %d, Created: %s", backup.Index, diff --git a/src/managers/backupmgr/cleanup.go b/src/managers/backupmgr/cleanup.go index 46052966..f4672415 100644 --- a/src/managers/backupmgr/cleanup.go +++ b/src/managers/backupmgr/cleanup.go @@ -198,10 +198,6 @@ func (m *BackupManager) getBackupSaveFiles() ([]BackupSaveFile, error) { for i := range saves { saves[i].Index = uint(i) } - // Reverse the saves to have newest first - for i, j := 0, len(saves)-1; i < j; i, j = i+1, j-1 { - saves[i], saves[j] = saves[j], saves[i] - } return saves, nil } diff --git a/src/managers/backupmgr/manager.go b/src/managers/backupmgr/manager.go index fe500f10..3be0198d 100644 --- a/src/managers/backupmgr/manager.go +++ b/src/managers/backupmgr/manager.go @@ -211,6 +211,12 @@ func (m *BackupManager) ListBackups(limit int) ([]BackupSaveFile, error) { defer m.mu.Unlock() saves, err := m.getBackupSaveFiles() + + // Reverse the saves to have newest first + for i, j := 0, len(saves)-1; i < j; i, j = i+1, j-1 { + saves[i], saves[j] = saves[j], saves[i] + } + if err != nil { return nil, err } From 6db2648df792219489835decf5c70ffccdc83b9d Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 19:35:44 +0100 Subject: [PATCH 07/27] changed save file index type from uint to int for functional indexing in BackupManager, Update version to 5.8.1 --- src/config/config.go | 2 +- src/managers/backupmgr/cleanup.go | 2 +- src/managers/backupmgr/types.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 4875b89d..f4ed92b4 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.8.0" + Version = "5.8.1" Branch = "release" ) diff --git a/src/managers/backupmgr/cleanup.go b/src/managers/backupmgr/cleanup.go index f4672415..54502462 100644 --- a/src/managers/backupmgr/cleanup.go +++ b/src/managers/backupmgr/cleanup.go @@ -196,7 +196,7 @@ func (m *BackupManager) getBackupSaveFiles() ([]BackupSaveFile, error) { }) // Add the index to each save for i := range saves { - saves[i].Index = uint(i) + saves[i].Index = i } return saves, nil diff --git a/src/managers/backupmgr/types.go b/src/managers/backupmgr/types.go index d719ce1c..721b861c 100644 --- a/src/managers/backupmgr/types.go +++ b/src/managers/backupmgr/types.go @@ -30,7 +30,7 @@ type RetentionPolicy struct { } type BackupSaveFile struct { - Index uint + Index int SaveFile string SaveTime time.Time } From 7eb111646125980bb3412a825afcd60471cda1f2 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 19:36:04 +0100 Subject: [PATCH 08/27] frontend adjustments for backup manager changes --- .../assets/css/info-notice.css | 5 ++++ UIMod/onboard_bundled/assets/js/server-api.js | 9 ++----- UIMod/onboard_bundled/ui/index.html | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/UIMod/onboard_bundled/assets/css/info-notice.css b/UIMod/onboard_bundled/assets/css/info-notice.css index 22f11d08..ebcd7b3b 100644 --- a/UIMod/onboard_bundled/assets/css/info-notice.css +++ b/UIMod/onboard_bundled/assets/css/info-notice.css @@ -87,6 +87,11 @@ font-weight: 600; } +.status-bad { + color: #f44336; + font-weight: 600; +} + @media (max-width: 768px) { .info-notice { margin: 10px 5px; diff --git a/UIMod/onboard_bundled/assets/js/server-api.js b/UIMod/onboard_bundled/assets/js/server-api.js index 2a3d9421..2b1b5a7d 100644 --- a/UIMod/onboard_bundled/assets/js/server-api.js +++ b/UIMod/onboard_bundled/assets/js/server-api.js @@ -73,7 +73,7 @@ function fetchBackups() { const li = document.createElement('li'); li.className = 'backup-item'; - const backupType = getBackupType(backup); + const backupType = "Dotsave" const fileName = "Backup Index: " + backup.Index; const formattedDate = "Created: " + new Date(backup.SaveTime).toLocaleString(); @@ -105,12 +105,7 @@ function fetchBackups() { } function getBackupType(backup) { - if (backup.BinFile && backup.XMLFile && backup.MetaFile) { - return 'preterrain-trio'; - } else if (backup.BinFile && !backup.XMLFile && !backup.MetaFile) { - return 'Dotsave'; - } - return 'Unknown'; + return "Dotsave"; } function fetchPlayers() { diff --git a/UIMod/onboard_bundled/ui/index.html b/UIMod/onboard_bundled/ui/index.html index 4e70928e..606f9baf 100644 --- a/UIMod/onboard_bundled/ui/index.html +++ b/UIMod/onboard_bundled/ui/index.html @@ -71,12 +71,37 @@

{{.UIText_Connected_PlayersHeader}}

{{.UIText_Backup_Manager}}

+
+

+ ⚠️ + Backup Manager Update Info (Click to Expand) + ⚠️ +

+
+ 📢 + The Backup manager was optimized & reworked in the latest patch (v5.8.1) to be faster and more reliable with huge numbers of backups. +

+ Please report any anomalies or bugs you encounter either on the GitHub Issues page or on the SSUI Discord Server. +

+

+ With this update, the Backup manager no longer supports the old (pre-terrain update) terrain and save system. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using an older version of SSUI, disabling auto-updates via the config.json file. +

+

MensRea - Developer of this Update

+

JacksonTheMaster - Project Lead

+
+
+

From 46128285bc736b86a1fd74a26fdeea175328ae57 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 19:38:44 +0100 Subject: [PATCH 09/27] moved getBackupSaveFiles method into its own file for maintainability --- src/managers/backupmgr/cleanup.go | 81 ------------------------- src/managers/backupmgr/getbackups.go | 90 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 81 deletions(-) create mode 100644 src/managers/backupmgr/getbackups.go diff --git a/src/managers/backupmgr/cleanup.go b/src/managers/backupmgr/cleanup.go index 54502462..f65e1db7 100644 --- a/src/managers/backupmgr/cleanup.go +++ b/src/managers/backupmgr/cleanup.go @@ -1,20 +1,15 @@ package backupmgr import ( - "archive/zip" - "encoding/xml" "fmt" "os" "path/filepath" "sort" - "strings" "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) -const filetimeEpochOffset = 116444736000000000 // difference between 1601 and 1970 in 100-ns units - // Cleanup performs backup cleanup according to retention policy func (m *BackupManager) Cleanup() error { m.mu.Lock() @@ -126,82 +121,6 @@ func (m *BackupManager) cleanSafeBackupDir() error { return nil } -// getBackupSaveFiles retrieves all backup save files from the safe backup directory -func (m *BackupManager) getBackupSaveFiles() ([]BackupSaveFile, error) { - var saves []BackupSaveFile - - err := filepath.WalkDir(m.config.SafeBackupDir, func(path string, de os.DirEntry, err error) error { - if err != nil { - return err - } - if !de.IsDir() { - // Process the save file - filename := de.Name() - - // Skip invalid backup files - if !isValidBackupFile(filename) { - return nil - } - - // Get the full path - fullPath := filepath.Join(m.config.SafeBackupDir, filename) - - // Get the save time from the file - // Unzip the save file and open the world_meta.xml file inside - r, err := zip.OpenReader(fullPath) - if err != nil { - return err - } - defer r.Close() - worldMetadata, err := r.Open("world_meta.xml") - if err != nil { - return err - } - defer worldMetadata.Close() - // Read the world_meta.xml file content using the XML library - type WorldMeta struct { - SaveTime int64 `xml:"DateTime"` - } - var meta WorldMeta - decoder := xml.NewDecoder(worldMetadata) - if err := decoder.Decode(&meta); err != nil { - return err - } - - // Convert FILETIME (100-ns intervals) → Unix time (seconds + nanoseconds) - ns := (meta.SaveTime - filetimeEpochOffset) * 100 - saveTime := time.Unix(0, ns) - - // Add the backup save file info to the list - saves = append(saves, BackupSaveFile{ - SaveFile: fullPath, - SaveTime: saveTime, - }) - } - return nil - }) - - // Handle errors - if err != nil { - // if the error contains no such file or directory, return nil but return a custom string intsted of the error - if strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "The system cannot find the file specified") { - return nil, fmt.Errorf("save dir doesn't seem to exist (yet). Try starting the gameserver and click ↻ once it's up. If the Save folder exists and you still get this error, verify the 'Use New Terrain and Save System' setting. Detailed Error: %w", err) - } - return nil, fmt.Errorf("failed to handle safe backup dir: %w", err) - } - - // Sort saves by save time ascending - sort.Slice(saves, func(i, j int) bool { - return saves[i].SaveTime.Before(saves[j].SaveTime) - }) - // Add the index to each save - for i := range saves { - saves[i].Index = i - } - - return saves, nil -} - // deleteBackupGroup removes all files in a backup group func (m *BackupManager) deleteBackupGroup(saveFile BackupSaveFile) { if err := os.Remove(saveFile.SaveFile); err != nil { diff --git a/src/managers/backupmgr/getbackups.go b/src/managers/backupmgr/getbackups.go new file mode 100644 index 00000000..057af9d3 --- /dev/null +++ b/src/managers/backupmgr/getbackups.go @@ -0,0 +1,90 @@ +package backupmgr + +import ( + "archive/zip" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const filetimeEpochOffset = 116444736000000000 // difference between 1601 and 1970 in 100-ns units + +// getBackupSaveFiles retrieves all backup save files from the safe backup directory +func (m *BackupManager) getBackupSaveFiles() ([]BackupSaveFile, error) { + var saves []BackupSaveFile + + err := filepath.WalkDir(m.config.SafeBackupDir, func(path string, de os.DirEntry, err error) error { + if err != nil { + return err + } + if !de.IsDir() { + // Process the save file + filename := de.Name() + + // Skip invalid backup files + if !isValidBackupFile(filename) { + return nil + } + + // Get the full path + fullPath := filepath.Join(m.config.SafeBackupDir, filename) + + // Get the save time from the file + // Unzip the save file and open the world_meta.xml file inside + r, err := zip.OpenReader(fullPath) + if err != nil { + return err + } + defer r.Close() + worldMetadata, err := r.Open("world_meta.xml") + if err != nil { + return err + } + defer worldMetadata.Close() + // Read the world_meta.xml file content using the XML library + type WorldMeta struct { + SaveTime int64 `xml:"DateTime"` + } + var meta WorldMeta + decoder := xml.NewDecoder(worldMetadata) + if err := decoder.Decode(&meta); err != nil { + return err + } + + // Convert FILETIME (100-ns intervals) → Unix time (seconds + nanoseconds) + ns := (meta.SaveTime - filetimeEpochOffset) * 100 + saveTime := time.Unix(0, ns) + + // Add the backup save file info to the list + saves = append(saves, BackupSaveFile{ + SaveFile: fullPath, + SaveTime: saveTime, + }) + } + return nil + }) + + // Handle errors + if err != nil { + // if the error contains no such file or directory, return nil but return a custom string intsted of the error + if strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "The system cannot find the file specified") { + return nil, fmt.Errorf("save dir doesn't seem to exist (yet). Try starting the gameserver and click ↻ once it's up. If the Save folder exists and you still get this error, verify the 'Use New Terrain and Save System' setting. Detailed Error: %w", err) + } + return nil, fmt.Errorf("failed to handle safe backup dir: %w", err) + } + + // Sort saves by save time ascending + sort.Slice(saves, func(i, j int) bool { + return saves[i].SaveTime.Before(saves[j].SaveTime) + }) + // Add the index to each save + for i := range saves { + saves[i].Index = i + } + + return saves, nil +} From fd771ae88862153baf555fce8979ae004e27b49e Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 19:41:58 +0100 Subject: [PATCH 10/27] removed unreachable code from RestoreBackup --- src/managers/backupmgr/restore.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/managers/backupmgr/restore.go b/src/managers/backupmgr/restore.go index d9c73373..fa3bac84 100644 --- a/src/managers/backupmgr/restore.go +++ b/src/managers/backupmgr/restore.go @@ -226,11 +226,8 @@ func (m *BackupManager) RestoreBackup(index int) error { return fmt.Errorf("failed to restore .save file %s: %w", backupFile, err) } restoredFiles[destFile] = backupFile - return nil // restore and mod time shenanigans successful, no need to return an error - logger.Backup.Debug(fmt.Sprintf("%v", restoredFiles)) - - return nil + return nil // restore and mod time shenanigans successful, no need to return an error } // revertRestore undoes a failed restore operation From 8a47d0e756f7ef66ec074a7192eb84a51d3bad3a Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 19:58:37 +0100 Subject: [PATCH 11/27] enforced new terrain system in backup manager; updated logging accordingly. Also changed backup manager config references to bmconfig in m *BackupManager Start --- src/managers/backupmgr/backupinterface.go | 20 +++++++++++++------- src/managers/backupmgr/manager.go | 4 ---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/managers/backupmgr/backupinterface.go b/src/managers/backupmgr/backupinterface.go index 6f0964be..0e990329 100644 --- a/src/managers/backupmgr/backupinterface.go +++ b/src/managers/backupmgr/backupinterface.go @@ -1,6 +1,7 @@ package backupmgr import ( + "fmt" "sync" "time" @@ -19,20 +20,20 @@ var activeHTTPHandlers []*HTTPHandler var initMutex sync.Mutex // InitGlobalBackupManager initializes the global backup manager instance -func InitGlobalBackupManager(config BackupConfig) error { +func InitGlobalBackupManager(bmconfig BackupConfig) error { // Lock to prevent concurrent initialization initMutex.Lock() defer initMutex.Unlock() // Shut down existing manager if it exists if GlobalBackupManager != nil { - logger.Backup.Debugf("%s Previous Backup manager found. Shutting it down.", config.Identifier) + logger.Backup.Debugf("%s Previous Backup manager found. Shutting it down.", bmconfig.Identifier) GlobalBackupManager.Shutdown() GlobalBackupManager = nil // Clear the manager to avoid stale references } - logger.Backup.Debugf("%s Creating a global backup manager with ID %s", config.Identifier, config.Identifier) - manager := NewBackupManager(config) + logger.Backup.Debugf("%s Creating a global backup manager with ID %s", bmconfig.Identifier, bmconfig.Identifier) + manager := NewBackupManager(bmconfig) GlobalBackupManager = manager // Update all active HTTP handlers with the new manager @@ -40,14 +41,19 @@ func InitGlobalBackupManager(config BackupConfig) error { handler.manager = GlobalBackupManager } + // Do not handle old terrain and save system backups + if !config.GetIsNewTerrainAndSaveSystem() { + return fmt.Errorf("the old terrain system and save format are no longer supported by backup manager. Please switch to the new terrain and save system if you wish to continue to use new SSUI features. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using an older version of SSUI, disabling auto-updates via the config.json file") + } + // Start the backup manager in a goroutine to avoid blocking go func(m *BackupManager) { - if err := m.Start(config.Identifier); err != nil { - logger.Backup.Warnf("%s Exited: "+err.Error(), config.Identifier) + if err := m.Start(bmconfig.Identifier); err != nil { + logger.Backup.Warnf("%s Exited: "+err.Error(), bmconfig.Identifier) } }(manager) - logger.Backup.Infof("%s Backup manager reloaded successfully", config.Identifier) + logger.Backup.Infof("%s Backup manager reloaded successfully", bmconfig.Identifier) return nil } diff --git a/src/managers/backupmgr/manager.go b/src/managers/backupmgr/manager.go index 3be0198d..f0113720 100644 --- a/src/managers/backupmgr/manager.go +++ b/src/managers/backupmgr/manager.go @@ -80,10 +80,6 @@ func (m *BackupManager) Initialize(identifier string) <-chan error { // Start begins the backup monitoring and cleanup routines func (m *BackupManager) Start(identifier string) error { - // Do not handle old terrain and save system backups - if !config.GetIsNewTerrainAndSaveSystem() { - return fmt.Errorf("The old terrain system and save format are no longer supported by backup manager. Please switch to the new system if you wish to continue to use new SSUI features") - } // Wait for initialization to complete logger.Backup.Debugf("%s is waiting for save folder initialization...", identifier) From a1285572a1d0a238a3c3f4c0631873b5ba8f2401 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 19:59:04 +0100 Subject: [PATCH 12/27] added really annoying warning to config when using the old terrain system with this version of SSUI --- src/config/config.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/config/config.go b/src/config/config.go index f4ed92b4..b4d1d1a9 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -282,6 +282,21 @@ func applyConfig(cfg *JsonConfig) { if GameBranch != "public" && GameBranch != "beta" { IsNewTerrainAndSaveSystem = false + fmt.Println("The old terrain system and save format are no longer fully supported by SSUI. Please switch to the new terrain and save system if you wish to continue to use SSUI with all features. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using an older version of SSUI, disabling auto-updates via the config.json file") + fmt.Println("Sleeping for 10 seconds to allow you to read and understand the above message...") + time.Sleep(3 * time.Second) + fmt.Println("Continuing with the old terrain and save system in 7 seconds...") + time.Sleep(2 * time.Second) + fmt.Println("Continuing with the old terrain and save system in 5 seconds...") + time.Sleep(2 * time.Second) + fmt.Println("Continuing with the old terrain and save system in 3 seconds...") + time.Sleep(1 * time.Second) + fmt.Println("Continuing with the old terrain and save system in 2 seconds...") + time.Sleep(1 * time.Second) + fmt.Println("Continuing with the old terrain and save system in 1 second...") + time.Sleep(1 * time.Second) + fmt.Println("Continuing with the old terrain and save system...") + } else { IsNewTerrainAndSaveSystem = true } From cae893ef5b5bdb5d6fe0117255ca7fa9f7aa5781 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Mon, 3 Nov 2025 20:21:28 +0100 Subject: [PATCH 13/27] update readme slightly --- readme.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index c299aeed..3348a36d 100644 --- a/readme.md +++ b/readme.md @@ -63,9 +63,9 @@ A sleek, retro-themed web UI to manage your Stationeers dedicated server. No mor - 🎮 **One-Click Controls** - Start/stop server or restore backups with simple buttons - 💾 **Smart Backups** - Automated backup system with easy restore - 🤖 **Discord Integration** - Control your server through Discord -- 🔒 **Secure by Default** - JWT auth, TLS, and randomized JWT key -- 🛠️ **Command Manager** - Execute server commands directly from the UI (and soon discord!) -- 🧩 **Beta: Mod Support** - Support for BepInEx mods (currently in beta, be careful!) +- 🔒 **Secure by Default** - JWT auth & TLS +- 🛠️ **Command Manager** - Execute server commands directly from the UI or from Discord commands +- 🧩 **Mod Support** - Support for BepInEx mods - 📦 **Docker Support** - Runs in Docker containers ## Detailed Documentation @@ -74,7 +74,7 @@ For comprehensive instructions, examples, and more details, visit our [GitHub Wi | Documentation Section | Description | |----------------------|-------------| -| [Features](https://github.com/JacksonTheMaster/StationeersServerUI/wiki/Features) | Complete list of features and capabilities | +| [Features](https://github.com/JacksonTheMaster/StationeersServerUI/wiki/Features) | near-complete list of features and capabilities | | [Requirements](https://github.com/JacksonTheMaster/StationeersServerUI/wiki/Requirements) | System requirements and prerequisites | | [Installation](https://github.com/JacksonTheMaster/StationeersServerUI/wiki/Installation) | Step-by-step installation guide | | [First-Time Setup](https://github.com/JacksonTheMaster/StationeersServerUI/wiki/First-Time-Setup) | Getting your server up and running | From 543f9b95eabe2457b159ec74ec426e558e1e073c Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Fri, 7 Nov 2025 04:27:00 +0100 Subject: [PATCH 14/27] Added logging the gameserver output to file, added SSUIlogs and gameserver logs to UI - > advanced settings --- UIMod/onboard_bundled/localization/en-US.json | 9 ++++-- UIMod/onboard_bundled/ui/config.html | 18 +++++++++++ src/config/config.go | 15 ++++++---- src/config/getters.go | 6 ++++ src/config/vars.go | 1 + src/managers/gamemgr/serverlog.go | 30 +++++++++++++++++++ src/web/configpage.go | 26 ++++++++++++++++ src/web/templatevars.go | 10 +++++++ 8 files changed, 108 insertions(+), 7 deletions(-) diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json index bda9e741..9426d739 100644 --- a/UIMod/onboard_bundled/localization/en-US.json +++ b/UIMod/onboard_bundled/localization/en-US.json @@ -60,7 +60,7 @@ "UIText_UPNPEnabled": "UPNP Enabled", "UIText_UPNPEnabledInfo": "Enable automatic UPNP port forwarding", "UIText_LocalIpAddress": "Local IP Address", - "UIText_LocalIpAddressInfo": "IP address to bind to", + "UIText_LocalIpAddressInfo": "IP address to bind to. Recommended to leave at 0.0.0.0 unless you really know what you're doing.", "UIText_StartLocalHost": "Start Local Host", "UIText_StartLocalHostInfo": "Keep this true. This is required for the server to work.", "UIText_ServerVisible": "Server Visible", @@ -84,7 +84,12 @@ "UIText_AllowAutoGameServerUpdates": "Enable Auto Game Server Updates", "UIText_AllowAutoGameServerUpdatesInfo": "Allow the gameserver to automatically query for and update to the latest version. Attention: Restarts the server when a new version was found and installed. Will send multiple warning messages to the sever with SAY commands 60-10 seconds before the restart.", "UIText_AutoStartServerOnStartup": "Auto Start Server on Startup", - "UIText_AutoStartServerOnStartupInfo": "Automatically start the gameserver when the SSUI is started. Defaults to false." + "UIText_AutoStartServerOnStartupInfo": "Automatically start the gameserver when the SSUI is started. Defaults to false.", + "UIText_CreateSSUILogFile": "Create SSUI Log Files", + "UIText_CreateSSUILogFileInfo": "Create log files for the SSUI log in UIMod/logs. Defaults to false.", + "UIText_CreateGameServerLogFile": "Create Game Server Log File", + "UIText_CreateGameServerLogFileInfo": "Create a unique log file per server start for the gameserver in UIMod/logs. Defaults to false." + }, "terrain": { "UIText_TerrainSettingsHeader": "Optional world generation settings", diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index b080d61c..b6cd90cc 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -256,6 +256,24 @@

{{.UIText_AdvancedConfiguration}}

{{.UIText_AllowAutoGameServerUpdatesInfo}}
+ +
+ + +
{{.UIText_CreateSSUILogFileInfo}}
+
+ +
+ + +
{{.UIText_CreateGameServerLogFileInfo}}
+
diff --git a/src/config/config.go b/src/config/config.go index b4d1d1a9..0e4ae426 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -51,11 +51,12 @@ type JsonConfig struct { StartLocation string `json:"StartLocation"` // Logging and debug settings - Debug *bool `json:"Debug"` - CreateSSUILogFile *bool `json:"CreateSSUILogFile"` - LogLevel int `json:"LogLevel"` - SubsystemFilters []string `json:"subsystemFilters"` - AdvertiserOverride string `json:"AdvertiserOverride"` + Debug *bool `json:"Debug"` + CreateSSUILogFile *bool `json:"CreateSSUILogFile"` + CreateGameServerLogFile *bool `json:"CreateGameServerLogFile"` + LogLevel int `json:"LogLevel"` + SubsystemFilters []string `json:"subsystemFilters"` + AdvertiserOverride string `json:"AdvertiserOverride"` // Authentication Settings Users map[string]string `json:"users"` // Map of username to hashed password @@ -230,6 +231,10 @@ func applyConfig(cfg *JsonConfig) { CreateSSUILogFile = createSSUILogFileVal cfg.CreateSSUILogFile = &createSSUILogFileVal + createGameServerLogFileVal := getBool(cfg.CreateGameServerLogFile, "CREATE_GAMESERVER_LOGFILE", false) + CreateGameServerLogFile = createGameServerLogFileVal + cfg.CreateGameServerLogFile = &createGameServerLogFileVal + LogLevel = getInt(cfg.LogLevel, "LOG_LEVEL", 20) isUpdateEnabledVal := getBool(cfg.IsUpdateEnabled, "IS_UPDATE_ENABLED", true) diff --git a/src/config/getters.go b/src/config/getters.go index a71d7ab0..cd637768 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -297,6 +297,12 @@ func GetCreateSSUILogFile() bool { return CreateSSUILogFile } +func GetCreateGameServerLogFile() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return CreateGameServerLogFile +} + func GetLogLevel() int { ConfigMu.RLock() defer ConfigMu.RUnlock() diff --git a/src/config/vars.go b/src/config/vars.go index f1e6e192..3aafbbff 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -47,6 +47,7 @@ var ( 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 diff --git a/src/managers/gamemgr/serverlog.go b/src/managers/gamemgr/serverlog.go index fc950cff..d73c6337 100644 --- a/src/managers/gamemgr/serverlog.go +++ b/src/managers/gamemgr/serverlog.go @@ -10,8 +10,10 @@ import ( "strconv" "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/ssestream" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/google/uuid" ) // readPipe for Windows @@ -21,6 +23,7 @@ func readPipe(pipe io.ReadCloser) { for scanner.Scan() { output := scanner.Text() ssestream.BroadcastConsoleOutput(output) + logToFile(output) } if err := scanner.Err(); err != nil { logger.Core.Debug("Pipe error: " + err.Error()) @@ -91,6 +94,7 @@ func tailLogFile(logFilePath string) { for scanner.Scan() { output := scanner.Text() ssestream.BroadcastConsoleOutput(output) + logToFile(output) } if err := scanner.Err(); err != nil { @@ -106,3 +110,29 @@ func tailLogFile(logFilePath string) { logger.Core.Debug("Received logDone signal, stopping tail -F") } + +func logToFile(message string) { + if config.GetCreateGameServerLogFile() { + logFileFolder := config.GetLogFolder() + if GameServerUUID != uuid.Nil { + logFileFolder += "/" + "serverlog_" + time.Now().Format("200601021504") + "_" + GameServerUUID.String() + ".log" + + // append the log file to the log folder + file, err := os.OpenFile(logFileFolder, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + logger.Core.Error("Error opening log file: " + err.Error()) + return + } + defer file.Close() + + _, err = file.WriteString(message + "\n") + if err != nil { + logger.Core.Error("Error writing to log file: " + err.Error()) + return + } + } else { + logger.Core.Error("Game Server UUID not set, cannot log to file") + return + } + } +} diff --git a/src/web/configpage.go b/src/web/configpage.go index a7e77269..a03598fb 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -105,6 +105,22 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { autoGameServerUpdatesFalseSelected = "selected" } + createSSUILogFileTrueSelected := "" + createSSUILogFileFalseSelected := "" + if config.GetCreateSSUILogFile() { + createSSUILogFileTrueSelected = "selected" + } else { + createSSUILogFileFalseSelected = "selected" + } + + createGameServerLogFileTrueSelected := "" + createGameServerLogFileFalseSelected := "" + if config.GetCreateGameServerLogFile() { + createGameServerLogFileTrueSelected = "selected" + } else { + createGameServerLogFileFalseSelected = "selected" + } + data := ConfigTemplateData{ // Config values DiscordToken: config.GetDiscordToken(), @@ -163,6 +179,12 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { AllowAutoGameServerUpdates: fmt.Sprintf("%v", config.GetAllowAutoGameServerUpdates()), AllowAutoGameServerUpdatesTrueSelected: autoGameServerUpdatesTrueSelected, AllowAutoGameServerUpdatesFalseSelected: autoGameServerUpdatesFalseSelected, + CreateSSUILogFile: fmt.Sprintf("%v", config.GetCreateSSUILogFile()), + CreateSSUILogFileTrueSelected: createSSUILogFileTrueSelected, + CreateSSUILogFileFalseSelected: createSSUILogFileFalseSelected, + CreateGameServerLogFile: fmt.Sprintf("%v", config.GetCreateGameServerLogFile()), + CreateGameServerLogFileTrueSelected: createGameServerLogFileTrueSelected, + CreateGameServerLogFileFalseSelected: createGameServerLogFileFalseSelected, // Localized UI text UIText_ServerConfig: localization.GetString("UIText_ServerConfig"), @@ -235,6 +257,10 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_AutoStartServerOnStartupInfo: localization.GetString("UIText_AutoStartServerOnStartupInfo"), UIText_AllowAutoGameServerUpdates: localization.GetString("UIText_AllowAutoGameServerUpdates"), UIText_AllowAutoGameServerUpdatesInfo: localization.GetString("UIText_AllowAutoGameServerUpdatesInfo"), + UIText_CreateSSUILogFile: localization.GetString("UIText_CreateSSUILogFile"), + UIText_CreateSSUILogFileInfo: localization.GetString("UIText_CreateSSUILogFileInfo"), + UIText_CreateGameServerLogFile: localization.GetString("UIText_CreateGameServerLogFile"), + UIText_CreateGameServerLogFileInfo: localization.GetString("UIText_CreateGameServerLogFileInfo"), UIText_DiscordIntegrationTitle: localization.GetString("UIText_DiscordIntegrationTitle"), UIText_DiscordBotToken: localization.GetString("UIText_DiscordBotToken"), diff --git a/src/web/templatevars.go b/src/web/templatevars.go index 8c0cc2ab..ad5d5a87 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -80,6 +80,12 @@ type ConfigTemplateData struct { AllowAutoGameServerUpdates string AllowAutoGameServerUpdatesTrueSelected string AllowAutoGameServerUpdatesFalseSelected string + CreateSSUILogFile string + CreateSSUILogFileTrueSelected string + CreateSSUILogFileFalseSelected string + CreateGameServerLogFile string + CreateGameServerLogFileTrueSelected string + CreateGameServerLogFileFalseSelected string UIText_ServerConfig string UIText_DiscordIntegration string @@ -153,6 +159,10 @@ type ConfigTemplateData struct { UIText_AutoStartServerOnStartupInfo string UIText_AllowAutoGameServerUpdates string UIText_AllowAutoGameServerUpdatesInfo string + UIText_CreateSSUILogFile string + UIText_CreateSSUILogFileInfo string + UIText_CreateGameServerLogFile string + UIText_CreateGameServerLogFileInfo string UIText_DiscordIntegrationTitle string UIText_DiscordBotToken string From 8664d2ea78fbbe06791dde2e877d60933c27c91c Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Fri, 7 Nov 2025 05:01:01 +0100 Subject: [PATCH 15/27] fix save CreateGameServerLogFile to config file --- src/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/config.go b/src/config/config.go index 0e4ae426..c7ffdb01 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -374,6 +374,7 @@ func safeSaveConfig() error { AuthTokenLifetime: AuthTokenLifetime, Debug: &IsDebugMode, CreateSSUILogFile: &CreateSSUILogFile, + CreateGameServerLogFile: &CreateGameServerLogFile, LogLevel: LogLevel, LogClutterToConsole: &LogClutterToConsole, SubsystemFilters: SubsystemFilters, From 420040e700b9eb497f1755707681736744926807 Mon Sep 17 00:00:00 2001 From: akirilov Date: Fri, 7 Nov 2025 05:08:41 +0100 Subject: [PATCH 16/27] Fix log dir not being created correctly --- .gitignore | 1 + src/managers/gamemgr/serverlog.go | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ecfce688..ca2aa0b9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ frontend/node_modules frontend/dist frontend/build UIMod/onboard_bundled/v2 +UIMod/logs/* \ No newline at end of file diff --git a/src/managers/gamemgr/serverlog.go b/src/managers/gamemgr/serverlog.go index d73c6337..31d7f694 100644 --- a/src/managers/gamemgr/serverlog.go +++ b/src/managers/gamemgr/serverlog.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "path" "runtime" "strconv" "time" @@ -16,6 +17,9 @@ import ( "github.com/google/uuid" ) +const defaultLogFolderMode = 0755 +const defaultLogFileMode = 0644 + // readPipe for Windows func readPipe(pipe io.ReadCloser) { scanner := bufio.NewScanner(pipe) @@ -114,11 +118,25 @@ func tailLogFile(logFilePath string) { func logToFile(message string) { if config.GetCreateGameServerLogFile() { logFileFolder := config.GetLogFolder() + // Check if the log folder exists, if not create it + _, err := os.Stat(logFileFolder) + if os.IsNotExist(err) { + err := os.Mkdir(logFileFolder, defaultLogFolderMode) + if err != nil { + logger.Core.Error("Error creating log folder: " + err.Error()) + return + } + } else if err != nil { + logger.Core.Error("Error checking log folder: " + err.Error()) + return + } + if GameServerUUID != uuid.Nil { - logFileFolder += "/" + "serverlog_" + time.Now().Format("200601021504") + "_" + GameServerUUID.String() + ".log" + logFileName := fmt.Sprintf("serverlog_%s_%s.log", time.Now().Format("200601021504"), GameServerUUID.String()) + logFilePath := path.Join(logFileFolder, logFileName) // append the log file to the log folder - file, err := os.OpenFile(logFileFolder, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, defaultLogFileMode) if err != nil { logger.Core.Error("Error opening log file: " + err.Error()) return From 9daea10c14ee54be12c2f23ef6b3a0961c97e4ba Mon Sep 17 00:00:00 2001 From: akirilov Date: Sat, 8 Nov 2025 06:19:22 +0100 Subject: [PATCH 17/27] Fixed console scrolling for active console --- UIMod/onboard_bundled/assets/js/console-manager.js | 7 +++---- UIMod/onboard_bundled/assets/js/sscm.js | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/UIMod/onboard_bundled/assets/js/console-manager.js b/UIMod/onboard_bundled/assets/js/console-manager.js index 26c6c3b4..87baab40 100644 --- a/UIMod/onboard_bundled/assets/js/console-manager.js +++ b/UIMod/onboard_bundled/assets/js/console-manager.js @@ -150,6 +150,7 @@ function handleConsole() { commandContainer.append(prompt, input, suggestions); consoleElement.appendChild(commandContainer); + consoleElement.scrollTop = consoleElement.scrollHeight; } catch (error) { console.error('Error checking SSCM enabled status:', error); return; // Exit on error @@ -176,10 +177,8 @@ function handleConsole() { const message = document.createElement('div'); message.textContent = event.data; consoleElement.insertBefore(message, consoleElement.querySelector('.sscm-command-container')); // Insert before input - // Auto-scroll only if at bottom - if (consoleElement.scrollTop + consoleElement.clientHeight >= consoleElement.scrollHeight - 10) { - consoleElement.scrollTop = consoleElement.scrollHeight; - } + // Auto-scroll + consoleElement.scrollTop = consoleElement.scrollHeight; }; outputEventSource.onopen = () => { diff --git a/UIMod/onboard_bundled/assets/js/sscm.js b/UIMod/onboard_bundled/assets/js/sscm.js index ce639c3c..d628ae3b 100644 --- a/UIMod/onboard_bundled/assets/js/sscm.js +++ b/UIMod/onboard_bundled/assets/js/sscm.js @@ -127,15 +127,14 @@ function appendToConsole(message) { if (commandContainer) { consoleDiv.insertBefore(messageElement, commandContainer); // Insert before input + consoleDiv.scrollTop = consoleDiv.scrollHeight; } else { console.log("SSCM failed to insert command box"); return } - // Auto-scroll only if at bottom - if (consoleDiv.scrollTop + consoleDiv.clientHeight >= consoleDiv.scrollHeight - 10) { - consoleDiv.scrollTop = consoleDiv.scrollHeight; - } + // Auto-scroll + consoleDiv.scrollTop = consoleDiv.scrollHeight; } // Enhanced autocomplete functionality From 511d474a50dbd1b298f4c959c21754d883ae2b44 Mon Sep 17 00:00:00 2001 From: akirilov Date: Sat, 8 Nov 2025 06:32:28 +0100 Subject: [PATCH 18/27] Remove duplicate scroll --- UIMod/onboard_bundled/assets/js/sscm.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/UIMod/onboard_bundled/assets/js/sscm.js b/UIMod/onboard_bundled/assets/js/sscm.js index d628ae3b..724c5aee 100644 --- a/UIMod/onboard_bundled/assets/js/sscm.js +++ b/UIMod/onboard_bundled/assets/js/sscm.js @@ -132,9 +132,6 @@ function appendToConsole(message) { console.log("SSCM failed to insert command box"); return } - - // Auto-scroll - consoleDiv.scrollTop = consoleDiv.scrollHeight; } // Enhanced autocomplete functionality From a40e4e3fd35e66045aef50070b5a3d9be68a678c Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 11 Dec 2025 02:57:54 +0100 Subject: [PATCH 19/27] fix double info text about old save support --- src/managers/backupmgr/backupinterface.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/managers/backupmgr/backupinterface.go b/src/managers/backupmgr/backupinterface.go index 0e990329..8c5b0d9b 100644 --- a/src/managers/backupmgr/backupinterface.go +++ b/src/managers/backupmgr/backupinterface.go @@ -43,7 +43,7 @@ func InitGlobalBackupManager(bmconfig BackupConfig) error { // Do not handle old terrain and save system backups if !config.GetIsNewTerrainAndSaveSystem() { - return fmt.Errorf("the old terrain system and save format are no longer supported by backup manager. Please switch to the new terrain and save system if you wish to continue to use new SSUI features. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using an older version of SSUI, disabling auto-updates via the config.json file") + return fmt.Errorf("the old terrain system and save format are no longer supported by backup manager. Please switch to the new terrain and save system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using an older version of SSUI (5.8 and below), disabling auto-updates via the config.json file") } // Start the backup manager in a goroutine to avoid blocking From 9f56bba0705e76743aecd59288fdc6f0aaec3b8b Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 11 Dec 2025 03:08:17 +0100 Subject: [PATCH 20/27] updated info note on dashboard reg BM update --- UIMod/onboard_bundled/ui/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UIMod/onboard_bundled/ui/index.html b/UIMod/onboard_bundled/ui/index.html index 606f9baf..35adfbf2 100644 --- a/UIMod/onboard_bundled/ui/index.html +++ b/UIMod/onboard_bundled/ui/index.html @@ -74,17 +74,17 @@

{{.UIText_Backup_Manager}}

⚠️ - Backup Manager Update Info (Click to Expand) + Important Backup Manager Update Info (Click to Expand) ⚠️

📢 - The Backup manager was optimized & reworked in the latest patch (v5.8.1) to be faster and more reliable with huge numbers of backups. + The Backup manager was optimized & reworked in the latest update (v5.9) to be faster and more reliable with huge numbers of backups.

Please report any anomalies or bugs you encounter either on the GitHub Issues page or on the SSUI Discord Server.

- With this update, the Backup manager no longer supports the old (pre-terrain update) terrain and save system. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using an older version of SSUI, disabling auto-updates via the config.json file. + With this update, the Backup manager no longer supports the old (pre-terrain update) terrain and save system. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using SSUI 5.8 and below, disabling auto-updates via the config.json file.

MensRea - Developer of this Update

JacksonTheMaster - Project Lead

From cab1fc3cbdc021c13bc1597a2a45b0f30eb78041 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Thu, 11 Dec 2025 03:34:38 +0100 Subject: [PATCH 21/27] Updated world configuration with community options and update World ID info for better user guidance in UI --- .../assets/js/world-gen-config.js | 28 ++++++++----------- UIMod/onboard_bundled/localization/en-US.json | 2 +- UIMod/onboard_bundled/ui/config.html | 6 ++++ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/UIMod/onboard_bundled/assets/js/world-gen-config.js b/UIMod/onboard_bundled/assets/js/world-gen-config.js index 1210fb7d..24c1060f 100644 --- a/UIMod/onboard_bundled/assets/js/world-gen-config.js +++ b/UIMod/onboard_bundled/assets/js/world-gen-config.js @@ -1,32 +1,28 @@ // Validation configuration object const worldConfigs = { Lunar: { - conditions: ['DefaultStart', 'Brutal'], - locations: ['LunarSpawnCraterVesper', 'LunarSpawnMontesUmbrarum', 'LunarSpawnCraterNox', 'LunarSpawnMonsArcanus'] + conditions: ['DefaultStart', 'DefaultStartCommunity', 'Brutal', 'BrutalCommunity'], + locations: ['LunarSpawnRoundRobin', 'LunarSpawnCraterVesper', 'LunarSpawnMontesUmbrarum', 'LunarSpawnCraterNox', 'LunarSpawnMonsArcanus'] }, Mars2: { - conditions: ['DefaultStart', 'Brutal'], - locations: ['MarsSpawnCanyonOverlook', 'MarsSpawnButchersFlat', 'MarsSpawnFindersCanyon', 'MarsSpawnHellasCrags', 'MarsSpawnDonutFlats'] + conditions: ['DefaultStart', 'DefaultStartCommunity', 'Brutal', 'BrutalCommunity'], + locations: ['MarsSpawnRoundRobin', 'MarsSpawnCanyonOverlook', 'MarsSpawnButchersFlat', 'MarsSpawnFindersCanyon', 'MarsSpawnHellasCrags', 'MarsSpawnDonutFlats'] }, Europa3: { - conditions: ['EuropaDefault', 'EuropaBrutal'], - locations: ['EuropaSpawnIcyBasin', 'EuropaSpawnGlacialChannel', 'EuropaSpawnBalgatanPass', 'EuropaSpawnFrigidHighlands', 'EuropaSpawnTyreValley'] + conditions: ['EuropaDefault', 'EuropaDefaultCommunity', 'EuropaBrutal', 'EuropaBrutalCommunity'], + locations: ['EuropaSpawnRoundRobin','EuropaSpawnIcyBasin', 'EuropaSpawnGlacialChannel', 'EuropaSpawnBalgatanPass', 'EuropaSpawnFrigidHighlands', 'EuropaSpawnTyreValley'] }, MimasHerschel: { - conditions: ['MimasDefault', 'MimasBrutal'], - locations: ['MimasSpawnCentralMesa', 'MimasSpawnHarrietCrater', 'MimasSpawnCraterField', 'MimasSpawnDustBowl'] + conditions: ['MimasDefault', 'MimasDefaultCommunity', 'MimasBrutal', 'MimasBrutalCommunity'], + locations: ['MimasSpawnRoundRobin','MimasSpawnCentralMesa', 'MimasSpawnHarrietCrater', 'MimasSpawnCraterField', 'MimasSpawnDustBowl'] }, Vulcan2: { - conditions: ['VulcanDefault', 'VulcanBrutal'], - locations: ['VulcanSpawnVestaValley', 'VulcanSpawnEtnasFury', 'VulcanSpawnIxionsDemise', 'VulcanSpawnTitusReach'] - }, - Vulcan: { - conditions: ['VulcanDefault', 'VulcanBrutal'], - locations: ['VulcanSpawnVestaValley', 'VulcanSpawnEtnasFury', 'VulcanSpawnIxionsDemise', 'VulcanSpawnTitusReach'] + conditions: ['VulcanDefault', 'VulcanDefaultCommunity', 'VulcanDefaultCommunity', 'VulcanBrutal', 'VulcanBrutalCommunity'], + locations: ['VulcanSpawnRoundRobin', 'VulcanSpawnVestaValley', 'VulcanSpawnEtnasFury', 'VulcanSpawnIxionsDemise', 'VulcanSpawnTitusReach'] }, Venus: { - conditions: ['VenusDefault', 'VulcanBrutal (yes, VULCAN Brutal!)'], - locations: ['VenusSpawnGaiaValley', 'VenusSpawnDaisyValley', 'VenusSpawnFaithValley', 'VenusSpawnDuskValley'] + conditions: ['VenusDefault', 'VenusDefaultCommunity', 'VulcanBrutal', 'VulcanBrutalCommunity'], + locations: ['VenusSpawnRoundRobin', 'VenusSpawnGaiaValley', 'VenusSpawnDaisyValley', 'VenusSpawnFaithValley', 'VenusSpawnDuskValley'] } }; diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json index 9426d739..adbbd138 100644 --- a/UIMod/onboard_bundled/localization/en-US.json +++ b/UIMod/onboard_bundled/localization/en-US.json @@ -49,7 +49,7 @@ "UIText_SaveName": "Save Name", "UIText_SaveNameInfo": "Name of the save folder, like 'MySave' or 'Europa Brutal'", "UIText_WorldID": "World ID", - "UIText_WorldIDInfo": "World ID used when creating a new world. For a list of world IDs, see the Dedicated Server Wiki or configure it easily from the setup wizard." + "UIText_WorldIDInfo": "World ID used when creating a new world. For a list of world IDs, see the Dedicated Server Wiki or configure them easily from the setup wizard. For more options, see the World generation tab." }, "network": { "UIText_NetworkConfiguration": "Network Configuration", diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index b6cd90cc..befa0fe2 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -284,6 +284,12 @@

{{.UIText_TerrainSettingsHeader}}


+
+ + +
{{.UIText_WorldIDInfo}}
+
+
Date: Thu, 11 Dec 2025 03:39:51 +0100 Subject: [PATCH 22/27] bump version for 5.9 release --- src/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/config.go b/src/config/config.go index c7ffdb01..fc2d5482 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.8.1" + Version = "5.9.0" Branch = "release" ) From 2787e30ef1ee1b66865c2288e1ced10633518d97 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Sat, 13 Dec 2025 23:52:15 +0100 Subject: [PATCH 23/27] moved more color definitions to variables.css for future theming --- .../onboard_bundled/assets/css/components.css | 53 ++++++++--------- UIMod/onboard_bundled/assets/css/tabs.css | 57 +++++++++---------- .../onboard_bundled/assets/css/variables.css | 16 ++++++ 3 files changed, 68 insertions(+), 58 deletions(-) diff --git a/UIMod/onboard_bundled/assets/css/components.css b/UIMod/onboard_bundled/assets/css/components.css index a1ef562f..bb9ddb9a 100644 --- a/UIMod/onboard_bundled/assets/css/components.css +++ b/UIMod/onboard_bundled/assets/css/components.css @@ -1,15 +1,17 @@ @import '/static/css/variables.css'; -button { +button, +.save-button, +.back-button { padding: 1rem 2rem; font-size: 1rem; - background-color: #232338; + background-color: var(--button-bg); color: var(--primary); border: 2px solid var(--primary); border-radius: 8px; cursor: pointer; transition: all var(--transition-normal); - box-shadow: 0 0 10px rgba(0, 255, 171, 0.4), 0 0 20px rgba(0, 255, 171, 0.1); + box-shadow: 0 0 10px var(--button-glow), 0 0 20px var(--button-glow-soft); font-family: 'Press Start 2P', cursive; line-height: 1.5; letter-spacing: 1px; @@ -20,41 +22,48 @@ button { hyphens: auto; } -button::before { +button::before, +.save-button::before, +.back-button::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; - background: linear-gradient(120deg, transparent, rgba(0, 255, 171, 0.2), transparent); + background: linear-gradient(120deg, transparent, var(--primary-glow), transparent); transition: left var(--transition-slow); } -button:hover::before { +button:hover::before, +.save-button:hover::before, +.back-button:hover::before { left: 100%; } -button:hover { - background-color: #333350; +button:hover, +.save-button:hover, +.back-button:hover { + background-color: var(--button-bg-hover); transform: translateY(-3px); - box-shadow: 0 0 15px rgba(0, 255, 171, 0.7), 0 0 30px rgba(0, 255, 171, 0.5); + box-shadow: 0 0 15px var(--button-glow-strong), 0 0 30px var(--button-glow-stronger); } -button:active { +button:active, +.save-button:active, +.back-button:active { transform: translateY(1px); } /* Form inputs */ input[type="text"], input[type="password"], -input[type="submit"], select { width: 100%; padding: 12px; margin: 10px 0; box-sizing: border-box; - background-color: rgba(0, 0, 0, 0.6); + background-color: var(--input-bg); color: var(--primary); border: 2px solid var(--primary-dim); border-radius: 4px; @@ -74,7 +83,8 @@ select:focus { input[type="submit"] { width: auto; padding: 12px 30px; - background-color: rgba(0, 255, 171, 0.2); + background-color: var(--submit-bg); + color: var(--primary); cursor: pointer; transition: all var(--transition-normal); font-family: 'Press Start 2P', cursive; @@ -83,27 +93,12 @@ input[type="submit"] { input[type="submit"]:hover { background-color: var(--primary); - color: #000; + color: var(--submit-hover-text); transform: translateY(-3px); } .save-button, .back-button { - padding: 1rem 2rem; - font-size: 1rem; - background-color: #232338; - color: var(--primary); - border: 2px solid var(--primary); - border-radius: 8px; - cursor: pointer; - transition: all var(--transition-normal); - box-shadow: 0 0 10px rgba(0, 255, 171, 0.4), 0 0 20px rgba(0, 255, 171, 0.1); - font-family: 'Press Start 2P', cursive; - line-height: 1.5; - letter-spacing: 1px; - position: relative; - overflow: hidden; - will-change: transform, box-shadow; -webkit-appearance: none; appearance: none; box-sizing: border-box; diff --git a/UIMod/onboard_bundled/assets/css/tabs.css b/UIMod/onboard_bundled/assets/css/tabs.css index 735ec239..3e13fb22 100644 --- a/UIMod/onboard_bundled/assets/css/tabs.css +++ b/UIMod/onboard_bundled/assets/css/tabs.css @@ -3,17 +3,17 @@ .tab-container { width: 100%; margin-bottom: 30px; - } - - .tab-buttons { +} + +.tab-buttons { display: flex; margin-bottom: 0; - } - - .tab-button { - background: rgba(0, 0, 0, 0.3); +} + +.tab-button { + background: var(--tab-bg); border: 2px solid var(--primary-dim); - border-bottom: 2px solid #00000000; + border-bottom: 2px solid transparent; padding: 10px 20px; margin-right: 5px; color: var(--primary); @@ -23,32 +23,31 @@ transition: all var(--transition-normal); font-size: 0.9rem; margin-bottom: -2px; - } - - .tab-button:hover { - background-color: rgba(0, 255, 171, 0.1); - box-shadow: 0 -4px 10px rgba(0, 255, 171, 0.3); +} + +.tab-button:hover { + background-color: var(--tab-hover-bg); + box-shadow: 0 -4px 10px var(--primary-glow); border-bottom: 2px solid var(--primary-dim); - } - - .tab-button.active { - background-color: rgba(0, 255, 171, 0.2); +} + +.tab-button.active { + background-color: var(--tab-active-bg); opacity: 1; - box-shadow: 0 -4px 10px rgba(0, 255, 171, 0.5); - } - - .tab-content { + box-shadow: 0 -4px 10px var(--tab-active-glow); +} + +.tab-content { display: none; padding: 0; - } - - .tab-content.active { +} + +.tab-content.active { display: block; animation: fadeIn var(--transition-normal); - } - - @keyframes fadeIn { +} + +@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } - } - \ No newline at end of file +} \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/css/variables.css b/UIMod/onboard_bundled/assets/css/variables.css index ae653584..90ebafc0 100644 --- a/UIMod/onboard_bundled/assets/css/variables.css +++ b/UIMod/onboard_bundled/assets/css/variables.css @@ -16,4 +16,20 @@ --transition-fast: 0.2s ease; --transition-normal: 0.3s ease; --transition-slow: 0.5s ease; + + /* v5.9+ Variables */ + --button-bg: #232338; + --button-bg-hover: #333350; + --button-glow: rgba(0, 255, 171, 0.4); + --button-glow-strong: rgba(0, 255, 171, 0.7); + --button-glow-soft: rgba(0, 255, 171, 0.1); + --button-glow-stronger: rgba(0, 255, 171, 0.5); + --input-bg: rgba(0, 0, 0, 0.6); + --submit-bg: rgba(0, 255, 171, 0.2); + --submit-hover-text: #000; + --tab-bg: rgba(0, 0, 0, 0.3); + --tab-hover-bg: rgba(0, 255, 171, 0.1); + --tab-active-bg: rgba(0, 255, 171, 0.2); + --tab-active-glow: rgba(0, 255, 171, 0.5); + } \ No newline at end of file From 4a8b6b28ec0cc92d6e3236f0fb4270ea5d51c58b Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Sun, 14 Dec 2025 00:28:27 +0100 Subject: [PATCH 24/27] further refactored CSS to enhance use of variables for improved theming and consistent styling across UI components --- .../onboard_bundled/assets/css/background.css | 8 +- UIMod/onboard_bundled/assets/css/config.css | 151 ++++++------------ UIMod/onboard_bundled/assets/css/home.css | 2 +- .../onboard_bundled/assets/css/variables.css | 52 ++++-- 4 files changed, 89 insertions(+), 124 deletions(-) diff --git a/UIMod/onboard_bundled/assets/css/background.css b/UIMod/onboard_bundled/assets/css/background.css index c0b2bd10..10282311 100644 --- a/UIMod/onboard_bundled/assets/css/background.css +++ b/UIMod/onboard_bundled/assets/css/background.css @@ -179,7 +179,7 @@ header { .planet { transform: translateY(-50%); - will-change: transform; /* Improves animation performance */ + will-change: transform; } #banner { @@ -188,12 +188,12 @@ header { border-radius: 12px; position: relative; z-index: 1; - box-shadow: 0 0 30px rgba(0, 255, 171, 0.4); + box-shadow: 0 0 20px var(--button-glow), 0 0 20px var(--button-glow-soft); transition: transform var(--transition-normal), box-shadow var(--transition-normal); - will-change: transform, box-shadow; /* Optimize performance */ + will-change: transform, box-shadow; } #banner:hover { transform: scale(1.02); - box-shadow: 0 0 40px rgba(0, 255, 171, 0.6); + box-shadow: 0 0 40px var(--button-glow), 0 0 20px var(--button-glow-soft); } \ 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 e42fb13a..9262c3cd 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -1,6 +1,5 @@ @import '/static/css/variables.css'; - /* Wizard Button */ .wizard-button-container { display: flex; @@ -11,18 +10,18 @@ .wizard-button { display: flex; align-items: center; - background-color: rgba(0, 255, 171, 0.15); + background-color: var(--wizard-bg); border: 2px solid var(--primary); border-radius: 12px; padding: 12px 24px; font-family: 'Press Start 2P', cursive; font-size: 0.9rem; letter-spacing: 1px; - transition: all 0.3s ease; + transition: all var(--transition-normal); position: relative; overflow: hidden; z-index: 1; - box-shadow: 0 0 15px rgba(0, 255, 171, 0.3); + box-shadow: 0 0 15px var(--wizard-glow); } .wizard-button::before { @@ -32,14 +31,14 @@ left: -100%; width: 100%; height: 100%; - background: linear-gradient(90deg, transparent, rgba(0, 255, 171, 0.3), transparent); + background: linear-gradient(90deg, transparent, var(--primary-glow), transparent); transition: left 0.7s ease; z-index: -1; } .wizard-button:hover { transform: translateY(-3px) scale(1.02); - box-shadow: 0 0 20px rgba(0, 255, 171, 0.6); + box-shadow: 0 0 20px var(--wizard-glow-strong); } .wizard-button:hover::before { @@ -55,7 +54,7 @@ width: 24px; height: 24px; margin-right: 10px; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 64 64' id='wizard' xmlns='http://www.w3.org/2000/svg' fill='%23000000'%3E%3Cg id='SVGRepo_bgCarrier' stroke-width='0'%3E%3C/g%3E%3Cg id='SVGRepo_tracerCarrier' stroke-linecap='round' stroke-linejoin='round'%3E%3C/g%3E%3Cg id='SVGRepo_iconCarrier'%3E%3Ctitle%3Ewizard%3C/title%3E%3Ccircle cx='33' cy='23' r='23' style='fill:%23edebdc'%3E%3C/circle%3E%3Cline x1='7' y1='17' x2='7' y2='19' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Cline x1='7' y1='23' x2='7' y2='25' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Cpath d='M21.778,47H47.222A8.778,8.778,0,0,1,56,55.778V61a0,0,0,0,1,0,0H13a0,0,0,0,1,0,0V55.778A8.778,8.778,0,0,1,21.778,47Z' style='fill:%239dc1e4;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Cpolygon points='32 61 28 61 34 49 38 49 32 61' style='fill:%23ffffff;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/polygon%3E%3Cpath d='M59,39H11v4.236A5.763,5.763,0,0,0,16.764,49L34,55l19.236-6A5.763,5.763,0,0,0,59,43.236Z' style='fill:%239dc1e4;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Cline x1='3' y1='21' x2='5' y2='21' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Cline x1='9' y1='21' x2='11' y2='21' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Ccircle cx='55.5' cy='6.5' r='2.5' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/circle%3E%3Ccircle cx='13.984' cy='6.603' r='1.069' style='fill:%234c241d'%3E%3C/circle%3E%3Cellipse cx='35' cy='39' rx='24' ry='6' style='fill:%236b4f5b;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/ellipse%3E%3Ccircle cx='5.984' cy='30.603' r='1.069' style='fill:%234c241d'%3E%3C/circle%3E%3Cpath d='M48,13V10.143A6.143,6.143,0,0,0,41.857,4H27.143A6.143,6.143,0,0,0,21,10.143V13' style='fill:%239dc1e4;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Crect x='20' y='17.81' width='29' height='14.19' style='fill:%23ffe8dc;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/rect%3E%3Cpath d='M41.972,13H48a4,4,0,0,1,4,4h0a4,4,0,0,1-4,4H21a4,4,0,0,1-4-4h0a4,4,0,0,1,4-4H37' style='fill:%23ffffff;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Ccircle cx='39.5' cy='25.5' r='1.136' style='fill:%234c241d'%3E%3C/circle%3E%3Ccircle cx='29.5' cy='25.5' r='1.136' style='fill:%234c241d'%3E%3C/circle%3E%3Cpath d='M43.875,32a6.472,6.472,0,0,0-5.219-2.2A5.2,5.2,0,0,0,35,31.974,5.2,5.2,0,0,0,31.344,29.8,6.472,6.472,0,0,0,26.125,32H20v4.5a14.5,14.5,0,0,0,29,0V32Z' style='fill:%23ffffff;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Cline x1='33' y1='36' x2='37' y2='36' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Crect x='32' y='10' width='5' height='5' transform='translate(1.266 28.056) rotate(-45)' style='fill:%23bd53b5;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/rect%3E%3C/g%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 64 64' id='wizard' xmlns='http://www.w3.org/2000/svg' fill='%23000000'%3E%3C!-- SVG content unchanged, but stroke/fill colours are part of the icon design --%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; } @@ -74,33 +73,33 @@ } .section-nav-button { - background-color: rgba(0, 0, 0, 0.4); - border: 2px solid rgba(0, 255, 171, 0.5); + background-color: var(--nav-bg); + border: 2px solid var(--nav-border); border-radius: 8px; padding: 10px 15px; color: var(--primary); cursor: pointer; font-family: 'Share Tech Mono', monospace; - transition: all 0.3s ease; + transition: all var(--transition-normal); white-space: nowrap; } .section-nav-button:hover { - background-color: rgba(0, 0, 0, 0.6); + background-color: var(--nav-bg-hover); transform: translateY(-2px); - box-shadow: 0 0 10px rgba(0, 255, 171, 0.4); + box-shadow: 0 0 10px var(--button-glow); /* reusing from previous button vars */ } .section-nav-button.active { - background-color: rgba(0, 255, 171, 0.2); + background-color: var(--tab-active-bg); /* reuse from tabs */ border-color: var(--primary); - box-shadow: 0 0 15px rgba(0, 255, 171, 0.5); + box-shadow: 0 0 15px var(--button-glow) } /* Configuration Sections */ .config-section { display: none; - animation: fadeIn 0.4s ease; + animation: fadeIn var(--transition-normal); } .config-section.active { @@ -126,46 +125,34 @@ vertical-align: middle; } -/* Server Icon - gear/cog icon */ -.server-icon { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z'/%3E%3C/svg%3E"); +/* Icons – colours are baked in as white; keep as-is */ +.server-icon, +.discord-icon, +.detection-icon { background-repeat: no-repeat; background-position: center; } -/* Discord Icon */ .discord-icon { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 127.14 96.36' fill='%23ffffff'%3E%3Cpath d='M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; background-size: 20px 20px; } -/* Detection Manager Icon - radar/search icon */ -.detection-icon { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z'/%3E%3Cpath d='M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm-1 5h2v2h-2v-2zm0-2h2v1h-2v-1zm0-1h2v1h-2v-1z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; -} - -/* Make icons more visible on tab buttons */ .tab-button { display: flex; align-items: center; justify-content: center; } -/* Active tab icon highlight */ .tab-button.active .icon { filter: brightness(1.2); } -.fill-hint-wraper{ +.fill-hint-wraper { background-color: var(--danger); border-radius: 8px; } -.fill-hint{ +.fill-hint { font-size: 1rem; color: #ffffff; padding: 2%; @@ -173,43 +160,7 @@ text-align: center; } -/* Responsive adjustments */ -@media (max-width: 768px) { - .section-navigation { - flex-wrap: wrap; - justify-content: flex-start; - } - - .section-nav-button { - padding: 8px 12px; - font-size: 0.85em; - } - - .wizard-button { - width: 100%; - justify-content: center; - padding: 10px 15px; - } - - .section-title { - font-size: 0.9rem; - } -} - -/* Animation */ -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - - +/* Select dropdown */ select { appearance: none; -webkit-appearance: none; @@ -219,23 +170,24 @@ select { background-position: right 10px center; background-size: 16px; cursor: pointer; - box-shadow: 0 0 10px rgba(0, 255, 171, 0.4), 0 0 20px rgba(0, 255, 171, 0.1); + box-shadow: 0 0 10px var(--button-glow), 0 0 20px var(--button-glow-soft); } select:hover { - background-color: rgba(0, 0, 0, 0.8); + background-color: var(--select-bg-hover); border-color: var(--primary); } select option { - background-color: #1b1b2f; + background-color: var(--option-bg); color: var(--primary); font-family: 'Share Tech Mono', monospace; padding: 10px; } +/* Form styling */ .form-container { - background-color: rgba(0, 0, 0, 0.2); + background-color: var(--form-bg); padding: 20px; border-radius: 8px; margin-bottom: 30px; @@ -243,7 +195,7 @@ select option { } .form-container:hover { - box-shadow: 0 0 15px rgba(0, 255, 171, 0.2); + box-shadow: 0 0 15px var(--primary-glow); } .form-group { @@ -270,8 +222,7 @@ select option { justify-content: space-between; } - -/* Discord config page */ +/* Discord-specific sections */ .integration-status { display: flex; align-items: center; @@ -285,7 +236,7 @@ select option { .integration-status:hover { transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0, 255, 171, 0.2); + box-shadow: 0 5px 15px var(--primary-glow); } .highlight-label { @@ -304,7 +255,6 @@ select option { letter-spacing: 1px; } - .channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); @@ -312,50 +262,31 @@ select option { margin-bottom: 30px; } -.grid-container { - padding: 20px; - border-radius: 8px; - background-color: rgba(114, 137, 218, 0.1); - border: 1px solid rgba(114, 137, 218, 0.5); - margin-bottom: 30px; - transition: all var(--transition-normal); - transform: translateZ(0); - /* Force hardware acceleration */ -} - -.grid-container:hover { - transform: translateY(-3px); - box-shadow: 0 5px 15px rgba(114, 137, 218, 0.3); -} - -.grid-container.disabled { - opacity: 0.5; - filter: grayscale(50%); -} - +.grid-container, .discord-container { padding: 20px; border-radius: 8px; - background-color: rgba(114, 137, 218, 0.1); - border: 1px solid rgba(114, 137, 218, 0.5); + background-color: var(--discord-bg); + border: 1px solid var(--discord-border); margin-bottom: 30px; transition: all var(--transition-normal); transform: translateZ(0); - /* Force hardware acceleration */ } +.grid-container:hover, .discord-container:hover { transform: translateY(-3px); - box-shadow: 0 5px 15px rgba(114, 137, 218, 0.3); + box-shadow: 0 5px 15px var(--discord-glow); } +.grid-container.disabled, .discord-container.disabled { opacity: 0.5; filter: grayscale(50%); } .info-panel { - background-color: rgba(0, 0, 0, 0.2); + background-color: var(--form-bg); padding: 20px; border-radius: 8px; margin-top: 30px; @@ -378,4 +309,14 @@ select option { .feature-list li:hover { transform: translateX(3px); background-color: rgba(0, 0, 0, 0.4); +} + +/* Responsive & Animation unchanged */ +@media (max-width: 768px) { + /* ... unchanged ... */ +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } } \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/css/home.css b/UIMod/onboard_bundled/assets/css/home.css index 47062e6d..2b3e2d68 100644 --- a/UIMod/onboard_bundled/assets/css/home.css +++ b/UIMod/onboard_bundled/assets/css/home.css @@ -103,7 +103,7 @@ color: var(--primary); margin-bottom: 30px; border-radius: 8px; - box-shadow: inset 0 0 10px rgba(0, 255, 171, 0.5); + box-shadow: inset 0 0 10px var(--button-glow), 0 0 20px var(--button-glow-soft); font-family: 'Share Tech Mono', 'Courier New', monospace; position: relative; scrollbar-width: thin; diff --git a/UIMod/onboard_bundled/assets/css/variables.css b/UIMod/onboard_bundled/assets/css/variables.css index 90ebafc0..85d81679 100644 --- a/UIMod/onboard_bundled/assets/css/variables.css +++ b/UIMod/onboard_bundled/assets/css/variables.css @@ -1,25 +1,34 @@ :root { /* Colors */ - --primary: #00FFAB; + --primary: + #00FFAB; --primary-dim: rgba(0, 255, 171, 0.7); --primary-glow: rgba(0, 255, 171, 0.3); - --bg-dark: #0a0a14; + --bg-dark: + #0a0a14; --bg-panel: #1b1b2f8f; - --accent: #0084ff; - --text-bright: #e0ffe9; - --text-header: #ffffff; - --danger: #ff3860; - --success: #48c774; - --warning: #ffdd57; - + --accent: + #0084ff; + --text-bright: + #e0ffe9; + --text-header: + #ffffff; + --danger: + #ff3860; + --success: + #48c774; + --warning: + #ffdd57; + /* Transitions */ --transition-fast: 0.2s ease; --transition-normal: 0.3s ease; --transition-slow: 0.5s ease; - /* v5.9+ Variables */ - --button-bg: #232338; - --button-bg-hover: #333350; + --button-bg: + #232338; + --button-bg-hover: + #333350; --button-glow: rgba(0, 255, 171, 0.4); --button-glow-strong: rgba(0, 255, 171, 0.7); --button-glow-soft: rgba(0, 255, 171, 0.1); @@ -31,5 +40,20 @@ --tab-hover-bg: rgba(0, 255, 171, 0.1); --tab-active-bg: rgba(0, 255, 171, 0.2); --tab-active-glow: rgba(0, 255, 171, 0.5); - - } \ No newline at end of file + /* v5.9+ Variables config page*/ + --wizard-bg: rgba(0, 255, 171, 0.15); + --wizard-glow: rgba(0, 255, 171, 0.3); + --wizard-glow-strong: rgba(0, 255, 171, 0.6); + --nav-bg: rgba(0, 0, 0, 0.4); + --nav-bg-hover: rgba(0, 0, 0, 0.6); + --nav-border: rgba(0, 255, 171, 0.5); + --form-bg: rgba(0, 0, 0, 0.2); + --discord-accent: #7289da; + /* Discord brand colour */ + --discord-bg: rgba(114, 137, 218, 0.1); + /* rgba version of Discord blue */ + --discord-border: rgba(114, 137, 218, 0.5); + --discord-glow: rgba(114, 137, 218, 0.3); + --select-bg-hover: rgba(0, 0, 0, 0.8); + --option-bg: #1b1b2f; +} \ No newline at end of file From 77933c0c2f6ce420d6538e111613ccb1c3ebdba4 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Sun, 14 Dec 2025 04:00:23 +0100 Subject: [PATCH 25/27] added conditional xmas elements to UI from 1st of dec to 10th of jan (updated discord icon (yet again)) --- UIMod/onboard_bundled/assets/js/xmas.js | 1082 +++++++++++++++++ UIMod/onboard_bundled/assets/xmas/c1.webp | Bin 0 -> 9218 bytes UIMod/onboard_bundled/assets/xmas/c2.webp | Bin 0 -> 19856 bytes UIMod/onboard_bundled/assets/xmas/c3.webp | Bin 0 -> 22938 bytes UIMod/onboard_bundled/assets/xmas/c4.webp | Bin 0 -> 15504 bytes .../assets/xmas/stationeers-winter.webp | Bin 0 -> 15276 bytes UIMod/onboard_bundled/ui/config.html | 1 + UIMod/onboard_bundled/ui/index.html | 3 +- 8 files changed, 1085 insertions(+), 1 deletion(-) create mode 100644 UIMod/onboard_bundled/assets/js/xmas.js create mode 100644 UIMod/onboard_bundled/assets/xmas/c1.webp create mode 100644 UIMod/onboard_bundled/assets/xmas/c2.webp create mode 100644 UIMod/onboard_bundled/assets/xmas/c3.webp create mode 100644 UIMod/onboard_bundled/assets/xmas/c4.webp create mode 100644 UIMod/onboard_bundled/assets/xmas/stationeers-winter.webp diff --git a/UIMod/onboard_bundled/assets/js/xmas.js b/UIMod/onboard_bundled/assets/js/xmas.js new file mode 100644 index 00000000..df9cbbef --- /dev/null +++ b/UIMod/onboard_bundled/assets/js/xmas.js @@ -0,0 +1,1082 @@ +document.addEventListener('DOMContentLoaded', function() { + + // Check if the date is between Dec 1 and Jan 10 (inclusive, across year boundary) + const now = new Date(); + const isXmas = (now.getMonth() === 11 && now.getDate() >= 1) || // Dec 1–31 + (now.getMonth() === 0 && now.getDate() <= 10); // Jan 1–10 + + // If Christmas period is over, do nothing + if (!isXmas) { + return; + } + + console.log('Christmas sleigh ready to fly'); + + // Inject the sleigh CSS + const sleighStyle = document.createElement('style'); + sleighStyle.textContent = ` + .sleigh-container { + position: fixed; + z-index: -1; + transition: transform 0.3s; + animation: flySleigh 30s linear infinite; + scale: 0.5; + } + + .sleigh-container:hover { + transform: scale(1.1);F + } + + @keyframes flySleigh { + 0% { + left: -400px; + top: 40%; + } + 50% { + top: 30%; + } + 100% { + left: calc(100% + 400px); + top: 20%; + } +} + + @keyframes bounce { + 0%, 100% { transform: translateX(-50%) translateY(0); } + 50% { transform: translateX(-50%) translateY(-8px); } + } + + /* Sleigh styles */ + .sleigh-santa { + position: relative; + width: 295px; + height: 155px; + transform: rotate(-1deg); + } + + .santa { + position: absolute; + bottom: 0; + left: 50%; + width: 125px; + height: 107px; + z-index: 10; + } + + .santa--sleigh { + bottom: 0; + left: 0; + transform: rotateY(180deg); + } + + .santa--sleigh:before, + .santa--sleigh:after { + content: ""; + position: absolute; + bottom: 0; + background-color: #8B0000; + } + + .santa--sleigh:before { + left: -10px; + width: 129px; + height: 30px; + border-radius: 5px 5% 10px 65%; + transform: rotate(0); + z-index: 10; + border-bottom: 2px solid #DAA520; + } + + .santa--sleigh:after { + border: 2px solid #DAA520; + left: 70px; + bottom: 0px; + width: 50px; + height: 57px; + border-radius: 50% 10px 16px 10px; + transform: rotate(1deg); + box-shadow: -98px -2px 0px -18px #8B0000; + } + + .santa__hat-part { + position: absolute; + top: 7px; + left: 31px; + width: 43px; + height: 58px; + border-radius: 50%; + transform: rotate(28deg); + background-color: #d63527; + } + + .santa__hat-part:before, + .santa__hat-part:after { + content: ""; + position: absolute; + } + + .santa__hat-part:nth-of-type(1):before { + top: 9px; + left: 45px; + width: 7px; + height: 7px; + border-radius: 50%; + background-color: #fff; + animation: santa-hat-bobble 1s linear alternate infinite; + } + + .santa__hat-part:nth-of-type(1):after { + top: 3px; + left: 19px; + width: 30px; + height: 7px; + border-radius: 50%; + transform: rotate(22deg); + background-color: #d63527; + animation: santa-hat-main 1s linear alternate infinite; + } + + .santa__hat-part:nth-of-type(2) { + position: absolute; + top: 18px; + left: 31px; + width: 44px; + height: 34px; + border-radius: 50%; + transform: rotate(12deg); + background-color: #fff; + } + + .santa__face { + position: absolute; + top: 25px; + left: 37px; + width: 31px; + height: 17px; + border-radius: 20px 20px 50% 50%; + transform: rotate(10deg); + background-color: #fde2b7; + z-index: 10; + } + + .santa__beard-part { + position: absolute; + top: 8px; + left: -14px; + width: 15px; + height: 17px; + border-radius: 50%; + background-color: #fff; + } + + .santa__beard-part:before, + .santa__beard-part:after { + content: ""; + position: absolute; + background-color: #fff; + } + + .santa__beard-part:before { + top: 12px; + left: 1px; + width: 15px; + height: 17px; + border-radius: 50%; + } + + .santa__beard-part:nth-of-type(2) { + top: 16px; + left: -8px; + width: 26px; + height: 30px; + } + + .santa__beard-part:nth-of-type(2):before { + top: 16px; + left: 13px; + width: 19px; + height: 17px; + } + + .santa__beard-part:nth-of-type(2):after { + top: 1px; + left: 13px; + width: 19px; + height: 17px; + } + + .santa__beard-part:nth-of-type(3) { + top: 16px; + left: 14px; + width: 27px; + height: 28px; + } + + .santa__beard-part:nth-of-type(3):before { + top: -4px; + left: 13px; + width: 17px; + height: 17px; + } + + .santa__eyebrows { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 7px; + border-radius: 50%; + background-color: #fff; + } + + .santa__eyebrows--left { + top: 1px; + left: 4px; + transform: rotate(65deg); + } + + .santa__eyebrows--right { + top: 2px; + left: 22px; + transform: rotate(-65deg); + } + + .santa__eye { + position: absolute; + width: 3px; + height: 4px; + border-radius: 50%; + background-color: #000; + } + + .santa__eye--left { + top: 8px; + left: 2px; + } + + .santa__eye--right { + top: 8px; + left: 20px; + } + + .santa__nose { + position: absolute; + top: 10px; + left: 6px; + width: 12px; + height: 9px; + border-radius: 50%; + z-index: 10; + background-color: #f7d194; + } + + .santa__cheek { + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + z-index: 10; + background-color: #f4cfe3; + } + + .santa__cheek--left { + top: 12px; + left: -3px; + } + + .santa__cheek--right { + top: 13px; + left: 22px; + } + + .santa__body { + position: absolute; + top: 54px; + left: 16px; + width: 88px; + height: 53px; + } + + .santa__body:before { + content: ""; + position: absolute; + top: -23px; + right: -10px; + width: 53px; + height: 51px; + border-radius: 42% 50%; + background-color: #362312; + z-index: -1; + box-shadow: 10px -21px 0px -20px #e1b12c, 15px -30px 0px -18px #362312; + animation: santa-sac 0.6s linear alternate infinite; + } + + .santa__body-top { + top: -3px; + left: 10px; + position: absolute; + width: 45px; + height: 39px; + border-radius: 50% 50% 10% 10%; + background-color: #d63527; + z-index: 5; + } + + .santa__body-top:before { + content: ""; + top: 28px; + left: 0px; + position: absolute; + width: 45px; + height: 5px; + background-color: #000; + transform: rotate(1deg); + } + + .santa__body-top:after { + content: ""; + top: 27px; + left: 10px; + position: absolute; + width: 7px; + height: 5px; + background-color: #000; + border: 1px solid #fff; + border-radius: 3px; + transform: rotate(1deg); + } + + .santa__hand--left { + top: 5px; + left: 19px; + width: 33px; + height: 30px; + overflow: hidden; + position: absolute; + } + + .santa__hand-inner { + position: absolute; + top: 10px; + left: 8px; + width: 49px; + z-index: 100; + height: 7px; + border-radius: 10px; + transform: rotate(12deg); + background-color: #d63527; + animation: sleigh-santa-hand-left 1s linear alternate infinite; + } + + .santa__hand-inner:before { + content: ""; + position: absolute; + width: 8px; + height: 7px; + top: -2px; + left: -6px; + background-color: #000; + border-radius: 50%; + transform: rotate(25deg); + } + + .santa__hand--right { + position: absolute; + top: 4px; + left: 3px; + width: 11px; + height: 8px; + transform: rotate(25deg); + border-radius: 10px; + height: 7px; + background-color: #d63527; + animation: sleigh-santa-hand-right 1s linear alternate infinite; + } + + .santa__hand--right:before { + content: ""; + position: absolute; + width: 8px; + height: 7px; + top: -2px; + left: -6px; + background-color: #000; + border-radius: 50%; + transform: rotate(10deg); + } + + .sleigh-feet { + position: absolute; + bottom: -10px; + left: 0px; + width: 145px; + height: 11px; + transform: rotate(-5deg); + border-bottom: 5px solid #996515; + border-right: 5px solid #996515; + border-radius: 10px; + z-index: 2; + } + + .sleigh-feet:before, + .sleigh-feet:after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 5px; + height: 5px; + background-color: #996515; + } + + .sleigh-feet:before { + top: 2px; + left: 34px; + height: 9px; + } + + .sleigh-feet:after { + top: 3px; + left: 108px; + width: 5px; + height: 8px; + } + + .lead { + position: absolute; + top: 92px; + left: 84px; + width: 182px; + height: 33px; + overflow: hidden; + z-index: 10; + transform: rotate(0deg); + animation: sleigh-santa-lead-right 1s linear alternate infinite; + } + + .lead--back { + top: 85px; + left: 105px; + width: 149px; + transform: rotate(4deg); + z-index: 0; + animation: sleigh-santa-lead-left 1s linear alternate infinite; + } + + .lead-inner { + position: absolute; + bottom: 0; + left: -12px; + width: 100%; + height: 48px; + border-bottom: 1px solid #fff; + border-radius: 50%; + } + + .reindeer { + position: absolute; + width: 115px; + height: 155px; + right: 5px; + top: 17px; + transform: rotate(25deg); + z-index: 0; + } + + .reindeer:before { + content: ""; + position: absolute; + top: 65px; + left: 76px; + width: 8px; + height: 31px; + background-color: #8B0000; + z-index: 10; + transform: rotate(-55deg); + } + + .reindeer:after { + content: ""; + position: absolute; + height: 5px; + width: 5px; + border-radius: 50%; + background-color: #DAA520; + z-index: 11; + left: 64px; + top: 68px; + box-shadow: 8px 6px 0 0 #DAA520, 18px 13px 0 0 #DAA520, 26px 18px 0 0 #DAA520; + } + + .reindeer__face { + position: absolute; + width: 30px; + height: 22px; + top: 44px; + left: 72px; + border-radius: 10px 10px 50% 50%; + transform: rotate(-3deg); + background-color: #654321; + animation: reindeer-face 1.6s linear alternate infinite; + } + + .reindeer__face:before { + content: ""; + position: absolute; + background-color: #654321; + width: 29px; + height: 16px; + border-radius: 50%; + top: 0px; + left: 11px; + transform: rotate(-49deg); + } + + .reindeer__face:after { + content: ""; + position: absolute; + background-color: #8B0000; + width: 8px; + height: 8px; + border-radius: 50%; + top: -8px; + left: 31px; + } + + .reindeer__horn { + position: absolute; + width: 29px; + height: 4px; + border-radius: 2px; + background-color: #DEB887; + } + + .reindeer__horn:before, + .reindeer__horn:after { + content: ""; + position: absolute; + background-color: #DEB887; + border-radius: 2px; + } + + .reindeer__horn--left { + top: -7px; + left: -21px; + transform: rotate(38deg); + } + + .reindeer__horn--left:before { + top: -4px; + left: 6px; + width: 14px; + height: 4px; + transform: rotate(43deg); + } + + .reindeer__horn--left:after { + top: -4px; + left: 13px; + width: 14px; + height: 4px; + transform: rotate(53deg); + } + + .reindeer__horn--right { + top: -12px; + left: -6px; + width: 24px; + transform: rotate(62deg); + } + + .reindeer__horn--right:before { + top: -3px; + left: 5px; + width: 10px; + height: 4px; + transform: rotate(43deg); + } + + .reindeer__horn--right:after { + top: -3px; + left: 11px; + width: 10px; + height: 4px; + transform: rotate(53deg); + } + + .reindeer__ear { + position: absolute; + width: 21px; + height: 11px; + top: 4px; + left: -18px; + border-radius: 4px 0 50% 50%; + transform: rotate(4deg); + background-color: #654321; + } + + .reindeer__ear:before { + content: ""; + position: absolute; + top: -2px; + left: 34px; + width: 4px; + height: 5px; + border-radius: 50%; + transform: rotate(-35deg); + background-color: #000; + } + + .reindeer__body { + position: absolute; + width: 58px; + height: 31px; + top: 84px; + left: 28px; + border-radius: 50% 0; + transform: rotate(-3deg); + background-color: #654321; + } + + .reindeer__body:before { + content: ""; + position: absolute; + width: 46px; + height: 26px; + top: -15px; + left: 32px; + border-radius: 0 0 50% 50%; + transform: rotate(-55deg); + background-color: #654321; + } + + .reindeer__body:after { + content: ""; + position: absolute; + width: 43px; + height: 26px; + top: -11px; + left: 29px; + border-radius: 0 0 50% 50%; + transform: rotate(-30deg); + background-color: #654321; + } + + .reindeer__foot--inside { + z-index: 2; + transform: rotate(-12deg) translate(3px, 0px); + } + + .reindeer__foot-inner { + position: absolute; + } + + .reindeer__foot-inner:before, + .reindeer__foot-inner:after { + content: ""; + position: absolute; + } + + .reindeer__foot--front .reindeer__foot-inner { + width: 40px; + height: 8px; + top: 13px; + left: 35px; + border-radius: 0 50%; + transform: rotate(-17deg); + transform-origin: center; + background-color: #654321; + animation: reindeer-front 1.6s linear alternate infinite; + } + + .reindeer__foot--front .reindeer__foot-inner:before { + width: 28px; + height: 8px; + top: 0px; + left: 37px; + border-radius: 2px 50%; + transform: rotate(131deg); + background-color: #654321; + animation: reindeer-front-ext 1.7s linear alternate infinite; + } + + .reindeer__foot--front .reindeer__foot-inner:after { + width: 8px; + height: 9px; + top: 27px; + left: 32px; + border-radius: 2px; + transform: rotate(131deg); + background-color: #362514; + animation: reindeer-front-ext-hoof 1.7s linear alternate infinite; + } + + .reindeer__foot--back .reindeer__foot-inner { + width: 56px; + height: 9px; + top: 35px; + left: -29px; + border-radius: 0 50%; + transform: rotate(-73deg); + background-color: #654321; + animation: reindeer-back 1.7s linear alternate infinite; + } + + .reindeer__foot--back .reindeer__foot-inner:before { + width: 25px; + height: 16px; + top: 4px; + left: 25px; + border-radius: 0 50%; + transform: rotate(15deg); + background-color: #654321; + } + + .reindeer__foot--back .reindeer__foot-inner:after { + width: 8px; + height: 9px; + top: -2px; + left: -2px; + border-radius: 2px 0 2px 2px; + transform: rotate(14deg); + background-color: #362514; + } + + .reindeer__tail { + position: absolute; + width: 27px; + height: 26px; + top: 6px; + left: -8px; + border-radius: 50% 2px; + transform: rotate(-17deg); + background-color: #654321; + } + + .reindeer__tail:before { + content: ""; + position: absolute; + background-color: #654321; + border-radius: 50%; + top: -2px; + left: -3px; + width: 15px; + height: 5px; + transform: rotate(25deg); + } + + .reindeer__spots { + position: absolute; + width: 4px; + height: 4px; + top: 6px; + left: 8px; + border-radius: 50% 2px; + background-color: #DEB887; + box-shadow: 5px 5px 0 0 #DEB887, -5px 5px 0 0 #DEB887; + } + + .particles { + position: absolute; + bottom: -30px; + left: 0; + width: 7px; + height: 7px; + background-color: transparent; + transform: rotate(45deg); + z-index: -10; + } + + .particles:before, + .particles:after { + position: absolute; + content: ""; + background-color: #FFF; + } + + .particles:after { + left: 0px; + top: 0px; + width: 4px; + height: 4px; + transform: rotate(-5deg); + animation: particles 4s linear infinite; + box-shadow: -20px 15px 0px 0px #FFF, -40px -5px 0px 0px #FFF, -20px 45px 0px 0px #FFF, -50px 30px 0px 0px #FFF, 30px -20px 0px 0px #FFF, 50px -60px 0px 0px #FFF, 100px -110px 0px 0px #FFF, 140px -160px 0px 0px #FFF, 50px -90px 0px 0px #FFF, 100px -140px 0px 0px #FFF, 140px -190px 0px 0px #FFF; + } + + .particles:before { + left: 10px; + top: 0px; + width: 2px; + height: 2px; + transform: rotate(-10deg); + animation: particles 5s linear infinite; + box-shadow: -20px 15px 0px 0px #FFF, -40px -5px 0px 0px #FFF, -20px 45px 0px 0px #FFF, -50px 30px 0px 0px #FFF, 30px -20px 0px 0px #FFF, 50px -60px 0px 0px #FFF, 100px -110px 0px 0px #FFF, 140px -160px 0px 0px #FFF, 50px -90px 0px 0px #FFF, 100px -140px 0px 0px #FFF, 140px -190px 0px 0px #FFF; + } + + @keyframes reindeer-face { + 0% { transform: rotate(-8deg); } + 100% { transform: rotate(2deg); } + } + + @keyframes reindeer-back { + 0% { transform: rotate(-81deg) translate(-6px, 0px); } + 100% { transform: rotate(-60deg) translate(0px, 0px); } + } + + @keyframes reindeer-front { + 0% { transform: rotate(-24deg); } + 100% { transform: rotate(-13deg); } + } + + @keyframes reindeer-front-ext { + 0% { + transform-origin: left; + transform: rotate(55deg); + } + 100% { + transform-origin: left; + transform: rotate(145deg); + } + } + + @keyframes reindeer-front-ext-hoof { + 0% { + transform-origin: left top; + transform: rotate(-32deg) translate(14px, 1px); + } + 100% { + transform-origin: left top; + transform: rotate(52deg) translate(-21px, 0px); + } + } + + @keyframes sleigh-santa-lead-left { + 0% { transform: rotate(1deg); } + 100% { transform: rotate(-2deg); } + } + + @keyframes sleigh-santa-hand-left { + 0% { transform: rotate(15deg); } + 100% { transform: rotate(-2deg); } + } + + @keyframes sleigh-santa-lead-right { + 0% { transform: rotate(2deg); } + 100% { transform: rotate(-4deg); } + } + + @keyframes sleigh-santa-hand-right { + 0% { transform: rotate(15deg); } + 100% { transform: rotate(-30deg); } + } + + @keyframes santa-hat-main { + 0% { + transform-origin: left; + transform: rotate(-15deg); + } + 100% { + transform-origin: left; + transform: rotate(15deg); + } + } + + @keyframes santa-hat-bobble { + 0% { transform: translate(0px, -15px); } + 100% { transform: translate(0px, 5px); } + } + + @keyframes santa-sac { + 0% { transform: rotate(5deg); } + 100% { transform: rotate(0deg); } + } + + @keyframes particles { + 0% { + transform: translate(20px, -20px); + opacity: 1; + } + 100% { + transform: translate(-80px, 80px); + opacity: 0; + } + } + `; + document.head.appendChild(sleighStyle); + + // Create the sleigh container + const sleighContainer = document.createElement('div'); + sleighContainer.className = 'sleigh-container'; + sleighContainer.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; +document.body.appendChild(sleighContainer); +// make the stars snowflakes moving downwards + +const style = document.createElement('style'); + style.textContent = ` + @keyframes stars { + 0% { transform: translateY(0px); } + 100% { transform: translateY(+2000px); } + } + @keyframes stars { + 0% { transform: translateY(0); } + 100% { transform: translateY(3000px); } + } + + #space-background::before { + background-image: + radial-gradient(2px 2px at 17px 43px, #fff, transparent), + radial-gradient(2px 2px at 39px 157px, #fff, transparent), + radial-gradient(2px 2px at 73px 91px, #fff, transparent), + radial-gradient(2px 2px at 109px 134px, #fff, transparent), + radial-gradient(2px 2px at 143px 27px, #fff, transparent), + radial-gradient(2px 2px at 53px 79px, #fff, transparent), + radial-gradient(2px 2px at 85px 123px, #fff, transparent), + radial-gradient(2px 2px at 174px 36px, #fff, transparent), + radial-gradient(2px 2px at 102px 64px, #fff, transparent), + radial-gradient(2px 2px at 127px 187px, #fff, transparent); + background-size: 250px 250px; + opacity: 0.6; + animation: none !important; /* Static layer - no movement */ + } + + /* Moving larger/closer snowflakes layer (replaces Layer 2 stars) */ + #space-background::after { + background-image: + radial-gradient(4px 4px at 43px 267px, #fff, transparent), + radial-gradient(4px 4px at 167px 312px, #fff, transparent), + radial-gradient(4px 4px at 213px 117px, #fff, transparent), + radial-gradient(4px 4px at 81px 209px, #fff, transparent), + radial-gradient(4px 4px at 239px 349px, #fff, transparent), + radial-gradient(4px 4px at 124px 281px, #fff, transparent), + radial-gradient(4px 4px at 198px 153px, #fff, transparent), + radial-gradient(4px 4px at 56px 379px, #fff, transparent), + radial-gradient(3px 3px at 297px 246px, #fff, transparent), + radial-gradient(3px 3px at 147px 133px, #fff, transparent); + background-size: 400px 400px; + opacity: 0.8; + animation: stars 180s linear infinite !important; /* Slower for gentle snow fall */ + } + + `; + document.head.appendChild(style); + + +function addChristmasOrbitingTreats() { + // Hide original orbits + document.querySelectorAll('.orbit').forEach(orbit => { + orbit.style.display = 'none'; + }); + + const container = document.getElementById('planet-container'); + if (!container) return; + + // Helper to create a properly positioned orbiting image + function createOrbitingImage(imgUrl, size, orbitRadius, speed, delay = 0) { + const orbit = document.createElement('div'); + orbit.classList.add('orbit'); + orbit.style.width = `${orbitRadius * 2}px`; + orbit.style.height = `${orbitRadius * 2}px`; + orbit.style.position = 'absolute'; + orbit.style.left = '50%'; + orbit.style.top = '50%'; + orbit.style.transform = 'translate(-50%, -50%)'; + orbit.style.animation = `orbit ${speed}s linear infinite ${delay}s`; + + const img = document.createElement('img'); + img.src = imgUrl; + img.style.width = `${size}px`; + img.style.height = `${size}px`; + img.style.objectFit = 'contain'; + img.style.position = 'absolute'; + img.style.left = '0'; + img.style.top = '50%'; + img.style.transform = 'translateY(-50%)'; + img.style.pointerEvents = 'none'; + img.style.filter = 'drop-shadow(0 0 20px rgba(255,255,255,0.7))'; + + orbit.appendChild(img); + container.appendChild(orbit); + } + + // Better transparent PNGs for cleaner look + createOrbitingImage('/static/xmas/c4.webp', 130, 1100, 40, -5); + + createOrbitingImage('/static/xmas/c3.webp', 100, 850, 55, -12); + + createOrbitingImage('/static/xmas/c2.webp', 120, 600, 65, -20); + + createOrbitingImage('/static/xmas/c1.webp', 110, 400, 32, -8); +} + +function replaceBanner() { + const banner = document.getElementById('banner'); + banner.src = '/static/xmas/stationeers-winter.webp'; +} + +addChristmasOrbitingTreats(); +replaceBanner(); + +}); \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/xmas/c1.webp b/UIMod/onboard_bundled/assets/xmas/c1.webp new file mode 100644 index 0000000000000000000000000000000000000000..e8df702639113ee6212b064c37aeb509b3b80eb2 GIT binary patch literal 9218 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 UxVb*rf3sMang6*eH`OimKYtj9IRF3v literal 0 HcmV?d00001 diff --git a/UIMod/onboard_bundled/assets/xmas/c2.webp b/UIMod/onboard_bundled/assets/xmas/c2.webp new file mode 100644 index 0000000000000000000000000000000000000000..70876ecbe2342bbd14ee338d26c90ecd354210ac GIT binary patch literal 19856 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 z00031000G`1ONd5005K#00000000620RRF31ONa4QaLyP0ssd91ONa4FflMN00000 HDyB;gx4X%1vOo7EJxSY>Hci@` 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 TUeW(~@$vHhra_wd@ib@ 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 literal 0 HcmV?d00001 diff --git a/UIMod/onboard_bundled/assets/xmas/stationeers-winter.webp b/UIMod/onboard_bundled/assets/xmas/stationeers-winter.webp new file mode 100644 index 0000000000000000000000000000000000000000..4043b98e7caaba79d5b6a2d7674c13f706df8b45 GIT binary patch literal 15276 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 literal 0 HcmV?d00001 diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index befa0fe2..5672510b 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -482,6 +482,7 @@

{{.UIText_DiscordIntegrationBenefits}}

+ \ No newline at end of file diff --git a/UIMod/onboard_bundled/ui/index.html b/UIMod/onboard_bundled/ui/index.html index 35adfbf2..f03aa860 100644 --- a/UIMod/onboard_bundled/ui/index.html +++ b/UIMod/onboard_bundled/ui/index.html @@ -177,7 +177,7 @@

+ \ No newline at end of file From 9a2c606e72fc5d0d96dcec6de3c559f320244e71 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster Date: Sun, 14 Dec 2025 04:40:00 +0100 Subject: [PATCH 26/27] added dynamic announcement section to UI with support for fetching and displaying announcements from external JSON source (github pages) --- .../assets/js/dynamic-announcement.js | 114 ++++++++++++++++++ UIMod/onboard_bundled/ui/index.html | 96 ++++++--------- 2 files changed, 150 insertions(+), 60 deletions(-) create mode 100644 UIMod/onboard_bundled/assets/js/dynamic-announcement.js diff --git a/UIMod/onboard_bundled/assets/js/dynamic-announcement.js b/UIMod/onboard_bundled/assets/js/dynamic-announcement.js new file mode 100644 index 00000000..73b75665 --- /dev/null +++ b/UIMod/onboard_bundled/assets/js/dynamic-announcement.js @@ -0,0 +1,114 @@ +// dynamic-announcement.js +(function () { + // Configuration - change only these values if needed + const ANNOUNCEMENT_ID = 'dynamic-announcement'; + const JSON_URL = 'https://steamserverui.github.io/StationeersServerUI/dynamic-announcement.json'; + const FETCH_TIMEOUT = 8000; // ms + + // Find the announcement container + const container = document.getElementById(ANNOUNCEMENT_ID); + if (!container) { + console.warn(`[Dynamic Announcement] Element #${ANNOUNCEMENT_ID} not found on page.`); + return; + } + + // Hide it initially (in case CSS shows it by default) + container.style.display = 'none'; + + // Helper: simple timeout for fetch + const fetchWithTimeout = (url, options = {}, timeout = FETCH_TIMEOUT) => { + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Fetch timeout')), timeout) + ) + ]); + }; + + // Main logic + fetchWithTimeout(JSON_URL, { method: 'GET', cache: 'no-cache' }) + .then(response => { + if (!response.ok) { + if (response.status === 404) { + console.info('[Dynamic Announcement] No announcement (404) - staying hidden.'); + return null; + } + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (!data) return; // 404 or empty + + // Validate required fields + if (!data.headline || !data.bodyHtml) { + console.warn('[Dynamic Announcement] JSON is missing required fields.'); + return; + } + + // Optional date range check + const now = Date.now(); + const start = data.validFrom ? new Date(data.validFrom).getTime() : null; + const end = data.validUntil ? new Date(data.validUntil).getTime() : null; + + if ((start !== null && now < start) || (end !== null && now > end)) { + console.info('[Dynamic Announcement] Current date is outside the valid range.'); + return; + } + + // Fill the template + const headerElement = container.querySelector('h3'); + if (headerElement) { + headerElement.innerHTML = ` + 📢 + ${escapeHtml(data.headline)} + `; + } + + const contentDiv = container.querySelector('.collapsible-content'); + + // Short description (optional) + let shortHtml = ''; + if (data.shortDescription) { + shortHtml = `

${escapeHtml(data.shortDescription)}

`; + } + + // Warning (optional) + let warningHtml = ''; + if (data.warningHtml) { + warningHtml = `

${data.warningHtml}

`; + } + + // Signature (optional) + let signatureHtml = ''; + if (data.author || data.authorRole) { + const author = data.author ? escapeHtml(data.author) : ''; + const role = data.authorRole ? escapeHtml(data.authorRole) : ''; + signatureHtml = `

${author}${author && role ? ' - ' : ''}${role}

`; + } + + contentDiv.innerHTML = ` + ${shortHtml} +

${data.bodyHtml}

+ ${warningHtml} +

+ ${signatureHtml} + `; + + // Show the announcement + container.style.display = ''; // revert to CSS default (usually block) + console.info('[Dynamic Announcement] Announcement loaded and displayed.'); + }) + .catch(err => { + // On any error (network, timeout, JSON parse, etc.) just keep it hidden + console.info('[Dynamic Announcement] Failed to load announcement:', err.message); + }); + + // Simple HTML escape utility (prevents XSS if you trust the JSON source) + function escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +})(); \ No newline at end of file diff --git a/UIMod/onboard_bundled/ui/index.html b/UIMod/onboard_bundled/ui/index.html index f03aa860..cd1bc714 100644 --- a/UIMod/onboard_bundled/ui/index.html +++ b/UIMod/onboard_bundled/ui/index.html @@ -63,6 +63,24 @@

Stationeers Server UI v{{.Version}}{{.SSUIIdentifier}}

- SSUI Discord @@ -203,6 +203,7 @@

+ +

{{.UIText_Connected_PlayersHeader}}

@@ -70,67 +88,24 @@

{{.UIText_Connected_PlayersHeader}}

-

{{.UIText_Backup_Manager}}

-
-

- ⚠️ - Important Backup Manager Update Info (Click to Expand) - ⚠️ -

-
- 📢 - The Backup manager was optimized & reworked in the latest update (v5.9) to be faster and more reliable with huge numbers of backups. -

- Please report any anomalies or bugs you encounter either on the GitHub Issues page or on the SSUI Discord Server. -

-

- With this update, the Backup manager no longer supports the old (pre-terrain update) terrain and save system. Please switch to the new Terrain system if you wish to continue to use new SSUI features. Alternatively, you can continue to use the old system by using SSUI 5.8 and below, disabling auto-updates via the config.json file. -

-

MensRea - Developer of this Update

-

JacksonTheMaster - Project Lead

+

{{.UIText_Backup_Manager}}

+

+
+ +
-
-

-
- - -
-
    -
    - -