This server provides a complete REST API alongside the HTML interface. All UI actions map to API endpoints, allowing full programmatic control.
diff --git a/UIMod/onboard_bundled/assets/css/home.css b/UIMod/onboard_bundled/assets/css/home.css
index 76b86a83..47062e6d 100644
--- a/UIMod/onboard_bundled/assets/css/home.css
+++ b/UIMod/onboard_bundled/assets/css/home.css
@@ -92,9 +92,9 @@
}
}
-/* Console styling with custom scrollbar */
#console,
-#detection-console {
+#detection-console,
+#backendlog-console {
border: 2px solid var(--primary);
padding: 20px;
height: 400px;
@@ -113,31 +113,36 @@
}
#console::-webkit-scrollbar,
-#detection-console::-webkit-scrollbar {
+#detection-console::-webkit-scrollbar,
+#backendlog-console::-webkit-scrollbar {
width: 8px;
}
#console::-webkit-scrollbar-track,
-#detection-console::-webkit-scrollbar-track {
+#detection-console::-webkit-scrollbar-track,
+#backendlog-console::-webkit-scrollbar-track {
background: #000;
border-radius: 4px;
}
#console::-webkit-scrollbar-thumb,
-#detection-console::-webkit-scrollbar-thumb {
+#detection-console::-webkit-scrollbar-thumb,
+#backendlog-console::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
transition: background var(--transition-fast);
}
#console::-webkit-scrollbar-thumb:hover,
-#detection-console::-webkit-scrollbar-thumb:hover {
+#detection-console::-webkit-scrollbar-thumb:hover,
+#backendlog-console::-webkit-scrollbar-thumb:hover {
background: var(--primary-dim);
}
/* Console scanline effect */
#console::before,
-#detection-console::before {
+#detection-console::before,
+#backendlog-console::before {
content: '';
position: absolute;
inset: 0;
@@ -157,6 +162,23 @@
word-wrap: break-word;
}
+.log-console-element {
+ padding: 10px;
+ margin: 5px 0;
+ border-radius: 4px;
+ border-left: 3px solid var(--primary);
+ background-color: rgba(0, 255, 171, 0.05);
+ transition: all var(--transition-normal);
+ word-wrap: break-word;
+}
+
+.log-console-element-warn {
+ border-left-color: var(--warning)
+}
+.log-console-element-error {
+ border-left-color: var(--danger);
+}
+
.detection-event:hover {
background-color: rgba(0, 255, 171, 0.1);
transform: translateX(3px);
@@ -217,7 +239,22 @@
transform: translateY(-2px);
}
-#backupRefreshButton {
+/* Players */
+#players {
+ position: relative;
+ margin-top: 40px;
+ background-color: rgba(0, 255, 171, 0.05);
+ padding: 20px;
+ border-radius: 8px;
+ border: 1px solid rgba(0, 255, 171, 0.3);
+ transition: transform var(--transition-normal);
+}
+
+#players:hover {
+ transform: translateY(-2px);
+}
+
+#playerListRefreshButton {
position: absolute;
top: 20px;
right: 20px;
@@ -230,7 +267,7 @@
justify-content: center;
}
-.backup-item {
+.player-item {
background-color: rgba(0, 0, 0, 0.4);
padding: 15px;
margin-bottom: 15px;
@@ -243,40 +280,181 @@
line-height: 1.6;
}
-.backup-item.animate-in {
+.player-item:hover, .player-item.animate-in:hover {
+ background-color: rgba(0, 0, 0, 0.6);
+ border-color: var(--primary);
+ transform: translateX(5px);
+}
+
+.player-item.animate-in {
animation: slideIn 0.5s ease-out forwards;
}
+.player-content {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.player-avatar {
+ width: 45px;
+ height: 45px;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: transform 0.2s;
+ object-fit: cover;
+ object-position: center;
+}
+
+.player-avatar:hover {
+ transform: scale(1.1);
+}
+
+.player-name {
+ font-size: 1.1rem;
+ color: #fff;
+}
+
+/* Backups */
+
+
+#backupRefreshButton {
+ padding: 5px 10px;
+ font-size: 1.3rem;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.backup-controls {
+ position: absolute;
+ top: 30px;
+ right: 20px;
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+
+@media (max-width: 767px) {
+ .backup-controls {
+ position: unset;
+ }
+}
+
+#backupLimit {
+ padding: 8px 12px;
+ background-color: rgba(0, 0, 0, 0.6);
+ color: var(--text-bright);
+ border: 1px solid rgba(0, 255, 171, 0.5);
+ border-radius: 4px;
+ font-family: 'Press Start 2P', cursive;
+ font-size: 0.7rem;
+}
+
+.backup-item {
+ background-color: rgba(0, 0, 0, 0.4);
+ padding: 20px;
+ margin-bottom: 15px;
+ border-radius: 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border: 2px solid rgba(0, 255, 171, 0.3);
+ transition: all var(--transition-normal);
+ position: relative;
+ overflow: hidden;
+}
+
+.backup-item.animate-in {
+ animation: slideIn 0.6s ease-out forwards;
+}
+
.backup-item:hover, .backup-item.animate-in:hover {
background-color: rgba(0, 0, 0, 0.6);
border-color: var(--primary);
transform: translateX(5px);
}
-.backup-item button {
- padding: 8px 16px;
- background-color: rgba(0, 255, 171, 0.2);
- color: var(--text-bright);
- border: 1px solid var(--primary);
- border-radius: 4px;
- cursor: pointer;
- font-family: 'Press Start 2P', cursive;
- font-size: 0.8rem;
- transition: all var(--transition-normal);
+.backup-info {
+ flex: 1;
+}
+
+.backup-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.backup-name {
+ font-weight: bold;
+ color: var(--text-bright);
+ font-size: 0.9rem;
+}
+
+.backup-type {
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 0.6rem;
+ text-transform: uppercase;
+ font-weight: bold;
+}
+
+.backup-type.preterrain-trio {
+ background-color: rgba(255, 165, 0, 0.2);
+ color: #ffa500;
+ border: 1px solid rgba(255, 165, 0, 0.4);
}
-.backup-item button:hover {
- background-color: var(--primary);
- color: #000;
+.backup-type.dotsave {
+ background-color: rgba(0, 255, 171, 0.2);
+ color: var(--primary);
+ border: 1px solid rgba(0, 255, 171, 0.4);
+}
+
+.backup-date {
+ color: var(--text-dim);
+ font-size: 0.7rem;
+ opacity: 0.8;
+}
+
+.restore-btn {
+ padding: 10px 18px;
+ background-color: rgba(0, 255, 171, 0.1);
+ color: var(--text-bright);
+ border: 2px solid var(--primary);
+ border-radius: 8px;
+ cursor: pointer;
+ font-family: 'Press Start 2P', cursive;
+ font-size: 0.7rem;
+ transition: all var(--transition-normal);
+ text-transform: uppercase;
+}
+
+.restore-btn:hover {
+ background-color: var(--primary);
+ color: #000;
+}
+
+.no-backups, .backuperror {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-dim);
+ font-style: italic;
+ background-color: rgba(0, 0, 0, 0.2);
+ border-radius: 8px;
+ border: 1px dashed rgba(0, 255, 171, 0.3);
}
@keyframes slideIn {
- 0% {
- opacity: 0;
- transform: scale(0.95) translateX(-20px);
- }
- 100% {
- opacity: 1;
- transform: scale(1) translateX(0);
- }
+ 0% {
+ opacity: 0;
+ transform: scale(0.9) translateX(-30px) rotateX(10deg);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1) translateX(0) rotateX(0deg);
+ }
}
\ No newline at end of file
diff --git a/UIMod/onboard_bundled/assets/css/mobile.css b/UIMod/onboard_bundled/assets/css/mobile.css
index 0383e741..2c73c016 100644
--- a/UIMod/onboard_bundled/assets/css/mobile.css
+++ b/UIMod/onboard_bundled/assets/css/mobile.css
@@ -26,7 +26,7 @@
padding: 10px 20px;
}
- #console, #detection-console {
+ #console, #detection-console, #backendlog-console {
height: 200px;
}
diff --git a/UIMod/onboard_bundled/assets/js/console-manager.js b/UIMod/onboard_bundled/assets/js/console-manager.js
index 70f9c04a..26c6c3b4 100644
--- a/UIMod/onboard_bundled/assets/js/console-manager.js
+++ b/UIMod/onboard_bundled/assets/js/console-manager.js
@@ -95,7 +95,12 @@ function handleConsole() {
"Stabilizing frame rate... lol, just kidding, welcome to 12 FPS city.",
"Checking for updates... new bug introduced, feature still broken!",
"Assembling solar tracker... now it's tracking the admin instead.",
- "Balancing gas mixtures... kaboom imminent, run you fool!"
+ "Balancing gas mixtures... kaboom imminent, run you fool!",
+ "Spoiler: object reference not set to an instance of an object, lol",
+ "Fun fact: SSUI was originally a simple powershell script",
+ "Convincing server that 'out of memory' is just a state of mind.",
+ "Moo, Moo! I'm a cow!",
+ "Welcome home, Sir!"
];
const addMessage = (text, color, style = 'normal') => {
@@ -194,7 +199,7 @@ function handleConsole() {
consoleElement.innerHTML = ''; // Clear console for fresh start
handleConsole();
}
- }, 2000);
+ }, 5000);
}
};
});
@@ -220,4 +225,64 @@ function handleConsole() {
consoleElement.scrollTop = consoleElement.scrollHeight;
}, 500);
}
+}
+
+function setupLogStreams({ consoleId, streamUrls, maxMessages, messageClass }) {
+ const consoleElement = document.getElementById(consoleId);
+ if (!consoleElement) {
+ console.error(`Console element with ID '${consoleId}' not found.`);
+ return;
+ }
+
+ // Clear the console initially
+ consoleElement.innerHTML = '';
+
+ const connectStream = (streamUrl) => {
+ const eventSource = new EventSource(streamUrl);
+
+ eventSource.onmessage = event => {
+ const message = document.createElement('div');
+ let finalClass = messageClass;
+
+ // Check event.data for specific log levels and modify class
+ if (event.data.includes('/INFO')) {
+ finalClass += '-info';
+ } else if (event.data.includes('/WARN')) {
+ finalClass += '-warn';
+ } else if (event.data.includes('/ERROR')) {
+ finalClass += '-error';
+ }
+
+ message.classList.add("log-console-element", finalClass);
+
+ const content = document.createElement('span');
+ content.textContent = event.data;
+
+ message.append(content);
+ consoleElement.appendChild(message);
+
+ // Limit the number of messages
+ while (consoleElement.childElementCount > maxMessages) {
+ consoleElement.firstChild.remove();
+ }
+
+ // Auto-scroll to the bottom
+ consoleElement.scrollTop = consoleElement.scrollHeight;
+ };
+
+ eventSource.onopen = () => {
+ console.log(`Stream ${streamUrl} connected for console ${consoleId}`);
+ };
+
+ eventSource.onerror = () => {
+ console.error(`Stream ${streamUrl} disconnected for console ${consoleId}`);
+ eventSource.close();
+ if (window.location.pathname === '/') {
+ setTimeout(() => connectStream(streamUrl), 5000); // Reconnect after 5 seconds
+ }
+ };
+ };
+
+ // Connect to all provided stream URLs
+ streamUrls.forEach(url => connectStream(url));
}
\ No newline at end of file
diff --git a/UIMod/onboard_bundled/assets/js/main.js b/UIMod/onboard_bundled/assets/js/main.js
index bba910b8..2726a9dc 100644
--- a/UIMod/onboard_bundled/assets/js/main.js
+++ b/UIMod/onboard_bundled/assets/js/main.js
@@ -11,9 +11,20 @@ document.addEventListener('DOMContentLoaded', () => {
if (window.location.pathname == '/') {
setupTabs();
fetchDetectionEvents();
+ setupLogStreams({
+ consoleId: 'backendlog-console',
+ streamUrls: [
+ '/logs/info',
+ '/logs/warn',
+ '/logs/error',
+ ],
+ maxMessages: 500,
+ messageClass: 'log-console-element'
+ });
fetchBackups();
+ fetchPlayers();
handleConsole();
- pollServerStatus();
+ pollRecurringTasks();
if (animationState != 'disabled') {
// Create planets with size, orbit radius, speed, and color
const planetContainer = document.getElementById('planet-container');
diff --git a/UIMod/onboard_bundled/assets/js/server-api.js b/UIMod/onboard_bundled/assets/js/server-api.js
index f7300812..b443b406 100644
--- a/UIMod/onboard_bundled/assets/js/server-api.js
+++ b/UIMod/onboard_bundled/assets/js/server-api.js
@@ -41,31 +41,158 @@ function triggerSteamCMD() {
}
function fetchBackups() {
- fetch('/api/v2/backups?mode=classic')
- .then(response => response.text())
- .then(data => {
+ const limit = document.getElementById('backupLimit').value;
+ const url = limit ? `/api/v2/backups?limit=${limit}` : '/api/v2/backups';
+
+ return fetch(url)
+ .then(response => {
+ const contentType = response.headers.get('Content-Type');
+ if (contentType && contentType.includes('application/json')) {
+ return response.json().then(data => ({ status: response.ok, data }));
+ } else {
+ return response.text().then(text => ({ status: response.ok, text }));
+ }
+ })
+ .then(result => {
const backupList = document.getElementById('backupList');
backupList.innerHTML = '';
- if (data.trim() === "No valid backup files found.") {
- backupList.textContent = data;
- } else {
- let animationCount = 0; // Track number of animated items
- data.split('\n').filter(Boolean).forEach((backup) => {
- const li = document.createElement('li');
- li.className = 'backup-item';
- li.innerHTML = `${backup}
Restore `;
- backupList.appendChild(li);
- if (animationCount < 20) {
- setTimeout(() => {
- li.classList.add('animate-in');
- }, animationCount * 100);
- animationCount++;
- }
- });
+ if (!result.status || result.text) {
+ backupList.innerHTML = `
${result.text || 'Failed to load backups'} `;
+ return;
+ }
+
+ const data = result.data;
+ if (!data || data.length === 0) {
+ backupList.innerHTML = '
No valid backup files found. ';
+ return;
}
+
+ let animationCount = 0;
+ data.forEach((backup) => {
+ 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();
+
+ li.innerHTML = `
+
+
Restore
+ `;
+
+ backupList.appendChild(li);
+
+ if (animationCount < 20) {
+ setTimeout(() => {
+ li.classList.add('animate-in');
+ }, animationCount * 50);
+ animationCount++;
+ }
+ });
})
- .catch(err => console.error("Failed to fetch backups:", err));
+ .catch(err => {
+ console.error("Failed to fetch backups:", err);
+ document.getElementById('backupList').innerHTML = '
Failed to load backups ';
+ });
+}
+
+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';
+}
+
+function fetchPlayers() {
+ const playersDiv = document.getElementById('players');
+ const playerList = document.getElementById('playerList');
+
+ const playerImages = [
+ "/static/playerimages/anna.webp",
+ "/static/playerimages/dan.webp",
+ "/static/playerimages/darragh.webp",
+ "/static/playerimages/david.webp",
+ "/static/playerimages/dean.webp",
+ "/static/playerimages/garrison.webp",
+ "/static/playerimages/ivette.webp",
+ "/static/playerimages/john.webp",
+ "/static/playerimages/julia.webp",
+ "/static/playerimages/ove.webp",
+ "/static/playerimages/pierre.webp",
+ "/static/playerimages/rolf.webp",
+ "/static/playerimages/ronald.webp",
+ ];
+
+ return fetch('/api/v2/server/status/connectedplayers')
+ .then(response => response.json())
+ .then(data => {
+ playerList.innerHTML = '';
+
+ if (!Array.isArray(data) || data.length === 0) {
+ playersDiv.style.display = 'none';
+ return;
+ }
+
+ playersDiv.style.display = 'block';
+ let animationCount = 0;
+ data.forEach(playerObj => {
+ const player = Object.values(playerObj)[0];
+ const li = document.createElement('li');
+ li.className = 'player-item';
+
+ // Create player item content
+ const playerContent = document.createElement('div');
+ playerContent.className = 'player-content';
+
+ // Avatar
+ const avatar = document.createElement('img');
+ let persistedImage = sessionStorage.getItem(`playerImage_${player.steamID}`);
+ if (!persistedImage) {
+ // Assign rnd image and persist it until page reload
+ persistedImage = playerImages[Math.floor(Math.random() * playerImages.length)];
+ sessionStorage.setItem(`playerImage_${player.steamID}`, persistedImage);
+ }
+ avatar.src = persistedImage;
+ avatar.alt = `${player.username}'s avatar`;
+ avatar.className = 'player-avatar';
+ avatar.title = player.steamID;
+ avatar.addEventListener('click', () => {
+ window.open(`https://steamcommunity.com/profiles/${player.steamID}`, '_blank');
+ });
+
+ const name = document.createElement('span');
+ name.textContent = player.username;
+ name.className = 'player-name';
+
+ playerContent.appendChild(avatar);
+ playerContent.appendChild(name);
+ li.appendChild(playerContent);
+ playerList.appendChild(li);
+
+ // Animation
+ if (animationCount < 20) {
+ setTimeout(() => {
+ li.classList.add('animate-in');
+ }, animationCount * 100);
+ animationCount++;
+ }
+ });
+ })
+ .catch(err => {
+ console.error("Failed to fetch players:", err);
+ playersDiv.style.display = 'none';
+ playerList.textContent = 'Error loading players.';
+ });
}
function extractIndex(backupText) {
@@ -81,12 +208,15 @@ function restoreBackup(index) {
typeTextWithCallback(status, data, 20, () => {
setTimeout(() => status.hidden = true, 30000);
});
+ showPopup('info', data);
})
.catch(err => console.error(`Failed to restore backup ${index}:`, err));
}
-function pollServerStatus() {
+function pollRecurringTasks() {
window.gamserverstate = false;
+
+ // Poll server status every 3.5 seconds
const statusInterval = setInterval(() => {
fetch('/api/v2/server/status')
.then(response => response.json())
@@ -100,10 +230,23 @@ function pollServerStatus() {
console.error("Failed to fetch server status:", err);
updateStatusIndicator(false, true); // Set error state
});
- }, 3500); // Poll every 3.5 seconds (adjusted from 1000 to reduce server load checking the status each time)
+ }, 3500);
+
+ // Poll connectred players every 10 seconds
+ const playersInterval = setInterval(() => {
+ fetchPlayers()
+ .catch(err => {
+ console.error("Failed to fetch connectedplayers:", err);
+ });
+ }, 10000);
- // Store the interval ID so we can clear it if needed
- window.statusPollingInterval = statusInterval;
+ // Poll backups every 30 seconds
+ const backupsInterval = setInterval(() => {
+ fetchBackups()
+ .catch(err => {
+ console.error("Failed to fetch backups:", err);
+ });
+ }, 30000);
}
function updateStatusIndicator(isRunning, isError = false) {
diff --git a/UIMod/onboard_bundled/assets/playerimages/anna.webp b/UIMod/onboard_bundled/assets/playerimages/anna.webp
new file mode 100644
index 00000000..695405b5
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/anna.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/dan.webp b/UIMod/onboard_bundled/assets/playerimages/dan.webp
new file mode 100644
index 00000000..1c7ed3c3
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/dan.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/darragh.webp b/UIMod/onboard_bundled/assets/playerimages/darragh.webp
new file mode 100644
index 00000000..2693871a
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/darragh.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/david.webp b/UIMod/onboard_bundled/assets/playerimages/david.webp
new file mode 100644
index 00000000..9c83d0c7
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/david.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/dean.webp b/UIMod/onboard_bundled/assets/playerimages/dean.webp
new file mode 100644
index 00000000..46947f95
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/dean.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/garrison.webp b/UIMod/onboard_bundled/assets/playerimages/garrison.webp
new file mode 100644
index 00000000..1ea69009
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/garrison.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/ivette.webp b/UIMod/onboard_bundled/assets/playerimages/ivette.webp
new file mode 100644
index 00000000..e642848c
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/ivette.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/john.webp b/UIMod/onboard_bundled/assets/playerimages/john.webp
new file mode 100644
index 00000000..e1e4262a
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/john.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/julia.webp b/UIMod/onboard_bundled/assets/playerimages/julia.webp
new file mode 100644
index 00000000..708e9eb9
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/julia.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/ove.webp b/UIMod/onboard_bundled/assets/playerimages/ove.webp
new file mode 100644
index 00000000..f82fc4d1
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/ove.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/pierre.webp b/UIMod/onboard_bundled/assets/playerimages/pierre.webp
new file mode 100644
index 00000000..c87d7b2e
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/pierre.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/rolf.webp b/UIMod/onboard_bundled/assets/playerimages/rolf.webp
new file mode 100644
index 00000000..3668e710
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/rolf.webp differ
diff --git a/UIMod/onboard_bundled/assets/playerimages/ronald.webp b/UIMod/onboard_bundled/assets/playerimages/ronald.webp
new file mode 100644
index 00000000..13121c2a
Binary files /dev/null and b/UIMod/onboard_bundled/assets/playerimages/ronald.webp differ
diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json
index ac8e12ee..5cc0140d 100644
--- a/UIMod/onboard_bundled/localization/en-US.json
+++ b/UIMod/onboard_bundled/localization/en-US.json
@@ -5,9 +5,11 @@
"UIText_StopButton": "Stop Server",
"UIText_Settings": "Edit Config",
"UIText_Update_SteamCMD": "Update Server",
- "UIText_Console": "Console",
- "UIText_Detection_Events": "Detection Events",
+ "UIText_Console": "Game Log",
+ "UIText_Detection_Events": "Events",
+ "UIText_Backend_Log": "Backend Log",
"UIText_Backup_Manager": "Backup Manager",
+ "UIText_Connected_PlayersHeader": "Connected Players",
"UIText_Discord_Info": "Join the Discord and help make SSUI better or get support!",
"UIText_API_Info": "API Endpoint Reference",
"UIText_Copyright": "Copyright",
@@ -30,7 +32,7 @@
"UIText_ServerName": "Server Name",
"UIText_ServerNameInfo": "Name displayed in server list",
"UIText_SaveFileName": "Save File Name",
- "UIText_SaveFileNameInfo": "Name of save folder. Must be capitalized. To create a new world, provide the World type to generate. (MyVulcanMap Vulcan) WorldTypes can be found in the Stationeers Wiki -> Dedicated Server page.",
+ "UIText_SaveFileNameInfo": "Name of save folder. Must be capitalized. To create a new world, provide the World type to generate. (MyVulcanMap Vulcan) Possible World types: Moon, Mars, Europa, Mimas, Vulcan, Space, Venus -- BETA BRANCH: Mars2, Europa3, MimasHerschel, Vulcan, Venus, Lunar",
"UIText_MaxPlayers": "Max Players",
"UIText_MaxPlayersInfo": "Maximum number of players allowed",
"UIText_ServerPassword": "Server Password",
@@ -40,7 +42,7 @@
"UIText_AutoSave": "Auto Save",
"UIText_AutoSaveInfo": "Set to TRUE to enable automatic saving",
"UIText_SaveInterval": "Save Interval",
- "UIText_SaveIntervalInfo": "Time in seconds between saves",
+ "UIText_SaveIntervalInfo": "Time in seconds between saves. Recommended to
not recede 60 seconds.",
"UIText_AutoPauseServer": "Auto Pause Server",
"UIText_AutoPauseServerInfo": "Automatically pause server when no players are connected"
},
@@ -71,7 +73,7 @@
"UIText_AdditionalParams": "Additional Parameters",
"UIText_AdditionalParamsInfo": "Format: CustomParam1 Value1 CustomParam2 Value2",
"UIText_AutoRestartServerTimer": "Scheduled Gameserver Restart",
- "UIText_AutoRestartServerTimerInfo": "Timeframe in
minutes to schedule an automatic gameserver restart. 0 = disabled, 1440 = 24 hours, etc.. If SSCM is enabled, you will see \"Attention, server is restarting in 30/20/10/5 seconds!\" messages
ingame before restarting.",
+ "UIText_AutoRestartServerTimerInfo": "
Timeframe in minutes or time format (e.g., 15:04 or 03:04PM) to schedule an automatic gameserver restart. 0 = disabled, 1440 = 24 hours, etc. You will see 'Attention, server is restarting in 30/20/10/5 seconds!' messages ingame before the restart.
",
"UIText_GameBranch": "Game Branch",
"UIText_GameBranchInfo": "Branch of the game to use. When changed, requires to restart SSUI!"
},
@@ -139,14 +141,14 @@
"UIText_ServerName_SkipButton": "Skip",
"UIText_SaveIdentifier_Title": "Stationeers Server UI",
"UIText_SaveIdentifier_HeaderTitle": "Save Identifier Setup",
- "UIText_SaveIdentifier_StepMessage": "Set a save identifier like 'SpaceStation13 Vulcan'. Capitalize the first letter of each word. Possible World types can be found in the Stationeers Wiki -> Dedicated Server",
+ "UIText_SaveIdentifier_StepMessage": "Name of save folder, like 'MySave Lunar'. Capitalize the first letter of each word. To create a new world, provide the World type to generate. (MyLunarMap Lunar) Possible World types: Moon, Mars, Europa, Mimas, Vulcan, Space, Venus -- BETA BRANCH: Mars2, Europa3, MimasHerschel, Vulcan, Venus, Lunar",
"UIText_SaveIdentifier_PrimaryPlaceholder": "Requires a SaveName and WorldType for first start!",
"UIText_SaveIdentifier_PrimaryLabel": "Save Identifier",
"UIText_SaveIdentifier_SubmitButton": "Save & Continue",
"UIText_SaveIdentifier_SkipButton": "Skip",
"UIText_MaxPlayers_Title": "Stationeers Server UI",
"UIText_MaxPlayers_HeaderTitle": "Player Limit Setup",
- "UIText_MaxPlayers_StepMessage": "Choose the maximum number of players that can connect to the server.",
+ "UIText_MaxPlayers_StepMessage": "Choose the maximum number of players that can connect to the server. Recommended to not exceed 20",
"UIText_MaxPlayers_PrimaryPlaceholder": "8",
"UIText_MaxPlayers_PrimaryLabel": "Max Players",
"UIText_MaxPlayers_SubmitButton": "Save & Continue",
@@ -160,14 +162,14 @@
"UIText_ServerPassword_SkipButton": "Skip",
"UIText_GameBranch_Title": "Stationeers Server UI",
"UIText_GameBranch_HeaderTitle": "Game Branch Setup",
- "UIText_GameBranch_StepMessage": "Enter a beta branch or skip this to use the release version. If switching branches, make sure to r e s t a r t SSUI after completing this wizzard.",
+ "UIText_GameBranch_StepMessage": "Enter a beta branch or skip this to use the release version. If switching branches, make sure to click Update Server on the Main dashboard after completing this wizzard.",
"UIText_GameBranch_PrimaryPlaceholder": "beta",
"UIText_GameBranch_PrimaryLabel": "Game Branch",
"UIText_GameBranch_SubmitButton": "Save & Continue",
- "UIText_GameBranch_SkipButton": "Use Release Version",
+ "UIText_GameBranch_SkipButton": "Use Normal Version",
"UIText_NewTerrainAndSaveSystem_Title": "CHOOSE TERRAIN SYSTEM",
"UIText_NewTerrainAndSaveSystem_HeaderTitle": "Very important step!",
- "UIText_NewTerrainAndSaveSystem_StepMessage": "Just switched to Beta? Flip Terrain and Save System to support that! Enter 'yes' to enable or 'no' to disable.",
+ "UIText_NewTerrainAndSaveSystem_StepMessage": "Just switched to Beta? If yes, enable handling of the new terrain and save system here. Enter 'yes' to enable or 'no' to use the old system.",
"UIText_NewTerrainAndSaveSystem_PrimaryPlaceholder": "yes/no",
"UIText_NewTerrainAndSaveSystem_PrimaryLabel": "Enable new System",
"UIText_NewTerrainAndSaveSystem_SubmitButton": "Save & Continue",
@@ -265,7 +267,7 @@
"UIText_LocalIPAddress_SkipButton": "Skip",
"UIText_AdminAccount_Title": "Stationeers Server UI",
"UIText_AdminAccount_HeaderTitle": "Admin Account Setup",
- "UIText_AdminAccount_StepMessage": "Set up your admin account.",
+ "UIText_AdminAccount_StepMessage": "Set up your SSUI admin account.",
"UIText_AdminAccount_PrimaryPlaceholder": "Username",
"UIText_AdminAccount_PrimaryLabel": "Username",
"UIText_AdminAccount_SecondaryLabel": "Password",
@@ -280,7 +282,7 @@
"UIText_SSCM_SubmitButton": "Continue",
"UIText_SSCM_SkipButton": "Keep enabled",
"UIText_Finalize_Title": "Finalize Setup",
- "UIText_Finalize_StepMessage": "Ready to finalize? Your configuration has already been saved while you completed this setup. If you want to change any of the settings, you may click Return to Start and skip whatever you want to keep. Most options can also be changed on the config Tab in the UI.",
+ "UIText_Finalize_StepMessage": "Ready to finalize? Your configuration has already been saved while you completed this setup. If you want to change any of the settings, you may click Return to Start and skip whatever you want to keep. Most options can also be changed on the config Tab in the UI later.",
"UIText_Finalize_SubmitButton": "Return to Start",
"UIText_Finalize_SkipButton": "Skip Authentication",
"UIText_Login_Title": "Stationeers Server UI",
diff --git a/UIMod/onboard_bundled/ui/index.html b/UIMod/onboard_bundled/ui/index.html
index d6b14440..75c0e631 100644
--- a/UIMod/onboard_bundled/ui/index.html
+++ b/UIMod/onboard_bundled/ui/index.html
@@ -4,7 +4,7 @@
-
Game Server Control
+
SSUI v{{.Version}}{{.SSUIIdentifier}}
@@ -37,7 +37,7 @@
- Stationeers Server UI v{{.Version}} ({{.Branch}})
+ Stationeers Server UI v{{.Version}}{{.SSUIIdentifier}}
{{.UIText_StartButton}}
{{.UIText_StopButton}}
@@ -50,6 +50,7 @@
Stationeers Server UI v{{.Version}} ({{.Branch}})
{{.UIText_Console}}
{{.UIText_Detection_Events}}
+ {{.UIText_Backend_Log}}
@@ -57,14 +58,32 @@
Stationeers Server UI v{{.Version}} ({{.Branch}})
+
-
-
{{.UIText_Backup_Manager}}
-
↻
-
+
+
{{.UIText_Connected_PlayersHeader}}
+
↻
+
+
+
{{.UIText_Backup_Manager}}
+
+
+ Last 5
+ Last 10
+ Last 20
+ Last 50
+ All backups
+
+ ↻
+
+
+
+
⚠️
diff --git a/build/version.go b/build/version.go
new file mode 100644
index 00000000..dc240999
--- /dev/null
+++ b/build/version.go
@@ -0,0 +1,57 @@
+//go:build ignore
+// +build ignore
+
+// version.go is a helper script to sync the backends version to package.json so the Electron auto updater can find its latest update on GitHub.
+// Gets called only when the user runs the "Build: Full Project (Prep a release)" task.
+
+// The source of truth for the current version is the backends version defined in config.go
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+)
+
+func main() {
+ err := incrementUIVersion()
+ if err != nil {
+ panic(err)
+ }
+}
+
+func incrementUIVersion() error {
+ backendVersion := config.Version
+
+ // Define the path to package.json
+ packagePath := filepath.Join(".", "frontend", "package.json")
+
+ // Read the package.json file
+ data, err := os.ReadFile(packagePath)
+ if err != nil {
+ return err
+ }
+
+ // Create a regex to match the version field (e.g., "version": "v1.2.3")
+ // The regex captures the entire line, including quotes and commas
+ re, err := regexp.Compile(`"version"\s*:\s*"v\d+\.\d+\.\d+"`)
+ if err != nil {
+ return err
+ }
+
+ // Prepare the replacement string
+ replacement := `"version": "v` + backendVersion + `"`
+
+ // Perform the replacement
+ updatedData := re.ReplaceAllString(string(data), replacement)
+
+ // Write the updated data back to package.json
+ if err := os.WriteFile(packagePath, []byte(updatedData), 0644); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json
new file mode 100644
index 00000000..bdef8201
--- /dev/null
+++ b/frontend/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["svelte.svelte-vscode"]
+}
diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json
new file mode 100644
index 00000000..9562291b
--- /dev/null
+++ b/frontend/.vscode/settings.json
@@ -0,0 +1,11 @@
+{
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "svelte.plugin.typescript.diagnostics.enable": true,
+ "files.watcherExclude": {
+ "**/node_modules": true,
+ "**/.svelte-kit": true
+ },
+ "[svelte]": {
+ "editor.defaultFormatter": "svelte.svelte-vscode"
+ }
+ }
\ No newline at end of file
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 00000000..382941e0
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,47 @@
+# Svelte + Vite
+
+This template should help get you started developing with Svelte in Vite.
+
+## Recommended IDE Setup
+
+[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
+
+## Need an official Svelte framework?
+
+Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
+
+## Technical considerations
+
+**Why use this over SvelteKit?**
+
+- It brings its own routing solution which might not be preferable for some users.
+- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
+
+This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
+
+Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
+
+**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
+
+Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
+
+**Why include `.vscode/extensions.json`?**
+
+Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
+
+**Why enable `checkJs` in the JS template?**
+
+It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
+
+**Why is HMR not preserving my local component state?**
+
+HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
+
+If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
+
+```js
+// store.js
+// An extremely simple external store
+import { writable } from 'svelte/store'
+export default writable(0)
+```
diff --git a/frontend/electron-builder.yml b/frontend/electron-builder.yml
new file mode 100644
index 00000000..5b261e84
--- /dev/null
+++ b/frontend/electron-builder.yml
@@ -0,0 +1,25 @@
+appId: com.jacksonthemaster.ssui
+productName: Steam Server UI
+copyright: Copyright © 2025 JacksonTheMaster
+artifactName: SSUI-Desktop-v${version}-${os}.${ext}
+directories:
+ output: dist_electron
+ buildResources: resources
+files:
+ - "package.json"
+ - "main.cjs"
+extraResources:
+ - from: "../UIMod/onboard_bundled/v2/"
+ to: "UIMod/onboard_bundled/v2/"
+win:
+ target: nsis
+ icon: ../media/logo.png
+linux:
+ target:
+ - deb
+ icon: ../media/logo.png
+publish:
+ provider: github
+ owner: SteamServerUI
+ repo: SteamServerUI
+ releaseType: release
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 00000000..71aa518b
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Steam Server UI
+
+
+
+
+
+
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
new file mode 100644
index 00000000..5696a2de
--- /dev/null
+++ b/frontend/jsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "module": "ESNext",
+ /**
+ * svelte-preprocess cannot figure out whether you have
+ * a value or a type, so tell TypeScript to enforce using
+ * `import type` instead of `import` for Types.
+ */
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ /**
+ * To have warnings / errors of the Svelte compiler at the
+ * correct position, enable source maps by default.
+ */
+ "sourceMap": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ /**
+ * Typecheck JS in `.svelte` and `.js` files by default.
+ * Disable this if you'd like to use dynamic types.
+ */
+ "checkJs": true
+ },
+ /**
+ * Use global.d.ts instead of compilerOptions.types
+ * to avoid limiting type declarations.
+ */
+ "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
+}
diff --git a/frontend/main.cjs b/frontend/main.cjs
new file mode 100644
index 00000000..32bd5238
--- /dev/null
+++ b/frontend/main.cjs
@@ -0,0 +1,458 @@
+const { app, BrowserWindow, Menu, dialog } = require('electron');
+const { autoUpdater } = require('electron-updater');
+const path = require('path');
+const fs = require('fs');
+const express = require('express');
+const https = require('https');
+const forge = require('node-forge');
+const os = require('os');
+
+// Configure auto-updater
+// Only check for updates in production builds
+if (process.env.NODE_ENV !== 'development') {
+ autoUpdater.setFeedURL({
+ provider: 'github',
+ owner: 'SteamServerUI',
+ repo: 'SteamServerUI'
+ });
+
+ // Disable automatic installation on Linux for security/stability
+ if (process.platform === 'linux') {
+ autoUpdater.autoInstallOnAppQuit = false;
+ autoUpdater.autoDownload = true;
+ }
+}
+
+// Auto-updater event handlers
+autoUpdater.on('checking-for-update', () => {
+ console.log('Checking for update...');
+});
+
+autoUpdater.on('update-available', (info) => {
+ console.log('Update available:', info.version);
+ // Optional: Show notification to user
+ dialog.showMessageBox({
+ type: 'info',
+ title: 'Update Available',
+ message: `A new version (${info.version}) is available. It will be downloaded in the background.`,
+ buttons: ['OK']
+ });
+});
+
+autoUpdater.on('update-not-available', (info) => {
+ console.log('Update not available.');
+});
+
+autoUpdater.on('error', (err) => {
+ console.error('Auto-updater error:', err);
+});
+
+autoUpdater.on('download-progress', (progressObj) => {
+ let log_message = "Download speed: " + progressObj.bytesPerSecond;
+ log_message = log_message + ' - Downloaded ' + progressObj.percent + '%';
+ log_message = log_message + ' (' + progressObj.transferred + "/" + progressObj.total + ')';
+ console.log(log_message);
+});
+
+autoUpdater.on('update-downloaded', (info) => {
+ console.log('Update downloaded');
+
+ if (process.platform === 'linux') {
+ // On Linux, show manual installation instructions
+ const updatePath = path.join(require('os').homedir(), '.cache', 'steamserverui-updater', 'pending');
+ dialog.showMessageBox({
+ type: 'info',
+ title: 'Update Downloaded',
+ message: `Update v${info.version} has been downloaded!\n\nFor security reasons, please install manually:\n\n1. Close this application\n2. Open terminal and run:\n sudo dpkg -i "${updatePath}/SSUI-Desktop-v${info.version}-linux.deb"\n\nOr double-click the downloaded .deb file in your file manager.`,
+ buttons: ['Open Download Folder', 'Later', 'Quit App']
+ }).then((result) => {
+ if (result.response === 0) {
+ // Open the download folder
+ require('electron').shell.openPath(updatePath);
+ } else if (result.response === 2) {
+ // Quit the app so user can install manually
+ app.quit();
+ }
+ });
+ } else {
+ // Windows
+ dialog.showMessageBox({
+ type: 'info',
+ title: 'Update Ready',
+ message: 'Update has been downloaded. The application will restart to apply the update.',
+ buttons: ['Restart Now', 'Later']
+ }).then((result) => {
+ if (result.response === 0) {
+ autoUpdater.quitAndInstall();
+ }
+ });
+ }
+});
+
+// Certificate generation function
+function generateSelfSignedCertificate() {
+ console.log('Generating self-signed certificate...');
+
+ // Generate a key pair
+ const keys = forge.pki.rsa.generateKeyPair(2048);
+
+ // Create a certificate
+ const cert = forge.pki.createCertificate();
+ cert.publicKey = keys.publicKey;
+ cert.serialNumber = '01';
+ cert.validity.notBefore = new Date();
+ cert.validity.notAfter = new Date();
+ cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
+
+ const attrs = [
+ { name: 'commonName', value: 'SteamServerUIDesktop' },
+ { name: 'countryName', value: 'SE' },
+ { shortName: 'ST', value: 'CA' },
+ { name: 'localityName', value: 'Local' },
+ { name: 'organizationName', value: 'SteamServerUI' },
+ { shortName: 'OU', value: 'ElectronApp' }
+ ];
+
+ cert.setSubject(attrs);
+ cert.setIssuer(attrs);
+ cert.setExtensions([
+ {
+ name: 'basicConstraints',
+ cA: true
+ },
+ {
+ name: 'keyUsage',
+ keyCertSign: true,
+ digitalSignature: true,
+ nonRepudiation: true,
+ keyEncipherment: true,
+ dataEncipherment: true
+ },
+ {
+ name: 'extKeyUsage',
+ serverAuth: true,
+ clientAuth: true,
+ codeSigning: true,
+ emailProtection: true,
+ timeStamping: true
+ },
+ {
+ name: 'nsCertType',
+ client: true,
+ server: true,
+ email: true,
+ objsign: true,
+ sslCA: true,
+ emailCA: true,
+ objCA: true
+ },
+ {
+ name: 'subjectAltName',
+ altNames: [
+ { type: 2, value: 'localhost' },
+ { type: 2, value: '127.0.0.1' },
+ { type: 7, ip: '127.0.0.1' },
+ { type: 7, ip: '::1' }
+ ]
+ }
+ ]);
+
+ // Sign the certificate
+ cert.sign(keys.privateKey);
+
+ // Convert to PEM format
+ const certPem = forge.pki.certificateToPem(cert);
+ const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
+
+ return {
+ cert: certPem,
+ key: keyPem
+ };
+}
+
+// Get or create certificate
+function getCertificate() {
+ const certDir = path.join(os.homedir(), '.steamserverui');
+ const certPath = path.join(certDir, 'cert.pem');
+ const keyPath = path.join(certDir, 'key.pem');
+
+ // Create directory if it doesn't exist
+ if (!fs.existsSync(certDir)) {
+ fs.mkdirSync(certDir, { recursive: true });
+ }
+
+ // Check if certificate files exist and are valid
+ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
+ try {
+ const cert = fs.readFileSync(certPath, 'utf8');
+ const key = fs.readFileSync(keyPath, 'utf8');
+
+ // Check if certificate is still valid (not expired)
+ const certObj = forge.pki.certificateFromPem(cert);
+ const now = new Date();
+ if (now < certObj.validity.notAfter) {
+ console.log('Using existing certificate');
+ return { cert, key };
+ } else {
+ console.log('Certificate expired, generating new one');
+ }
+ } catch (err) {
+ console.log('Error reading existing certificate, generating new one:', err.message);
+ }
+ }
+
+ // Generate new certificate
+ const certData = generateSelfSignedCertificate();
+
+ // Save certificate files
+ fs.writeFileSync(certPath, certData.cert);
+ fs.writeFileSync(keyPath, certData.key);
+
+ console.log('Certificate saved to:', certDir);
+ return certData;
+}
+
+// Static file server
+let server;
+let currentPortIndex = 0;
+const HTTPS_PORTS = [28443, 28889, 29443, 27443, 26443, 35443, 34443, 30443, 34943];
+
+function startServer() {
+ const expressApp = express();
+
+ // Determine the assets directory path
+ let assetsPath;
+
+ // In production
+ const prodPath = path.join(process.resourcesPath, 'UIMod/onboard_bundled/v2');
+
+ // Check if path exists
+ if (fs.existsSync(prodPath)) {
+ assetsPath = prodPath;
+ } else {
+ dialog.showErrorBox('Error', 'Could not find assets directory: ' + prodPath);
+ app.exit(1);
+ return false;
+ }
+
+ console.log('Serving assets from:', assetsPath);
+
+ // Serve static files from the assets directory
+ expressApp.use(express.static(assetsPath));
+
+ // Get certificate
+ const certData = getCertificate();
+
+ // Create HTTPS server
+ const httpsOptions = {
+ key: certData.key,
+ cert: certData.cert
+ };
+
+ server = https.createServer(httpsOptions, expressApp);
+
+ return new Promise((resolve, reject) => {
+ server.listen(HTTPS_PORTS[currentPortIndex], () => {
+ console.log(`HTTPS Server running at https://localhost:${HTTPS_PORTS[currentPortIndex]}`);
+ resolve(true);
+ });
+
+ server.on('error', (err) => {
+ console.error('Server error:', err);
+ if (err.code === 'EADDRINUSE' && currentPortIndex < HTTPS_PORTS.length - 1) {
+ currentPortIndex++;
+ console.log(`Port ${HTTPS_PORTS[currentPortIndex - 1]} in use, trying ${HTTPS_PORTS[currentPortIndex]}`);
+ server.close();
+ startServer().then(resolve).catch(reject);
+ } else {
+ let errorMsg = err.code === 'EADDRINUSE' ? 'All default HTTPS ports are in use. Please free up a port and try again.' : err.message;
+ dialog.showErrorBox('Server Error', errorMsg);
+ reject(err);
+ }
+ });
+ });
+}
+
+async function createWindow() {
+ // Start the local server first
+ const serverStarted = await startServer();
+ if (!serverStarted) return;
+
+ const win = new BrowserWindow({
+ width: 1920,
+ height: 1080,
+ webPreferences: {
+ nodeIntegration: false,
+ webSecurity: true
+ }
+ });
+
+ // Load from local HTTPS server instead of HTTP
+ console.log(`Loading UI from: https://localhost:${HTTPS_PORTS[currentPortIndex]}/index.html`);
+ win.loadURL(`https://localhost:${HTTPS_PORTS[currentPortIndex]}/index.html`);
+
+ // For debugging
+ // win.webContents.openDevTools();
+}
+
+// Create application menu with update check option
+function createMenu() {
+ const template = [
+ {
+ label: 'Options',
+ submenu: [
+ {
+ label: 'Check for Updates',
+ click: () => {
+ autoUpdater.checkForUpdatesAndNotify();
+ }
+ },
+ {
+ label: 'About',
+ click: () => {
+ dialog.showMessageBox({
+ type: 'info',
+ title: 'About Steam Server UI',
+ message: `SSUI Desktop ${app.getVersion()}\nCopyright © 2025 JacksonTheMaster`,
+ buttons: ['OK']
+ });
+ }
+ },
+ {
+ label: 'Backends',
+ submenu: [
+ {
+ label: 'Reset',
+ click: async () => {
+ try {
+ // Get the main window
+ const mainWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
+
+ if (mainWindow) {
+ // Clear all cookies
+ await mainWindow.webContents.session.clearStorageData({
+ storages: ['cookies', 'localstorage', 'sessionstorage']
+ });
+
+ mainWindow.reload();
+
+ dialog.showMessageBox(mainWindow, {
+ type: 'info',
+ title: 'Reset Complete',
+ message: 'All cookies and local storage have been cleared. The application has been reloaded.',
+ buttons: ['OK']
+ });
+ }
+ } catch (error) {
+ console.error('Error resetting backend data:', error);
+ dialog.showErrorBox('Reset Failed', 'Failed to reset backend data. Please try again. Alternatively, you can manually clear your cookies and local storage from the Developer Console at ctrl+shift+i.');
+ }
+ }
+ }
+ ]
+ },
+ {
+ label: 'Edit',
+ submenu: [
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' }
+ ]
+ },
+ {
+ label: 'View',
+ submenu: [
+ { role: 'reload' },
+ { role: 'forcereload' },
+ { role: 'toggledevtools' },
+ { type: 'separator' },
+ { role: 'resetzoom' },
+ { role: 'zoomin' },
+ { role: 'zoomout' },
+ { type: 'separator' },
+ { role: 'togglefullscreen' }
+ ]
+ },
+ {
+ label: 'Open Github Page',
+ click: () => {
+ require('electron').shell.openExternal('https://github.com/SteamServerUI/SteamServerUI');
+ }
+
+ },
+ {
+ label: 'Join Discord Server',
+ click: () => {
+ require('electron').shell.openExternal('https://discord.com/invite/8n3vN92MyJ');
+ }
+ },
+ {
+ label: 'Report Issue',
+ click: () => {
+ require('electron').shell.openExternal('https://github.com/SteamServerUI/SteamServerUI/issues');
+ }
+ }
+ ]
+ }
+ ];
+
+ const menu = Menu.buildFromTemplate(template);
+ Menu.setApplicationMenu(menu);
+}
+
+app.commandLine.appendSwitch('ignore-certificate-errors');
+app.commandLine.appendSwitch('ignore-certificate-errors-spki-list');
+app.commandLine.appendSwitch('ignore-ssl-errors');
+
+app.whenReady().then(() => {
+ createWindow();
+ createMenu();
+
+ // Check for updates after app is ready (but not in development)
+ if (!process.env.NODE_ENV || process.env.NODE_ENV === 'production') {
+ // Check for updates immediately
+ autoUpdater.checkForUpdatesAndNotify();
+
+ // Check for updates every 30 minutes
+ setInterval(() => {
+ autoUpdater.checkForUpdatesAndNotify();
+ }, 30 * 60 * 1000);
+ }
+});
+
+app.on('window-all-closed', () => {
+ app.quit();
+});
+
+app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+});
+
+app.on('quit', () => {
+ if (server) {
+ server.closeAllConnections();
+ server.close(() => {
+ console.log('Server closed');
+ });
+ // Force quit after 5 seconds if server doesn't close
+ setTimeout(() => {
+ console.log('Forcing app exit after timeout');
+ app.exit(0);
+ }, 5000);
+ }
+});
+
+// Minimal error handling
+process.on('uncaughtException', (err) => {
+ console.error('Uncaught Exception:', err);
+ dialog.showErrorBox('Unexpected Error', 'An unexpected error occurred: ' + err.message);
+ app.exit(1);
+});
+
+process.on('unhandledRejection', (err) => {
+ console.error('Unhandled Rejection:', err);
+ dialog.showErrorBox('Unexpected Error', 'An unexpected error occurred: ' + (err.message || err));
+ app.exit(1);
+});
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 00000000..ce2936d9
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,5277 @@
+{
+ "name": "steamserverui",
+ "version": "v5.5.8",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "steamserverui",
+ "version": "v5.5.8",
+ "license": "proprietary",
+ "dependencies": {
+ "electron-updater": "^6.6.2",
+ "express": "^5.1.0",
+ "https": "^1.0.0",
+ "node-forge": "^1.3.1"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.1.1",
+ "concurrently": "^9.1.2",
+ "electron": "^36.1.0",
+ "electron-builder": "^26.0.12",
+ "svelte": "^5.23.1",
+ "vite": "^6.3.5",
+ "wait-on": "^8.0.3"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@develar/schema-utils": {
+ "version": "2.6.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.0",
+ "ajv-keywords": "^3.4.1"
+ },
+ "engines": {
+ "node": ">= 8.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/@electron/asar": {
+ "version": "3.2.18",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^5.0.0",
+ "glob": "^7.1.6",
+ "minimatch": "^3.0.4"
+ },
+ "bin": {
+ "asar": "bin/asar.js"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
+ "node_modules/@electron/asar/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@electron/asar/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@electron/fuses": {
+ "version": "1.8.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.1",
+ "fs-extra": "^9.0.1",
+ "minimist": "^1.2.5"
+ },
+ "bin": {
+ "electron-fuses": "dist/bin.js"
+ }
+ },
+ "node_modules/@electron/fuses/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/fuses/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@electron/fuses/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^2.2.0",
+ "fs-extra": "^8.1.0",
+ "got": "^11.8.5",
+ "progress": "^2.0.3",
+ "semver": "^6.2.0",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "global-agent": "^3.0.0"
+ }
+ },
+ "node_modules/@electron/node-gyp": {
+ "version": "10.2.0-electron.1",
+ "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
+ "integrity": "sha512-CrYo6TntjpoMO1SHjl5Pa/JoUsECNqNdB7Kx49WLQpWzPw53eEITJ2Hs9fh/ryUYDn4pxZz11StaBYBrLFJdqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "glob": "^8.1.0",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^10.2.1",
+ "nopt": "^6.0.0",
+ "proc-log": "^2.0.1",
+ "semver": "^7.3.5",
+ "tar": "^6.2.1",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": ">=12.13.0"
+ }
+ },
+ "node_modules/@electron/node-gyp/node_modules/glob": {
+ "version": "8.1.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^5.0.1",
+ "once": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@electron/node-gyp/node_modules/minimatch": {
+ "version": "5.1.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/node-gyp/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/notarize": {
+ "version": "2.5.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "fs-extra": "^9.0.1",
+ "promise-retry": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@electron/notarize/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/notarize/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@electron/notarize/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@electron/osx-sign": {
+ "version": "1.3.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "compare-version": "^0.1.2",
+ "debug": "^4.3.4",
+ "fs-extra": "^10.0.0",
+ "isbinaryfile": "^4.0.8",
+ "minimist": "^1.2.6",
+ "plist": "^3.0.5"
+ },
+ "bin": {
+ "electron-osx-flat": "bin/electron-osx-flat.js",
+ "electron-osx-sign": "bin/electron-osx-sign.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@electron/osx-sign/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@electron/osx-sign/node_modules/isbinaryfile": {
+ "version": "4.0.10",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/gjtorikian/"
+ }
+ },
+ "node_modules/@electron/osx-sign/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@electron/osx-sign/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@electron/rebuild": {
+ "version": "3.7.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/node-gyp": "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2",
+ "@malept/cross-spawn-promise": "^2.0.0",
+ "chalk": "^4.0.0",
+ "debug": "^4.1.1",
+ "detect-libc": "^2.0.1",
+ "fs-extra": "^10.0.0",
+ "got": "^11.7.0",
+ "node-abi": "^3.45.0",
+ "node-api-version": "^0.2.0",
+ "ora": "^5.1.0",
+ "read-binary-file-arch": "^1.0.6",
+ "semver": "^7.3.5",
+ "tar": "^6.0.5",
+ "yargs": "^17.0.1"
+ },
+ "bin": {
+ "electron-rebuild": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=12.13.0"
+ }
+ },
+ "node_modules/@electron/rebuild/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@electron/rebuild/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@electron/rebuild/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@electron/rebuild/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@electron/universal": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/asar": "^3.2.7",
+ "@malept/cross-spawn-promise": "^2.0.0",
+ "debug": "^4.3.1",
+ "dir-compare": "^4.2.0",
+ "fs-extra": "^11.1.1",
+ "minimatch": "^9.0.3",
+ "plist": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16.4"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/fs-extra": {
+ "version": "11.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/minimatch": {
+ "version": "9.0.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@electron/universal/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.4",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "9.3.0",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@hapi/topo": {
+ "version": "5.1.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@malept/cross-spawn-promise": {
+ "version": "2.0.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/malept"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
+ }
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/@malept/flatpak-bundler": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "fs-extra": "^9.0.0",
+ "lodash": "^4.17.15",
+ "tmp-promise": "^3.0.2"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/@malept/flatpak-bundler/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promisify": "^1.1.3",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.41.0",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.41.0",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@sideway/address": {
+ "version": "4.1.5",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0"
+ }
+ },
+ "node_modules/@sideway/formula": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@sideway/pinpoint": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.5",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
+ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
+ "debug": "^4.4.1",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.17",
+ "vitefu": "^1.0.6"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "4.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.7"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/fs-extra": {
+ "version": "9.0.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.0.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.15.20",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.10",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/7zip-bin": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-db": {
+ "version": "1.54.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.14.1",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/app-builder-bin": {
+ "version": "5.0.0-alpha.12",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/app-builder-lib": {
+ "version": "26.0.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@develar/schema-utils": "~2.6.5",
+ "@electron/asar": "3.2.18",
+ "@electron/fuses": "^1.8.0",
+ "@electron/notarize": "2.5.0",
+ "@electron/osx-sign": "1.3.1",
+ "@electron/rebuild": "3.7.0",
+ "@electron/universal": "2.0.1",
+ "@malept/flatpak-bundler": "^0.4.0",
+ "@types/fs-extra": "9.0.13",
+ "async-exit-hook": "^2.0.1",
+ "builder-util": "26.0.11",
+ "builder-util-runtime": "9.3.1",
+ "chromium-pickle-js": "^0.2.0",
+ "config-file-ts": "0.2.8-rc1",
+ "debug": "^4.3.4",
+ "dotenv": "^16.4.5",
+ "dotenv-expand": "^11.0.6",
+ "ejs": "^3.1.8",
+ "electron-publish": "26.0.11",
+ "fs-extra": "^10.1.0",
+ "hosted-git-info": "^4.1.0",
+ "is-ci": "^3.0.0",
+ "isbinaryfile": "^5.0.0",
+ "js-yaml": "^4.1.0",
+ "json5": "^2.2.3",
+ "lazy-val": "^1.0.5",
+ "minimatch": "^10.0.0",
+ "plist": "3.1.0",
+ "resedit": "^1.7.0",
+ "semver": "^7.3.8",
+ "tar": "^6.1.12",
+ "temp-file": "^3.4.0",
+ "tiny-async-pool": "1.3.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "dmg-builder": "26.0.12",
+ "electron-builder-squirrel-windows": "26.0.12"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-exit-hook": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.9.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/builder-util": {
+ "version": "26.0.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.1.6",
+ "7zip-bin": "~5.2.0",
+ "app-builder-bin": "5.0.0-alpha.12",
+ "builder-util-runtime": "9.3.1",
+ "chalk": "^4.1.2",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.4",
+ "fs-extra": "^10.1.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.0",
+ "is-ci": "^3.0.0",
+ "js-yaml": "^4.1.0",
+ "sanitize-filename": "^1.6.3",
+ "source-map-support": "^0.5.19",
+ "stat-mode": "^1.0.0",
+ "temp-file": "^3.4.0",
+ "tiny-async-pool": "1.3.0"
+ }
+ },
+ "node_modules/builder-util-runtime": {
+ "version": "9.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "sax": "^1.2.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/builder-util/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/builder-util/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/builder-util/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "16.1.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/fs": "^2.1.0",
+ "@npmcli/move-file": "^2.0.0",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.1.0",
+ "glob": "^8.0.1",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^7.7.1",
+ "minipass": "^3.1.6",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "mkdirp": "^1.0.4",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^9.0.0",
+ "tar": "^6.1.11",
+ "unique-filename": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/cacache/node_modules/glob": {
+ "version": "8.1.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^5.0.1",
+ "once": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/cacache/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cacache/node_modules/minimatch": {
+ "version": "5.1.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/chromium-pickle-js": {
+ "version": "0.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/clone": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "5.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/compare-version": {
+ "version": "0.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concurrently": {
+ "version": "9.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "lodash": "^4.17.21",
+ "rxjs": "^7.8.1",
+ "shell-quote": "^1.8.1",
+ "supports-color": "^8.1.1",
+ "tree-kill": "^1.2.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/config-file-ts": {
+ "version": "0.2.8-rc1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "glob": "^10.3.12",
+ "typescript": "^5.4.3"
+ }
+ },
+ "node_modules/config-file-ts/node_modules/glob": {
+ "version": "10.4.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/config-file-ts/node_modules/minimatch": {
+ "version": "9.0.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/config-file-ts/node_modules/minipass": {
+ "version": "7.1.2",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/defaults": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.0.4",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/dir-compare": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimatch": "^3.0.5",
+ "p-limit": "^3.1.0 "
+ }
+ },
+ "node_modules/dir-compare/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/dir-compare/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/dmg-builder": {
+ "version": "26.0.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "app-builder-lib": "26.0.12",
+ "builder-util": "26.0.11",
+ "builder-util-runtime": "9.3.1",
+ "fs-extra": "^10.1.0",
+ "iconv-lite": "^0.6.2",
+ "js-yaml": "^4.1.0"
+ },
+ "optionalDependencies": {
+ "dmg-license": "^1.0.11"
+ }
+ },
+ "node_modules/dmg-builder/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/dmg-builder/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/dmg-builder/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.5.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "11.0.7",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dotenv": "^16.4.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/electron": {
+ "version": "36.2.1",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/get": "^2.0.0",
+ "@types/node": "^22.7.7",
+ "extract-zip": "^2.0.1"
+ },
+ "bin": {
+ "electron": "cli.js"
+ },
+ "engines": {
+ "node": ">= 12.20.55"
+ }
+ },
+ "node_modules/electron-builder": {
+ "version": "26.0.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "app-builder-lib": "26.0.12",
+ "builder-util": "26.0.11",
+ "builder-util-runtime": "9.3.1",
+ "chalk": "^4.1.2",
+ "dmg-builder": "26.0.12",
+ "fs-extra": "^10.1.0",
+ "is-ci": "^3.0.0",
+ "lazy-val": "^1.0.5",
+ "simple-update-notifier": "2.0.0",
+ "yargs": "^17.6.2"
+ },
+ "bin": {
+ "electron-builder": "cli.js",
+ "install-app-deps": "install-app-deps.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/electron-builder-squirrel-windows": {
+ "version": "26.0.12",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "app-builder-lib": "26.0.12",
+ "builder-util": "26.0.11",
+ "electron-winstaller": "5.4.0"
+ }
+ },
+ "node_modules/electron-builder/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/electron-builder/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/electron-builder/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/electron-publish": {
+ "version": "26.0.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/fs-extra": "^9.0.11",
+ "builder-util": "26.0.11",
+ "builder-util-runtime": "9.3.1",
+ "chalk": "^4.1.2",
+ "form-data": "^4.0.0",
+ "fs-extra": "^10.1.0",
+ "lazy-val": "^1.0.5",
+ "mime": "^2.5.2"
+ }
+ },
+ "node_modules/electron-publish/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/electron-publish/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/electron-publish/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/electron-updater": {
+ "version": "6.6.2",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz",
+ "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==",
+ "license": "MIT",
+ "dependencies": {
+ "builder-util-runtime": "9.3.1",
+ "fs-extra": "^10.1.0",
+ "js-yaml": "^4.1.0",
+ "lazy-val": "^1.0.5",
+ "lodash.escaperegexp": "^4.1.2",
+ "lodash.isequal": "^4.5.0",
+ "semver": "^7.6.3",
+ "tiny-typed-emitter": "^2.1.0"
+ }
+ },
+ "node_modules/electron-updater/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/electron-updater/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/electron-updater/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/electron-updater/node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/electron-winstaller": {
+ "version": "5.4.0",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@electron/asar": "^3.2.1",
+ "debug": "^4.1.1",
+ "fs-extra": "^7.0.1",
+ "lodash": "^4.17.21",
+ "temp": "^0.9.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@electron/windows-sign": "^1.1.2"
+ }
+ },
+ "node_modules/electron-winstaller/node_modules/fs-extra": {
+ "version": "7.0.1",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.4",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.4",
+ "@esbuild/android-arm": "0.25.4",
+ "@esbuild/android-arm64": "0.25.4",
+ "@esbuild/android-x64": "0.25.4",
+ "@esbuild/darwin-arm64": "0.25.4",
+ "@esbuild/darwin-x64": "0.25.4",
+ "@esbuild/freebsd-arm64": "0.25.4",
+ "@esbuild/freebsd-x64": "0.25.4",
+ "@esbuild/linux-arm": "0.25.4",
+ "@esbuild/linux-arm64": "0.25.4",
+ "@esbuild/linux-ia32": "0.25.4",
+ "@esbuild/linux-loong64": "0.25.4",
+ "@esbuild/linux-mips64el": "0.25.4",
+ "@esbuild/linux-ppc64": "0.25.4",
+ "@esbuild/linux-riscv64": "0.25.4",
+ "@esbuild/linux-s390x": "0.25.4",
+ "@esbuild/linux-x64": "0.25.4",
+ "@esbuild/netbsd-arm64": "0.25.4",
+ "@esbuild/netbsd-x64": "0.25.4",
+ "@esbuild/openbsd-arm64": "0.25.4",
+ "@esbuild/openbsd-x64": "0.25.4",
+ "@esbuild/sunos-x64": "0.25.4",
+ "@esbuild/win32-arm64": "0.25.4",
+ "@esbuild/win32-ia32": "0.25.4",
+ "@esbuild/win32-x64": "0.25.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "1.4.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/exponential-backoff": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/express": {
+ "version": "5.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.4.4",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.9",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/foreground-child/node_modules/signal-exit": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "8.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/global-agent/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hosted-git-info": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/https": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
+ "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
+ "license": "ISC"
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "9.0.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "sprintf-js": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-ci": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ci-info": "^3.2.0"
+ },
+ "bin": {
+ "is-ci": "bin.js"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-interactive": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-lambda": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isbinaryfile": {
+ "version": "5.0.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/gjtorikian/"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.3",
+ "chalk": "^4.0.2",
+ "filelist": "^1.0.4",
+ "minimatch": "^3.1.2"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/jake/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/jake/node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/joi": {
+ "version": "17.13.3",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "^9.3.0",
+ "@hapi/topo": "^5.1.0",
+ "@sideway/address": "^4.1.5",
+ "@sideway/formula": "^3.0.1",
+ "@sideway/pinpoint": "^2.0.0"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsbn": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lazy-val": {
+ "version": "1.0.5",
+ "license": "MIT"
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.escaperegexp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
+ "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
+ "node_modules/log-symbols": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.0",
+ "is-unicode-supported": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.17",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0"
+ }
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "10.2.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "agentkeepalive": "^4.2.1",
+ "cacache": "^16.1.0",
+ "http-cache-semantics": "^4.1.0",
+ "http-proxy-agent": "^5.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "is-lambda": "^1.0.1",
+ "lru-cache": "^7.7.1",
+ "minipass": "^3.1.6",
+ "minipass-collect": "^1.0.2",
+ "minipass-fetch": "^2.0.3",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^0.6.3",
+ "promise-retry": "^2.0.1",
+ "socks-proxy-agent": "^7.0.0",
+ "ssri": "^9.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/agent-base": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/http-proxy-agent": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/once": "2",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime": {
+ "version": "2.6.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.0.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "3.3.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-fetch": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.1.6",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^2.1.2"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.13"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.75.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-abi/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-api-version": {
+ "version": "0.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/node-api-version/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-forge": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
+ "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
+ "license": "(BSD-3-Clause OR GPL-2.0)",
+ "engines": {
+ "node": ">= 6.13.0"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^1.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ora": {
+ "version": "5.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.1.0",
+ "chalk": "^4.1.0",
+ "cli-cursor": "^3.1.0",
+ "cli-spinners": "^2.5.0",
+ "is-interactive": "^1.0.0",
+ "is-unicode-supported": "^0.1.0",
+ "log-symbols": "^4.1.0",
+ "strip-ansi": "^6.0.0",
+ "wcwidth": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/path-scurry/node_modules/minipass": {
+ "version": "7.1.2",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/pe-library": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jet2jet"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/plist": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.8",
+ "base64-js": "^1.5.1",
+ "xmlbuilder": "^15.1.1"
+ },
+ "engines": {
+ "node": ">=10.4.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.8",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/proc-log": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.6.3",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/read-binary-file-arch": {
+ "version": "1.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "bin": {
+ "read-binary-file-arch": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resedit": {
+ "version": "1.7.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pe-library": "^0.4.1"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jet2jet"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/restore-cursor": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.41.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.7"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.41.0",
+ "@rollup/rollup-android-arm64": "4.41.0",
+ "@rollup/rollup-darwin-arm64": "4.41.0",
+ "@rollup/rollup-darwin-x64": "4.41.0",
+ "@rollup/rollup-freebsd-arm64": "4.41.0",
+ "@rollup/rollup-freebsd-x64": "4.41.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.41.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.41.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.41.0",
+ "@rollup/rollup-linux-arm64-musl": "4.41.0",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.41.0",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.41.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.41.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.41.0",
+ "@rollup/rollup-linux-x64-gnu": "4.41.0",
+ "@rollup/rollup-linux-x64-musl": "4.41.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.41.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.41.0",
+ "@rollup/rollup-win32-x64-msvc": "4.41.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "license": "MIT"
+ },
+ "node_modules/sanitize-filename": {
+ "version": "1.6.3",
+ "dev": true,
+ "license": "WTFPL OR ISC",
+ "dependencies": {
+ "truncate-utf8-bytes": "^1.0.0"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.4.1",
+ "license": "ISC"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/send": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/send/node_modules/mime-db": {
+ "version": "1.54.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/send/node_modules/mime-types": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-update-notifier/node_modules/semver": {
+ "version": "7.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^9.0.5",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^6.0.2",
+ "debug": "^4.3.3",
+ "socks": "^2.6.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/agent-base": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/ssri": {
+ "version": "9.0.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/stat-mode": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.32.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^1.4.6",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/temp": {
+ "version": "0.9.4",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mkdirp": "^0.5.1",
+ "rimraf": "~2.6.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/temp-file": {
+ "version": "3.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-exit-hook": "^2.0.1",
+ "fs-extra": "^10.0.0"
+ }
+ },
+ "node_modules/temp-file/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/temp-file/node_modules/jsonfile": {
+ "version": "6.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/temp-file/node_modules/universalify": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/temp/node_modules/mkdirp": {
+ "version": "0.5.6",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/temp/node_modules/rimraf": {
+ "version": "2.6.3",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/tiny-async-pool": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^5.5.0"
+ }
+ },
+ "node_modules/tiny-async-pool/node_modules/semver": {
+ "version": "5.7.2",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver"
+ }
+ },
+ "node_modules/tiny-typed-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
+ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tmp": {
+ "version": "0.2.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/tmp-promise": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tmp": "^0.2.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/truncate-utf8-bytes": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "WTFPL",
+ "dependencies": {
+ "utf8-byte-length": "^1.0.1"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-db": {
+ "version": "1.54.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unique-filename": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "unique-slug": "^3.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "0.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/utf8-byte-length": {
+ "version": "1.0.5",
+ "dev": true,
+ "license": "(WTFPL OR MIT)"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.0.6",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wait-on": {
+ "version": "8.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.8.2",
+ "joi": "^17.13.3",
+ "lodash": "^4.17.21",
+ "minimist": "^1.2.8",
+ "rxjs": "^7.8.2"
+ },
+ "bin": {
+ "wait-on": "bin/wait-on"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/wcwidth": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defaults": "^1.0.3"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "license": "ISC"
+ },
+ "node_modules/xmlbuilder": {
+ "version": "15.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 00000000..c181676d
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "steamserverui",
+ "main": "main.cjs",
+ "private": true,
+ "version": "v5.5.8",
+ "description": "Svelte UI Interface for Steam Server UI (SSUI) Backend",
+ "author": {
+ "name": "JacksonTheMaster",
+ "email": "ssui@jmg-it.de"
+ },
+ "license": "proprietary",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/SteamServerUI/SteamServerUI.git"
+ },
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "vite": "vite",
+ "electron": "vite build && electron-builder --linux --win",
+ "build": "vite build",
+ "build:electron:dir": "vite build && electron-builder --linux --win --dir",
+ "build:electron": "vite build && electron-builder --linux --win",
+ "build:electron:win": "vite build && electron-builder --win",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "electron-updater": "^6.6.2",
+ "express": "^5.1.0",
+ "https": "^1.0.0",
+ "node-forge": "^1.3.1"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.1.1",
+ "concurrently": "^9.1.2",
+ "electron": "^36.1.0",
+ "electron-builder": "^26.0.12",
+ "svelte": "^5.23.1",
+ "vite": "^6.3.5",
+ "wait-on": "^8.0.3"
+ }
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 00000000..298bfb25
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,87 @@
+
+
+{#if isScreenSupported || forceShowApp}
+
+
+
+
+
+{:else}
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/src/AuthGuard.svelte b/frontend/src/AuthGuard.svelte
new file mode 100644
index 00000000..9549bf41
--- /dev/null
+++ b/frontend/src/AuthGuard.svelte
@@ -0,0 +1,63 @@
+
+
+{#if isChecking}
+
+{:else if serverStatus === 'error'}
+
+{:else if !$authState.isAuthenticated && checkAuth}
+
+{:else}
+ {@render children?.()}
+{/if}
\ No newline at end of file
diff --git a/frontend/src/BackendInitializer.svelte b/frontend/src/BackendInitializer.svelte
new file mode 100644
index 00000000..1ffa20a3
--- /dev/null
+++ b/frontend/src/BackendInitializer.svelte
@@ -0,0 +1,116 @@
+
+
+{#if isInitialized}
+ {@render children?.()}
+{:else}
+
+{/if}
\ No newline at end of file
diff --git a/frontend/src/Login.svelte b/frontend/src/Login.svelte
new file mode 100644
index 00000000..d0c3afb3
--- /dev/null
+++ b/frontend/src/Login.svelte
@@ -0,0 +1,928 @@
+
+
+
+
+
+
+ {#if errorMessage || activeError}
+
+ {#if errorMessage === 'endpoint not found'}
+ {#if isRunningInElectron()}
+ You are using the Desktop App, so you must define your Backend.
+ {:else}
+ The selected server doesn't have the expected login endpoint.
+ {/if}
+ {:else if activeStatus === 'cert-error'}
+ {activeError}
+
+ Check Backend
+
+ {:else if activeError}
+ {activeError}
+ {:else}
+ {errorMessage}
+ {/if}
+
+ {/if}
+
+ {#if showNewBackendForm}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 00000000..6d8e8fc9
--- /dev/null
+++ b/frontend/src/app.css
@@ -0,0 +1,29 @@
+:root {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+input, button, select, textarea {
+ font-family: inherit;
+ font-size: inherit;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Dashboard/DashboardView.svelte b/frontend/src/components/Dashboard/DashboardView.svelte
new file mode 100644
index 00000000..9345989f
--- /dev/null
+++ b/frontend/src/components/Dashboard/DashboardView.svelte
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Dashboard/cards/ConsoleCard.svelte b/frontend/src/components/Dashboard/cards/ConsoleCard.svelte
new file mode 100644
index 00000000..8d5e3bbb
--- /dev/null
+++ b/frontend/src/components/Dashboard/cards/ConsoleCard.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Dashboard/cards/QuickActionsCard.svelte b/frontend/src/components/Dashboard/cards/QuickActionsCard.svelte
new file mode 100644
index 00000000..73800767
--- /dev/null
+++ b/frontend/src/components/Dashboard/cards/QuickActionsCard.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/Dashboard/cards/WarnCard.svelte b/frontend/src/components/Dashboard/cards/WarnCard.svelte
new file mode 100644
index 00000000..b348e349
--- /dev/null
+++ b/frontend/src/components/Dashboard/cards/WarnCard.svelte
@@ -0,0 +1,73 @@
+
+
+
+ Welcome to the StationeersServerUI V2 interface! This is a new, visually appealing backport from SteamServerUI, built with Svelte for a modern look. However, it’s here just for looks and lacks many features of the original V1 UI available at the default UI . We do NOT plan to add more features to this UI at this time. For full functionality, we recommend using the V1 UI.
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/MainContent.svelte b/frontend/src/components/MainContent.svelte
new file mode 100644
index 00000000..482c074c
--- /dev/null
+++ b/frontend/src/components/MainContent.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+ {#if activeView === 'dashboard'}
+
+
+
+ {:else if activeView === 'settings'}
+
+
+
+ {:else if activeView === 'logs'}
+
+
+
+ {:else if activeView === 'console'}
+
+
+
+ {/if}
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/nav/Sidebar.svelte b/frontend/src/components/nav/Sidebar.svelte
new file mode 100644
index 00000000..566fe372
--- /dev/null
+++ b/frontend/src/components/nav/Sidebar.svelte
@@ -0,0 +1,164 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/nav/TopNav.svelte b/frontend/src/components/nav/TopNav.svelte
new file mode 100644
index 00000000..db73bf98
--- /dev/null
+++ b/frontend/src/components/nav/TopNav.svelte
@@ -0,0 +1,740 @@
+
+
+
+
+
setActiveView('dashboard')}>
+ ⚙️
+ SSUI
+
+
+
+ {#each views as view}
+ setActiveView(view.id)}
+ >
+ {view.name}
+
+ {/each}
+
+
+
+
+
e.stopPropagation()}>
+
+ {getStatusIndicator(activeBackend)}
+ {activeBackend}
+ {showBackendDropdown ? '▲' : '▼'}
+
+
+ {#if showBackendDropdown}
+
+
+
+ {#each backends as backendId}
+
changeActiveBackend(backendId)}
+ >
+
+
+ {backendId}
+
+ {#if backendId === activeBackend}
+
✓
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {formattedDate}
+ {formattedTime}
+
+
+
e.stopPropagation()}>
+
+ {getDisplayInitials($userInfo)}
+
+
+ {#if showUserMenu}
+
+
+
+
+ 🌙
+ Switch Theme
+
+
+
+ 🚪
+ Logout & Reset Interface
+
+
+
+ {/if}
+
+ {#if showUserSettings}
+
+
+
+ {/if}
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/resuables/InitializingView.svelte b/frontend/src/components/resuables/InitializingView.svelte
new file mode 100644
index 00000000..6d7fe5d7
--- /dev/null
+++ b/frontend/src/components/resuables/InitializingView.svelte
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+ {#if serverStatus === 'checking'}
+ Connecting to Backend
+ {:else if serverStatus === 'online'}
+ Initializing Backend
+ {:else if serverStatus === 'offline'}
+ {statusDescOffline}
+ {:else if serverStatus === 'error'}
+ Backend error
+ {:else if serverStatus === 'cert-error'}
+ Backend Certificate or https error
+ {:else if serverStatus === 'unreachable'}
+ Server not found
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if errorMessage}
+
+
+
+
+
+
+
{errorMessage}
+
+ {/if}
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/resuables/ReloadAll.svelte b/frontend/src/components/resuables/ReloadAll.svelte
new file mode 100644
index 00000000..5a61fb83
--- /dev/null
+++ b/frontend/src/components/resuables/ReloadAll.svelte
@@ -0,0 +1,130 @@
+
+
+
+
+ {isLoading ? '🕐 Reloading...' : '🔃 Reload Backend'}
+
+
+ {#if responseMessage}
+
+
+ {#if isError}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
{responseMessage}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/resuables/ScreenNotSupported.svelte b/frontend/src/components/resuables/ScreenNotSupported.svelte
new file mode 100644
index 00000000..aa279379
--- /dev/null
+++ b/frontend/src/components/resuables/ScreenNotSupported.svelte
@@ -0,0 +1,193 @@
+
+
+ coords.set({ x: 0, y: 0 })}
+>
+ {#if visible}
+
+
+
+
+
Screen too Small
+
+ Sorry, this display size is not recommended currently. If you continue, the app may not get displayed properly. Please use a device with a minimum width of 1024px and height of 600px, such as an iPad, or decrease the window scalnig. If you're using a phone, consider rotating it to landscape mode.
+
+
+ I don't care, show me the page anyway
+
+
+
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/resuables/ToggleServer.svelte b/frontend/src/components/resuables/ToggleServer.svelte
new file mode 100644
index 00000000..7ab8ab37
--- /dev/null
+++ b/frontend/src/components/resuables/ToggleServer.svelte
@@ -0,0 +1,164 @@
+
+
+
+
+ sendCommand('start')}
+ disabled={isLoading}
+ class:active={lastAction === 'start' && !isLoading}
+ class="start-button"
+ >
+ {isLoading && lastAction === 'start' ? 'Starting...' : 'Start Server'}
+
+
+ sendCommand('stop')}
+ disabled={isLoading}
+ class:active={lastAction === 'stop' && !isLoading}
+ class="stop-button"
+ >
+ {isLoading && lastAction === 'stop' ? 'Stopping...' : 'Stop Server'}
+
+
+
+ {#if responseMessage}
+
+
+ {#if isError}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
{responseMessage}
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/settings/ConfigManager.svelte b/frontend/src/components/settings/ConfigManager.svelte
new file mode 100644
index 00000000..699defb5
--- /dev/null
+++ b/frontend/src/components/settings/ConfigManager.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+{#if loading}
+
+
Loading Config Manager...
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/src/components/settings/DetectionManager.svelte b/frontend/src/components/settings/DetectionManager.svelte
new file mode 100644
index 00000000..ef0d43bc
--- /dev/null
+++ b/frontend/src/components/settings/DetectionManager.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+{#if loading}
+
+
Loading Detection Manager...
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/src/components/settings/SettingsView.svelte b/frontend/src/components/settings/SettingsView.svelte
new file mode 100644
index 00000000..86b93202
--- /dev/null
+++ b/frontend/src/components/settings/SettingsView.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+ {#if activeSidebarTab === 'Config Manager'}
+
+ {/if}
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/settings/UserSettings.svelte b/frontend/src/components/settings/UserSettings.svelte
new file mode 100644
index 00000000..abdabcb5
--- /dev/null
+++ b/frontend/src/components/settings/UserSettings.svelte
@@ -0,0 +1,374 @@
+
+
+
+
+
Admin: User Settings
+
+
+ DO NOT USE THIS; REPLACES CURRENT USER IN STATIONEERSERVERUI!
+
+
+
+
+ {#if statusMessage}
+
+ {isError ? '⚠️' : '✓'}
+ {statusMessage}
+ statusMessage = ''}>×
+
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/views/BackupsView.svelte b/frontend/src/components/views/BackupsView.svelte
new file mode 100644
index 00000000..d03673dd
--- /dev/null
+++ b/frontend/src/components/views/BackupsView.svelte
@@ -0,0 +1,864 @@
+
+
+
+
+
+
+ {#if error}
+
+ {error}
+ ×
+
+ {/if}
+
+ {#if success}
+
+ {success}
+ ×
+
+ {/if}
+
+
+
+
+
+
+
Available Backups ({backups.length})
+
+ {#if isLoading && !hasInitialLoad}
+
Loading backups...
+ {:else if error && backups.length === 0}
+
+
⚠️
+
Unable to load backups
+
Please check your connection and Backup path Settings
+
+ {isLoading ? 'Retrying...' : 'Retry'}
+
+
+ {:else if backups.length === 0}
+
+
📦
+
No backups found
+
Create your first backup to get started
+
+ {:else}
+
+ {#each backups as backup}
+
+
+
{backup.displayName}
+
+ {backup.name}
+ {backup.size}
+
+
+
+
+ openRestoreModal(backup)}
+ disabled={isRestoring || backupStatus.isRunning}
+ >
+ ↶
+ Restore
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+{#if showRestoreModal}
+
+
e.stopPropagation()}>
+
+
+
+
Are you sure you want to restore this backup?
+
+ {selectedBackup?.displayName}
+
+ {selectedBackup?.name}
+
+
+
+
+
+ Skip pre-restore backup
+
+
+ If unchecked, a backup will be created before restoring
+
+
+
+
+ ⚠️
+ This action will overwrite current data. This cannot be undone.
+
+
+
+
+
+ Cancel
+
+
+ ↶
+ {isRestoring ? 'Restoring...' : 'Restore Backup'}
+
+
+
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/src/components/views/ConsoleView.svelte b/frontend/src/components/views/ConsoleView.svelte
new file mode 100644
index 00000000..9541cd46
--- /dev/null
+++ b/frontend/src/components/views/ConsoleView.svelte
@@ -0,0 +1,464 @@
+
+
+
+
+
+ switchTab('console-tab')}
+ >
+ Console
+
+ switchTab('detection-tab')}
+ >
+ Detections
+
+
+
+ {autoScroll ? '⏬ Auto-scroll: ON' : '⏫ Auto-scroll: OFF'}
+
+
+
+
+ {#if activeTab === 'console-tab'}
+
+
+ {#each consoleMessages as message}
+
+ {message.text}
+
+ {/each}
+
+
+ {:else}
+
+
+ {#each detectionEvents as event}
+
+ {event.timestamp}:
+ {event.message}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/views/LogsView.svelte b/frontend/src/components/views/LogsView.svelte
new file mode 100644
index 00000000..124d5bd6
--- /dev/null
+++ b/frontend/src/components/views/LogsView.svelte
@@ -0,0 +1,472 @@
+
+
+
+
+
+
+
+ Time Range
+
+ Recent
+ Last 1 Minute
+ Last 10 Minutes
+ Last 20 Minutes
+ Last 30 Minutes
+ Last 1 Hour
+ Last 12 Hours
+ Last 24 Hours
+ All Time
+
+
+
+
+ Reconnect
+ Clear
+
+
+
+
+ {#if filteredLogs.length === 0}
+
No logs to display. Select a log level or change the Time Range to view logs...
+ {:else}
+ {#each filteredLogs as log}
+
+ {log.timestamp}
+ {log.level}
+ {log.message}
+
+ {/each}
+ {/if}
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 00000000..c8c70ee8
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,7 @@
+import './app.css';
+import App from './App.svelte';
+import { mount } from 'svelte';
+
+mount(App, {
+ target: document.getElementById('app'),
+});
\ No newline at end of file
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 00000000..4a1cd98d
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,579 @@
+// api.js
+
+import { writable, get } from 'svelte/store';
+
+// Store for backend configuration
+export const backendConfig = writable({
+ active: 'default', // Currently active backend
+ backends: {
+ default: {
+ url: '/', // Default backend URL is the current host
+ token: null // Authentication token (JWT)
+ }
+ }
+});
+
+// Store for authentication state
+export const authState = writable({
+ isAuthenticated: false,
+ isAuthenticating: false,
+ authError: null
+});
+
+// Track initialization state
+let isInitialized = false;
+
+// Helper to get the current backend configuration
+export function getCurrentBackend() {
+ const config = get(backendConfig);
+ return config.backends[config.active] || config.backends.default;
+}
+
+// Helper to get the current backend URL
+export function getCurrentBackendUrl() {
+ const backend = getCurrentBackend();
+ return backend.url === '/' ? '' : backend.url;
+}
+
+// Helper to get the current authentication token
+export function getCurrentAuthToken() {
+ return getCurrentBackend().token;
+}
+
+// Add or update a backend
+export function setBackend(id, url) {
+ backendConfig.update(config => {
+ // If the backend already exists, preserve its token
+ const existingToken = config.backends[id]?.token || null;
+ config.backends[id] = { url, token: existingToken };
+ return config;
+ });
+}
+
+// Set the active backend and verify authentication
+export async function setActiveBackend(id) {
+ let success = false;
+ let prevBackendId = get(backendConfig).active;
+
+ // Only update if different to prevent unnecessary reloads
+ if (prevBackendId !== id) {
+ backendConfig.update(config => {
+ if (config.backends[id]) {
+ config.active = id;
+ }
+ return config;
+ });
+
+ // After changing backend, check authentication status
+ try {
+ await syncAuthState();
+ success = true;
+ } catch (error) {
+ console.error('Error syncing auth state after backend change:', error);
+ }
+ } else {
+ // If selecting the same backend, consider it successful
+ success = true;
+ }
+
+ return success;
+}
+
+// Update the token for a backend and persist it
+export function updateAuthToken(id, token) {
+ backendConfig.update(config => {
+ if (config.backends[id]) {
+ config.backends[id].token = token;
+ }
+ return config;
+ });
+
+ // Update the auth state
+ if (id === get(backendConfig).active) {
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: !!token,
+ authError: null
+ }));
+ }
+}
+
+// Clear authentication for the current backend
+export function clearAuthentication() {
+ const currentBackendId = get(backendConfig).active;
+ updateAuthToken(currentBackendId, null);
+
+ // Also clear the auth cookie by making a logout request
+ apiFetch('/auth/logout', { method: 'POST' })
+ .catch(err => console.error('Error during logout:', err));
+}
+
+/**
+ * Fetch wrapper that automatically adds the backend URL and handles authentication
+ * @param {string} endpoint - The API endpoint (e.g., "/api/v2/whatever")
+ * @param {Object} options - Fetch options
+ * @returns {Promise} - The fetch promise
+ */
+export async function apiFetch(endpoint, options = {}) {
+ // Get the current backend configuration
+ const backendUrl = getCurrentBackendUrl();
+ const token = getCurrentAuthToken();
+
+ // Ensure endpoint starts with "/" if it's not an empty string
+ const normalizedEndpoint = endpoint.startsWith('/') || endpoint === '' ? endpoint : `/${endpoint}`;
+
+ // Construct the full URL
+ const url = `${backendUrl}${normalizedEndpoint}`;
+
+ // Set up headers if not provided
+ options.headers = options.headers || {};
+
+ // Always include credentials for CORS requests
+ options.credentials = 'include';
+
+ // For non-login endpoints, manually set the AuthToken cookie as well
+ // This serves as a fallback in case the HttpOnly cookie isn't being sent
+ if (token && !endpoint.includes('/auth/login')) {
+ const cookieHeader = document.cookie;
+ if (!cookieHeader.includes('AuthToken=')) {
+ // Only set cookie header if it's not already set by the browser
+ options.headers['Cookie'] = `AuthToken=${token}`;
+ }
+
+ // Also send the token in the Authorization header as a backup method
+ options.headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ // Perform the fetch
+ return await fetch(url, options);
+}
+
+/**
+ * Fetch wrapper with timeout that automatically adds the backend URL and handles authentication
+ * @param {string} endpoint - The API endpoint (e.g., "/api/v2/whatever")
+ * @param {Object} options - Fetch options
+ * @param {number} timeoutMs - Timeout in milliseconds
+ * @returns {Promise} - The fetch promise
+ */
+export async function apiFetchTimeout(endpoint, options = {}, timeoutMs) {
+ // Create a new options object to avoid modifying the original
+ const timeoutOptions = { ...options };
+
+ // Set up AbortController for timeout
+ const controller = new AbortController();
+ timeoutOptions.signal = controller.signal;
+
+ // Set timeout
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ // Use apiFetch for the actual request
+ const response = await apiFetch(endpoint, timeoutOptions);
+ return response;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Helper for JSON API calls
+ * @param {string} endpoint - The API endpoint
+ * @param {Object} options - Fetch options
+ * @returns {Promise} - Parsed JSON response
+ */
+export async function apiJson(endpoint, options = {}) {
+ // Default to JSON content type if not specified
+ options.headers = options.headers || {};
+ if (!options.headers['Content-Type'] && options.method && options.method !== 'GET') {
+ options.headers['Content-Type'] = 'application/json';
+ }
+
+ const response = await apiFetch(endpoint, options);
+
+ // Check for auth errors
+ if (response.status === 401) {
+ // Update auth state
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ authError: 'Unauthorized'
+ }));
+ throw new Error('Authentication required');
+ }
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Helper for text API calls
+ * @param {string} endpoint - The API endpoint
+ * @param {Object} options - Fetch options
+ * @returns {Promise} - Text response
+ */
+export async function apiText(endpoint, options = {}) {
+ const response = await apiFetch(endpoint, options);
+
+ // Check for auth errors
+ if (response.status === 401) {
+ // Update auth state
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ authError: 'Unauthorized'
+ }));
+ throw new Error('Authentication required');
+ }
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
+ }
+
+ return response.text();
+}
+
+/**
+ * Helper for Server-Sent Events (SSE)
+ * @param {string} endpoint - The SSE endpoint
+ * @param {function} onMessage - Callback for each message
+ * @param {function} onError - Error callback
+ * @returns {Object} - Control object with a close() method
+ */
+export function apiSSE(endpoint, onMessage, onError = console.error) {
+ // Get the current backend URL
+ const backendUrl = getCurrentBackendUrl();
+ const token = getCurrentAuthToken();
+
+ // Ensure endpoint starts with "/" if it's not an empty string
+ const normalizedEndpoint = endpoint.startsWith('/') || endpoint === '' ? endpoint : `/${endpoint}`;
+
+ // Construct the full URL
+ const baseUrl = backendUrl || window.location.origin;
+ const url = new URL(`${baseUrl}${normalizedEndpoint}`);
+
+ // Add token as query param as a fallback for EventSource which can't set headers
+ if (token) {
+ url.searchParams.set('token', token);
+ }
+
+ let eventSource = null;
+ let isActive = true;
+ let currentBackendId = get(backendConfig).active;
+
+ try {
+ // Create EventSource for SSE with withCredentials to send cookies
+ const eventSourceOptions = { withCredentials: true };
+ eventSource = new EventSource(url.toString(), eventSourceOptions);
+
+ // Set up event handlers
+ eventSource.onmessage = event => {
+ try {
+ // Try to parse as JSON first
+ const data = JSON.parse(event.data);
+ onMessage(data);
+ } catch (e) {
+ // If not JSON, pass the raw string
+ onMessage(event.data);
+ }
+ };
+
+ eventSource.onerror = error => {
+ // Check if the error might be an authentication issue
+ if (eventSource.readyState === EventSource.CLOSED) {
+ // Update auth state if we suspect auth issues
+ syncAuthState().catch(console.error);
+ }
+ onError(error);
+
+ // Auto-reconnect after a delay if still active
+ if (isActive && !eventSource) {
+ setTimeout(() => {
+ if (isActive && document.visibilityState !== 'hidden') {
+ // Try to reconnect with a fresh EventSource
+ try {
+ eventSource = new EventSource(url.toString(), eventSourceOptions);
+ } catch (reconnectError) {
+ onError(reconnectError);
+ }
+ }
+ }, 2000);
+ }
+ };
+
+ // Subscribe to backend config changes to handle backend changes without page reload
+ const unsubscribe = backendConfig.subscribe(config => {
+ if (isActive && config.active !== currentBackendId) {
+ console.log('The Backend changed, I am reconnecting a SSE connection');
+ currentBackendId = config.active;
+
+ // Close existing connection
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+
+ // Create a new connection with updated backend info
+ setTimeout(() => {
+ if (isActive) {
+ try {
+ // Get fresh URL and token from new backend
+ const newBackendUrl = getCurrentBackendUrl();
+ const newToken = getCurrentAuthToken();
+ const newUrl = new URL(`${newBackendUrl || window.location.origin}${normalizedEndpoint}`);
+
+ if (newToken) {
+ newUrl.searchParams.set('token', newToken);
+ }
+
+ // Create new EventSource
+ eventSource = new EventSource(newUrl.toString(), eventSourceOptions);
+
+ // Set up event handlers again
+ eventSource.onmessage = eventSource.onmessage;
+ eventSource.onerror = eventSource.onerror;
+ } catch (reconnectError) {
+ onError(reconnectError);
+ }
+ }
+ }, 100);
+ }
+ });
+
+ // Return control object with enhanced close method
+ return {
+ close: () => {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+ isActive = false;
+ unsubscribe();
+ }
+ };
+ } catch (error) {
+ onError(error);
+ isActive = false;
+ // Return a dummy control object
+ return {
+ close: () => {}
+ };
+ }
+}
+
+/**
+ * Login to the current backend
+ * @param {string} username - Username
+ * @param {string} password - Password
+ * @returns {Promise} - Success status
+ */
+export async function login(username, password) {
+ // Set authenticating state
+ authState.update(state => ({
+ ...state,
+ isAuthenticating: true,
+ authError: null
+ }));
+
+ try {
+ const response = await apiFetch('/auth/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'include', // Ensure cookies are stored
+ body: JSON.stringify({ username, password })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Authentication failed');
+ }
+
+ const data = await response.json();
+
+ // Save the token for future reference
+ updateAuthToken(get(backendConfig).active, data.token);
+
+ // Also manually set the cookie as a fallback for SameSite restrictions
+ document.cookie = `AuthToken=${data.token}; path=/; max-age=${60*60*24}`;
+
+ // Update auth state
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: true,
+ isAuthenticating: false,
+ authError: null
+ }));
+
+ return true;
+ } catch (error) {
+ // Update auth state with error
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ authError: error.message
+ }));
+
+ return false;
+ }
+}
+
+export async function syncAuthState() {
+ const currentBackendId = get(backendConfig).active;
+ const backend = getCurrentBackend();
+
+ // Update auth state to checking
+ authState.update(state => ({
+ ...state,
+ isAuthenticating: true
+ }));
+
+ try {
+ // Make a simple request with 500ms timeout to verify authentication
+ const response = await apiFetchTimeout('/api/v2/auth/check', {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json'
+ },
+ credentials: 'include' // Ensure cookies are sent
+ }, 500);
+
+ if (response.status === 401) {
+ // Authentication required but we're not authenticated
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ authError: 'Authentication required'
+ }));
+ return false;
+ } else if (response.status === 404) {
+ // Endpoint not found - this server might not require authentication
+ // or is using a different auth endpoint
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ authError: 'endpoint not found'
+ }));
+ return false;
+ } else if (!response.ok) {
+ // Some other error
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ authError: `API error: ${response.status} ${response.statusText}`
+ }));
+ return false;
+ }
+
+ // Successfully authenticated
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: true,
+ isAuthenticating: false,
+ authError: null
+ }));
+ return true;
+ } catch (error) {
+ // Handle timeout or other errors
+ const errorMessage = error.name === 'AbortError' ? 'Connection timed out. The server may be slow or unreachable.' : error.message || 'Connection error';
+ console.warn('Auth check failed:', error);
+
+ // Sleep for 60ms, then retry once, else continue failing
+ await new Promise(resolve => setTimeout(resolve, 60));
+ try {
+ const retryResponse = await apiFetchTimeout('/api/v2/auth/check', {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json'
+ },
+ credentials: 'include'
+ }, 500);
+
+ if (retryResponse.ok) {
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: true,
+ isAuthenticating: false,
+ authError: null
+ }));
+ return true;
+ }
+ } catch (retryError) {
+ console.warn('Retry auth check failed:', retryError);
+ }
+
+ authState.update(state => ({
+ ...state,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ authError: errorMessage
+ }));
+ return false;
+ }
+}
+
+// Initial setup function to load saved backend configurations
+export function initializeApiService() {
+ if (isInitialized) return;
+
+ try {
+ // Try to load saved config from localStorage
+ const savedConfig = localStorage.getItem('ssui-backend-config');
+ if (savedConfig) {
+ const parsed = JSON.parse(savedConfig);
+
+ // Validate and normalize the loaded configuration
+ const validatedConfig = {
+ active: parsed.active && parsed.backends?.[parsed.active] ? parsed.active : 'default',
+ backends: {
+ default: {
+ url: '/',
+ token: parsed.backends?.default?.token || null
+ }
+ }
+ };
+
+ // Merge all backends from storage
+ if (parsed.backends) {
+ for (const [id, backend] of Object.entries(parsed.backends)) {
+ if (id !== 'default') {
+ validatedConfig.backends[id] = {
+ url: backend.url,
+ token: backend.token || null
+ };
+ }
+ }
+ }
+
+ // Apply the validated config
+ backendConfig.set(validatedConfig);
+ }
+
+ // Subscribe to changes and save to localStorage
+ const unsubscribe = backendConfig.subscribe(value => {
+ try {
+ localStorage.setItem('ssui-backend-config', JSON.stringify(value));
+ } catch (error) {
+ console.error('Error saving backend config:', error);
+ }
+ });
+
+ isInitialized = true;
+
+ // Check authentication status
+ syncAuthState().catch(console.error);
+
+ // Return cleanup function (though this is a service, so it won't typically be cleaned up)
+ return unsubscribe;
+ } catch (error) {
+ console.error('Error initializing API service:', error);
+ isInitialized = true; // Prevent repeated failed initializations
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/services/whoami.js b/frontend/src/services/whoami.js
new file mode 100644
index 00000000..0fbd8565
--- /dev/null
+++ b/frontend/src/services/whoami.js
@@ -0,0 +1,117 @@
+import { writable } from 'svelte/store';
+import { apiFetch } from './api.js';
+
+/**
+ * User information store
+ */
+export const userInfo = writable({
+ username: null,
+ accessLevel: null,
+ isLoading: true,
+ isAuthenticated: false,
+ lastFetched: null,
+ error: null
+});
+
+/**
+ * Fetch current user information from the API
+ */
+export async function fetchUserInfo() {
+ // Set loading state
+ userInfo.update(state => ({
+ ...state,
+ isLoading: true,
+ error: null
+ }));
+
+ try {
+ const response = await apiFetch('/api/v2/auth/whoami');
+
+ // Parse the JSON from the response
+ const data = await response.json();
+ console.log('You are logged in as:', data);
+
+ if (data && data.username) {
+ // Update store with successful data
+ userInfo.set({
+ username: data.username,
+ accessLevel: data.accessLevel || 'user',
+ isLoading: false,
+ isAuthenticated: true,
+ lastFetched: new Date(),
+ error: null
+ });
+ return data;
+ } else {
+ throw new Error('Invalid response format: missing username');
+ }
+ } catch (error) {
+ console.error('Failed to fetch user info:', error);
+
+ // Update store with error state
+ userInfo.set({
+ username: null,
+ accessLevel: null,
+ isLoading: false,
+ isAuthenticated: false,
+ lastFetched: null,
+ error: error.message || 'Failed to fetch user information'
+ });
+
+ throw error;
+ }
+}
+
+/**
+ * Get formatted user initials for avatar display
+ */
+export function getUserInitials(username) {
+ if (!username) return 'USR';
+
+ // Split username and take first letter of each word, max 3 characters
+ const words = username.split(/[\s_-]+/);
+ if (words.length === 1) {
+ return username.substring(0, 3).toUpperCase();
+ }
+ return words.slice(0, 2).map(word => word.charAt(0).toUpperCase()).join('');
+}
+
+/**
+ * Format access level for display
+ */
+export function formatAccessLevel(accessLevel) {
+ if (!accessLevel) return 'Unknown';
+ return accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1);
+}
+
+/**
+ * Check if user info needs to be refreshed (older than 5 minutes)
+ */
+export function shouldRefreshUserInfo(lastFetched) {
+ if (!lastFetched) return true;
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
+ return lastFetched < fiveMinutesAgo;
+}
+
+/**
+ * Initialize user info on app start
+ */
+export function initUserInfo() {
+ return fetchUserInfo().catch(error => {
+ console.warn('Initial user info fetch failed:', error);
+ });
+}
+
+/**
+ * Clear user info (for logout)
+ */
+export function clearUserInfo() {
+ userInfo.set({
+ username: null,
+ accessLevel: null,
+ isLoading: false,
+ isAuthenticated: false,
+ lastFetched: null,
+ error: null
+ });
+}
\ No newline at end of file
diff --git a/frontend/src/tabs.js b/frontend/src/tabs.js
new file mode 100644
index 00000000..264397a7
--- /dev/null
+++ b/frontend/src/tabs.js
@@ -0,0 +1,20 @@
+function switchTab(tabId) {
+ // Hide all tabs
+ document.querySelectorAll('.tab').forEach(tab => {
+ tab.classList.remove('active');
+ });
+
+ // Show the selected tab
+ document.getElementById(tabId).classList.add('active');
+
+ // Update tab buttons
+ document.querySelectorAll('.tab-button').forEach(button => {
+ button.classList.remove('active');
+ });
+
+ // Activate the clicked button
+ document.querySelector(`.tab-button[onclick*="${tabId}"]`).classList.add('active');
+ }
+
+ // Export for Svelte
+ export { switchTab };
\ No newline at end of file
diff --git a/frontend/src/themes/theme.css b/frontend/src/themes/theme.css
new file mode 100644
index 00000000..1e516f6f
--- /dev/null
+++ b/frontend/src/themes/theme.css
@@ -0,0 +1,88 @@
+/* themes/theme.css */
+:root {
+ /* Only used on login page, fix this later */
+ --bg-primary: #1e1e1e;
+ --bg-secondary: #252526;
+ --bg-tertiary: #2d2d2d;
+ --bg-hover: #3c3c3c;
+ --bg-active: #3e4033;
+
+ --text-primary: #d4d4d4;
+ --text-secondary: #a9a9a9;
+ --text-accent: #6a9955; /* Forest green accent */
+ --text-warning: #ce9178;
+
+ --border-color: #3e3e3e;
+
+ --accent-primary: #6a9955; /* Forest green */
+ --accent-secondary: #4d7240;
+ --accent-tertiary: #5f7e52;
+
+ --shadow-light: 0 2px 8px rgba(0, 0, 0, 0.3);
+ --shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.4);
+
+ --transition-speed: 250ms;
+
+ /* Layout */
+ --top-nav-height: 3rem;
+ --sidebar-width: 150px;
+ --sidebar-collapsed-width: 60px;
+ }
+
+ body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ margin: 0;
+ padding: 0;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ overflow: hidden;
+ }
+
+ button {
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 3px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ transition: all var(--transition-speed) ease;
+ }
+
+ button:hover {
+ background-color: var(--bg-hover);
+ border-color: var(--accent-primary);
+ }
+
+ button.active {
+ background-color: var(--bg-active);
+ border-color: var(--accent-primary);
+ color: var(--accent-primary);
+ }
+
+ a {
+ color: var(--accent-primary);
+ text-decoration: none;
+ transition: color var(--transition-speed) ease;
+ }
+
+ a:hover {
+ color: var(--accent-tertiary);
+ }
+
+ /* Scrollbar styling */
+ ::-webkit-scrollbar {
+ width: 10px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--bg-tertiary);
+ border-radius: 5px;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--accent-secondary);
+ }
\ No newline at end of file
diff --git a/frontend/src/themes/theme.js b/frontend/src/themes/theme.js
new file mode 100644
index 00000000..becb588b
--- /dev/null
+++ b/frontend/src/themes/theme.js
@@ -0,0 +1,384 @@
+// src/theme/theme.js
+
+// Available Vars
+//"--bg-primary"
+//"--bg-secondary"
+//"--bg-tertiary"
+//"--bg-hover"
+//"--bg-active"
+//"--text-primary"
+//"--text-secondary"
+//"--text-accent"
+//"--text-warning"
+//"--border-color"
+//"--accent-primary"
+//"--accent-secondary"
+//"--accent-tertiary"
+//"--shadow-light"
+//"--shadow-medium"
+//"--transition-speed"
+//"--top-nav-height"
+
+
+// Define your themes
+const themes = {
+ forest: {
+ name: "Forest Dark",
+ properties: {
+ "--bg-primary": "#1e1e1e",
+ "--bg-secondary": "#252526",
+ "--bg-tertiary": "#2d2d2d",
+ "--bg-hover": "#3c3c3c",
+ "--bg-active": "#3e4033",
+ "--text-primary": "#d4d4d4",
+ "--text-secondary": "#a9a9a9",
+ "--text-accent": "#6a9955",
+ "--text-warning": "#ce9178",
+ "--border-color": "#3e3e3e",
+ "--accent-primary": "#6a9955",
+ "--accent-secondary": "#4d7240",
+ "--accent-tertiary": "#5f7e52",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.3)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.4)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ vaxholmDark: {
+ name: "Vaxholm Dark",
+ properties: {
+ "--bg-primary": "#121a12",
+ "--bg-secondary": "#1b2a1b",
+ "--bg-tertiary": "#243224",
+ "--bg-hover": "#2e3a2e",
+ "--bg-active": "#3a4a3a",
+ "--text-primary": "#d9e6d9",
+ "--text-secondary": "#a3b3a3",
+ "--text-accent": "#7a9a7a",
+ "--text-warning": "#c9a67a",
+ "--border-color": "#2a3a2a",
+ "--accent-primary": "#7a9a7a",
+ "--accent-secondary": "#5f7a5f",
+ "--accent-tertiary": "#6a8a6a",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.5)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.6)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ archipelagoPastel: {
+ name: "Archipelago Pastel",
+ properties: {
+ "--bg-primary": "#2e3b3e",
+ "--bg-secondary": "#3e4b4e",
+ "--bg-tertiary": "#4e5b5e",
+ "--bg-hover": "#5e6b6e",
+ "--bg-active": "#6e7b7e",
+ "--text-primary": "#dce7e7",
+ "--text-secondary": "#b0c0c0",
+ "--text-accent": "#a3c1ad",
+ "--text-warning": "#d9bba3",
+ "--border-color": "#4a5a5a",
+ "--accent-primary": "#a3c1ad",
+ "--accent-secondary": "#8bb394",
+ "--accent-tertiary": "#7aa383",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.25)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.35)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ colorblindFriendly: {
+ name: "Colorblind Friendly",
+ properties: {
+ "--bg-primary": "#121212",
+ "--bg-secondary": "#1e1e1e",
+ "--bg-tertiary": "#2a2a2a",
+ "--bg-hover": "#383838",
+ "--bg-active": "#454545",
+ "--text-primary": "#ffffff",
+ "--text-secondary": "#bfbfbf",
+ "--text-accent": "#ffb300", // bright yellow for visibility
+ "--text-warning": "#ff3b3b", // bright red
+ "--border-color": "#666666",
+ "--accent-primary": "#ffb300",
+ "--accent-secondary": "#ffaa00",
+ "--accent-tertiary": "#cc8800",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.7)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.8)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ cyberpunkGlow: {
+ name: "Cyberpunk Glow",
+ properties: {
+ "--bg-primary": "#0a0a23",
+ "--bg-secondary": "#1a1a3a",
+ "--bg-tertiary": "#2a2a5a",
+ "--bg-hover": "#3a3a7a",
+ "--bg-active": "#4a4a9a",
+ "--text-primary": "#e0e0ff",
+ "--text-secondary": "#a0a0ff",
+ "--text-accent": "#ff00ff",
+ "--text-warning": "#ff4d4d",
+ "--border-color": "#660066",
+ "--accent-primary": "#ff00ff",
+ "--accent-secondary": "#cc00cc",
+ "--accent-tertiary": "#990099",
+ "--shadow-light": "0 0 8px #ff00ff",
+ "--shadow-medium": "0 0 16px #ff00ff",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ v1Classic: {
+ name: "Stationeers Server UI (Classic)",
+ properties: {
+ "--bg-primary": "#0a0a14",
+ "--bg-secondary": "#1b1b2f8f",
+ "--bg-tertiary": "#2a2a5a",
+ "--bg-hover": "#2a2a5a",
+ "--bg-active": "#4a4a9a",
+ "--text-primary": "#00fca9",
+ "--text-secondary": "#00fca9",
+ "--text-accent": "#00fca9",
+ "--text-warning": "#ff4d4d",
+ "--border-color": "#660066",
+ "--accent-primary": "#0eefa9",
+ "--accent-secondary": "#cc00cc",
+ "--accent-tertiary": "#990099",
+ "--shadow-light": "0 0 8px #0df2aa",
+ "--shadow-medium": "0 0 16px #0df2aa",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ lightArchipelago: {
+ name: "Light Archipelago",
+ properties: {
+ "--bg-primary": "#f0f4f3",
+ "--bg-secondary": "#d9e4e1",
+ "--bg-tertiary": "#c0d1cd",
+ "--bg-hover": "#b0c4bf",
+ "--bg-active": "#a0b4af",
+ "--text-primary": "#2a3a33",
+ "--text-secondary": "#4a5a53",
+ "--text-accent": "#7a9a7a",
+ "--text-warning": "#c97a5a",
+ "--border-color": "#a0b0a8",
+ "--accent-primary": "#7a9a7a",
+ "--accent-secondary": "#5f7a5f",
+ "--accent-tertiary": "#6a8a6a",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.1)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.15)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+ oceanBreeze: {
+ name: "Ocean Breeze",
+ properties: {
+ "--bg-primary": "#1a2a38",
+ "--bg-secondary": "#253545",
+ "--bg-tertiary": "#2f4055",
+ "--bg-hover": "#3a4c66",
+ "--bg-active": "#4a5c76",
+ "--text-primary": "#e0eaf0",
+ "--text-secondary": "#b0c0d0",
+ "--text-accent": "#68c1e8",
+ "--text-warning": "#f0ad4e",
+ "--border-color": "#456277",
+ "--accent-primary": "#68c1e8",
+ "--accent-secondary": "#4fa3ca",
+ "--accent-tertiary": "#3a89b0",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.3)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.4)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ sunsetGlow: {
+ name: "Sunset Glow",
+ properties: {
+ "--bg-primary": "#272133",
+ "--bg-secondary": "#332940",
+ "--bg-tertiary": "#3e304d",
+ "--bg-hover": "#4b3a5d",
+ "--bg-active": "#57446d",
+ "--text-primary": "#f5e6ff",
+ "--text-secondary": "#d1b6e1",
+ "--text-accent": "#ff9e7a",
+ "--text-warning": "#ffcc66",
+ "--border-color": "#5d4970",
+ "--accent-primary": "#ff9e7a",
+ "--accent-secondary": "#e68a6a",
+ "--accent-tertiary": "#cc775a",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.35)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.45)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ mintChocolate: {
+ name: "Mint Chocolate",
+ properties: {
+ "--bg-primary": "#1e2721",
+ "--bg-secondary": "#26322a",
+ "--bg-tertiary": "#2e3d33",
+ "--bg-hover": "#38493e",
+ "--bg-active": "#425548",
+ "--text-primary": "#e0f0e8",
+ "--text-secondary": "#b0c5b8",
+ "--text-accent": "#7fe0c3",
+ "--text-warning": "#d9b382",
+ "--border-color": "#3d4940",
+ "--accent-primary": "#7fe0c3",
+ "--accent-secondary": "#58c4a3",
+ "--accent-tertiary": "#3ba483",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.3)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.4)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ lavenderFields: {
+ name: "Lavender Fields",
+ properties: {
+ "--bg-primary": "#2b2440",
+ "--bg-secondary": "#352e4e",
+ "--bg-tertiary": "#3f385c",
+ "--bg-hover": "#4a426a",
+ "--bg-active": "#554c78",
+ "--text-primary": "#ece8ff",
+ "--text-secondary": "#c7c0e3",
+ "--text-accent": "#b28dff",
+ "--text-warning": "#ffad9c",
+ "--border-color": "#4d4566",
+ "--accent-primary": "#b28dff",
+ "--accent-secondary": "#9a77e0",
+ "--accent-tertiary": "#8360c6",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.35)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.45)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ },
+
+ nordAurora: {
+ name: "Kirkeness",
+ properties: {
+ "--bg-primary": "#2e3440",
+ "--bg-secondary": "#3b4252",
+ "--bg-tertiary": "#434c5e",
+ "--bg-hover": "#4c566a",
+ "--bg-active": "#5e6779",
+ "--text-primary": "#eceff4",
+ "--text-secondary": "#d8dee9",
+ "--text-accent": "#88c0d0",
+ "--text-warning": "#ebcb8b",
+ "--border-color": "#4c566a",
+ "--accent-primary": "#88c0d0",
+ "--accent-secondary": "#81a1c1",
+ "--accent-tertiary": "#5e81ac",
+ "--shadow-light": "0 2px 8px rgba(0, 0, 0, 0.25)",
+ "--shadow-medium": "0 4px 12px rgba(0, 0, 0, 0.35)",
+ "--transition-speed": "250ms",
+ "--top-nav-height": "3rem",
+ "--sidebar-width": "150px",
+ "--sidebar-collapsed-width": "60px",
+ },
+ }
+ };
+
+
+
+// Get theme names as an array
+const themeNames = Object.keys(themes);
+
+// Current theme state
+let currentTheme = 'v1Classic';
+
+// Apply theme to document
+function applyTheme(themeName) {
+ const theme = themes[themeName];
+ if (!theme) return;
+
+ currentTheme = themeName;
+
+ // Apply each CSS variable
+ const root = document.documentElement;
+ Object.entries(theme.properties).forEach(([property, value]) => {
+ root.style.setProperty(property, value);
+ });
+
+ // Save to localStorage
+ localStorage.setItem('theme', themeName);
+}
+
+// Rotate to next theme
+function nextTheme() {
+ const currentIndex = themeNames.indexOf(currentTheme);
+ const nextIndex = (currentIndex + 1) % themeNames.length;
+ applyTheme(themeNames[nextIndex]);
+}
+
+// Get current theme name
+function getCurrentTheme() {
+ return currentTheme;
+}
+
+// Get all theme names
+function getThemes() {
+ return themeNames;
+}
+
+// Initialize theme from localStorage or default
+function initTheme() {
+ const savedTheme = localStorage.getItem('theme');
+ if (savedTheme && themes[savedTheme]) {
+ applyTheme(savedTheme);
+ } else {
+ applyTheme(currentTheme);
+ }
+}
+
+// Export the service functions
+export default {
+ initTheme,
+ applyTheme,
+ nextTheme,
+ getCurrentTheme,
+ getThemes
+};
\ No newline at end of file
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 00000000..4078e747
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 00000000..b0683fd2
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,7 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+export default {
+ // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 00000000..90f788e8
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,29 @@
+import { defineConfig } from 'vite'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [svelte()],
+ build: {
+ outDir: '../UIMod/onboard_bundled/v2', // Change output directory to ../dist
+ rollupOptions: {
+ output: {
+ // Set the name of the JS bundle
+ entryFileNames: 'assets/ssui.js',
+ // Set the name of the CSS bundle and other assets
+ assetFileNames: (assetInfo) => {
+ // Using the non-deprecated names property which is an array
+ if (assetInfo.names && assetInfo.names.some(name => name.endsWith('.css'))) {
+ return 'assets/ssui.css'
+ }
+ return 'assets/[name].[ext]'
+ }
+ }
+ }
+ },
+ server: {
+ host: '0.0.0.0', // Bind to all interfaces
+ port: 5173, // Match your expected port
+ strictPort: true // Fail if port 5173 is taken
+ }
+})
\ No newline at end of file
diff --git a/go.mod b/go.mod
index cb7da326..679eae5c 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,9 @@ require (
github.com/fsnotify/fsnotify v1.7.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
- github.com/jacksonthemaster/discordrichpresence v1.0.3
+ github.com/jacksonthemaster/discordrichpresence v1.1.0
golang.org/x/crypto v0.37.0
+ golang.org/x/sys v0.35.0
)
-require (
- github.com/gorilla/websocket v1.5.3 // indirect
- golang.org/x/sys v0.32.0 // indirect
-)
+require github.com/gorilla/websocket v1.5.3 // indirect
diff --git a/go.sum b/go.sum
index 859be41a..870eb11e 100644
--- a/go.sum
+++ b/go.sum
@@ -9,15 +9,15 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/jacksonthemaster/discordrichpresence v1.0.3 h1:yB1TJ+yFTwCk4pxafxx/jYAsQyJ0RkfYUpRrjr0we7o=
-github.com/jacksonthemaster/discordrichpresence v1.0.3/go.mod h1:XA0SB8bsEc5oJCQcXjC78BfNBj9FoLFA4ysfevkUjHE=
+github.com/jacksonthemaster/discordrichpresence v1.1.0 h1:4UmompqAKyEpspR/Z0LFj6vdSWJf6FZQ/J787KvdxMM=
+github.com/jacksonthemaster/discordrichpresence v1.1.0/go.mod h1:XA0SB8bsEc5oJCQcXjC78BfNBj9FoLFA4ysfevkUjHE=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
-golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/server.go b/server.go
index 613e66f3..90a4ecb0 100644
--- a/server.go
+++ b/server.go
@@ -36,23 +36,22 @@ var v1uiFS embed.FS
func main() {
var wg sync.WaitGroup
- logger.Main.Install("Starting setup...")
+ logger.ConfigureConsole()
+ logger.Install.Info("Starting setup...")
loader.ReloadConfig() // Load the config file before starting the setup process
- // Start the installation process and wait for it to complete
- wg.Add(1)
- go setup.Install(&wg)
-
- // Wait for the installation to finish before starting the rest of the server
+ setup.Install(&wg)
wg.Wait()
-
- // Load config,discordbot, backupmgr and detectionmgr using the loader package
+ logger.Main.Debug("Initializing resources...")
loader.InitVirtFS(v1uiFS)
- loader.InitBackend()
- loader.InitDetector()
-
- loader.AfterStartComplete()
-
- cli.StartConsole(&wg)
-
+ logger.Main.Debug("Initializing Backend...")
+ loader.InitBackend(&wg)
+ wg.Wait()
+ logger.Main.Debug("Initializing after start tasks...")
+ loader.AfterStartComplete(&wg)
+ wg.Wait()
+ logger.Main.Debug("Starting webserver...")
web.StartWebServer(&wg)
+ logger.Main.Debug("Initializing SSUICLI...")
+ cli.StartConsole(&wg)
+ wg.Wait()
}
diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go
index d501127d..39c3a91b 100644
--- a/src/cli/runtimecommands.go
+++ b/src/cli/runtimecommands.go
@@ -3,10 +3,16 @@
package cli
import (
+ "archive/zip"
"bufio"
+ "encoding/json"
"errors"
"fmt"
+ "io"
"os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
"sort"
"strings"
"sync"
@@ -25,6 +31,8 @@ const (
cliPrompt = "\033[32m" + "SSUICLI" + " » " + "\033[0m"
)
+var isSupportMode bool
+
// CommandFunc defines the signature for command handler functions.
type CommandFunc func(args []string) error
@@ -49,7 +57,7 @@ func RegisterCommand(name string, handler CommandFunc, aliases ...string) {
// StartConsole starts a non-blocking console input loop in a separate goroutine.
func StartConsole(wg *sync.WaitGroup) {
- if !config.IsConsoleEnabled {
+ if !config.GetIsConsoleEnabled() {
logger.Core.Info("SSUICLI runtime console is disabled in config, skipping...")
return
}
@@ -150,6 +158,8 @@ func init() {
RegisterCommand("stopserver", WrapNoReturn(stopServer), "stop")
RegisterCommand("runsteamcmd", WrapNoReturn(runSteamCMD), "steamcmd", "stcmd")
RegisterCommand("testlocalization", WrapNoReturn(testLocalization), "tl")
+ RegisterCommand("supportmode", WrapNoReturn(supportMode), "sm")
+ RegisterCommand("supportpackage", WrapNoReturn(supportPackage), "sp")
}
func startServer() {
@@ -173,7 +183,7 @@ func exitfromcli() {
func deleteConfig() {
//remove file at config.ConfigPath
- if err := os.Remove(config.ConfigPath); err != nil {
+ if err := os.Remove(config.GetConfigPath()); err != nil {
logger.Core.Error("Error deleting config file: " + err.Error())
return
}
@@ -191,7 +201,96 @@ func runSteamCMD() {
}
func testLocalization() {
- currentLanguageSetting := config.LanguageSetting
+ currentLanguageSetting := config.GetLanguageSetting()
s := localization.GetString("UIText_StartButton")
- logger.Core.Info(s + " (current language: " + currentLanguageSetting + ")")
+ logger.Core.Info("Start Server Button text (current language: " + currentLanguageSetting + "): " + s)
+}
+
+func supportMode() {
+
+ if isSupportMode {
+ config.SetIsDebugMode(false)
+ config.SetLogLevel(20)
+ config.SetCreateSSUILogFile(false)
+ isSupportMode = false
+ logger.Core.Info("Support mode disabled.")
+ return
+ }
+ config.SetIsDebugMode(true)
+ config.SetLogLevel(10)
+ config.SetCreateSSUILogFile(true)
+ isSupportMode = true
+ loader.ReloadBackend()
+ time.Sleep(1000 * time.Millisecond)
+ logger.Core.Info("Support mode enabled. To generate a support package, type 'supportpackage' or 'sp'.")
+}
+
+func supportPackage() {
+ if !isSupportMode {
+ logger.Core.Error("Support mode is not enabled.")
+ return
+ }
+ zipFileName := fmt.Sprintf("support_package_%s.zip", time.Now().Format("20060102_150405"))
+ zipFile, _ := os.Create(zipFileName)
+ defer zipFile.Close()
+ zw := zip.NewWriter(zipFile)
+ defer zw.Close()
+
+ filepath.Walk("./UIMod/logs", func(p string, i os.FileInfo, err error) error {
+ if err != nil || i.IsDir() {
+ return nil
+ }
+ f, _ := os.Open(p)
+ defer f.Close()
+ w, _ := zw.Create(strings.TrimPrefix(p, "./"))
+ io.Copy(w, f)
+ return nil
+ })
+
+ configData, _ := os.ReadFile("./UIMod/config/config.json")
+
+ var configMap map[string]interface{}
+ if err := json.Unmarshal(configData, &configMap); err != nil {
+ logger.Core.Error("Failed to unmarshal config.json for support package")
+ return
+ }
+ delete(configMap, "discordToken")
+ delete(configMap, "users")
+ delete(configMap, "JwtKey")
+ delete(configMap, "AdminPassword")
+ sanitizedConfig, err := json.MarshalIndent(configMap, "", " ")
+ if err != nil {
+ logger.Core.Error("Failed to marshal sanitized config into support package")
+ return
+ }
+
+ // Write sanitized config to zip
+ w, _ := zw.Create("UIMod/config/config.json")
+
+ if _, err := w.Write(sanitizedConfig); err != nil {
+ logger.Core.Error("Failed to write sanitized config to support package")
+ }
+
+ // Gather system information
+ var osVersion string
+ if runtime.GOOS == "windows" {
+ cmd := exec.Command("cmd", "/c", "ver")
+ output, _ := cmd.Output()
+ osVersion = strings.TrimSpace(string(output))
+ } else if runtime.GOOS == "linux" {
+ d, _ := os.ReadFile("/etc/os-release")
+ for _, l := range strings.Split(string(d), "\n") {
+ if strings.HasPrefix(l, "PRETTY_NAME=") {
+ osVersion = strings.TrimPrefix(l, "PRETTY_NAME=")
+ break
+ }
+ }
+ } else {
+ osVersion = "unknown"
+ }
+
+ info := fmt.Sprintf("OS: %s\nVersion: %s\nArch: %s\nBranch: %s\nVersion: %s\nTime: %s",
+ runtime.GOOS, osVersion, runtime.GOARCH, config.GetBranch(), config.GetVersion(), time.Now().Format(time.RFC3339))
+ w, _ = zw.Create("system_info.txt")
+ w.Write([]byte(info))
}
diff --git a/src/cli/terminalmsg.go b/src/cli/terminalmsg.go
deleted file mode 100644
index 4d9c87a5..00000000
--- a/src/cli/terminalmsg.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package cli
-
-import (
- "fmt"
- "runtime"
- "time"
-
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
-)
-
-// PrintStartupMessage prints a stylish startup message to the terminal
-func PrintStartupMessage() {
- // Clear some space
- fmt.Println()
- fmt.Println()
-
- // Main ASCII art logo
- fmt.Println(" ███████╗████████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗███████╗██████╗ ███████╗ ███████╗██╗ ██╗██╗")
- fmt.Println(" ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝██╔════╝██╔══██╗██╔════╝ ██╔════╝██║ ██║██║")
- fmt.Println(" ███████╗ ██║ ███████║ ██║ ██║██║ ██║██╔██╗ ██║█████╗ █████╗ ██████╔╝███████╗█████╗███████╗██║ ██║██║")
- fmt.Println(" ╚════██║ ██║ ██╔══██║ ██║ ██║██║ ██║██║╚██╗██║██╔══╝ ██╔══╝ ██╔══██╗╚════██║╚════╝╚════██║██║ ██║██║")
- fmt.Println(" ███████║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║███████╗███████╗██║ ██║███████║ ███████║╚██████╔╝██║")
- fmt.Println(" ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝ ╚══════╝ ╚═════╝ ╚═╝")
-
- // Decorative line
- fmt.Println(" ╔═══════════════════════════════════════════════════════════════════════════════════════════════════╗")
- // Tagline
- fmt.Println(" ║ 🎮 YOUR ONE-STOP SHOP FOR RUNNING A STATIONEERS SERVER 🎮 ║")
- // System info
- fmt.Printf(" ║ 🚀 Version: %s 📅 %s 💻 Runtime: %.3s/%s ║\n",
- config.Version,
- time.Now().Format("2006-01-02 15:04:05"),
- runtime.GOOS,
- runtime.GOARCH)
- // Decorative line
- fmt.Println(" ╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝")
-
- // Web UI info
- fmt.Println("\n 🌐 Web UI available at: https://localhost:8443 (default) or https://:" + config.SSUIWebPort)
- fmt.Println("\n 🌐 Support available at: https://discord.gg/8n3vN92MyJ")
-
- // Quote
- fmt.Println("\n JacksonTheMaster: \"Managing game servers shouldn't be rocket science... unless it's a rocket game!\"")
-}
-
-func PrintFirstTimeSetupMessage() {
- // Setup guide
- fmt.Println(" 📋 GETTING STARTED:")
- fmt.Println(" ┌─────────────────────────────────────────────────────────────────────────────────────────────┐")
- fmt.Println(" │ • Ready, set, go! Welcome to StationeersServerUI, new User! │")
- fmt.Println(" │ • The good news: you made it here, which means you are likely ready to run your server! │")
- fmt.Println(" │ • If this is your first time here, no worries: SSUI is made to be easy to use. │")
- fmt.Println(" │ • Configure your server by visiting the WebUI! │")
- fmt.Println(" │ • Support is provided at https://discord.gg/8n3vN92MyJ │")
- fmt.Println(" │ • For more details, check the GitHub Wiki: │")
- fmt.Println(" │ • https://github.com/JacksonTheMaster/StationeersServerUI/v5/wiki │")
- fmt.Println(" └─────────────────────────────────────────────────────────────────────────────────────────────┘")
-}
diff --git a/src/config/config.go b/src/config/config.go
index 2f592b5b..56afb16c 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.5.9"
+ Version = "5.6.1"
Branch = "release"
)
@@ -27,14 +27,14 @@ type JsonConfig struct {
BlackListFilePath string `json:"blackListFilePath"`
IsDiscordEnabled *bool `json:"isDiscordEnabled"`
ErrorChannelID string `json:"errorChannelID"`
- BackupKeepLastN int `json:"backupKeepLastN"`
- IsCleanupEnabled *bool `json:"isCleanupEnabled"`
- BackupKeepDailyFor int `json:"backupKeepDailyFor"`
- BackupKeepWeeklyFor int `json:"backupKeepWeeklyFor"`
- BackupKeepMonthlyFor int `json:"backupKeepMonthlyFor"`
- BackupCleanupInterval int `json:"backupCleanupInterval"`
- BackupWaitTime int `json:"backupWaitTime"`
- IsNewTerrainAndSaveSystem *bool `json:"IsNewTerrainAndSaveSystem"`
+ BackupKeepLastN int `json:"backupKeepLastN"` // Number of most recent backups to keep (default: 2000)
+ IsCleanupEnabled *bool `json:"isCleanupEnabled"` // Enable automatic cleanup of backups (default: false)
+ BackupKeepDailyFor int `json:"backupKeepDailyFor"` // Retention period in hours for daily backups
+ BackupKeepWeeklyFor int `json:"backupKeepWeeklyFor"` // Retention period in hours for weekly backups
+ BackupKeepMonthlyFor int `json:"backupKeepMonthlyFor"` // Retention period in hours for monthly backups
+ BackupCleanupInterval int `json:"backupCleanupInterval"` // Hours between backup cleanup operations
+ BackupWaitTime int `json:"backupWaitTime"` // Seconds to wait before copying backups
+ IsNewTerrainAndSaveSystem *bool `json:"IsNewTerrainAndSaveSystem"` // Use new terrain and save system
GameBranch string `json:"gameBranch"`
Difficulty string `json:"Difficulty"`
StartCondition string `json:"StartCondition"`
@@ -74,7 +74,7 @@ type JsonConfig struct {
IsConsoleEnabled *bool `json:"IsConsoleEnabled"`
LanguageSetting string `json:"LanguageSetting"`
AutoStartServerOnStartup *bool `json:"AutoStartServerOnStartup"`
- AdditionalLoginHeaderText string `json:"AdditionalLoginHeaderText"`
+ SSUIIdentifier string `json:"SSUIIdentifier"`
SSUIWebPort string `json:"SSUIWebPort"`
}
@@ -89,6 +89,7 @@ type CustomDetection struct {
// LoadConfig loads and initializes the configuration
func LoadConfig() (*JsonConfig, error) {
ConfigMu.Lock()
+ defer ConfigMu.Unlock()
var jsonConfig JsonConfig
file, err := os.Open(ConfigPath)
@@ -106,7 +107,6 @@ func LoadConfig() (*JsonConfig, error) {
// Other errors (e.g., permissions), fail immediately
return nil, fmt.Errorf("failed to open config file: %v", err)
}
- ConfigMu.Unlock()
// Apply configuration
applyConfig(&jsonConfig)
@@ -115,8 +115,6 @@ func LoadConfig() (*JsonConfig, error) {
// applyConfig applies the configuration with JSON -> env -> fallback hierarchy
func applyConfig(cfg *JsonConfig) {
- ConfigMu.Lock()
- defer ConfigMu.Unlock()
// Apply values with hierarchy
DiscordToken = getString(cfg.DiscordToken, "DISCORD_TOKEN", "")
ControlChannelID = getString(cfg.ControlChannelID, "CONTROL_CHANNEL_ID", "")
@@ -162,7 +160,7 @@ func applyConfig(cfg *JsonConfig) {
GamePort = getString(cfg.GamePort, "GAME_PORT", "27016")
UpdatePort = getString(cfg.UpdatePort, "UPDATE_PORT", "27015")
LanguageSetting = getString(cfg.LanguageSetting, "LANGUAGE_SETTING", "en-US")
- AdditionalLoginHeaderText = getString(cfg.AdditionalLoginHeaderText, "ADDITIONAL_LOGIN_HEADER_TEXT", "")
+ SSUIIdentifier = getString(cfg.SSUIIdentifier, "SSUI_IDENTIFIER", "")
SSUIWebPort = getString(cfg.SSUIWebPort, "SSUI_WEB_PORT", "8443")
upnpEnabledVal := getBool(cfg.UPNPEnabled, "UPNP_ENABLED", false)
@@ -265,8 +263,96 @@ func applyConfig(cfg *JsonConfig) {
ConfiguredSafeBackupDir = filepath.Join("./saves/", WorldName, "Safebackups")
}
+// use safeSaveConfig EXCLUSIVELY though setter functions
+// M U S T be called while holding a lock on ConfigMu!
+func safeSaveConfig() error {
+
+ cfg := JsonConfig{
+ DiscordToken: DiscordToken,
+ ControlChannelID: ControlChannelID,
+ StatusChannelID: StatusChannelID,
+ ConnectionListChannelID: ConnectionListChannelID,
+ LogChannelID: LogChannelID,
+ SaveChannelID: SaveChannelID,
+ ControlPanelChannelID: ControlPanelChannelID,
+ DiscordCharBufferSize: DiscordCharBufferSize,
+ BlackListFilePath: BlackListFilePath,
+ IsDiscordEnabled: &IsDiscordEnabled,
+ ErrorChannelID: ErrorChannelID,
+ BackupKeepLastN: BackupKeepLastN,
+ IsCleanupEnabled: &IsCleanupEnabled,
+ BackupKeepDailyFor: int(BackupKeepDailyFor / time.Hour), // Convert to hours
+ BackupKeepWeeklyFor: int(BackupKeepWeeklyFor / time.Hour), // Convert to hours
+ BackupKeepMonthlyFor: int(BackupKeepMonthlyFor / time.Hour), // Convert to hours
+ BackupCleanupInterval: int(BackupCleanupInterval / time.Hour), // Convert to hours
+ BackupWaitTime: int(BackupWaitTime / time.Second), // Convert to seconds
+ IsNewTerrainAndSaveSystem: &IsNewTerrainAndSaveSystem,
+ GameBranch: GameBranch,
+ Difficulty: Difficulty,
+ StartCondition: StartCondition,
+ StartLocation: StartLocation,
+ ServerName: ServerName,
+ SaveInfo: SaveInfo,
+ ServerMaxPlayers: ServerMaxPlayers,
+ ServerPassword: ServerPassword,
+ ServerAuthSecret: ServerAuthSecret,
+ AdminPassword: AdminPassword,
+ GamePort: GamePort,
+ UpdatePort: UpdatePort,
+ UPNPEnabled: &UPNPEnabled,
+ AutoSave: &AutoSave,
+ SaveInterval: SaveInterval,
+ AutoPauseServer: &AutoPauseServer,
+ LocalIpAddress: LocalIpAddress,
+ StartLocalHost: &StartLocalHost,
+ ServerVisible: &ServerVisible,
+ UseSteamP2P: &UseSteamP2P,
+ ExePath: ExePath,
+ AdditionalParams: AdditionalParams,
+ Users: Users,
+ AuthEnabled: &AuthEnabled,
+ JwtKey: JwtKey,
+ AuthTokenLifetime: AuthTokenLifetime,
+ Debug: &IsDebugMode,
+ CreateSSUILogFile: &CreateSSUILogFile,
+ LogLevel: LogLevel,
+ LogClutterToConsole: &LogClutterToConsole,
+ SubsystemFilters: SubsystemFilters,
+ IsUpdateEnabled: &IsUpdateEnabled,
+ IsSSCMEnabled: &IsSSCMEnabled,
+ AutoRestartServerTimer: AutoRestartServerTimer,
+ AllowPrereleaseUpdates: &AllowPrereleaseUpdates,
+ AllowMajorUpdates: &AllowMajorUpdates,
+ IsConsoleEnabled: &IsConsoleEnabled,
+ LanguageSetting: LanguageSetting,
+ AutoStartServerOnStartup: &AutoStartServerOnStartup,
+ SSUIIdentifier: SSUIIdentifier,
+ SSUIWebPort: SSUIWebPort,
+ }
+
+ file, err := os.Create(ConfigPath)
+ if err != nil {
+ return fmt.Errorf("error creating config.json: %v", err)
+ }
+ defer file.Close()
+
+ encoder := json.NewEncoder(file)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(cfg); err != nil {
+ return fmt.Errorf("error encoding config.json: %v", err)
+ }
+
+ return nil
+}
+
// use SaveConfig EXCLUSIVELY though loader.SaveConfig to trigger a reload afterwards!
-func SaveConfig(cfg *JsonConfig) error {
+// when the config gets updated, changes do not get reflected at runtime UNLESS a backend reload / config reload is triggered
+// This can be done via configchanger.SaveConfig
+func SaveConfigToFile(cfg *JsonConfig) error {
+
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
file, err := os.Create(ConfigPath)
if err != nil {
return fmt.Errorf("error creating config.json: %v", err)
diff --git a/src/config/configchanger/configuration.go b/src/config/configchanger/changeconfig.go
similarity index 95%
rename from src/config/configchanger/configuration.go
rename to src/config/configchanger/changeconfig.go
index 5cd6d666..25a89aeb 100644
--- a/src/config/configchanger/configuration.go
+++ b/src/config/configchanger/changeconfig.go
@@ -9,7 +9,6 @@ import (
"strconv"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader"
)
func SaveConfigForm(w http.ResponseWriter, r *http.Request) {
@@ -55,7 +54,7 @@ func SaveConfigForm(w http.ResponseWriter, r *http.Request) {
switch fieldType.Kind() {
case reflect.String:
field.SetString(formValue) // Set the value, even if it's empty to allow clearing the field
- case reflect.Ptr:
+ case reflect.Pointer:
if fieldType.Elem().Kind() == reflect.Bool {
newBool := formValue == "true" // Convert form value to bool
field.Set(reflect.ValueOf(&newBool)) // Set the pointer to the new bool
@@ -64,7 +63,7 @@ func SaveConfigForm(w http.ResponseWriter, r *http.Request) {
}
// Save the updated config
- if err := loader.SaveConfig(existingConfig); err != nil {
+ if err := SaveConfig(existingConfig); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -120,7 +119,7 @@ func SaveConfigRestful(w http.ResponseWriter, r *http.Request) {
if strValue, ok := value.(string); ok {
field.SetString(strValue)
}
- case reflect.Ptr:
+ case reflect.Pointer:
if fieldType.Elem().Kind() == reflect.Bool {
if boolValue, ok := value.(bool); ok {
field.Set(reflect.ValueOf(&boolValue)) // Set the pointer to the new bool
@@ -156,7 +155,7 @@ func SaveConfigRestful(w http.ResponseWriter, r *http.Request) {
}
// Save the updated config
- if err := loader.SaveConfig(existingConfig); err != nil {
+ if err := SaveConfig(existingConfig); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
diff --git a/src/config/configchanger/saveconfig.go b/src/config/configchanger/saveconfig.go
new file mode 100644
index 00000000..6163b3fb
--- /dev/null
+++ b/src/config/configchanger/saveconfig.go
@@ -0,0 +1,20 @@
+package configchanger
+
+import (
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+)
+
+func SaveConfig(cfg *config.JsonConfig, reloadBackend ...bool) error {
+ err := config.SaveConfigToFile(cfg)
+ if err != nil {
+ logger.Core.Error("Failed to save config: " + err.Error())
+ return err
+ }
+ // Call ReloadBackend by default, unless reloadBackend is explicitly false
+ if len(reloadBackend) == 0 || reloadBackend[0] {
+ loader.ReloadBackend()
+ }
+ return nil
+}
diff --git a/src/config/getters.go b/src/config/getters.go
new file mode 100644
index 00000000..c7220d69
--- /dev/null
+++ b/src/config/getters.go
@@ -0,0 +1,477 @@
+package config
+
+import (
+ "time"
+)
+
+func GetDiscordToken() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return DiscordToken
+}
+
+func GetControlChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ControlChannelID
+}
+
+func GetStatusChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return StatusChannelID
+}
+
+func GetConnectionListChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ConnectionListChannelID
+}
+
+func GetLogChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return LogChannelID
+}
+
+func GetSaveChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SaveChannelID
+}
+
+func GetControlPanelChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ControlPanelChannelID
+}
+
+func GetDiscordCharBufferSize() int {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return DiscordCharBufferSize
+}
+
+func GetBlackListFilePath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BlackListFilePath
+}
+
+func GetIsDiscordEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsDiscordEnabled
+}
+
+func GetErrorChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ErrorChannelID
+}
+
+func GetBackupKeepLastN() int {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupKeepLastN
+}
+
+func GetIsCleanupEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsCleanupEnabled
+}
+
+// GetBackupKeepDailyFor returns the retention period for daily backups in hours.
+func GetBackupKeepDailyFor() time.Duration {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupKeepDailyFor
+}
+
+func GetBackupKeepWeeklyFor() time.Duration {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupKeepWeeklyFor
+}
+
+// GetBackupKeepMonthlyFor returns the retention period for monthly backups in hours.
+func GetBackupKeepMonthlyFor() time.Duration {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupKeepMonthlyFor
+}
+
+// GetBackupCleanupInterval returns the cleanup interval in hours.
+func GetBackupCleanupInterval() time.Duration {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupCleanupInterval
+}
+
+func GetIsNewTerrainAndSaveSystem() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsNewTerrainAndSaveSystem
+}
+
+func GetGameBranch() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return GameBranch
+}
+
+func GetDifficulty() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return Difficulty
+}
+
+func GetStartCondition() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return StartCondition
+}
+
+func GetStartLocation() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return StartLocation
+}
+
+func GetServerName() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ServerName
+}
+
+func GetSaveInfo() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SaveInfo
+}
+
+func GetWorldName() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return WorldName
+}
+
+func GetBackupWorldName() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupWorldName
+}
+
+func GetServerMaxPlayers() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ServerMaxPlayers
+}
+
+func GetServerPassword() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ServerPassword
+}
+
+func GetServerAuthSecret() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ServerAuthSecret
+}
+
+func GetAdminPassword() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AdminPassword
+}
+
+func GetGamePort() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return GamePort
+}
+
+func GetUpdatePort() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return UpdatePort
+}
+
+func GetUPNPEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return UPNPEnabled
+}
+
+func GetAutoSave() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AutoSave
+}
+
+func GetSaveInterval() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SaveInterval
+}
+
+func GetAutoPauseServer() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AutoPauseServer
+}
+
+func GetLocalIpAddress() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return LocalIpAddress
+}
+
+func GetStartLocalHost() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return StartLocalHost
+}
+
+func GetServerVisible() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ServerVisible
+}
+
+func GetUseSteamP2P() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return UseSteamP2P
+}
+
+func GetExePath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ExePath
+}
+
+func GetAdditionalParams() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AdditionalParams
+}
+
+func GetUsers() map[string]string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return Users
+}
+
+func GetAuthEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AuthEnabled
+}
+
+func GetJwtKey() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return JwtKey
+}
+
+func GetAuthTokenLifetime() int {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AuthTokenLifetime
+}
+
+func GetIsDebugMode() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsDebugMode
+}
+
+func GetCreateSSUILogFile() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return CreateSSUILogFile
+}
+
+func GetLogLevel() int {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return LogLevel
+}
+
+func GetLogClutterToConsole() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return LogClutterToConsole
+}
+
+func GetSubsystemFilters() []string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SubsystemFilters
+}
+
+func GetIsUpdateEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsUpdateEnabled
+}
+
+func GetIsSSCMEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsSSCMEnabled
+}
+
+func GetSSCMFilePath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SSCMFilePath
+}
+
+func GetSSCMPluginDir() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SSCMPluginDir
+}
+
+func GetSSCMWebDir() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SSCMWebDir
+}
+
+func GetAutoRestartServerTimer() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AutoRestartServerTimer
+}
+
+func GetAllowPrereleaseUpdates() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AllowPrereleaseUpdates
+}
+
+func GetAllowMajorUpdates() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AllowMajorUpdates
+}
+
+func GetIsConsoleEnabled() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsConsoleEnabled
+}
+
+func GetLanguageSetting() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return LanguageSetting
+}
+
+func GetAutoStartServerOnStartup() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return AutoStartServerOnStartup
+}
+
+func GetSSUIIdentifier() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SSUIIdentifier
+}
+
+func GetSSUIWebPort() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SSUIWebPort
+}
+
+// GetIsFirstTimeSetup returns the IsFirstTimeSetup
+func GetIsFirstTimeSetup() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return IsFirstTimeSetup
+}
+
+func GetConfigPath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ConfigPath
+}
+
+func GetVersion() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return Version
+}
+
+func GetBranch() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return Branch
+}
+
+func GetTLSCertPath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return TLSCertPath
+}
+
+func GetTLSKeyPath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return TLSKeyPath
+}
+
+func GetUIModFolder() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return UIModFolder
+}
+
+func GetMaxSSEConnections() int {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return MaxSSEConnections
+}
+
+func GetSSEMessageBufferSize() int {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return SSEMessageBufferSize
+}
+
+func GetLogFolder() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return LogFolder
+}
+
+func GetConfiguredBackupDir() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ConfiguredBackupDir
+}
+
+func GetConfiguredSafeBackupDir() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ConfiguredSafeBackupDir
+}
+
+func GetCustomDetectionsFilePath() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return CustomDetectionsFilePath
+}
+
+func GetGameServerAppID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return GameServerAppID
+}
diff --git a/src/config/setters.go b/src/config/setters.go
new file mode 100644
index 00000000..19e58065
--- /dev/null
+++ b/src/config/setters.go
@@ -0,0 +1,632 @@
+package config
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Although this is a not a real setter, this function can be used to save the config safely
+func SetSaveConfig() error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+ return safeSaveConfig()
+}
+
+// Setup and System Settings
+func SetIsFirstTimeSetup(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsFirstTimeSetup = value
+ return safeSaveConfig()
+}
+
+func SetIsSSCMEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsSSCMEnabled = value
+ return safeSaveConfig()
+}
+
+// ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT
+// ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT
+// ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT
+// ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT
+// ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT
+
+// Debug and Logging Settings
+func SetIsDebugMode(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsDebugMode = value
+ return safeSaveConfig()
+}
+
+// SetCreateSSUILogFile sets the CreateSSUILogFile with validation
+func SetCreateSSUILogFile(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ CreateSSUILogFile = value
+ return safeSaveConfig()
+}
+
+// SetLogLevel sets the LogLevel with validation
+func SetLogLevel(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value < 0 {
+ return fmt.Errorf("log level cannot be negative")
+ }
+
+ LogLevel = value
+ return safeSaveConfig()
+}
+
+func SetLogClutterToConsole(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ LogClutterToConsole = value
+ return safeSaveConfig()
+}
+
+func SetSubsystemFilters(value []string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ for _, v := range value {
+ if strings.TrimSpace(v) == "" {
+ return fmt.Errorf("subsystem filter cannot be empty")
+ }
+ }
+
+ SubsystemFilters = value
+ return safeSaveConfig()
+}
+
+// SetSSEMessageBufferSize sets the SSEMessageBufferSize with validation
+func SetSSEMessageBufferSize(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value <= 0 {
+ return fmt.Errorf("SSE message buffer size must be positive")
+ }
+
+ SSEMessageBufferSize = value
+ return safeSaveConfig()
+}
+
+// SetMaxSSEConnections sets the MaxSSEConnections with validation
+func SetMaxSSEConnections(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value <= 0 {
+ return fmt.Errorf("max SSE connections must be positive")
+ }
+
+ MaxSSEConnections = value
+ return safeSaveConfig()
+}
+
+func SetLanguageSetting(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ LanguageSetting = value
+ return safeSaveConfig()
+}
+
+func SetSSUIWebPort(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("SSUI web port cannot be empty")
+ }
+
+ SSUIWebPort = value
+ return safeSaveConfig()
+}
+
+func SetSSUIIdentifier(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ SSUIIdentifier = value
+ return safeSaveConfig()
+}
+
+// Game Settings
+func SetGameBranch(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("game branch cannot be empty")
+ }
+
+ GameBranch = value
+ return safeSaveConfig()
+}
+
+func SetDifficulty(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ Difficulty = value
+ return safeSaveConfig()
+}
+
+func SetStartCondition(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ StartCondition = value
+ return safeSaveConfig()
+}
+
+func SetStartLocation(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ StartLocation = value
+ return safeSaveConfig()
+}
+
+func SetIsNewTerrainAndSaveSystem(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsNewTerrainAndSaveSystem = value
+ return safeSaveConfig()
+}
+
+// Server Settings
+func SetServerName(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ServerName = value
+ return safeSaveConfig()
+}
+
+func SetSaveInfo(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ SaveInfo = value
+ return safeSaveConfig()
+}
+
+func SetServerMaxPlayers(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ServerMaxPlayers = value
+ return safeSaveConfig()
+}
+
+func SetServerPassword(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ServerPassword = value
+ return safeSaveConfig()
+}
+
+func SetServerAuthSecret(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ServerAuthSecret = value
+ return safeSaveConfig()
+}
+
+func SetAdminPassword(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AdminPassword = value
+ return safeSaveConfig()
+}
+
+func SetGamePort(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ GamePort = value
+ return safeSaveConfig()
+}
+
+func SetUpdatePort(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ UpdatePort = value
+ return safeSaveConfig()
+}
+
+func SetUPNPEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ UPNPEnabled = value
+ return safeSaveConfig()
+}
+
+func SetAutoSave(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AutoSave = value
+ return safeSaveConfig()
+}
+
+func SetSaveInterval(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ SaveInterval = value
+ return safeSaveConfig()
+}
+
+func SetAutoPauseServer(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AutoPauseServer = value
+ return safeSaveConfig()
+}
+
+func SetLocalIpAddress(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ LocalIpAddress = value
+ return safeSaveConfig()
+}
+
+func SetStartLocalHost(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ StartLocalHost = value
+ return safeSaveConfig()
+}
+
+func SetServerVisible(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ServerVisible = value
+ return safeSaveConfig()
+}
+
+func SetUseSteamP2P(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ UseSteamP2P = value
+ return safeSaveConfig()
+}
+
+func SetExePath(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ExePath = value
+ return safeSaveConfig()
+}
+
+func SetAdditionalParams(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AdditionalParams = value
+ return safeSaveConfig()
+}
+
+func SetAutoStartServerOnStartup(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AutoStartServerOnStartup = value
+ return safeSaveConfig()
+}
+
+func SetAutoRestartServerTimer(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AutoRestartServerTimer = value
+ return safeSaveConfig()
+}
+
+// Backup Settings
+func SetBackupKeepLastN(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value < 0 {
+ return fmt.Errorf("backup keep last N cannot be negative")
+ }
+
+ BackupKeepLastN = value
+ return safeSaveConfig()
+}
+
+func SetIsCleanupEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsCleanupEnabled = value
+ return safeSaveConfig()
+}
+
+func SetBackupKeepDailyFor(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value < 0 {
+ return fmt.Errorf("backup keep daily for cannot be negative")
+ }
+
+ BackupKeepDailyFor = time.Duration(value) * time.Hour
+ return safeSaveConfig()
+}
+
+func SetBackupKeepWeeklyFor(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value < 0 {
+ return fmt.Errorf("backup keep weekly for cannot be negative")
+ }
+
+ BackupKeepWeeklyFor = time.Duration(value) * time.Hour
+ return safeSaveConfig()
+}
+
+func SetBackupKeepMonthlyFor(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value < 0 {
+ return fmt.Errorf("backup keep monthly for cannot be negative")
+ }
+
+ BackupKeepMonthlyFor = time.Duration(value) * time.Hour
+ return safeSaveConfig()
+}
+
+func SetBackupCleanupInterval(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value <= 0 {
+ return fmt.Errorf("backup cleanup interval must be positive")
+ }
+
+ BackupCleanupInterval = time.Duration(value) * time.Hour
+ return safeSaveConfig()
+}
+
+func SetBackupWaitTime(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value < 0 {
+ return fmt.Errorf("backup wait time cannot be negative")
+ }
+
+ BackupWaitTime = time.Duration(value) * time.Second
+ return safeSaveConfig()
+}
+
+// Discord Settings
+func SetIsDiscordEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsDiscordEnabled = value
+ return safeSaveConfig()
+}
+
+func SetDiscordToken(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ DiscordToken = value
+ return safeSaveConfig()
+}
+
+func SetControlChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ControlChannelID = value
+ return safeSaveConfig()
+}
+
+// SetStatusChannelID sets the StatusChannelID
+func SetStatusChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ StatusChannelID = value
+ return safeSaveConfig()
+}
+
+// SetLogChannelID sets the LogChannelID
+func SetLogChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ LogChannelID = value
+ return safeSaveConfig()
+}
+
+// SetErrorChannelID sets the ErrorChannelID
+func SetErrorChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ErrorChannelID = value
+ return safeSaveConfig()
+}
+
+// SetConnectionListChannelID sets the ConnectionListChannelID
+func SetConnectionListChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ConnectionListChannelID = value
+ return safeSaveConfig()
+}
+
+// SetSaveChannelID sets the SaveChannelID
+func SetSaveChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ SaveChannelID = value
+ return safeSaveConfig()
+}
+
+// SetControlPanelChannelID sets the ControlPanelChannelID
+func SetControlPanelChannelID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ControlPanelChannelID = value
+ return safeSaveConfig()
+}
+
+// SetDiscordCharBufferSize sets the DiscordCharBufferSize with validation
+func SetDiscordCharBufferSize(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value <= 0 {
+ return fmt.Errorf("discord char buffer size must be positive")
+ }
+
+ DiscordCharBufferSize = value
+ return safeSaveConfig()
+}
+
+func SetExceptionMessageID(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ ExceptionMessageID = value
+ return safeSaveConfig()
+}
+
+// SetBlackListFilePath sets the BlackListFilePath with validation
+func SetBlackListFilePath(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("blacklist file path cannot be empty")
+ }
+
+ BlackListFilePath = value
+ return safeSaveConfig()
+}
+
+func SetAuthEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AuthEnabled = value
+ return safeSaveConfig()
+}
+
+// SetJwtKey sets the JwtKey with validation
+func SetJwtKey(value string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if len(value) < 32 {
+ return fmt.Errorf("JWT key must be at least 32 bytes")
+ }
+
+ JwtKey = value
+ return safeSaveConfig()
+}
+
+// SetAuthTokenLifetime sets the AuthTokenLifetime with validation
+func SetAuthTokenLifetime(value int) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ if value <= 0 {
+ return fmt.Errorf("auth token lifetime must be positive")
+ }
+
+ AuthTokenLifetime = value
+ return safeSaveConfig()
+}
+
+// SetUsers merges the provided key-value pairs into the existing Users map with validation
+func SetUsers(value map[string]string) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ // Initialize Users map if it's nil
+ if Users == nil {
+ Users = make(map[string]string)
+ }
+
+ // Validate and merge each key-value pair
+ for k, v := range value {
+ if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
+ return fmt.Errorf("user key or value cannot be empty")
+ }
+ Users[k] = v // Update or add the key-value pair
+ }
+
+ return safeSaveConfig()
+}
+
+// Update Settings
+func SetIsUpdateEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsUpdateEnabled = value
+ return safeSaveConfig()
+}
+
+func SetAllowPrereleaseUpdates(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AllowPrereleaseUpdates = value
+ return safeSaveConfig()
+}
+
+func SetAllowMajorUpdates(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ AllowMajorUpdates = value
+ return safeSaveConfig()
+}
+
+func SetIsConsoleEnabled(value bool) error {
+ ConfigMu.Lock()
+ defer ConfigMu.Unlock()
+
+ IsConsoleEnabled = value
+ return safeSaveConfig()
+}
diff --git a/src/config/vars.go b/src/config/vars.go
index 26df6c36..e8e3cda8 100644
--- a/src/config/vars.go
+++ b/src/config/vars.go
@@ -6,22 +6,16 @@ import (
"time"
"github.com/bwmarrin/discordgo"
- "github.com/google/uuid"
)
/*
config.Version and config.Branch can be found in config.go
ConfigMu protects all config variables. Lock it for writes; reads are safe
-if writes only happen via applyConfig or with ConfigMu locked.
-
-WARNING: Do NOT set any config vars without locking ConfigMu:
-config.ConfigMu.Lock()
-config.SomeConfigVar = newValue
-config.ConfigMu.Unlock()
+if writes only happen via applyConfig or with ConfigMu locked. Uses getters where possible.
*/
-var ConfigMu sync.Mutex
+var ConfigMu sync.RWMutex
// Game Server configuration
var (
@@ -51,25 +45,22 @@ var (
// Logging, debugging and misc
var (
- IsDebugMode bool //only used for pprof server, keep it like this and check the log level instead. Debug = 10
- CreateSSUILogFile bool
- LogLevel int
- LogMessageBuffer string
- IsFirstTimeSetup bool
- BufferFlushTicker *time.Ticker
- SSEMessageBufferSize = 2000
- MaxSSEConnections = 20
- GameServerAppID = "600760"
- ExePath string
- GameBranch string
- SubsystemFilters []string
- GameServerUUID uuid.UUID // Assined at startup to the current instance of the server we are managing. Currently unused.
- AutoRestartServerTimer string
- IsConsoleEnabled bool
- LogClutterToConsole bool // surpresses clutter mono logs from the gameserver
- LanguageSetting string
- AutoStartServerOnStartup bool
- AdditionalLoginHeaderText string
+ IsDebugMode bool //only used for pprof server, keep it like this and check the log level instead. Debug = 10
+ CreateSSUILogFile bool
+ LogLevel int
+ IsFirstTimeSetup bool
+ SSEMessageBufferSize = 2000
+ MaxSSEConnections = 20
+ GameServerAppID = "600760"
+ ExePath string
+ GameBranch string
+ SubsystemFilters []string
+ AutoRestartServerTimer string
+ IsConsoleEnabled bool
+ LogClutterToConsole bool // surpresses clutter mono logs from the gameserver
+ LanguageSetting string
+ AutoStartServerOnStartup bool
+ SSUIIdentifier string
)
// Discord integration
@@ -85,7 +76,6 @@ var (
SaveChannelID string
ControlPanelChannelID string
DiscordCharBufferSize int
- ControlMessageID string
ExceptionMessageID string
BlackListFilePath string
)
diff --git a/src/core/loader/afterstart.go b/src/core/loader/afterstart.go
index d5ad20b6..8200e65f 100644
--- a/src/core/loader/afterstart.go
+++ b/src/core/loader/afterstart.go
@@ -1,6 +1,8 @@
package loader
import (
+ "sync"
+
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/discordrpc"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
@@ -8,16 +10,11 @@ import (
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup"
)
-func AfterStartComplete() {
- existingConfig, err := config.LoadConfig()
- if err != nil {
- logger.Core.Error("AfterStartComplete: Failed to Load config: " + err.Error())
- }
- err = SaveConfig(existingConfig, false) // save config, but explicitly DONT reload backend since config is already loaded
- if err != nil {
- logger.Core.Error("AfterStartComplete: Failed to save config: " + err.Error())
- }
- err = setup.CleanUpOldUIModFolderFiles()
+func AfterStartComplete(wg *sync.WaitGroup) {
+ wg.Add(1)
+ defer wg.Done()
+ config.SetSaveConfig() // Save config after startup through setters
+ err := setup.CleanUpOldUIModFolderFiles()
if err != nil {
logger.Core.Error("AfterStartComplete: Failed to clean up old pre-v5.5 UI mod folder files: " + err.Error())
}
@@ -25,11 +22,18 @@ func AfterStartComplete() {
if err != nil {
logger.Core.Error("AfterStartComplete: Failed to clean up old executables: " + err.Error())
}
- if config.AutoStartServerOnStartup {
+ if config.GetAutoStartServerOnStartup() {
logger.Core.Info("AutoStartServerOnStartup is enabled, starting server...")
gamemgr.InternalStartServer()
}
setup.SetupAutostartScripts()
- // start discordrpc in a separate goroutine
discordrpc.StartDiscordRPC()
+
+ go func() {
+ //time.Sleep(10 * time.Second)
+ printStartupMessage()
+ if config.GetIsFirstTimeSetup() {
+ printFirstTimeSetupMessage()
+ }
+ }()
}
diff --git a/src/core/loader/helpers.go b/src/core/loader/helpers.go
index 94ac68d8..7a02792a 100644
--- a/src/core/loader/helpers.go
+++ b/src/core/loader/helpers.go
@@ -2,76 +2,150 @@ package loader
import (
"fmt"
- "strconv"
+ "strings"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
)
-// this is a Hack, but it works for now. Ideally, move the getter setter logic from SteamServerUI to StationeersServerUI, but not feasible at the moment.
-func SaveConfig(cfg *config.JsonConfig, reloadBackend ...bool) error {
- err := config.SaveConfig(cfg)
- if err != nil {
- logger.Core.Error("Failed to save config: " + err.Error())
- return err
+func PrintConfigDetails() {
+ logger.Config.Debug("=== Game Server Configuration Details ===")
+
+ // Helper function to print sections
+ printSection := func(title string, fields map[string]string) {
+ logger.Config.Debug(fmt.Sprintf("\n%s", title))
+ logger.Config.Debug(strings.Repeat("-", len(title)))
+ for key, value := range fields {
+ logger.Config.Debug(fmt.Sprintf("%-30s: %s", key, value))
+ }
}
- // Call ReloadBackend by default, unless reloadBackend is explicitly false
- if len(reloadBackend) == 0 || reloadBackend[0] {
- ReloadBackend()
+
+ // General Configuration
+ general := map[string]string{
+ "Branch": config.GetBranch(),
+ "Version": config.GetVersion(),
+ "IsFirstTimeSetup": fmt.Sprintf("%v", config.GetIsFirstTimeSetup()),
+ "IsDebugMode": fmt.Sprintf("%v", config.GetIsDebugMode()),
+ "IsConsoleEnabled": fmt.Sprintf("%v", config.GetIsConsoleEnabled()),
+ "AutoStartServerOnStartup": fmt.Sprintf("%v", config.GetAutoStartServerOnStartup()),
+ "LanguageSetting": config.GetLanguageSetting(),
+ "ConfigPath": config.GetConfigPath(),
}
- return nil
-}
+ printSection("General Configuration", general)
-func PrintConfigDetails() {
- logger.Config.Debug("Gameserver config values loaded")
- logger.Config.Debug("---- GENERAL CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("Branch: %s", config.Branch))
- logger.Config.Debug(fmt.Sprintf("GameBranch: %s", config.GameBranch))
- logger.Config.Debug("IsDiscordEnabled: " + strconv.FormatBool(config.IsDiscordEnabled))
- logger.Config.Debug("IsCleanupEnabled: " + strconv.FormatBool(config.IsCleanupEnabled))
- logger.Config.Debug("IsDebugMode (pprof Server): " + strconv.FormatBool(config.IsDebugMode))
- logger.Config.Debug("IsFirstTimeSetup: " + strconv.FormatBool(config.IsFirstTimeSetup))
-
- logger.Config.Debug("---- DISCORD CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("BlackListFilePath: %s", config.BlackListFilePath))
- logger.Config.Debug(fmt.Sprintf("ConnectionListChannelID: %s", config.ConnectionListChannelID))
- logger.Config.Debug(fmt.Sprintf("ControlChannelID: %s", config.ControlChannelID))
- logger.Config.Debug(fmt.Sprintf("ControlPanelChannelID: %s", config.ControlPanelChannelID))
- logger.Config.Debug(fmt.Sprintf("DiscordCharBufferSize: %d", config.DiscordCharBufferSize))
- logger.Config.Debug(fmt.Sprintf("DiscordToken: %s", config.DiscordToken))
- logger.Config.Debug(fmt.Sprintf("ErrorChannelID: %s", config.ErrorChannelID))
- logger.Config.Debug(fmt.Sprintf("IsDiscordEnabled: %v", config.IsDiscordEnabled))
- logger.Config.Debug(fmt.Sprintf("LogChannelID: %s", config.LogChannelID))
- logger.Config.Debug(fmt.Sprintf("LogMessageBuffer: %s", config.LogMessageBuffer))
- logger.Config.Debug(fmt.Sprintf("SaveChannelID: %s", config.SaveChannelID))
- logger.Config.Debug(fmt.Sprintf("StatusChannelID: %s", config.StatusChannelID))
-
- logger.Config.Debug("---- BACKUP CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("BackupKeepLastN: %d", config.BackupKeepLastN))
- logger.Config.Debug(fmt.Sprintf("BackupKeepDailyFor: %s", config.BackupKeepDailyFor))
- logger.Config.Debug(fmt.Sprintf("BackupKeepWeeklyFor: %s", config.BackupKeepWeeklyFor))
- logger.Config.Debug(fmt.Sprintf("BackupKeepMonthlyFor: %s", config.BackupKeepMonthlyFor))
- logger.Config.Debug(fmt.Sprintf("BackupCleanupInterval: %s", config.BackupCleanupInterval))
- logger.Config.Debug(fmt.Sprintf("ConfiguredBackupDir: %s", config.ConfiguredBackupDir))
- logger.Config.Debug(fmt.Sprintf("ConfiguredSafeBackupDir: %s", config.ConfiguredSafeBackupDir))
- logger.Config.Debug(fmt.Sprintf("BackupWaitTime: %s", config.BackupWaitTime))
-
- logger.Config.Debug("---- AUTHENTICATION CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("AuthTokenLifetime: %d", config.AuthTokenLifetime))
- logger.Config.Debug(fmt.Sprintf("JwtKey: %s", config.JwtKey))
-
- logger.Config.Debug("---- MISC CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("Branch: %s", config.Branch))
- logger.Config.Debug(fmt.Sprintf("GameServerAppID: %s", config.GameServerAppID))
- logger.Config.Debug(fmt.Sprintf("Version: %s", config.Version))
- logger.Config.Debug(fmt.Sprintf("IsNewTerrainAndSaveSystem: %v", config.IsNewTerrainAndSaveSystem))
-
- logger.Config.Debug("---- UPDATER CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("AllowPrereleaseUpdates: %v", config.AllowPrereleaseUpdates))
- logger.Config.Debug(fmt.Sprintf("AllowMajorUpdates: %v", config.AllowMajorUpdates))
- logger.Config.Debug(fmt.Sprintf("IsUpdateEnabled: %v", config.IsUpdateEnabled))
-
- logger.Config.Debug("---- SSCM CONFIG VARS ----")
- logger.Config.Debug(fmt.Sprintf("SSCMFilePath: %s", config.SSCMFilePath))
- logger.Config.Debug(fmt.Sprintf("IsSSCMEnabled: %v", config.IsSSCMEnabled))
+ // Server Configuration
+ server := map[string]string{
+ "GameBranch": config.GetGameBranch(),
+ "ServerName": config.GetServerName(),
+ "WorldName": config.GetWorldName(),
+ "BackupWorldName": config.GetBackupWorldName(),
+ "ServerMaxPlayers": config.GetServerMaxPlayers(),
+ "GamePort": config.GetGamePort(),
+ "UpdatePort": config.GetUpdatePort(),
+ "UPNPEnabled": fmt.Sprintf("%v", config.GetUPNPEnabled()),
+ "AutoSave": fmt.Sprintf("%v", config.GetAutoSave()),
+ "SaveInterval": config.GetSaveInterval(),
+ "AutoPauseServer": fmt.Sprintf("%v", config.GetAutoPauseServer()),
+ "LocalIpAddress": config.GetLocalIpAddress(),
+ "StartLocalHost": fmt.Sprintf("%v", config.GetStartLocalHost()),
+ "ServerVisible": fmt.Sprintf("%v", config.GetServerVisible()),
+ "UseSteamP2P": fmt.Sprintf("%v", config.GetUseSteamP2P()),
+ "ExePath": config.GetExePath(),
+ "AdditionalParams": config.GetAdditionalParams(),
+ "GameServerAppID": config.GetGameServerAppID(),
+ "Difficulty": config.GetDifficulty(),
+ "StartCondition": config.GetStartCondition(),
+ "StartLocation": config.GetStartLocation(),
+ "SaveInfo": config.GetSaveInfo(),
+ "IsNewTerrainAndSaveSystem": fmt.Sprintf("%v", config.GetIsNewTerrainAndSaveSystem()),
+ }
+ printSection("Server Configuration", server)
+
+ // Discord Configuration
+ discord := map[string]string{
+ "IsDiscordEnabled": fmt.Sprintf("%v", config.GetIsDiscordEnabled()),
+ "ControlChannelID": config.GetControlChannelID(),
+ "StatusChannelID": config.GetStatusChannelID(),
+ "ConnectionListChannelID": config.GetConnectionListChannelID(),
+ "LogChannelID": config.GetLogChannelID(),
+ "SaveChannelID": config.GetSaveChannelID(),
+ "ControlPanelChannelID": config.GetControlPanelChannelID(),
+ "ErrorChannelID": config.GetErrorChannelID(),
+ "DiscordCharBufferSize": fmt.Sprintf("%d", config.GetDiscordCharBufferSize()),
+ "BlackListFilePath": config.GetBlackListFilePath(),
+ }
+ printSection("Discord Configuration", discord)
+
+ // Backup Configuration
+ backup := map[string]string{
+ "BackupKeepLastN": fmt.Sprintf("%d", config.GetBackupKeepLastN()),
+ "IsCleanupEnabled": fmt.Sprintf("%v", config.GetIsCleanupEnabled()),
+ "BackupKeepDailyFor": fmt.Sprintf("%v", config.GetBackupKeepDailyFor()),
+ "BackupKeepWeeklyFor": fmt.Sprintf("%v", config.GetBackupKeepWeeklyFor()),
+ "BackupKeepMonthlyFor": fmt.Sprintf("%v", config.GetBackupKeepMonthlyFor()),
+ "BackupCleanupInterval": fmt.Sprintf("%v", config.GetBackupCleanupInterval()),
+ "ConfiguredBackupDir": config.GetConfiguredBackupDir(),
+ "ConfiguredSafeBackupDir": config.GetConfiguredSafeBackupDir(),
+ }
+ printSection("Backup Configuration", backup)
+
+ // Authentication Configuration
+ auth := map[string]string{
+ "AuthEnabled": fmt.Sprintf("%v", config.GetAuthEnabled()),
+ "AuthTokenLifetime": fmt.Sprintf("%d", config.GetAuthTokenLifetime()),
+ }
+ printSection("Authentication Configuration", auth)
+
+ // Logging Configuration
+ logging := map[string]string{
+ "CreateSSUILogFile": fmt.Sprintf("%v", config.GetCreateSSUILogFile()),
+ "LogLevel": fmt.Sprintf("%d", config.GetLogLevel()),
+ "LogClutterToConsole": fmt.Sprintf("%v", config.GetLogClutterToConsole()),
+ "SubsystemFilters": fmt.Sprintf("%v", config.GetSubsystemFilters()),
+ "LogFolder": config.GetLogFolder(),
+ }
+ printSection("Logging Configuration", logging)
+
+ // Updater Configuration
+ updater := map[string]string{
+ "IsUpdateEnabled": fmt.Sprintf("%v", config.GetIsUpdateEnabled()),
+ "AllowPrereleaseUpdates": fmt.Sprintf("%v", config.GetAllowPrereleaseUpdates()),
+ "AllowMajorUpdates": fmt.Sprintf("%v", config.GetAllowMajorUpdates()),
+ "AutoRestartServerTimer": config.GetAutoRestartServerTimer(),
+ }
+ printSection("Updater Configuration", updater)
+
+ // SSCM Configuration
+ sscm := map[string]string{
+ "IsSSCMEnabled": fmt.Sprintf("%v", config.GetIsSSCMEnabled()),
+ "SSCMFilePath": config.GetSSCMFilePath(),
+ "SSCMPluginDir": config.GetSSCMPluginDir(),
+ "SSCMWebDir": config.GetSSCMWebDir(),
+ }
+ printSection("SSCM Configuration", sscm)
+
+ // UI Configuration
+ ui := map[string]string{
+ "SSUIIdentifier": config.GetSSUIIdentifier(),
+ "SSUIWebPort": config.GetSSUIWebPort(),
+ "UIModFolder": config.GetUIModFolder(),
+ "MaxSSEConnections": fmt.Sprintf("%d", config.GetMaxSSEConnections()),
+ "SSEMessageBufferSize": fmt.Sprintf("%d", config.GetSSEMessageBufferSize()),
+ }
+ printSection("UI Configuration", ui)
+
+ // TLS Configuration
+ tls := map[string]string{
+ "TLSCertPath": config.GetTLSCertPath(),
+ "TLSKeyPath": config.GetTLSKeyPath(),
+ }
+ printSection("TLS Configuration", tls)
+
+ // Custom Detections
+ custom := map[string]string{
+ "CustomDetectionsFilePath": config.GetCustomDetectionsFilePath(),
+ }
+ printSection("Custom Detections Configuration", custom)
+
+ logger.Config.Debug("=======================================")
}
diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go
index b70c5448..78e1a1e6 100644
--- a/src/core/loader/loader.go
+++ b/src/core/loader/loader.go
@@ -3,6 +3,7 @@ package loader
import (
"embed"
+ "sync"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/discordbot"
@@ -15,12 +16,15 @@ import (
)
// only call this once at startup
-func InitBackend() {
+func InitBackend(wg *sync.WaitGroup) {
+ wg.Add(1)
+ defer wg.Done()
ReloadConfig()
ReloadSSCM()
ReloadBackupManager()
ReloadLocalizer()
ReloadDiscordBot()
+ InitDetector()
}
// use this to reload backend at runtime
@@ -32,6 +36,7 @@ func ReloadBackend() {
ReloadBackupManager()
ReloadLocalizer()
PrintConfigDetails()
+ logger.Core.Info("Backend reload done!")
}
// should ideally not be called standalone, if feasable, call ReloadBackend instead
@@ -45,7 +50,7 @@ func ReloadConfig() {
}
func ReloadSSCM() {
- if config.IsSSCMEnabled {
+ if config.GetIsSSCMEnabled() {
setup.InstallSSCM()
}
}
@@ -59,7 +64,7 @@ func ReloadBackupManager() {
}
func ReloadDiscordBot() {
- if config.IsDiscordEnabled {
+ if config.GetIsDiscordEnabled() {
go discordbot.InitializeDiscordBot()
logger.Discord.Info("Discord bot reloaded successfully")
}
diff --git a/src/core/loader/terminalmsg.go b/src/core/loader/terminalmsg.go
new file mode 100644
index 00000000..22125f41
--- /dev/null
+++ b/src/core/loader/terminalmsg.go
@@ -0,0 +1,53 @@
+package loader
+
+import (
+ "runtime"
+ "time"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+)
+
+// PrintStartupMessage prints a stylish startup message to the terminal
+func printStartupMessage() {
+ // Clear some space
+ logger.Core.Cleanf("")
+ logger.Core.Cleanf("")
+
+ // Main ASCII art logo
+ logger.Core.Cleanf(" ███████╗████████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗███████╗██████╗ ███████╗ ███████╗██╗ ██╗██╗")
+ logger.Core.Cleanf(" ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝██╔════╝██╔══██╗██╔════╝ ██╔════╝██║ ██║██║")
+ logger.Core.Cleanf(" ███████╗ ██║ ███████║ ██║ ██║██║ ██║██╔██╗ ██║█████╗ █████╗ ██████╔╝███████╗█████╗███████╗██║ ██║██║")
+ logger.Core.Cleanf(" ╚════██║ ██║ ██╔══██║ ██║ ██║██║ ██║██║╚██╗██║██╔══╝ ██╔══╝ ██╔══██╗╚════██║╚════╝╚════██║██║ ██║██║")
+ logger.Core.Cleanf(" ███████║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║███████╗███████╗██║ ██║███████║ ███████║╚██████╔╝██║")
+ logger.Core.Cleanf(" ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝ ╚══════╝ ╚═════╝ ╚═╝")
+ logger.Core.Cleanf(" ╔═══════════════════════════════════════════════════════════════════════════════════════════════════╗")
+ logger.Core.Cleanf(" ║ 🎮 YOUR ONE-STOP SHOP FOR RUNNING A STATIONEERS SERVER 🎮 ║")
+ logger.Core.Cleanf(" ║ 🚀 Version: %s 📅 %s 💻 Runtime: %.3s/%s ║",
+ config.GetVersion(),
+ time.Now().Format("2006-01-02 15:04"),
+ runtime.GOOS,
+ runtime.GOARCH)
+ logger.Core.Cleanf(" ╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝")
+
+ // Web UI info
+ logger.Core.Cleanf("\n 🌐 Web UI available at: https://localhost:8443 (default) or https://:" + config.GetSSUIWebPort())
+ logger.Core.Cleanf("\n 🌐 Support available at: https://discord.gg/8n3vN92MyJ")
+
+ // Quote
+ logger.Core.Cleanf("\n JacksonTheMaster: \"Managing game servers shouldn't be rocket science... unless it's a rocket game!\"")
+}
+
+func printFirstTimeSetupMessage() {
+ // Setup guide
+ logger.Core.Cleanf(" 📋 GETTING STARTED:")
+ logger.Core.Cleanf(" ┌─────────────────────────────────────────────────────────────────────────────────────────────┐")
+ logger.Core.Cleanf(" │ • Ready, set, go! Welcome to StationeersServerUI, new User! │")
+ logger.Core.Cleanf(" │ • The good news: you made it here, which means you are likely ready to run your server! │")
+ logger.Core.Cleanf(" │ • If this is your first time here, no worries: SSUI is made to be easy to use. │")
+ logger.Core.Cleanf(" │ • Configure your server by visiting the WebUI! │")
+ logger.Core.Cleanf(" │ • Support is provided at https://discord.gg/8n3vN92MyJ │")
+ logger.Core.Cleanf(" │ • For more details, check the GitHub Wiki: │")
+ logger.Core.Cleanf(" │ • https://github.com/JacksonTheMaster/StationeersServerUI/v5/wiki │")
+ logger.Core.Cleanf(" └─────────────────────────────────────────────────────────────────────────────────────────────┘")
+}
diff --git a/src/core/security/auth.go b/src/core/security/auth.go
index 235e8331..a052eb24 100644
--- a/src/core/security/auth.go
+++ b/src/core/security/auth.go
@@ -20,7 +20,7 @@ type UserCredentials struct {
// GenerateJWT creates a JWT for a given username
func GenerateJWT(username string) (string, error) {
- expirationTime := time.Now().Add(time.Duration(config.AuthTokenLifetime) * time.Minute)
+ expirationTime := time.Now().Add(time.Duration(config.GetAuthTokenLifetime()) * time.Minute)
claims := &jwt.MapClaims{
"exp": expirationTime.Unix(),
"iss": "StationeersServerUI",
@@ -28,7 +28,7 @@ func GenerateJWT(username string) (string, error) {
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- tokenString, err := token.SignedString([]byte(config.JwtKey))
+ tokenString, err := token.SignedString([]byte(config.GetJwtKey()))
if err != nil {
return "", err
}
@@ -38,7 +38,7 @@ func GenerateJWT(username string) (string, error) {
// ValidateCredentials checks username and password against stored users
func ValidateCredentials(creds UserCredentials) (bool, error) {
// Placeholder: assumes config.Users is a map[string]string (username -> hashed password)
- storedHash, exists := config.Users[creds.Username]
+ storedHash, exists := config.GetUsers()[creds.Username]
if !exists {
return false, nil
}
@@ -59,7 +59,7 @@ func HashPassword(password string) (string, error) {
func ValidateJWT(tokenString string) (bool, error) {
claims := &jwt.MapClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
- return []byte(config.JwtKey), nil
+ return []byte(config.GetJwtKey()), nil
})
if err != nil || !token.Valid {
return false, err
diff --git a/src/core/security/tls.go b/src/core/security/tls.go
index 00b4cf76..2d4a9756 100644
--- a/src/core/security/tls.go
+++ b/src/core/security/tls.go
@@ -8,6 +8,7 @@ import (
"encoding/pem"
"fmt"
"math/big"
+ "net"
"os"
"path/filepath"
"time"
@@ -16,14 +17,24 @@ import (
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
)
-// EnsureTLSCerts ensures TLS certificates exist and are valid at config.TLSCertPath and config.TLSKeyPath, generating self-signed ones if needed.
+// EnsureTLSCerts ensures TLS certificates exist and are valid at config.GetTLSCertPath() and config.GetTLSKeyPath(), generating self-signed ones if needed.
func EnsureTLSCerts() error {
+ logger.Security.Debug("=== Starting TLS certificate check ===")
+
+ certPath := config.GetTLSCertPath()
+ keyPath := config.GetTLSKeyPath()
+ logger.Security.Debug(fmt.Sprintf("Cert path: %s", certPath))
+ logger.Security.Debug(fmt.Sprintf("Key path: %s", keyPath))
+
// Check if cert and key files exist
- certExists := fileExists(config.TLSCertPath)
- keyExists := fileExists(config.TLSKeyPath)
- tlsDir := config.UIModFolder + "tls/"
+ certExists := fileExists(certPath)
+ keyExists := fileExists(keyPath)
+ logger.Security.Debug(fmt.Sprintf("Cert exists: %t, Key exists: %t", certExists, keyExists))
+
+ tlsDir := config.GetUIModFolder() + "tls/"
if _, err := os.Stat(tlsDir); os.IsNotExist(err) {
+ logger.Security.Debug("TLS directory doesn't exist, creating...")
if err := os.MkdirAll(tlsDir, os.ModePerm); err != nil {
logger.Security.Error("Failed to create directory " + tlsDir + ": " + err.Error())
return nil
@@ -31,73 +42,117 @@ func EnsureTLSCerts() error {
}
if certExists && keyExists {
+ logger.Security.Debug("Both cert and key files exist, checking validity...")
+
// Load and check if the cert is still valid
- certData, err := os.ReadFile(config.TLSCertPath)
+ certData, err := os.ReadFile(certPath)
if err != nil {
+ logger.Security.Error(fmt.Sprintf("Failed to read cert file: %v", err))
return fmt.Errorf("failed to read cert file: %v", err)
}
+
certBlock, _ := pem.Decode(certData)
if certBlock == nil {
+ logger.Security.Error("Failed to decode PEM certificate")
return fmt.Errorf("failed to decode PEM certificate")
}
+
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
+ logger.Security.Error(fmt.Sprintf("Failed to parse certificate: %v", err))
return fmt.Errorf("failed to parse certificate: %v", err)
}
+ // Log certificate details
+ logger.Security.Debug(fmt.Sprintf("Certificate Serial: %s", cert.SerialNumber.String()))
+ logger.Security.Debug(fmt.Sprintf("Certificate NotBefore: %s", cert.NotBefore.Format(time.RFC3339)))
+ logger.Security.Debug(fmt.Sprintf("Certificate NotAfter: %s", cert.NotAfter.Format(time.RFC3339)))
+ logger.Security.Debug(fmt.Sprintf("Current time: %s", time.Now().Format(time.RFC3339)))
+
// Check if expired or near expiry (within 10 days of 90-day validity)
- if time.Now().After(cert.NotAfter) || time.Now().Add(10*24*time.Hour).After(cert.NotAfter) {
- logger.Security.Warn("Certificate expired or near expiry, regenerating...")
+ now := time.Now()
+ nearExpiry := now.Add(10 * 24 * time.Hour)
+
+ if now.After(cert.NotAfter) {
+ logger.Security.Warn("Certificate is expired, regenerating...")
+ } else if nearExpiry.After(cert.NotAfter) {
+ logger.Security.Warn("Certificate is near expiry (within 10 days), regenerating...")
} else {
- // Cert is valid, we’re done
+ // Cert is valid, we're done
+ logger.Security.Debug("Certificate is valid and not near expiry, using existing cert")
+ logger.Security.Debug("=== TLS certificate check complete - using existing ===")
return nil
}
+ } else {
+ logger.Security.Debug("Certificate or key file missing, will generate new ones")
}
// Generate a new self-signed cert if files are missing or expired
+ logger.Security.Debug("Calling generateSelfSignedCert()...")
if err := generateSelfSignedCert(); err != nil {
+ logger.Security.Error(fmt.Sprintf("Failed to generate self-signed cert: %v", err))
return fmt.Errorf("failed to generate self-signed cert: %v", err)
}
- logger.Security.Info("Generated new self-signed TLS certificates at " + config.TLSCertPath + " and " + config.TLSKeyPath)
+ logger.Security.Info("Generated new self-signed TLS certificates at " + certPath + " and " + keyPath)
+ logger.Security.Debug("=== TLS certificate check complete - generated new ===")
return nil
}
-// generateSelfSignedCert creates a self-signed certificate and key pair at config.TLSCertPath and config.TLSKeyPath.
+// generateSelfSignedCert creates a self-signed certificate and key pair at config.GetTLSCertPath() and config.GetTLSKeyPath().
func generateSelfSignedCert() error {
- dir := filepath.Dir(config.TLSCertPath)
+ logger.Security.Debug("=== Generating new self-signed certificate ===")
+
+ certPath := config.GetTLSCertPath()
+ keyPath := config.GetTLSKeyPath()
+
+ dir := filepath.Dir(certPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", dir, err)
}
+
// Generate a private key
+ logger.Security.Debug("Generating RSA private key...")
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
+ serialNumber, _ := rand.Int(rand.Reader, big.NewInt(1000000))
+ logger.Security.Debug(fmt.Sprintf("Generated serial number: %s", serialNumber.String()))
+
// Create a certificate template
+ now := time.Now()
+ notAfter := now.Add(90 * 24 * time.Hour)
+
template := x509.Certificate{
- SerialNumber: big.NewInt(1),
+ SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"StationeersServerUI"},
CommonName: "localhost",
},
- NotBefore: time.Now(),
- NotAfter: time.Now().Add(90 * 24 * time.Hour), // 90 days
+ NotBefore: now,
+ NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
- DNSNames: []string{"localhost", "0.0.0.0"},
+ DNSNames: []string{"localhost", "ssui.local"},
+ IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}
+ logger.Security.Debug(fmt.Sprintf("Certificate template - NotBefore: %s, NotAfter: %s",
+ now.Format(time.RFC3339), notAfter.Format(time.RFC3339)))
+
// Create the certificate
+ logger.Security.Debug("Creating certificate...")
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return err
}
// Write the certificate to file
- certFile, err := os.Create(config.TLSCertPath)
+ logger.Security.Debug(fmt.Sprintf("Writing certificate to: %s", certPath))
+ certFile, err := os.Create(certPath)
if err != nil {
return err
}
@@ -105,13 +160,15 @@ func generateSelfSignedCert() error {
pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
// Write the private key to file
- keyFile, err := os.Create(config.TLSKeyPath)
+ logger.Security.Debug(fmt.Sprintf("Writing private key to: %s", keyPath))
+ keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+ logger.Security.Debug("=== Certificate generation complete ===")
return nil
}
diff --git a/src/core/ssestream/ssemanager.go b/src/core/ssestream/ssemanager.go
index 17203976..16e86919 100644
--- a/src/core/ssestream/ssemanager.go
+++ b/src/core/ssestream/ssemanager.go
@@ -9,7 +9,6 @@ import (
"time"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
)
// The SSE blocking issue is NOT related to the backend; the API handles 200 clients per channel fine.
@@ -52,7 +51,24 @@ func (m *SSEManager) CreateStreamHandler(streamType string) http.HandlerFunc {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
- w.Header().Set("Access-Control-Allow-Origin", "*")
+ origin := r.Header.Get("Origin")
+ if origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ } else {
+ // Default to the server's origin if none provided
+ serverOrigin := r.Host
+ if !strings.HasPrefix(serverOrigin, "http") {
+ if r.TLS != nil {
+ serverOrigin = "https://" + serverOrigin
+ } else {
+ serverOrigin = "http://" + serverOrigin
+ }
+ }
+ w.Header().Set("Access-Control-Allow-Origin", serverOrigin)
+ }
+
+ // Allow credentials
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
// Ensure the response writer supports flushing
flusher, ok := w.(http.Flusher)
@@ -81,7 +97,7 @@ func (m *SSEManager) CreateStreamHandler(streamType string) http.HandlerFunc {
_, err := fmt.Fprintf(w, "data: %s Stream Connected\n\n", streamType)
if err != nil {
m.removeClient(client)
- logger.SSE.Error(" ⚠️ Failed to send initial message: " + err.Error())
+ //logger.SSE.Error(" ⚠️ Failed to send initial message: " + err.Error())
return
}
flusher.Flush()
@@ -112,7 +128,7 @@ func (m *SSEManager) streamMessages(
case msg := <-client.messages:
_, err := fmt.Fprintf(w, "data: %s\n\n", msg)
if err != nil {
- logger.SSE.Error(" ❌ Failed to send message: " + err.Error())
+ //logger.SSE.Error(" ❌ Failed to send message: " + err.Error())
return
}
flusher.Flush()
@@ -125,7 +141,7 @@ func (m *SSEManager) streamMessages(
// excludeClutterLogs checks if a message should be dropped due to kinematic warnings. This is a workaround "fix" for a bug in the gameserver.
func (m *SSEManager) excludeClutterLogs(message string) bool {
- if config.LogClutterToConsole {
+ if config.GetLogClutterToConsole() {
return false
}
dropMessages := map[string]bool{
@@ -140,6 +156,7 @@ func (m *SSEManager) excludeClutterLogs(message string) bool {
"memorysetup": true,
"Microsoft Media Foundation video decoding": true,
"The referenced script on this Behaviour": true,
+ "Fallback handler could not load library": true,
}
// Check if message contains any of the drop messages
@@ -153,7 +170,7 @@ func (m *SSEManager) excludeClutterLogs(message string) bool {
// Log only if it's been more than a minute since last log and we have messages to report
if m.kinematicDropCount > 0 && now.Sub(m.lastKinematicLog) >= time.Minute {
- logger.SSE.Info(fmt.Sprintf("🗑️ Detected and Dropped %d unhelpful game server log messages. (Workaround for Gameserver Bug)", m.kinematicDropCount))
+ //logger.SSE.Info(fmt.Sprintf("🗑️ Detected and Dropped %d unhelpful game server log messages. (Workaround for Gameserver Bug)", m.kinematicDropCount))
m.lastKinematicLog = now
m.kinematicDropCount = 0 // Reset count after logging
}
@@ -180,7 +197,7 @@ func (m *SSEManager) Broadcast(message string) {
// Message sent successfully
default:
// Client channel is full, log and skip
- logger.SSE.Warn("⏳ Message dropped for slow client")
+ //logger.SSE.Warn("⏳ Message dropped for slow client")
}
}
}
diff --git a/src/core/ssestream/sseutils.go b/src/core/ssestream/sseutils.go
index 7d5210d5..eafa4a51 100644
--- a/src/core/ssestream/sseutils.go
+++ b/src/core/ssestream/sseutils.go
@@ -5,10 +5,15 @@ import (
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
)
-// Global managers for console and event streams
+// Global managers for SSE streams
var (
- ConsoleStreamManager = NewSSEManager(config.MaxSSEConnections, config.SSEMessageBufferSize)
- EventStreamManager = NewSSEManager(config.MaxSSEConnections, config.SSEMessageBufferSize)
+ ConsoleStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
+ EventStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
+ DebugLogStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
+ InfoLogStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
+ WarnLogStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
+ ErrorLogStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
+ BackendLogStreamManager = NewSSEManager(config.GetMaxSSEConnections(), config.GetSSEMessageBufferSize())
)
// BroadcastConsoleOutput sends log to all connected console log clients
@@ -20,3 +25,28 @@ func BroadcastConsoleOutput(message string) {
func BroadcastDetectionEvent(message string) {
EventStreamManager.Broadcast(message)
}
+
+// BroadcastDebugLog sends an event to all connected clients
+func BroadcastDebugLog(message string) {
+ DebugLogStreamManager.Broadcast(message)
+}
+
+// BroadcastInfoLog sends an event to all connected clients
+func BroadcastInfoLog(message string) {
+ InfoLogStreamManager.Broadcast(message)
+}
+
+// BroadcastWarnLog sends an event to all connected clients
+func BroadcastWarnLog(message string) {
+ WarnLogStreamManager.Broadcast(message)
+}
+
+// BroadcastErrorLog sends an event to all connected clients
+func BroadcastErrorLog(message string) {
+ ErrorLogStreamManager.Broadcast(message)
+}
+
+// BroadcastInternalLog sends an event to all connected clients
+func BroadcastBackendLog(message string) {
+ BackendLogStreamManager.Broadcast(message)
+}
diff --git a/src/discordbot/connectedplayers.go b/src/discordbot/connectedplayers.go
index d181b6ff..6817a560 100644
--- a/src/discordbot/connectedplayers.go
+++ b/src/discordbot/connectedplayers.go
@@ -16,21 +16,21 @@ var (
)
func AddToConnectedPlayers(username, steamID string, connectionTime time.Time, players map[string]string) {
- if !config.IsDiscordEnabled || config.DiscordSession == nil {
+ if !config.GetIsDiscordEnabled() || config.DiscordSession == nil {
logger.Discord.Debug("Discord not enabled or session not initialized")
return
}
content := formatConnectedPlayers(players)
- sendAndEditMessageInConnectedPlayersChannel(config.ConnectionListChannelID, content)
+ sendAndEditMessageInConnectedPlayersChannel(config.GetConnectionListChannelID(), content)
}
func RemoveFromConnectedPlayers(steamID string, players map[string]string) {
- if !config.IsDiscordEnabled || config.DiscordSession == nil {
+ if !config.GetIsDiscordEnabled() || config.DiscordSession == nil {
logger.Discord.Debug("Discord not enabled or session not initialized")
return
}
content := formatConnectedPlayers(players)
- sendAndEditMessageInConnectedPlayersChannel(config.ConnectionListChannelID, content)
+ sendAndEditMessageInConnectedPlayersChannel(config.GetConnectionListChannelID(), content)
}
func sendAndEditMessageInConnectedPlayersChannel(channelID, message string) {
diff --git a/src/discordbot/handleBlacklist.go b/src/discordbot/handleBlacklist.go
index 60afb3cd..9af21503 100644
--- a/src/discordbot/handleBlacklist.go
+++ b/src/discordbot/handleBlacklist.go
@@ -14,7 +14,7 @@ var blacklistMutex sync.Mutex
func banSteamID(steamID string) error {
// Read the current blacklist
- blacklist, err := readBlacklist(config.BlackListFilePath)
+ blacklist, err := readBlacklist(config.GetBlackListFilePath())
if err != nil {
return fmt.Errorf("error reading blacklist file: %v", err)
}
@@ -33,7 +33,7 @@ func banSteamID(steamID string) error {
// Write the updated blacklist back to the file
blacklistMutex.Lock()
defer blacklistMutex.Unlock()
- err = os.WriteFile(config.BlackListFilePath, []byte(blacklist), 0644)
+ err = os.WriteFile(config.GetBlackListFilePath(), []byte(blacklist), 0644)
if err != nil {
return fmt.Errorf("error writing to blacklist file: %v", err)
}
@@ -43,7 +43,7 @@ func banSteamID(steamID string) error {
func unbanSteamID(steamID string) error {
// Read the current blacklist
- blacklist, err := readBlacklist(config.BlackListFilePath)
+ blacklist, err := readBlacklist(config.GetBlackListFilePath())
if err != nil {
return fmt.Errorf("error reading blacklist file: %v", err)
}
@@ -68,7 +68,7 @@ func unbanSteamID(steamID string) error {
// Write the updated blacklist back to the file
blacklistMutex.Lock()
defer blacklistMutex.Unlock()
- err = os.WriteFile(config.BlackListFilePath, []byte(updatedBlacklist), 0644)
+ err = os.WriteFile(config.GetBlackListFilePath(), []byte(updatedBlacklist), 0644)
if err != nil {
return fmt.Errorf("error writing to blacklist file: %v", err)
}
diff --git a/src/discordbot/handleDeprecated.go b/src/discordbot/handleDeprecated.go
index dce24b8c..845df7e7 100644
--- a/src/discordbot/handleDeprecated.go
+++ b/src/discordbot/handleDeprecated.go
@@ -54,7 +54,7 @@ func handleUnbanCommand() {
// DEPRECATED
func listenToDiscordMessages(s *discordgo.Session, m *discordgo.MessageCreate) {
- if m.Author.ID == s.State.User.ID || m.ChannelID != config.ControlChannelID {
+ if m.Author.ID == s.State.User.ID || m.ChannelID != config.GetControlChannelID() {
logger.Discord.Debug("Ignoring message from " + m.Author.Username)
logger.Discord.Debug("Ignored message: " + m.Content)
logger.Discord.Debug("Message channel: " + m.ChannelID)
diff --git a/src/discordbot/handleReactions.go b/src/discordbot/handleReactions.go
index fe479187..c46cab44 100644
--- a/src/discordbot/handleReactions.go
+++ b/src/discordbot/handleReactions.go
@@ -19,7 +19,7 @@ func listenToDiscordReactions(s *discordgo.Session, r *discordgo.MessageReaction
}
// Check if the reaction was added to the control message for server control
- if r.MessageID == config.ControlMessageID {
+ if r.MessageID == ControlMessageID {
handleControlReactions(s, r)
return
}
@@ -81,7 +81,7 @@ func handleControlReactions(s *discordgo.Session, r *discordgo.MessageReactionAd
SendMessageToStatusChannel(fmt.Sprintf("%s triggered by %s.", actionMessage, username))
// Remove the reaction after processing
- err = s.MessageReactionRemove(config.ControlPanelChannelID, r.MessageID, r.Emoji.APIName(), r.UserID)
+ err = s.MessageReactionRemove(config.GetControlPanelChannelID(), r.MessageID, r.Emoji.APIName(), r.UserID)
if err != nil {
logger.Discord.Error("Error removing reaction: " + err.Error())
}
@@ -116,7 +116,7 @@ func handleExceptionReactions(s *discordgo.Session, r *discordgo.MessageReaction
sendMessageToErrorChannel(fmt.Sprintf("%s triggered by %s.", actionMessage, username))
// Remove the reaction after processing
- err = s.MessageReactionRemove(config.ErrorChannelID, r.MessageID, r.Emoji.APIName(), r.UserID)
+ err = s.MessageReactionRemove(config.GetErrorChannelID(), r.MessageID, r.Emoji.APIName(), r.UserID)
if err != nil {
logger.Discord.Error("Error removing reaction: " + err.Error())
}
diff --git a/src/discordbot/handleSlashcommands.go b/src/discordbot/handleSlashcommands.go
index 56d0a72f..5cf8b80e 100644
--- a/src/discordbot/handleSlashcommands.go
+++ b/src/discordbot/handleSlashcommands.go
@@ -31,10 +31,10 @@ var handlers = map[string]commandHandler{
// Check channel and handle initial validation
func listenToSlashCommands(s *discordgo.Session, i *discordgo.InteractionCreate) {
- if i.Type != discordgo.InteractionApplicationCommand || i.ChannelID != config.ControlChannelID {
+ if i.Type != discordgo.InteractionApplicationCommand || i.ChannelID != config.GetControlChannelID() {
respond(s, i, EmbedData{
Title: "Wrong Channel", Description: "Commands must be sent to the configured control channel",
- Color: 0xFF0000, Fields: []EmbedField{{Name: "Accepted Channel", Value: fmt.Sprintf("<#%s>", config.ControlChannelID), Inline: true}},
+ Color: 0xFF0000, Fields: []EmbedField{{Name: "Accepted Channel", Value: fmt.Sprintf("<#%s>", config.GetControlChannelID()), Inline: true}},
})
return
}
diff --git a/src/discordbot/interface.go b/src/discordbot/interface.go
index abfdf0ce..10c9a93a 100644
--- a/src/discordbot/interface.go
+++ b/src/discordbot/interface.go
@@ -18,12 +18,12 @@ func InitializeDiscordBot() {
logger.Discord.Debug("Previous Discord session found, closing it...")
config.DiscordSession.Close()
}
- if config.BufferFlushTicker != nil {
- config.BufferFlushTicker.Stop()
+ if BufferFlushTicker != nil {
+ BufferFlushTicker.Stop()
}
// Create new session
- config.DiscordSession, err = discordgo.New("Bot " + config.DiscordToken)
+ config.DiscordSession, err = discordgo.New("Bot " + config.GetDiscordToken())
if err != nil {
logger.Discord.Error("Error creating Discord session: " + err.Error())
return
@@ -33,12 +33,12 @@ func InitializeDiscordBot() {
config.DiscordSession.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildMessageReactions | discordgo.IntentsMessageContent
logger.Discord.Info("Starting Discord integration...")
- logger.Discord.Debug("Discord token: " + config.DiscordToken)
- logger.Discord.Debug("ControlChannelID: " + config.ControlChannelID)
- logger.Discord.Debug("StatusChannelID: " + config.StatusChannelID)
- logger.Discord.Debug("ConnectionListChannelID: " + config.ConnectionListChannelID)
- logger.Discord.Debug("LogChannelID: " + config.LogChannelID)
- logger.Discord.Debug("SaveChannelID: " + config.SaveChannelID)
+ logger.Discord.Debug("Discord token: " + config.GetDiscordToken())
+ logger.Discord.Debug("ControlChannelID: " + config.GetControlChannelID())
+ logger.Discord.Debug("StatusChannelID: " + config.GetStatusChannelID())
+ logger.Discord.Debug("ConnectionListChannelID: " + config.GetConnectionListChannelID())
+ logger.Discord.Debug("LogChannelID: " + config.GetLogChannelID())
+ logger.Discord.Debug("SaveChannelID: " + config.GetSaveChannelID())
// Open session first
err = config.DiscordSession.Open()
@@ -54,15 +54,13 @@ func InitializeDiscordBot() {
registerSlashCommands(config.DiscordSession)
logger.Discord.Info("Bot is now running.")
- SendMessageToStatusChannel("🤖 Bot Version " + config.Version + " Branch " + config.Branch + " connected to Discord.")
+ SendMessageToStatusChannel("🤖 Bot Version " + config.GetVersion() + " Branch " + config.GetBranch() + " connected to Discord.")
sendControlPanel() // Send control panel message to Discord
- UpdateBotStatusWithMessage("StationeersServerUI v" + config.Version)
+ UpdateBotStatusWithMessage("StationeersServerUI v" + config.GetVersion())
// Start buffer flush ticker
- config.ConfigMu.Lock()
- config.BufferFlushTicker = time.NewTicker(5 * time.Second)
- config.ConfigMu.Unlock()
+ BufferFlushTicker = time.NewTicker(5 * time.Second)
go func() {
- for range config.BufferFlushTicker.C {
+ for range BufferFlushTicker.C {
flushLogBufferToDiscord()
}
}()
diff --git a/src/discordbot/logstream.go b/src/discordbot/logstream.go
index 2784a8f4..a176de8e 100644
--- a/src/discordbot/logstream.go
+++ b/src/discordbot/logstream.go
@@ -7,24 +7,24 @@ import (
// PassLogMessageToDiscordLogBuffer is called from the detection module to add a log message to the buffer.
func PassLogStreamToDiscordLogBuffer(logMessage string) {
- config.LogMessageBuffer += logMessage + "\n"
- if len(config.LogMessageBuffer) >= config.DiscordCharBufferSize && config.IsDiscordEnabled {
+ LogMessageBuffer += logMessage + "\n"
+ if len(LogMessageBuffer) >= config.GetDiscordCharBufferSize() && config.GetIsDiscordEnabled() {
flushLogBufferToDiscord()
}
}
// FlushLogBufferToDiscord flushes the log buffer to Discord periodically with a configurable "DiscordCharBufferSize" character limit per message.
func flushLogBufferToDiscord() {
- if len(config.LogMessageBuffer) == 0 {
+ if len(LogMessageBuffer) == 0 {
return // No messages to send
}
- if !config.IsDiscordEnabled || config.DiscordSession == nil {
+ if !config.GetIsDiscordEnabled() || config.DiscordSession == nil {
return
}
discordMaxMessageLength := config.DiscordCharBufferSize
- message := config.LogMessageBuffer
+ message := LogMessageBuffer
for len(message) > 0 {
// Determine how much of the message we can send
@@ -34,7 +34,7 @@ func flushLogBufferToDiscord() {
}
// Send the chunk to Discord
- _, err := config.DiscordSession.ChannelMessageSend(config.LogChannelID, message[:chunkSize])
+ _, err := config.DiscordSession.ChannelMessageSend(config.GetLogChannelID(), message[:chunkSize])
if err != nil {
logger.Discord.Error("Error sending log to Discord: " + err.Error())
break
@@ -45,7 +45,5 @@ func flushLogBufferToDiscord() {
}
// Clear the buffer after sending
- config.ConfigMu.Lock()
- config.LogMessageBuffer = ""
- config.ConfigMu.Unlock()
+ LogMessageBuffer = ""
}
diff --git a/src/discordbot/sendMessage.go b/src/discordbot/sendMessage.go
index 95e17da0..367c1a24 100644
--- a/src/discordbot/sendMessage.go
+++ b/src/discordbot/sendMessage.go
@@ -10,8 +10,10 @@ import (
"github.com/bwmarrin/discordgo"
)
+var ControlMessageID string
+
func SendMessageToControlChannel(message string) {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return
}
if config.DiscordSession == nil {
@@ -19,14 +21,14 @@ func SendMessageToControlChannel(message string) {
return
}
//clearMessagesAboveLastN(config.ControlChannelID, 20)
- _, err := config.DiscordSession.ChannelMessageSend(config.ControlChannelID, message)
+ _, err := config.DiscordSession.ChannelMessageSend(config.GetControlChannelID(), message)
if err != nil {
logger.Discord.Error("Error sending message to control channel: " + err.Error())
}
}
func SendMessageToStatusChannel(message string) {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return
}
if config.DiscordSession == nil {
@@ -34,14 +36,14 @@ func SendMessageToStatusChannel(message string) {
return
}
//clearMessagesAboveLastN(config.StatusChannelID, 10)
- _, err := config.DiscordSession.ChannelMessageSend(config.StatusChannelID, message)
+ _, err := config.DiscordSession.ChannelMessageSend(config.GetStatusChannelID(), message)
if err != nil {
logger.Discord.Error("Error sending message to status channel: " + err.Error())
}
}
func SendMessageToSavesChannel(message string) {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return
}
if config.DiscordSession == nil {
@@ -49,14 +51,14 @@ func SendMessageToSavesChannel(message string) {
return
}
//clearMessagesAboveLastN(config.SaveChannelID, 300)
- _, err := config.DiscordSession.ChannelMessageSend(config.SaveChannelID, message)
+ _, err := config.DiscordSession.ChannelMessageSend(config.GetSaveChannelID(), message)
if err != nil {
logger.Discord.Error("Error sending message to saves channel: " + err.Error())
}
}
func SendUntrackedMessageToErrorChannel(message string) {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return
}
if config.DiscordSession == nil {
@@ -76,7 +78,7 @@ func SendUntrackedMessageToErrorChannel(message string) {
}
// Send the chunk
- _, err := config.DiscordSession.ChannelMessageSend(config.ErrorChannelID, message[:splitIndex])
+ _, err := config.DiscordSession.ChannelMessageSend(config.GetErrorChannelID(), message[:splitIndex])
if err != nil {
logger.Discord.Error("Error sending message to error channel: " + err.Error())
return
@@ -86,7 +88,7 @@ func SendUntrackedMessageToErrorChannel(message string) {
message = message[splitIndex:]
} else {
// Send the remaining part of the message
- _, err := config.DiscordSession.ChannelMessageSend(config.ErrorChannelID, message)
+ _, err := config.DiscordSession.ChannelMessageSend(config.GetErrorChannelID(), message)
if err != nil {
logger.Discord.Error("Error sending message to error channel: " + err.Error())
return // Return whatever was sent before the error
@@ -98,7 +100,7 @@ func SendUntrackedMessageToErrorChannel(message string) {
// unsused (replaced with SendUntrackedMessageToErrorChannel) in 4.3, needed for having a restart button on the last exception message like in v2. Might remve this in the future, but for now let's keep it.
func sendMessageToErrorChannel(message string) []*discordgo.Message {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return nil
}
if config.DiscordSession == nil {
@@ -119,7 +121,7 @@ func sendMessageToErrorChannel(message string) []*discordgo.Message {
}
// Send the chunk
- sentMessage, err := config.DiscordSession.ChannelMessageSend(config.ErrorChannelID, message[:splitIndex])
+ sentMessage, err := config.DiscordSession.ChannelMessageSend(config.GetErrorChannelID(), message[:splitIndex])
if err != nil {
logger.Discord.Error("Error sending message to error channel: " + err.Error())
return sentMessages // Return whatever was sent before the error
@@ -132,7 +134,7 @@ func sendMessageToErrorChannel(message string) []*discordgo.Message {
message = message[splitIndex:]
} else {
// Send the remaining part of the message
- sentMessage, err := config.DiscordSession.ChannelMessageSend(config.ErrorChannelID, message)
+ sentMessage, err := config.DiscordSession.ChannelMessageSend(config.GetErrorChannelID(), message)
if err != nil {
logger.Discord.Error("Error sending message to error channel: " + err.Error())
return sentMessages // Return whatever was sent before the error
@@ -148,7 +150,7 @@ func sendMessageToErrorChannel(message string) []*discordgo.Message {
}
func sendControlPanel() {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return
}
messageContent := "Control Panel:\n\nReact with the following to perform actions:\n" +
@@ -156,26 +158,24 @@ func sendControlPanel() {
"⏹️ Stop the server\n\n" +
"♻️ Restart the server\n\n"
- msg, err := config.DiscordSession.ChannelMessageSend(config.ControlPanelChannelID, messageContent)
+ msg, err := config.DiscordSession.ChannelMessageSend(config.GetControlPanelChannelID(), messageContent)
if err != nil {
logger.Discord.Error("Error sending control panel: " + err.Error())
return
}
// Add reactions (acting as buttons) to the control message
- config.DiscordSession.MessageReactionAdd(config.ControlPanelChannelID, msg.ID, "▶️") // Start
- config.DiscordSession.MessageReactionAdd(config.ControlPanelChannelID, msg.ID, "⏹️") // Stop
- config.DiscordSession.MessageReactionAdd(config.ControlPanelChannelID, msg.ID, "♻️") // Restart
- config.ConfigMu.Lock()
- config.ControlMessageID = msg.ID
- config.ConfigMu.Unlock()
- clearMessagesAboveLastN(config.ControlPanelChannelID, 1) // Clear all old control panel messages
+ config.DiscordSession.MessageReactionAdd(config.GetControlPanelChannelID(), msg.ID, "▶️") // Start
+ config.DiscordSession.MessageReactionAdd(config.GetControlPanelChannelID(), msg.ID, "⏹️") // Stop
+ config.DiscordSession.MessageReactionAdd(config.GetControlPanelChannelID(), msg.ID, "♻️") // Restart
+ ControlMessageID = msg.ID
+ clearMessagesAboveLastN(config.GetControlPanelChannelID(), 1) // Clear all old control panel messages
}
// This function is used to clear messages above the last N messages in a channel. If you call this with 5, it will clear all messages in the channel besides the most recent 5.
func clearMessagesAboveLastN(channelID string, keep int) {
go func() {
- if !config.IsDiscordEnabled {
+ if !config.GetIsDiscordEnabled() {
return
}
if config.DiscordSession == nil {
diff --git a/src/discordbot/types.go b/src/discordbot/types.go
new file mode 100644
index 00000000..9469732b
--- /dev/null
+++ b/src/discordbot/types.go
@@ -0,0 +1,8 @@
+package discordbot
+
+import "time"
+
+var (
+ LogMessageBuffer string
+ BufferFlushTicker *time.Ticker
+)
diff --git a/src/localization/localization.go b/src/localization/localization.go
index 512871b0..55e77c0e 100644
--- a/src/localization/localization.go
+++ b/src/localization/localization.go
@@ -20,7 +20,7 @@ const fallbackLanguage = "en-us"
// reloads all translations and resets to the current language
func ReloadLocalizer() {
logger.Localization.Info("Reloading localization data")
- currentLanguage = strings.ToLower(config.LanguageSetting)
+ currentLanguage = strings.ToLower(config.GetLanguageSetting())
loadTranslations()
}
diff --git a/src/logger/linux.go b/src/logger/linux.go
new file mode 100644
index 00000000..85c6d359
--- /dev/null
+++ b/src/logger/linux.go
@@ -0,0 +1,6 @@
+//go:build linux
+
+package logger
+
+func ConfigureConsole() {
+}
diff --git a/src/logger/log-helpers.go b/src/logger/log-helpers.go
new file mode 100644
index 00000000..76ad7b69
--- /dev/null
+++ b/src/logger/log-helpers.go
@@ -0,0 +1,68 @@
+package logger
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+)
+
+func (l *Logger) writeToFile(logLine, subsystem string) {
+ const maxRetries = 5
+ const retryDelay = 100 * time.Millisecond
+
+ // Files to write: combined log + subsystem-specific log
+ logFiles := []string{
+ config.GetLogFolder() + "ssui.log", // Combined log
+ getSubsystemLogPath(subsystem), // Subsystem log (e.g., logs/install.log)
+ }
+
+ for _, logFile := range logFiles {
+ for attempt := 0; attempt < maxRetries; attempt++ {
+ // Ensure directory exists
+ if err := os.MkdirAll(filepath.Dir(logFile), os.ModePerm); err != nil {
+ fmt.Printf("%s%s [ERROR/LOGGER] Failed to create log file %s: %v%s\n",
+ colorRed, time.Now().Format("2006-01-02 15:04:05"), filepath.Dir(logFile), err, colorReset)
+ return
+ }
+
+ // Open file
+ file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err == nil {
+ defer file.Close()
+ if _, err := file.WriteString(logLine); err != nil {
+ fmt.Printf("%s%s [ERROR/LOGGER] Failed to write to log file %s: %v%s\n",
+ colorRed, time.Now().Format("2006-01-02 15:04:05"), logFile, err, colorReset)
+ }
+ break // Success, move to next file
+ }
+
+ // Retry on transient errors
+ if os.IsNotExist(err) || os.IsPermission(err) {
+ if attempt == maxRetries-1 {
+ fmt.Printf("%s%s [ERROR/LOGGER] Gave up writing to log file %s after %d attempts: %v%s\n",
+ colorRed, time.Now().Format("2006-01-02 15:04:05"), logFile, maxRetries, err, colorReset)
+ break
+ }
+ time.Sleep(retryDelay)
+ continue
+ }
+
+ // Non-retryable error
+ fmt.Printf("%s%s [ERROR/LOGGER] Failed to open log file %s: %v%s\n",
+ colorRed, time.Now().Format("2006-01-02 15:04:05"), logFile, err, colorReset)
+ break
+ }
+ }
+}
+
+// getSubsystemLogPath generates path for subsystem-specific log file
+func getSubsystemLogPath(subsystem string) string {
+ dir := filepath.Dir(config.GetLogFolder())
+ // Lowercase subsystem for cleaner filenames (e.g., install.log)
+ filename := fmt.Sprintf("%s.log", strings.ToLower(subsystem))
+ return filepath.Join(dir, filename)
+}
diff --git a/src/logger/logger.go b/src/logger/logger.go
index 832a7a1f..18ed310b 100644
--- a/src/logger/logger.go
+++ b/src/logger/logger.go
@@ -3,27 +3,26 @@ package logger
import (
"fmt"
"os"
- "path/filepath"
- "strings"
"sync"
"time"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/ssestream"
)
// Logger instances
var (
- Main = &Logger{prefix: SYS_MAIN}
- Web = &Logger{prefix: SYS_WEB}
- Discord = &Logger{prefix: SYS_DISCORD}
- Backup = &Logger{prefix: SYS_BACKUP}
- Detection = &Logger{prefix: SYS_DETECT}
- Core = &Logger{prefix: SYS_CORE}
- Config = &Logger{prefix: SYS_CONFIG}
- Install = &Logger{prefix: SYS_INSTALL}
- SSE = &Logger{prefix: SYS_SSE}
- Security = &Logger{prefix: SYS_SECURITY}
- Localization = &Logger{prefix: SYS_LOCALIZATION}
+ Main = &Logger{suffix: SYS_MAIN}
+ Web = &Logger{suffix: SYS_WEB}
+ Discord = &Logger{suffix: SYS_DISCORD}
+ Backup = &Logger{suffix: SYS_BACKUP}
+ Detection = &Logger{suffix: SYS_DETECT}
+ Core = &Logger{suffix: SYS_CORE}
+ Config = &Logger{suffix: SYS_CONFIG}
+ Install = &Logger{suffix: SYS_INSTALL}
+ SSE = &Logger{suffix: SYS_SSE}
+ Security = &Logger{suffix: SYS_SECURITY}
+ Localization = &Logger{suffix: SYS_LOCALIZATION}
)
// Severity Levels
@@ -32,6 +31,7 @@ const (
INFO = 20 // Normal operations
WARN = 30 // Potential issues
ERROR = 40 // Critical errors
+ CLEAN = 50 // Just the message, no timestamp or severity
)
// Subsystems
@@ -74,174 +74,179 @@ var subsystemColors = map[string]string{
SYS_LOCALIZATION: colorCyan, // Matches WEB, localization-related
}
+// Global channels and mutex for all loggers
+var (
+ globalLogChan chan logEntry
+ globalConsoleChan chan string
+ globalOnce sync.Once
+)
+
type Logger struct {
mu sync.Mutex
- prefix string // Subsystem identifier (e.g., "DISCORD")
+ suffix string // Subsystem identifier (e.g., "DISCORD")
}
type logEntry struct {
- severity int
- prefix string // Log type (e.g., "INFO", "CORE")
- color string
- message string
+ severity int
+ suffix string // Log type (e.g., "INFO", "DEBUG")
+ color string
+ message string
+ consoleLine string
+ fileLine string
+ logger *Logger // Reference to the logger for file writing
}
-// shouldLog checks severity and subsystem filters
-func (l *Logger) shouldLog(severity int) bool {
- // Subsystem filtering first
- if len(config.SubsystemFilters) > 0 {
- allowed := false
- for _, sub := range config.SubsystemFilters {
- if sub == l.prefix {
- allowed = true
- break
- }
- }
- if !allowed {
- return false // Subsystem not in filter, skip it
- }
- }
-
- effectiveLevel := config.LogLevel
- return severity >= effectiveLevel
+// Init initializes the global logging and console output goroutines
+func (l *Logger) Init() {
+ globalOnce.Do(func() {
+ globalLogChan = make(chan logEntry, 1000) // Buffered global channel for log processing
+ globalConsoleChan = make(chan string, 20) // Buffered global channel for console output
+ go processLogs()
+ go processConsoleOutput()
+ })
}
-// log handles the core logging logic
-func (l *Logger) log(entry logEntry) {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if !l.shouldLog(entry.severity) {
- return
- }
-
- timestamp := time.Now().Format("2006-01-02 15:04:05")
- // Use subsystem color by default, override with severity color if set
- entryColor := subsystemColors[l.prefix]
- if entry.color != colorReset {
- entryColor = entry.color
- }
- // Console version with colors
- consoleLine := fmt.Sprintf("%s%s [%s/%s] %s%s\n", entryColor, timestamp, entry.prefix, l.prefix, entry.message, colorReset)
- // File version without colors
- fileLine := fmt.Sprintf("%s [%s/%s] %s\n", timestamp, entry.prefix, l.prefix, entry.message)
- // Console output
- fmt.Print(consoleLine)
-
- // File output if enabled
- if config.CreateSSUILogFile {
- l.writeToFile(fileLine, l.prefix)
- }
+// Debugf logs a formatted debug message
+func (l *Logger) Debugf(format string, args ...any) {
+ l.log(logEntry{DEBUG, "DEBUG", colorReset, fmt.Sprintf(format, args...), "", "", l})
}
-func (l *Logger) writeToFile(logLine, subsystem string) {
- const maxRetries = 5
- const retryDelay = 100 * time.Millisecond
-
- // Files to write: combined log + subsystem-specific log
- logFiles := []string{
- config.LogFolder + "ssui.log", // Combined log
- getSubsystemLogPath(subsystem), // Subsystem log (e.g., logs/install.log)
- }
-
- for _, logFile := range logFiles {
- for attempt := 0; attempt < maxRetries; attempt++ {
- // Ensure directory exists
- if err := os.MkdirAll(filepath.Dir(logFile), os.ModePerm); err != nil {
- fmt.Printf("%s%s [ERROR/LOGGER] Failed to create log file %s: %v%s\n",
- colorRed, time.Now().Format("2006-01-02 15:04:05"), filepath.Dir(logFile), err, colorReset)
- return
- }
-
- // Open file
- file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err == nil {
- defer file.Close()
- if _, err := file.WriteString(logLine); err != nil {
- fmt.Printf("%s%s [ERROR/LOGGER] Failed to write to log file %s: %v%s\n",
- colorRed, time.Now().Format("2006-01-02 15:04:05"), logFile, err, colorReset)
- }
- break // Success, move to next file
- }
-
- // Retry on transient errors
- if os.IsNotExist(err) || os.IsPermission(err) {
- if attempt == maxRetries-1 {
- fmt.Printf("%s%s [ERROR/LOGGER] Gave up writing to log file %s after %d attempts: %v%s\n",
- colorRed, time.Now().Format("2006-01-02 15:04:05"), logFile, maxRetries, err, colorReset)
- break
- }
- time.Sleep(retryDelay)
- continue
- }
+// Infof logs a formatted info message
+func (l *Logger) Infof(format string, args ...any) {
+ l.log(logEntry{INFO, "INFO", colorReset, fmt.Sprintf(format, args...), "", "", l})
+}
- // Non-retryable error
- fmt.Printf("%s%s [ERROR/LOGGER] Failed to open log file %s: %v%s\n",
- colorRed, time.Now().Format("2006-01-02 15:04:05"), logFile, err, colorReset)
- break
- }
- }
+// Warnf logs a formatted warning message
+func (l *Logger) Warnf(format string, args ...any) {
+ l.log(logEntry{WARN, "WARN", colorYellow, fmt.Sprintf(format, args...), "", "", l})
}
-// getSubsystemLogPath generates path for subsystem-specific log file
-func getSubsystemLogPath(subsystem string) string {
- // Assuming config.LogFilePath is like "logs/ssui.log"
- dir := filepath.Dir(config.LogFolder)
- // Lowercase subsystem for cleaner filenames (e.g., install.log)
- filename := fmt.Sprintf("%s.log", strings.ToLower(subsystem))
- return filepath.Join(dir, filename)
+// Errorf logs a formatted error message
+func (l *Logger) Errorf(format string, args ...any) {
+ l.log(logEntry{ERROR, "ERROR", colorRed, fmt.Sprintf(format, args...), "", "", l})
}
-// Severity-based methods
+// Debug logs a debug message
func (l *Logger) Debug(message string) {
- l.log(logEntry{DEBUG, "DEBUG", colorReset, message}) // Subsystem color
+ l.log(logEntry{DEBUG, "DEBUG", colorReset, message, "", "", l})
}
+// Info logs an info message
func (l *Logger) Info(message string) {
- l.log(logEntry{INFO, "INFO", colorReset, message}) // Subsystem color
+ l.log(logEntry{INFO, "INFO", colorReset, message, "", "", l})
}
+// Warn logs a warning message
func (l *Logger) Warn(message string) {
- l.log(logEntry{WARN, "WARN", colorYellow, message}) // Yellow for warnings
+ l.log(logEntry{WARN, "WARN", colorYellow, message, "", "", l})
}
+// Error logs an error message
func (l *Logger) Error(message string) {
- l.log(logEntry{ERROR, "ERROR", colorRed, message}) // Red for errors
+ l.log(logEntry{ERROR, "ERROR", colorRed, message, "", "", l})
}
-// Subsystem-specific methods (update colors for consistency)
-func (l *Logger) Backup(message string) {
- l.log(logEntry{INFO, "BACKUP", colorReset, message}) // Green via subsystem
+// Error logs an error message
+func (l *Logger) Clean(message string) {
+ l.log(logEntry{CLEAN, "", "", message, "", "", l})
}
-func (l *Logger) Detection(message string) {
- l.log(logEntry{INFO, "DETECT", colorReset, message}) // Yellow via subsystem
+// Errorf logs a formatted error message
+func (l *Logger) Cleanf(format string, args ...any) {
+ l.log(logEntry{CLEAN, "", "", fmt.Sprintf(format, args...), "", "", l})
}
-func (l *Logger) Discord(message string) {
- l.log(logEntry{INFO, "DISCORD", colorReset, message}) // Magenta via subsystem
-}
+// log sends the log entry to the global channel
+func (l *Logger) log(entry logEntry) {
+ l.mu.Lock()
+ if !l.shouldLog(entry.severity) {
+ l.mu.Unlock()
+ return
+ }
-func (l *Logger) Core(message string) {
- l.log(logEntry{WARN, "CORE", colorReset, message}) // Magenta via subsystem
-}
+ // Initialize global channels if not already done
+ l.Init()
-func (l *Logger) Config(message string) {
- l.log(logEntry{WARN, "CONFIG", colorReset, message}) // Yellow via subsystem
+ timestamp := time.Now().Format("2006-01-02 15:04:05")
+ // Handle CLEAN severity separately
+ if entry.severity == CLEAN {
+ entry.consoleLine = fmt.Sprintf("%s\n", entry.message)
+ entry.fileLine = fmt.Sprintf("%s\n", entry.message)
+ } else {
+ // Use subsystem color by default, override with severity color if set
+ entryColor := subsystemColors[l.suffix]
+ if entry.color != colorReset {
+ entryColor = entry.color
+ }
+ entry.consoleLine = fmt.Sprintf("%s%s [%s/%s] %s%s\n", entryColor, timestamp, l.suffix, entry.suffix, entry.message, colorReset)
+ entry.fileLine = fmt.Sprintf("%s [%s/%s] %s\n", timestamp, l.suffix, entry.suffix, entry.message)
+ }
+ l.mu.Unlock()
+
+ // Send to global log channel (non-blocking unless channel is full)
+ select {
+ case globalLogChan <- entry:
+ default:
+ // Channel full, log to stderr
+ fmt.Fprintf(os.Stderr, "%s%s [ERROR/LOGGER] Log channel full, dropping: %s%s\n",
+ colorRed, timestamp, entry.fileLine, colorReset)
+ }
}
-func (l *Logger) Install(message string) {
- l.log(logEntry{INFO, "INSTALL", colorReset, message}) // Blue via subsystem
-}
+// processLogs handles SSE broadcasts and file output for all loggers
+func processLogs() {
+ for entry := range globalLogChan {
+ // Broadcast to SSE streams
+ if entry.severity >= DEBUG {
+ ssestream.BroadcastDebugLog(entry.fileLine)
+ }
+ if entry.severity == INFO {
+ ssestream.BroadcastInfoLog(entry.fileLine)
+ }
+ if entry.severity == WARN {
+ ssestream.BroadcastWarnLog(entry.fileLine)
+ }
+ if entry.severity == ERROR {
+ ssestream.BroadcastErrorLog(entry.fileLine)
+ }
+ ssestream.BroadcastBackendLog(entry.fileLine)
+
+ // File output if enabled
+ if config.GetCreateSSUILogFile() {
+ entry.logger.writeToFile(entry.fileLine, entry.logger.suffix)
+ }
-func (l *Logger) SSE(message string) {
- l.log(logEntry{INFO, "SSE", colorReset, message}) // Cyan via subsystem
+ // Send to global console channel
+ select {
+ case globalConsoleChan <- entry.consoleLine:
+ default:
+ // Console channel full, alert on SSE streams to inform user
+ ssestream.BroadcastErrorLog("ATTENTION: WINDOWS-RELATED ISSUE: THE TERMINAL WHERE SSUI IS RUNNING IS NO LONGER ACCEPTING MESSAGES. PLEASE CHECK THE TERMINAL AND PRESS ENTER TO FREE THE BUFFER.")
+ ssestream.BroadcastConsoleOutput("ATTENTION: WINDOWS-RELATED ISSUE: THE TERMINAL WHERE SSUI IS RUNNING IS NO LONGER ACCEPTING MESSAGES. PLEASE CHECK THE TERMINAL AND PRESS ENTER TO FREE THE BUFFER.")
+ }
+ }
}
-func (l *Logger) Security(message string) {
- l.log(logEntry{ERROR, "SECURITY", colorReset, message}) // Red via subsystem
+// processConsoleOutput handles console output for all loggers
+func processConsoleOutput() {
+ for consoleLine := range globalConsoleChan {
+ fmt.Print(consoleLine)
+ }
}
-func (l *Logger) Localization(message string) {
- l.log(logEntry{INFO, "LOCALIZATION", colorReset, message}) // Cyan via subsystem
+// shouldLog checks severity and subsystem filters
+func (l *Logger) shouldLog(severity int) bool {
+ if len(config.GetSubsystemFilters()) > 0 {
+ allowed := false
+ for _, sub := range config.GetSubsystemFilters() {
+ if sub == l.suffix {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ return false
+ }
+ }
+ return severity >= config.GetLogLevel()
}
diff --git a/src/logger/windows.go b/src/logger/windows.go
new file mode 100644
index 00000000..dec313a4
--- /dev/null
+++ b/src/logger/windows.go
@@ -0,0 +1,34 @@
+//go:build windows
+// +build windows
+
+package logger
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+
+ "golang.org/x/sys/windows"
+)
+
+// configureConsole sets up the Windows console to minimize blocking
+func ConfigureConsole() {
+ if runtime.GOOS != "windows" {
+ return
+ }
+ // Disable QuickEdit mode
+ handle, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to get console handle: %v\n", err)
+ return
+ }
+ var mode uint32
+ if err := windows.GetConsoleMode(handle, &mode); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to get console mode: %v\n", err)
+ return
+ }
+ mode &^= windows.ENABLE_QUICK_EDIT_MODE
+ if err := windows.SetConsoleMode(handle, mode); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to disable QuickEdit: %v\n", err)
+ }
+}
diff --git a/src/managers/backupmgr/backuphttp.go b/src/managers/backupmgr/backuphttp.go
index 4929d886..8ec322d4 100644
--- a/src/managers/backupmgr/backuphttp.go
+++ b/src/managers/backupmgr/backuphttp.go
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr"
)
// HTTPHandler provides HTTP endpoints for backup operations
@@ -80,10 +81,12 @@ func (h *HTTPHandler) RestoreBackupHandler(w http.ResponseWriter, r *http.Reques
return
}
+ gamemgr.InternalStopServer()
+
if err := h.manager.RestoreBackup(index); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- w.Write([]byte("Backup restored successfully"))
+ w.Write([]byte("Server stopped & Backup restored successfully, Start the server to load the restored backup"))
}
diff --git a/src/managers/backupmgr/backupinterface.go b/src/managers/backupmgr/backupinterface.go
index b5b7f526..d2c99036 100644
--- a/src/managers/backupmgr/backupinterface.go
+++ b/src/managers/backupmgr/backupinterface.go
@@ -47,16 +47,16 @@ func RegisterHTTPHandler(handler *HTTPHandler) {
func GetBackupConfig() BackupConfig {
return BackupConfig{
- WorldName: config.WorldName,
- BackupDir: config.ConfiguredBackupDir,
- SafeBackupDir: config.ConfiguredSafeBackupDir,
- WaitTime: 30 * time.Second,
+ WorldName: config.GetWorldName(),
+ BackupDir: config.GetConfiguredBackupDir(),
+ SafeBackupDir: config.GetConfiguredSafeBackupDir(),
+ WaitTime: 30 * time.Second, // not sure why we are not using config.BackupWaitTime here, but ill not touch it in this commit (config rework)
RetentionPolicy: RetentionPolicy{
- KeepLastN: config.BackupKeepLastN,
- KeepDailyFor: config.BackupKeepDailyFor,
- KeepWeeklyFor: config.BackupKeepWeeklyFor,
- KeepMonthlyFor: config.BackupKeepMonthlyFor,
- CleanupInterval: config.BackupKeepMonthlyFor,
+ KeepLastN: config.GetBackupKeepLastN(),
+ KeepDailyFor: config.GetBackupKeepDailyFor(),
+ KeepWeeklyFor: config.GetBackupKeepWeeklyFor(),
+ KeepMonthlyFor: config.GetBackupKeepMonthlyFor(),
+ CleanupInterval: config.GetBackupCleanupInterval(),
},
}
}
diff --git a/src/managers/backupmgr/cleanup.go b/src/managers/backupmgr/cleanup.go
index 340e4e3f..946b1b84 100644
--- a/src/managers/backupmgr/cleanup.go
+++ b/src/managers/backupmgr/cleanup.go
@@ -135,6 +135,10 @@ func (m *BackupManager) getBackupGroups() ([]BackupGroup, error) {
return nil
})
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)
}
diff --git a/src/managers/backupmgr/manager.go b/src/managers/backupmgr/manager.go
index 390daf87..1b908c84 100644
--- a/src/managers/backupmgr/manager.go
+++ b/src/managers/backupmgr/manager.go
@@ -86,7 +86,7 @@ func (m *BackupManager) Start() error {
m.watcher = watcher
go m.watchBackups()
- if config.IsCleanupEnabled {
+ if config.GetIsCleanupEnabled() {
go m.startCleanupRoutine()
}
@@ -128,19 +128,20 @@ func (m *BackupManager) handleNewBackup(filePath string) {
return
}
- if config.IsSSCMEnabled && config.IsNewTerrainAndSaveSystem {
- commandmgr.WriteCommand("SAVE")
- logger.Backup.Info("HEAD Save triggered via SSCM")
- } else {
- logger.Backup.Info("HEAD Save NOT refreshed via SSCM")
- }
-
m.wg.Add(1)
go func() {
defer m.wg.Done()
time.Sleep(m.config.WaitTime)
+ // save the world into Head save too if SSCM is enabled
+ if config.GetIsSSCMEnabled() && config.GetIsNewTerrainAndSaveSystem() {
+ commandmgr.WriteCommand("SAVE")
+ logger.Backup.Debug("HEAD Save triggered via SSCM")
+ } else {
+ logger.Backup.Debug("HEAD Save NOT refreshed via SSCM")
+ }
+
m.mu.Lock()
defer m.mu.Unlock()
@@ -162,7 +163,7 @@ func (m *BackupManager) handleNewBackup(filePath string) {
return
}
- logger.Backup.Info("Backup successfully copied to safe location: " + dstPath)
+ logger.Backup.Debug("Backup successfully copied to safe location: " + dstPath)
}()
}
diff --git a/src/managers/backupmgr/restore.go b/src/managers/backupmgr/restore.go
index f02a1046..2d3bcd9f 100644
--- a/src/managers/backupmgr/restore.go
+++ b/src/managers/backupmgr/restore.go
@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
+ "regexp"
"strings"
"time"
@@ -43,30 +44,32 @@ func (m *BackupManager) RestoreBackup(index int) error {
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)
- }
+ //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)
- }
- }
+ //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 shenenigans (https://discordapp.com/channels/276525882049429515/392080751648178188/1407157281606336602)
+ // 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)
@@ -118,8 +121,42 @@ func (m *BackupManager) RestoreBackup(index int) error {
outFile.Close()
}
- // Modify timestamps of extracted files to current system time
+ // 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
+
+ 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
diff --git a/src/managers/commandmgr/commandmgr.go b/src/managers/commandmgr/commandmgr.go
index 6c1a38b1..30b55d5e 100644
--- a/src/managers/commandmgr/commandmgr.go
+++ b/src/managers/commandmgr/commandmgr.go
@@ -24,12 +24,12 @@ func generateSalt() string {
// It checks if SSCM is enabled and ensures thread-safe file access.
func WriteCommand(command string) error {
// Check if SSCM is enabled
- if !config.IsSSCMEnabled {
+ if !config.GetIsSSCMEnabled() {
return nil // Silently return if disabled
}
// Validate file path
- if config.SSCMFilePath == "" {
+ if config.GetSSCMFilePath() == "" {
return os.ErrNotExist
}
@@ -47,13 +47,13 @@ func WriteCommand(command string) error {
prefixedCommand := prefix + " " + command
// Ensure directory exists
- dir := filepath.Dir(config.SSCMFilePath)
+ dir := filepath.Dir(config.GetSSCMFilePath())
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
// Write to file
- err := os.WriteFile(config.SSCMFilePath, []byte(prefixedCommand), 0644)
+ err := os.WriteFile(config.GetSSCMFilePath(), []byte(prefixedCommand), 0644)
if err != nil {
return err
}
diff --git a/src/managers/detectionmgr/customdetections.go b/src/managers/detectionmgr/customdetections.go
index 29c2fd52..459bff75 100644
--- a/src/managers/detectionmgr/customdetections.go
+++ b/src/managers/detectionmgr/customdetections.go
@@ -54,15 +54,15 @@ func (m *CustomDetectionsManager) LoadDetections() error {
defer m.mutex.Unlock()
// Create directory if it doesn't exist
- dir := filepath.Dir(config.CustomDetectionsFilePath)
+ dir := filepath.Dir(config.GetCustomDetectionsFilePath())
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Check if file exists, create if not
- if _, err := os.Stat(config.CustomDetectionsFilePath); os.IsNotExist(err) {
+ if _, err := os.Stat(config.GetCustomDetectionsFilePath()); os.IsNotExist(err) {
// Create empty file
- file, err := os.Create(config.CustomDetectionsFilePath)
+ file, err := os.Create(config.GetCustomDetectionsFilePath())
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
@@ -72,7 +72,7 @@ func (m *CustomDetectionsManager) LoadDetections() error {
}
// Read file
- file, err := os.Open(config.CustomDetectionsFilePath)
+ file, err := os.Open(config.GetCustomDetectionsFilePath())
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
@@ -109,7 +109,7 @@ func (m *CustomDetectionsManager) AddDetection(detection CustomDetection) error
m.updateDetector()
// Save to file directly
- file, err := os.Create(config.CustomDetectionsFilePath)
+ file, err := os.Create(config.GetCustomDetectionsFilePath())
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
@@ -139,7 +139,7 @@ func (m *CustomDetectionsManager) DeleteDetection(id string) error {
m.updateDetector()
// Save to file directly
- file, err := os.Create(config.CustomDetectionsFilePath)
+ file, err := os.Create(config.GetCustomDetectionsFilePath())
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
diff --git a/src/managers/detectionmgr/detector.go b/src/managers/detectionmgr/detector.go
index 88191b01..ddccf19c 100644
--- a/src/managers/detectionmgr/detector.go
+++ b/src/managers/detectionmgr/detector.go
@@ -106,7 +106,7 @@ func (d *Detector) processRegexPatterns(logMessage string) {
}{
{
// Player ready pattern
- pattern: regexp.MustCompile(`Client\s+(.+)\s+\((\d+)\)\s+is\s+ready!`),
+ pattern: regexp.MustCompile(`Client\s+(.+)\s+\((\d+)\)\s+is\s+ready!?`),
handler: func(matches []string, logMessage string) {
username := matches[1]
steamID := matches[2]
@@ -148,7 +148,7 @@ func (d *Detector) processRegexPatterns(logMessage string) {
},
{
// Player disconnect pattern
- pattern: regexp.MustCompile(`Client\s+disconnected:\s+\d+\s+\|\s+(.+)\s+connectTime:\s+\d+,\d+s,\s+ClientId:\s+(\d+)`),
+ pattern: regexp.MustCompile(`Client\s+disconnected:\s+\d+\s+\|\s+(.+)\s+connectTime:\s+\d+[\.,]\d+s,\s+ClientId:\s+(\d+)`),
handler: func(matches []string, logMessage string) {
username := matches[1]
steamID := matches[2]
@@ -267,6 +267,11 @@ func (d *Detector) GetConnectedPlayers() map[string]string {
return players
}
+// ClearConnectedPlayers clears the connected players map
+func (d *Detector) ClearConnectedPlayers() {
+ d.connectedPlayers = make(map[string]string)
+}
+
func (d *Detector) SetCustomPatterns(patterns []CustomPattern) {
d.customPatterns = patterns
}
diff --git a/src/managers/detectionmgr/handlers.go b/src/managers/detectionmgr/handlers.go
index b30e61ce..abcc6b9c 100644
--- a/src/managers/detectionmgr/handlers.go
+++ b/src/managers/detectionmgr/handlers.go
@@ -25,50 +25,50 @@ func DefaultHandlers() map[EventType]Handler {
EventCustomDetection: func(event Event) {
message := fmt.Sprintf("🎮 [Custom Detection] %s", event.Message)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventServerReady: func(event Event) {
message := "🎮 [Gameserver] 🔔 Server is ready to connect!"
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventServerStarting: func(event Event) {
message := "🎮 [Gameserver] 🕑 Server is starting up..."
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventServerError: func(event Event) {
message := "🎮 [Gameserver] ⚠️ Server error detected"
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventSettingsChanged: func(event Event) {
message := fmt.Sprintf("🎮 [Gameserver] ⚙️ %s", event.Message)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventServerHosted: func(event Event) {
message := fmt.Sprintf("🎮 [Gameserver] 🌐 %s", event.Message)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventNewGameStarted: func(event Event) {
message := fmt.Sprintf("🎮 [Gameserver] 🎲 %s", event.Message)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
EventServerRunning: func(event Event) {
message := "🎮 [Gameserver] ✅ Server process has started!"
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
},
@@ -76,7 +76,7 @@ func DefaultHandlers() map[EventType]Handler {
if event.PlayerInfo != nil {
message := fmt.Sprintf("🎮 [Gameserver] 🔄 Player %s (SteamID: %s) is connecting...",
event.PlayerInfo.Username, event.PlayerInfo.SteamID)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
}
@@ -85,7 +85,7 @@ func DefaultHandlers() map[EventType]Handler {
if event.PlayerInfo != nil {
message := fmt.Sprintf("🎮 [Gameserver] ✅ Player %s (SteamID: %s) is ready!",
event.PlayerInfo.Username, event.PlayerInfo.SteamID)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
}
@@ -94,7 +94,7 @@ func DefaultHandlers() map[EventType]Handler {
if event.PlayerInfo != nil {
message := fmt.Sprintf("🎮 [Gameserver] 👋 Player %s disconnected",
event.PlayerInfo.Username)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToStatusChannel(message)
}
@@ -104,7 +104,7 @@ func DefaultHandlers() map[EventType]Handler {
timeStr := time.Now().UTC().Format(time.RFC3339)
message := fmt.Sprintf("🎮 [Gameserver] 💾 World Saved: BackupIndex: %s UTC Time: %s",
event.BackupInfo.BackupIndex, timeStr)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendMessageToSavesChannel(message)
}
@@ -112,7 +112,7 @@ func DefaultHandlers() map[EventType]Handler {
EventException: func(event Event) {
// Initial alert message
alertMessage := "🎮 [Gameserver] 🚨 Exception detected!"
- logger.Detection.Detection(alertMessage)
+ logger.Detection.Info(alertMessage)
ssestream.BroadcastDetectionEvent(alertMessage)
discordbot.SendUntrackedMessageToErrorChannel(alertMessage)
@@ -121,7 +121,7 @@ func DefaultHandlers() map[EventType]Handler {
stackTrace := strings.ReplaceAll(event.ExceptionInfo.StackTrace, "\n", " | ")
message := fmt.Sprintf("Exception Details: Stack Trace: %s", stackTrace)
- logger.Detection.Detection(message)
+ logger.Detection.Info(message)
ssestream.BroadcastDetectionEvent(message)
discordbot.SendUntrackedMessageToErrorChannel(message)
}
diff --git a/src/managers/detectionmgr/interface.go b/src/managers/detectionmgr/interface.go
index 0d21c015..ec5774d0 100644
--- a/src/managers/detectionmgr/interface.go
+++ b/src/managers/detectionmgr/interface.go
@@ -1,6 +1,8 @@
// interface.go
package detectionmgr
+import "sync"
+
/*
Code-Public Detection API interface
- Exposes simplified interface for external references if needed
@@ -11,9 +13,25 @@ Code-Public Detection API interface
- State queries (connected players currently)
*/
-// Start initializes the detector and returns it
+var (
+ detectorInstance *Detector
+ once sync.Once
+)
+
+// Start initializes the detector and stores it as the singleton instance
func Start() *Detector {
- return NewDetector()
+ once.Do(func() {
+ detectorInstance = NewDetector()
+ })
+ return detectorInstance
+}
+
+// GetDetector returns the singleton detector instance
+func GetDetector() *Detector {
+ if detectorInstance == nil {
+ panic("Detector not initialized. Call Start() first.")
+ }
+ return detectorInstance
}
// AddHandler is a convenient method to register a handler for an event type
@@ -30,3 +48,8 @@ func ProcessLog(detector *Detector, logMessage string) {
func GetPlayers(detector *Detector) map[string]string {
return detector.GetConnectedPlayers()
}
+
+// Clearplayers clears the connected players
+func ClearPlayers(detector *Detector) {
+ detector.ClearConnectedPlayers()
+}
diff --git a/src/managers/detectionmgr/logstream.go b/src/managers/detectionmgr/logstream.go
index 865de822..264ec04e 100644
--- a/src/managers/detectionmgr/logstream.go
+++ b/src/managers/detectionmgr/logstream.go
@@ -23,7 +23,7 @@ func StreamLogs(detector *Detector) {
go func() {
logger.Detection.Debug("Connected to internal log stream.")
for logMessage := range logChan {
- if config.IsDiscordEnabled {
+ if config.GetIsDiscordEnabled() {
discordbot.PassLogStreamToDiscordLogBuffer(logMessage)
}
ProcessLog(detector, logMessage)
diff --git a/src/managers/gamemgr/args.go b/src/managers/gamemgr/args.go
index 08eaf448..8b0bff6f 100644
--- a/src/managers/gamemgr/args.go
+++ b/src/managers/gamemgr/args.go
@@ -30,51 +30,51 @@ func buildCommandArgs() []string {
-startlocation (Optional, defaults to "DefaultStartLocation" if not provided.)
*/
{Flag: "-file", RequiresValue: false},
- {Flag: "start", Value: config.WorldName, RequiresValue: true},
- {Flag: config.BackupWorldName, RequiresValue: false},
- {Flag: config.Difficulty, RequiresValue: false, Condition: func() bool { return config.Difficulty != "" }},
- {Flag: config.StartCondition, RequiresValue: false, Condition: func() bool { return config.StartCondition != "" }},
- {Flag: config.StartLocation, RequiresValue: false, Condition: func() bool { return config.StartLocation != "" }},
+ {Flag: "start", Value: config.GetWorldName(), RequiresValue: true},
+ {Flag: config.GetBackupWorldName(), RequiresValue: false},
+ {Flag: config.GetDifficulty(), RequiresValue: false, Condition: func() bool { return config.GetDifficulty() != "" }},
+ {Flag: config.GetStartCondition(), RequiresValue: false, Condition: func() bool { return config.GetStartCondition() != "" }},
+ {Flag: config.GetStartLocation(), RequiresValue: false, Condition: func() bool { return config.GetStartLocation() != "" }},
// file start end
{Flag: "-logFile", Value: "./debug.log", Condition: func() bool { return runtime.GOOS == "linux" }, RequiresValue: true},
{Flag: "-settings", RequiresValue: false},
- {Flag: "StartLocalHost", Value: strconv.FormatBool(config.StartLocalHost), RequiresValue: true},
- {Flag: "ServerVisible", Value: strconv.FormatBool(config.ServerVisible), RequiresValue: true},
- {Flag: "GamePort", Value: config.GamePort, RequiresValue: true},
- {Flag: "UPNPEnabled", Value: strconv.FormatBool(config.UPNPEnabled), RequiresValue: true},
- {Flag: "ServerName", Value: config.ServerName, RequiresValue: true},
- {Flag: "ServerPassword", Value: config.ServerPassword, Condition: func() bool { return config.ServerPassword != "" }, RequiresValue: true},
- {Flag: "ServerMaxPlayers", Value: config.ServerMaxPlayers, RequiresValue: true},
- {Flag: "AutoSave", Value: strconv.FormatBool(config.AutoSave), RequiresValue: true},
- {Flag: "SaveInterval", Value: config.SaveInterval, RequiresValue: true},
- {Flag: "ServerAuthSecret", Value: config.ServerAuthSecret, Condition: func() bool { return config.ServerAuthSecret != "" }, RequiresValue: true},
- {Flag: "UpdatePort", Value: config.UpdatePort, RequiresValue: true},
- {Flag: "AutoPauseServer", Value: strconv.FormatBool(config.AutoPauseServer), RequiresValue: true},
- {Flag: "UseSteamP2P", Value: strconv.FormatBool(config.UseSteamP2P), RequiresValue: true},
- {Flag: "AdminPassword", Value: config.AdminPassword, Condition: func() bool { return config.AdminPassword != "" }, RequiresValue: true},
+ {Flag: "StartLocalHost", Value: strconv.FormatBool(config.GetStartLocalHost()), RequiresValue: true},
+ {Flag: "ServerVisible", Value: strconv.FormatBool(config.GetServerVisible()), RequiresValue: true},
+ {Flag: "GamePort", Value: config.GetGamePort(), RequiresValue: true},
+ {Flag: "UPNPEnabled", Value: strconv.FormatBool(config.GetUPNPEnabled()), RequiresValue: true},
+ {Flag: "ServerName", Value: config.GetServerName(), RequiresValue: true},
+ {Flag: "ServerPassword", Value: config.GetServerPassword(), Condition: func() bool { return config.GetServerPassword() != "" }, RequiresValue: true},
+ {Flag: "ServerMaxPlayers", Value: config.GetServerMaxPlayers(), RequiresValue: true},
+ {Flag: "AutoSave", Value: strconv.FormatBool(config.GetAutoSave()), RequiresValue: true},
+ {Flag: "SaveInterval", Value: config.GetSaveInterval(), RequiresValue: true},
+ {Flag: "ServerAuthSecret", Value: config.GetServerAuthSecret(), Condition: func() bool { return config.GetServerAuthSecret() != "" }, RequiresValue: true},
+ {Flag: "UpdatePort", Value: config.GetUpdatePort(), RequiresValue: true},
+ {Flag: "AutoPauseServer", Value: strconv.FormatBool(config.GetAutoPauseServer()), RequiresValue: true},
+ {Flag: "UseSteamP2P", Value: strconv.FormatBool(config.GetUseSteamP2P()), RequiresValue: true},
+ {Flag: "AdminPassword", Value: config.GetAdminPassword(), Condition: func() bool { return config.GetAdminPassword() != "" }, RequiresValue: true},
}
}
- if !config.IsNewTerrainAndSaveSystem {
+ if !config.GetIsNewTerrainAndSaveSystem() {
argOrder = []Arg{
{Flag: "-nographics", RequiresValue: false},
{Flag: "-batchmode", RequiresValue: false},
- {Flag: "-LOAD", Value: config.SaveInfo, RequiresValue: true, NoQuote: true}, // LOAD has special handling because the gameserver expects 2 parameters
+ {Flag: "-LOAD", Value: config.GetSaveInfo(), RequiresValue: true, NoQuote: true}, // LOAD has special handling because the gameserver expects 2 parameters
{Flag: "-logFile", Value: "./debug.log", Condition: func() bool { return runtime.GOOS == "linux" }, RequiresValue: true},
{Flag: "-settings", RequiresValue: false},
- {Flag: "StartLocalHost", Value: strconv.FormatBool(config.StartLocalHost), RequiresValue: true},
- {Flag: "ServerVisible", Value: strconv.FormatBool(config.ServerVisible), RequiresValue: true},
- {Flag: "GamePort", Value: config.GamePort, RequiresValue: true},
- {Flag: "UPNPEnabled", Value: strconv.FormatBool(config.UPNPEnabled), RequiresValue: true},
- {Flag: "ServerName", Value: config.ServerName, RequiresValue: true},
- {Flag: "ServerPassword", Value: config.ServerPassword, Condition: func() bool { return config.ServerPassword != "" }, RequiresValue: true},
- {Flag: "ServerMaxPlayers", Value: config.ServerMaxPlayers, RequiresValue: true},
- {Flag: "AutoSave", Value: strconv.FormatBool(config.AutoSave), RequiresValue: true},
- {Flag: "SaveInterval", Value: config.SaveInterval, RequiresValue: true},
- {Flag: "ServerAuthSecret", Value: config.ServerAuthSecret, Condition: func() bool { return config.ServerAuthSecret != "" }, RequiresValue: true},
- {Flag: "UpdatePort", Value: config.UpdatePort, RequiresValue: true},
- {Flag: "AutoPauseServer", Value: strconv.FormatBool(config.AutoPauseServer), RequiresValue: true},
- {Flag: "UseSteamP2P", Value: strconv.FormatBool(config.UseSteamP2P), RequiresValue: true},
- {Flag: "AdminPassword", Value: config.AdminPassword, Condition: func() bool { return config.AdminPassword != "" }, RequiresValue: true},
+ {Flag: "StartLocalHost", Value: strconv.FormatBool(config.GetStartLocalHost()), RequiresValue: true},
+ {Flag: "ServerVisible", Value: strconv.FormatBool(config.GetServerVisible()), RequiresValue: true},
+ {Flag: "GamePort", Value: config.GetGamePort(), RequiresValue: true},
+ {Flag: "UPNPEnabled", Value: strconv.FormatBool(config.GetUPNPEnabled()), RequiresValue: true},
+ {Flag: "ServerName", Value: config.GetServerName(), RequiresValue: true},
+ {Flag: "ServerPassword", Value: config.GetServerPassword(), Condition: func() bool { return config.GetServerPassword() != "" }, RequiresValue: true},
+ {Flag: "ServerMaxPlayers", Value: config.GetServerMaxPlayers(), RequiresValue: true},
+ {Flag: "AutoSave", Value: strconv.FormatBool(config.GetAutoSave()), RequiresValue: true},
+ {Flag: "SaveInterval", Value: config.GetSaveInterval(), RequiresValue: true},
+ {Flag: "ServerAuthSecret", Value: config.GetServerAuthSecret(), Condition: func() bool { return config.GetServerAuthSecret() != "" }, RequiresValue: true},
+ {Flag: "UpdatePort", Value: config.GetUpdatePort(), RequiresValue: true},
+ {Flag: "AutoPauseServer", Value: strconv.FormatBool(config.GetAutoPauseServer()), RequiresValue: true},
+ {Flag: "UseSteamP2P", Value: strconv.FormatBool(config.GetUseSteamP2P()), RequiresValue: true},
+ {Flag: "AdminPassword", Value: config.GetAdminPassword(), Condition: func() bool { return config.GetAdminPassword() != "" }, RequiresValue: true},
}
}
@@ -104,13 +104,13 @@ func buildCommandArgs() []string {
}
}
- if config.AdditionalParams != "" {
- args = append(args, strings.Fields(config.AdditionalParams)...)
+ if config.GetAdditionalParams() != "" {
+ args = append(args, strings.Fields(config.GetAdditionalParams())...)
}
- if config.LocalIpAddress != "" {
+ if config.GetLocalIpAddress() != "" {
args = append(args, "LocalIpAddress")
- args = append(args, config.LocalIpAddress)
+ args = append(args, config.GetLocalIpAddress())
}
return args
diff --git a/src/managers/gamemgr/autorestart.go b/src/managers/gamemgr/autorestart.go
new file mode 100644
index 00000000..d1b1725a
--- /dev/null
+++ b/src/managers/gamemgr/autorestart.go
@@ -0,0 +1,146 @@
+package gamemgr
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/commandmgr"
+)
+
+var (
+ autoRestartDone chan struct{}
+ // other local vars are defined in processmanagement.go
+)
+
+// startAutoRestart runs a goroutine that restarts the server either after a specified duration in minutes
+// or at a specific time of day (HH:MM) every day.
+func startAutoRestart(schedule string, done chan struct{}) {
+ // Try parsing as a time in HH:MM format
+ if t, err := time.Parse("15:04", schedule); err == nil {
+ // Valid HH:MM format, schedule daily restart
+ go scheduleDailyRestart(t, done)
+ return
+ }
+
+ // Try parsing as a time in HH:MMAM/PM format
+ if t, err := time.Parse("03:04PM", schedule); err == nil {
+ // Valid HH:MMAM/PM format, schedule daily restart
+ go scheduleDailyRestart(t, done)
+ return
+ }
+
+ // Fallback to parsing as minutes duration
+ minutesInt, err := strconv.Atoi(schedule)
+ if err != nil {
+ logger.Core.Error("Invalid AutoRestartServerTimer format: " + schedule)
+ return
+ }
+ if minutesInt <= 0 {
+ logger.Core.Error("AutoRestartServerTimer must be a positive number of minutes or valid HH:MM or HH:MMAM/PM time")
+ return
+ }
+
+ ticker := time.NewTicker(time.Duration(minutesInt) * time.Minute)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ mu.Lock()
+ if !internalIsServerRunningNoLock() {
+ mu.Unlock()
+ logger.Core.Info("Auto-restart skipped: server is not running")
+ return
+ }
+ mu.Unlock()
+
+ if config.GetIsSSCMEnabled() {
+ commandmgr.WriteCommand("say Attention, server is restarting in 30 seconds!")
+ time.Sleep(10 * time.Second)
+ commandmgr.WriteCommand("say Attention, server is restarting in 20 seconds!")
+ time.Sleep(10 * time.Second)
+ commandmgr.WriteCommand("say Attention, server is restarting in 10 seconds, saving world now!")
+ commandmgr.WriteCommand("save")
+ time.Sleep(5 * time.Second)
+ commandmgr.WriteCommand("say Attention, server is restarting in 5 seconds!")
+ time.Sleep(5 * time.Second)
+ }
+ logger.Core.Info("Auto-restart triggered: stopping server")
+ if err := InternalStopServer(); err != nil {
+ logger.Core.Error("Auto-restart failed to stop server: " + err.Error())
+ return
+ }
+
+ logger.Core.Info("Auto-restart: waiting 5 seconds before restarting")
+ time.Sleep(5 * time.Second)
+
+ logger.Core.Info("Auto-restart: starting server")
+ if err := InternalStartServer(); err != nil {
+ logger.Core.Error("Auto-restart failed to start server: " + err.Error())
+ return
+ }
+ case <-done:
+ return
+ }
+ }
+}
+
+// scheduleDailyRestart schedules a server restart at the specified time of day (HH:MM) every day.
+func scheduleDailyRestart(t time.Time, done chan struct{}) {
+ // Extract hour and minute from the parsed time
+ hour, min := t.Hour(), t.Minute()
+
+ for {
+ now := time.Now()
+ next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, now.Location())
+ if now.After(next) || now.Equal(next) {
+ // If the time is in the past or now, schedule for tomorrow
+ next = next.Add(24 * time.Hour)
+ }
+ duration := next.Sub(now)
+
+ // Wait until the next restart time or until interrupted
+ timer := time.NewTimer(duration)
+ select {
+ case <-timer.C:
+ mu.Lock()
+ if !internalIsServerRunningNoLock() {
+ mu.Unlock()
+ logger.Core.Info("Auto-restart skipped: server is not running")
+ continue
+ }
+ mu.Unlock()
+
+ if config.GetIsSSCMEnabled() {
+ commandmgr.WriteCommand("say Attention, server is restarting in 30 seconds!")
+ time.Sleep(10 * time.Second)
+ commandmgr.WriteCommand("say Attention, server is restarting in 20 seconds!")
+ time.Sleep(10 * time.Second)
+ commandmgr.WriteCommand("say Attention, server is restarting in 10 seconds, saving world now!")
+ commandmgr.WriteCommand("save")
+ time.Sleep(5 * time.Second)
+ commandmgr.WriteCommand("say Attention, server is restarting in 5 seconds!")
+ time.Sleep(5 * time.Second)
+ }
+ logger.Core.Info("Daily auto-restart triggered: stopping server")
+ if err := InternalStopServer(); err != nil {
+ logger.Core.Error("Daily auto-restart failed to stop server: " + err.Error())
+ continue
+ }
+
+ logger.Core.Debug("Daily auto-restart: waiting 5 seconds before restarting")
+ time.Sleep(5 * time.Second)
+
+ logger.Core.Info("Daily auto-restart: starting server")
+ if err := InternalStartServer(); err != nil {
+ logger.Core.Error("Daily auto-restart failed to start server: " + err.Error())
+ continue
+ }
+ case <-done:
+ timer.Stop()
+ return
+ }
+ }
+}
diff --git a/src/managers/gamemgr/sscm.go b/src/managers/gamemgr/bepinex.go
similarity index 96%
rename from src/managers/gamemgr/sscm.go
rename to src/managers/gamemgr/bepinex.go
index 8ae2480a..081e8bcf 100644
--- a/src/managers/gamemgr/sscm.go
+++ b/src/managers/gamemgr/bepinex.go
@@ -15,9 +15,9 @@ import (
// Returns a map of environment variables and an error if setup fails.
func SetupBepInExEnvironment() ([]string, error) {
- executablePath := config.ExePath
+ executablePath := config.GetExePath()
- if !config.IsSSCMEnabled {
+ if !config.GetIsSSCMEnabled() {
logger.Core.Debug("SSCM is disabled, skipping environment setup")
return nil, nil
}
diff --git a/src/managers/gamemgr/processmanagement.go b/src/managers/gamemgr/processmanagement.go
index cdac8785..06e1a296 100644
--- a/src/managers/gamemgr/processmanagement.go
+++ b/src/managers/gamemgr/processmanagement.go
@@ -14,61 +14,17 @@ import (
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/commandmgr"
- "github.com/google/uuid"
)
var (
- cmd *exec.Cmd
- mu sync.Mutex
- logDone chan struct{}
- err error
- autoRestartDone chan struct{}
- processExited chan struct{}
+ cmd *exec.Cmd
+ mu sync.Mutex
+ logDone chan struct{}
+ err error
+ processExited chan struct{}
+ // autoRestartDone is defined in autorestart.go
)
-// InternalIsServerRunning checks if the server process is running.
-// Safe to call standalone as it manages its own locking.
-func InternalIsServerRunning() bool {
- mu.Lock()
- defer mu.Unlock()
- return internalIsServerRunningNoLock()
-}
-
-// internalIsServerRunningNoLock checks if the server process is running.
-// Caller M U S T hold mu.Lock().
-func internalIsServerRunningNoLock() bool {
- if cmd == nil || cmd.Process == nil {
- return false
- }
-
- if runtime.GOOS == "windows" {
- select {
- case <-processExited:
- cmd = nil
- clearGameServerUUID()
- return false
- default:
- // Process is still running
- return true
- }
- }
-
- if runtime.GOOS == "linux" {
- // On Unix-like systems, use Signal(0)
- if err := cmd.Process.Signal(syscall.Signal(0)); err != nil {
- logger.Core.Debug("Signal(0) failed, assuming process is dead: " + err.Error())
- cmd = nil
- clearGameServerUUID()
- return false
- }
- return true
- }
-
- logger.Core.Warn("Failed to check if server is running, assuming it's dead")
- return false
-}
-
func InternalStartServer() error {
mu.Lock()
defer mu.Unlock()
@@ -81,7 +37,7 @@ func InternalStartServer() error {
logger.Core.Info("=== GAMESERVER STARTING ===")
- if config.IsSSCMEnabled && runtime.GOOS == "linux" {
+ if config.GetIsSSCMEnabled() && runtime.GOOS == "linux" {
var envVars []string
// Set up SSCM (BepInEx/Doorstop) environment
@@ -90,28 +46,28 @@ func InternalStartServer() error {
return fmt.Errorf("failed to set up SSCM environment: %v", err)
}
// Create command after environment is set
- cmd = exec.Command(config.ExePath, args...)
+ cmd = exec.Command(config.GetExePath(), args...)
// Set the environment for the command
if envVars != nil {
cmd.Env = envVars
logger.Core.Info("BepInEx/Doorstop environment configured for server process")
}
- logger.Core.Info("• Executable: " + config.ExePath + " (with SSCM)")
+ logger.Core.Info("• Executable: " + config.GetExePath() + " (with SSCM)")
logger.Core.Info("• Arguments: " + strings.Join(args, " "))
}
- if !config.IsSSCMEnabled && runtime.GOOS == "linux" {
+ if !config.GetIsSSCMEnabled() && runtime.GOOS == "linux" {
// Use ExePath directly as the command
- cmd = exec.Command(config.ExePath, args...)
- logger.Core.Info("• Executable: " + config.ExePath)
+ cmd = exec.Command(config.GetExePath(), args...)
+ logger.Core.Info("• Executable: " + config.GetExePath())
logger.Core.Info("• Arguments: " + strings.Join(args, " "))
}
if runtime.GOOS == "windows" {
// On Windows, set the command to use the executable path and arguments
- cmd = exec.Command(config.ExePath, args...)
- logger.Core.Info("• Executable: " + config.ExePath)
+ cmd = exec.Command(config.GetExePath(), args...)
+ logger.Core.Info("• Executable: " + config.GetExePath())
logger.Core.Debug("Switching to pipes for logs as we are on Windows!")
stdout, err := cmd.StdoutPipe()
@@ -173,16 +129,15 @@ func InternalStartServer() error {
}
// create a UUID for this specific run
createGameServerUUID()
- logger.Core.Debug("Created Game Server with internal UUID: " + config.GameServerUUID.String())
// Start auto-restart goroutine if AutoRestartServerTimer is set greater than 0
- if config.AutoRestartServerTimer != "0" {
+ if config.GetAutoRestartServerTimer() != "0" {
if autoRestartDone != nil {
close(autoRestartDone)
}
autoRestartDone = make(chan struct{})
- go startAutoRestart(config.AutoRestartServerTimer, autoRestartDone)
- logger.Core.Info("Auto-restart scheduled every " + config.AutoRestartServerTimer + " minutes")
+ go startAutoRestart(config.GetAutoRestartServerTimer(), autoRestartDone)
+ logger.Core.Info("New Auto-restart scheduled: " + config.GetAutoRestartServerTimer())
}
return nil
@@ -200,7 +155,6 @@ func InternalStopServer() error {
if autoRestartDone != nil {
close(autoRestartDone)
autoRestartDone = nil
- logger.Core.Info("Auto-restart cycle interrupted due to manual stop")
}
// Process is running, stop it
@@ -266,62 +220,3 @@ func InternalStopServer() error {
clearGameServerUUID()
return nil
}
-
-// startAutoRestart runs a goroutine that restarts the server after the specified timeframe in minutes.
-func startAutoRestart(minutes string, done chan struct{}) {
- minutesInt, _ := strconv.Atoi(minutes)
- ticker := time.NewTicker(time.Duration(minutesInt) * time.Minute)
- defer ticker.Stop()
-
- for {
- select {
- case <-ticker.C:
- mu.Lock()
- if !internalIsServerRunningNoLock() {
- mu.Unlock()
- logger.Core.Info("Auto-restart skipped: server is not running")
- return
- }
- mu.Unlock()
-
- if config.IsSSCMEnabled {
- commandmgr.WriteCommand("say Attention, server is restarting in 30 seconds!")
- time.Sleep(10 * time.Second)
- commandmgr.WriteCommand("say Attention, server is restarting in 20 seconds!")
- time.Sleep(10 * time.Second)
- commandmgr.WriteCommand("say Attention, server is restarting in 10 seconds!")
- time.Sleep(5 * time.Second)
- commandmgr.WriteCommand("say Attention, server is restarting in 5 seconds!")
- time.Sleep(5 * time.Second)
- }
- logger.Core.Info("Auto-restart triggered: stopping server")
- if err := InternalStopServer(); err != nil {
- logger.Core.Error("Auto-restart failed to stop server: " + err.Error())
- return
- }
-
- logger.Core.Info("Auto-restart: waiting 5 seconds before restarting")
- time.Sleep(5 * time.Second)
-
- logger.Core.Info("Auto-restart: starting server")
- if err := InternalStartServer(); err != nil {
- logger.Core.Error("Auto-restart failed to start server: " + err.Error())
- return
- }
- case <-done:
- return
- }
- }
-}
-
-func clearGameServerUUID() {
- config.ConfigMu.Lock()
- defer config.ConfigMu.Unlock()
- config.GameServerUUID = uuid.Nil
-}
-
-func createGameServerUUID() {
- config.ConfigMu.Lock()
- defer config.ConfigMu.Unlock()
- config.GameServerUUID = uuid.New()
-}
diff --git a/src/managers/gamemgr/runcheck.go b/src/managers/gamemgr/runcheck.go
new file mode 100644
index 00000000..a57734f4
--- /dev/null
+++ b/src/managers/gamemgr/runcheck.go
@@ -0,0 +1,50 @@
+package gamemgr
+
+import (
+ "runtime"
+ "syscall"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+)
+
+// InternalIsServerRunning checks if the server process is running.
+// Safe to call standalone as it manages its own locking.
+func InternalIsServerRunning() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return internalIsServerRunningNoLock()
+}
+
+// internalIsServerRunningNoLock checks if the server process is running.
+// Caller M U S T hold mu.Lock().
+func internalIsServerRunningNoLock() bool {
+ if cmd == nil || cmd.Process == nil {
+ return false
+ }
+
+ if runtime.GOOS == "windows" {
+ select {
+ case <-processExited:
+ cmd = nil
+ clearGameServerUUID()
+ return false
+ default:
+ // Process is still running
+ return true
+ }
+ }
+
+ if runtime.GOOS == "linux" {
+ // On Unix-like systems, use Signal(0)
+ if err := cmd.Process.Signal(syscall.Signal(0)); err != nil {
+ logger.Core.Debug("Signal(0) failed, assuming process is dead: " + err.Error())
+ cmd = nil
+ clearGameServerUUID()
+ return false
+ }
+ return true
+ }
+
+ logger.Core.Warn("Failed to check if server is running, assuming it's dead")
+ return false
+}
diff --git a/src/managers/gamemgr/uuid.go b/src/managers/gamemgr/uuid.go
new file mode 100644
index 00000000..e968648b
--- /dev/null
+++ b/src/managers/gamemgr/uuid.go
@@ -0,0 +1,17 @@
+package gamemgr
+
+import (
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+ "github.com/google/uuid"
+)
+
+var GameServerUUID uuid.UUID
+
+func clearGameServerUUID() {
+ GameServerUUID = uuid.Nil
+}
+
+func createGameServerUUID() {
+ GameServerUUID = uuid.New()
+ logger.Core.Debug("Created Game Server with internal UUID: " + GameServerUUID.String())
+}
diff --git a/src/setup/cleanup.go b/src/setup/cleanup.go
index dde1e310..323be66d 100644
--- a/src/setup/cleanup.go
+++ b/src/setup/cleanup.go
@@ -12,9 +12,9 @@ import (
)
func CleanUpOldUIModFolderFiles() error {
- uiModFolder := config.UIModFolder
+ uiModFolder := config.GetUIModFolder()
customdetectionsSourceFile := filepath.Join(uiModFolder, "detectionmanager", "customdetections.json")
- customdetectionsDestinationFile := config.CustomDetectionsFilePath
+ customdetectionsDestinationFile := config.GetCustomDetectionsFilePath()
oldUiFolder := filepath.Join(uiModFolder, "ui") // used to test if we need clean up from a structure before v5.5 (since we now have embedded assets)
//if uiModFolder doesn't contain a folder called UI, return early as there is nothing to clean up
@@ -68,10 +68,10 @@ func CleanUpOldUIModFolderFiles() error {
func CleanUpOldExecutables() error {
// Exit early if update is disabled to allow running old versions if needed
- if !config.IsUpdateEnabled {
+ if !config.GetIsUpdateEnabled() || config.GetIsDebugMode() {
return nil
}
- currentBackendVersion := config.Version
+ currentBackendVersion := config.GetVersion()
pattern := `StationeersServerControlv(\d+\.\d+\.\d+)(?:\.exe|\.x86_64)$`
re, err := regexp.Compile(pattern)
if err != nil {
@@ -84,45 +84,47 @@ func CleanUpOldExecutables() error {
return fmt.Errorf("failed to get current directory: %w", err)
}
- // Walk through the directory
- err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ // Read only the root directory
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return fmt.Errorf("error reading directory: %w", err)
+ }
+
+ // Process each entry in the root directory
+ for _, entry := range entries {
+ info, err := entry.Info()
if err != nil {
return err
}
// Skip directories, non-matching files, and files with _old prefix
if info.IsDir() || !re.MatchString(info.Name()) || strings.HasPrefix(info.Name(), "_old") {
- return nil
+ continue
}
// Extract version from filename
matches := re.FindStringSubmatch(info.Name())
if len(matches) < 2 {
- return nil
+ continue
}
fileVersion := matches[1]
// Skip if the version matches the current backend version
if fileVersion == currentBackendVersion {
- return nil
+ continue
}
// Generate new filename with _old prefix
newName := "_old" + info.Name()
- newPath := filepath.Join(filepath.Dir(path), newName)
+ newPath := filepath.Join(dir, newName)
// Rename the file
+ path := filepath.Join(dir, info.Name())
err = os.Rename(path, newPath)
if err != nil {
return fmt.Errorf("failed to rename %s to %s: %w", path, newName, err)
}
logger.Install.Info(fmt.Sprintf("Old Executable cleanup: Renamed %s to %s", path, newName))
-
- return nil
- })
-
- if err != nil {
- return fmt.Errorf("error walking directory: %w", err)
}
return nil
diff --git a/src/setup/install.go b/src/setup/install.go
index bc567c20..fd66e9d2 100644
--- a/src/setup/install.go
+++ b/src/setup/install.go
@@ -23,6 +23,7 @@ var downloadBranch string // Holds the branch to download from
// Install performs the entire installation process and ensures the server waits for it to complete
func Install(wg *sync.WaitGroup) {
+ wg.Add(1)
defer wg.Done() // Signal that installation is complete
// Step 0: Check for updates
@@ -45,17 +46,17 @@ func Install(wg *sync.WaitGroup) {
}
func CheckAndDownloadUIMod() {
- uiModDir := config.UIModFolder
- configDir := config.UIModFolder + "config/"
- tlsDir := config.UIModFolder + "tls/"
+ uiModDir := config.GetUIModFolder()
+ configDir := config.GetUIModFolder() + "config/"
+ tlsDir := config.GetUIModFolder() + "tls/"
requiredDirs := []string{uiModDir, configDir}
// Set branch
- if config.Branch == "release" || config.Branch == "Release" {
+ if config.GetBranch() == "release" || config.GetBranch() == "Release" {
downloadBranch = "main"
} else {
- downloadBranch = config.Branch
+ downloadBranch = config.GetBranch()
}
logger.Install.Debug("Using branch: " + downloadBranch)
@@ -77,30 +78,25 @@ func CheckAndDownloadUIMod() {
// Check if the directory exists
if _, err := os.Stat(uiModDir); os.IsNotExist(err) {
// Initial download
- config.ConfigMu.Lock()
//check if tlsDir exists, if not, set isFirstTimeSetup to true
if _, err := os.Stat(tlsDir); os.IsNotExist(err) {
- config.IsFirstTimeSetup = true
+ config.SetIsFirstTimeSetup(true)
} else {
- config.IsFirstTimeSetup = false
+ config.SetIsFirstTimeSetup(false)
}
-
- config.ConfigMu.Unlock()
downloadAllFiles(files)
} else {
// Directory exists
- config.ConfigMu.Lock()
- config.IsFirstTimeSetup = false
- config.ConfigMu.Unlock()
- logger.Install.Info(fmt.Sprintf("IsUpdateEnabled: %v", config.IsUpdateEnabled))
- logger.Install.Info(fmt.Sprintf("IsFirstTimeSetup: %v", config.IsFirstTimeSetup))
- if config.IsUpdateEnabled {
+ config.SetIsFirstTimeSetup(false)
+ logger.Install.Debug(fmt.Sprintf("IsUpdateEnabled: %v", config.GetIsUpdateEnabled()))
+ logger.Install.Debug(fmt.Sprintf("IsFirstTimeSetup: %v", config.GetIsFirstTimeSetup()))
+ if config.GetIsUpdateEnabled() {
logger.Install.Info("🔍Validating UIMod files for updates...")
- if config.Branch == "release" || config.Branch == "Release" {
+ if config.GetBranch() == "release" || config.GetBranch() == "Release" {
downloadBranch = "main"
updateFilesIfDifferent(files)
} else {
- downloadBranch = config.Branch
+ downloadBranch = config.GetBranch()
updateFilesIfDifferent(files)
}
} else {
@@ -359,7 +355,7 @@ func createRequiredDirs(requiredDirs []string) {
// Create directories
for _, dir := range requiredDirs {
if _, err := os.Stat(dir); os.IsNotExist(err) {
- config.IsFirstTimeSetup = true
+ config.SetIsFirstTimeSetup(true)
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
logger.Install.Error("❌Error creating folder: " + err.Error())
diff --git a/src/setup/sscm.go b/src/setup/sscm.go
index 28fb4866..fde685f4 100644
--- a/src/setup/sscm.go
+++ b/src/setup/sscm.go
@@ -16,18 +16,18 @@ import (
var installMutex sync.Mutex
func CheckAndDownloadSSCM() {
- SSCMPluginDir := config.SSCMPluginDir
- sscmDir := config.SSCMWebDir
+ SSCMPluginDir := config.GetSSCMPluginDir()
+ sscmDir := config.GetSSCMWebDir()
requiredDirs := []string{SSCMPluginDir, sscmDir}
// Set branch
- if config.Branch == "release" || config.Branch == "Release" {
+ if config.GetBranch() == "release" || config.GetBranch() == "Release" {
downloadBranch = "main"
} else {
- downloadBranch = config.Branch
+ downloadBranch = config.GetBranch()
}
- logger.Install.Info("Using branch: " + downloadBranch)
+ logger.Install.Debug("Using branch for SSCM: " + downloadBranch)
// Define file mappings
files := map[string]string{
@@ -51,24 +51,20 @@ func CheckAndDownloadSSCM() {
}
// Initial download
- config.ConfigMu.Lock()
- config.IsFirstTimeSetup = true
- config.ConfigMu.Unlock()
+ config.SetIsFirstTimeSetup(true)
downloadAllFiles(files)
} else {
// Directory exists
- config.ConfigMu.Lock()
- config.IsFirstTimeSetup = false
- config.ConfigMu.Unlock()
- logger.Install.Info(fmt.Sprintf("IsUpdateEnabled: %v", config.IsUpdateEnabled))
- logger.Install.Info(fmt.Sprintf("IsFirstTimeSetup: %v", config.IsFirstTimeSetup))
- if config.IsUpdateEnabled {
+ config.SetIsFirstTimeSetup(false)
+ logger.Install.Debug(fmt.Sprintf("IsUpdateEnabled: %v", config.GetIsUpdateEnabled()))
+ logger.Install.Debug(fmt.Sprintf("IsFirstTimeSetup: %v", config.GetIsFirstTimeSetup()))
+ if config.GetIsUpdateEnabled() {
logger.Install.Info("🔍Validating SSCM files for updates...")
- if config.Branch == "release" || config.Branch == "Release" {
+ if config.GetBranch() == "release" || config.GetBranch() == "Release" {
downloadBranch = "main"
updateFilesIfDifferent(files)
} else {
- downloadBranch = config.Branch
+ downloadBranch = config.GetBranch()
updateFilesIfDifferent(files)
}
} else {
@@ -170,9 +166,7 @@ func InstallSSCM() {
CheckAndDownloadSSCM()
// Enable SSCM
- config.ConfigMu.Lock()
- config.IsSSCMEnabled = true
- config.ConfigMu.Unlock()
+ config.SetIsSSCMEnabled(true)
logger.Install.Info("✅SSCM enabled")
}
diff --git a/src/setup/steamcmd.go b/src/setup/steamcmd.go
index 962176a2..1364ce29 100644
--- a/src/setup/steamcmd.go
+++ b/src/setup/steamcmd.go
@@ -29,8 +29,8 @@ const (
// InstallAndRunSteamCMD installs and runs SteamCMD based on the platform (Windows/Linux).
// It returns the exit status of the SteamCMD execution and any error encountered.
func InstallAndRunSteamCMD() (int, error) {
- if config.Branch == "indev-no-steamcmd" {
- logger.Install.Info("🔍 Detected indev-no-steamcmd branch, skipping SteamCMD installation")
+ if config.GetBranch() == "indev-no-steamcmd" || config.GetIsDebugMode() {
+ logger.Install.Info("🔍 Detected indev-no-steamcmd branch or debug=true, skipping SteamCMD run")
return 0, nil
}
@@ -135,7 +135,7 @@ func runSteamCMD(steamCMDDir string) (int, error) {
cmd.Stderr = os.Stderr
// Run the command
- if config.LogLevel == 10 {
+ if config.GetLogLevel() == 10 {
cmdString := strings.Join(cmd.Args, " ")
logger.Install.Info("🕑 Running SteamCMD: " + cmdString)
} else {
@@ -157,11 +157,11 @@ func runSteamCMD(steamCMDDir string) (int, error) {
// buildSteamCMDCommand constructs the SteamCMD command based on the OS.
func buildSteamCMDCommand(steamCMDDir, currentDir string) *exec.Cmd {
//print the config.GameBranch and config.GameServerAppID
- logger.Install.Info("🔍 Game Branch: " + config.GameBranch)
- logger.Install.Debug("🔍 Game Server App ID: " + config.GameServerAppID)
+ logger.Install.Info("🔍 Game Branch: " + config.GetBranch())
+ logger.Install.Debug("🔍 Game Server App ID: " + config.GetGameServerAppID())
if runtime.GOOS == "windows" {
- return exec.Command(filepath.Join(steamCMDDir, "steamcmd.exe"), "+force_install_dir", currentDir, "+login", "anonymous", "+app_update", config.GameServerAppID, "-beta", config.GameBranch, "validate", "+quit")
+ return exec.Command(filepath.Join(steamCMDDir, "steamcmd.exe"), "+force_install_dir", currentDir, "+login", "anonymous", "+app_update", config.GetGameServerAppID(), "-beta", config.GetGameBranch(), "validate", "+quit")
}
- return exec.Command(filepath.Join(steamCMDDir, "steamcmd.sh"), "+force_install_dir", currentDir, "+login", "anonymous", "+app_update", config.GameServerAppID, "-beta", config.GameBranch, "validate", "+quit")
+ return exec.Command(filepath.Join(steamCMDDir, "steamcmd.sh"), "+force_install_dir", currentDir, "+login", "anonymous", "+app_update", config.GetGameServerAppID(), "-beta", config.GetGameBranch(), "validate", "+quit")
}
diff --git a/src/setup/update/updater.go b/src/setup/update/updater.go
index cdd8b456..f5ef8b7c 100644
--- a/src/setup/update/updater.go
+++ b/src/setup/update/updater.go
@@ -37,7 +37,7 @@ type Version struct {
// UpdateExecutable checks for and applies the latest release from GitHub
func UpdateExecutable() error {
- if !config.IsUpdateEnabled {
+ if !config.GetIsUpdateEnabled() {
logger.Install.Warn("⚠️ Update check is disabled. Skipping update check. Change 'IsUpdateEnabled' in config.json to true to re-enable update checks.")
time.Sleep(1000 * time.Millisecond)
logger.Install.Info("⚠️ Continuing in 3 seconds...")
@@ -49,12 +49,12 @@ func UpdateExecutable() error {
return nil
}
- if config.Branch != "release" {
+ if config.GetBranch() != "release" {
logger.Install.Warn("⚠️ You are running a development build. Skipping update check.")
return nil
}
- if config.AllowPrereleaseUpdates {
+ if config.GetAllowPrereleaseUpdates() {
logger.Install.Info("🕵️ Querying GitHub API for the latest (pre)release...")
} else {
logger.Install.Info("🕵️ Querying GitHub API for the latest stable release...")
@@ -65,16 +65,16 @@ func UpdateExecutable() error {
}
// Parse current and latest versions
- currentVer, err := parseVersion(config.Version)
+ currentVer, err := parseVersion(config.GetVersion())
if err != nil {
- return fmt.Errorf("❌ Failed to parse current version %s: %v", config.Version, err)
+ return fmt.Errorf("❌ Failed to parse current version %s: %v", config.GetVersion(), err)
}
latestVer, err := parseVersion(latestRelease.TagName)
if err != nil {
return fmt.Errorf("❌ Failed to parse latest version %s: %v", latestRelease.TagName, err)
}
- logger.Install.Info(fmt.Sprintf("Current version: %s, Latest version: %s", config.Version, latestRelease.TagName))
+ logger.Install.Info(fmt.Sprintf("Current version: %s, Latest version: %s", config.GetVersion(), latestRelease.TagName))
// Check if we should update
updateReason, shouldUpdate := shouldUpdate(currentVer, latestVer)
@@ -115,16 +115,16 @@ func UpdateExecutable() error {
}
// Download and replace
- logger.Install.Info(fmt.Sprintf("📡 Updating from %s to %s...", config.Version, latestRelease.TagName))
+ logger.Install.Info(fmt.Sprintf("📡 Updating from %s to %s...", config.GetVersion(), latestRelease.TagName))
if err := downloadNewExecutable(expectedExe, downloadURL); err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: %v. Keeping version %s.", err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: %v. Keeping version %s.", err, config.GetVersion()))
return err
}
// Set executable permissions on Linux
if runtime.GOOS != "windows" {
if err := os.Chmod(expectedExe, 0755); err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t make %s executable: %v. Keeping version %s.", expectedExe, err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t make %s executable: %v. Keeping version %s.", expectedExe, err, config.GetVersion()))
return err
}
}
@@ -133,13 +133,13 @@ func UpdateExecutable() error {
logger.Install.Info("🚀 Launching the new version and retiring the old one...")
if runtime.GOOS == "windows" {
if err := runAndExit(expectedExe); err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t launch %s: %v. Keeping version %s.", expectedExe, err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t launch %s: %v. Keeping version %s.", expectedExe, err, config.GetVersion()))
return err
}
}
if runtime.GOOS == "linux" {
if err := runAndExitLinux(expectedExe); err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t launch %s: %v. Keeping version %s.", expectedExe, err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t launch %s: %v. Keeping version %s.", expectedExe, err, config.GetVersion()))
return err
}
}
@@ -150,19 +150,19 @@ func UpdateExecutable() error {
func RestartMySelf() {
currentExe, err := os.Executable()
if err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t get current executable path: %v. Keeping version %s.", err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t get current executable path: %v. Keeping version %s.", err, config.GetVersion()))
return
}
if runtime.GOOS == "windows" {
if err := runAndExit(currentExe); err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.GetVersion()))
return
}
}
if runtime.GOOS == "linux" {
if err := runAndExitLinux(currentExe); err != nil {
- logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.Version))
+ logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.GetVersion()))
return
}
}
@@ -193,7 +193,7 @@ func shouldUpdate(current, latest Version) (string, bool) {
}
// Check if it’s a major update and not allowed
- if current.Major != latest.Major && !config.AllowMajorUpdates {
+ if current.Major != latest.Major && !config.GetAllowMajorUpdates() {
return "major-update", false
}
@@ -232,7 +232,7 @@ func getLatestRelease() (*githubRelease, error) {
continue
}
if i == 0 || isReleaseNewerVersion(version, latestVersion) {
- currentVersion, err := parseVersion(config.Version)
+ currentVersion, err := parseVersion(config.GetVersion())
if err == nil && isReleaseNewerVersion(currentVersion, version) {
if release.Prerelease {
logger.Install.Warn("Found a prerelease, but it is older than the running version. Skipping...")
@@ -250,7 +250,7 @@ func getLatestRelease() (*githubRelease, error) {
}
// Log warning if the latest release is a prerelease
- if latestRelease.Prerelease && !config.AllowPrereleaseUpdates {
+ if latestRelease.Prerelease && !config.GetAllowPrereleaseUpdates() {
logger.Install.Warn(fmt.Sprintf("⚠️ Pre-release Update found: Latest version %s is a pre-release. Enable 'AllowPrereleaseUpdates' in config.json to update to it.", latestRelease.TagName))
time.Sleep(1000 * time.Millisecond)
logger.Install.Info("⚠️ Continuing in 3 seconds...")
@@ -262,7 +262,7 @@ func getLatestRelease() (*githubRelease, error) {
}
// If prerelease and AllowPrereleaseUpdates is false, find the latest stable release
- if latestRelease.Prerelease && !config.AllowPrereleaseUpdates {
+ if latestRelease.Prerelease && !config.GetAllowPrereleaseUpdates() {
var stableRelease *githubRelease
var stableVersion Version
for i, release := range releases {
diff --git a/src/web/TwoBoxForm.go b/src/web/TwoBoxForm.go
index fbeb3a9b..bbee909f 100644
--- a/src/web/TwoBoxForm.go
+++ b/src/web/TwoBoxForm.go
@@ -406,7 +406,7 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) {
}
data := TemplateData{
- IsFirstTimeSetup: config.IsFirstTimeSetup,
+ IsFirstTimeSetup: config.GetIsFirstTimeSetup(),
Path: path,
Step: stepID,
FooterText: localization.GetString("UIText_FooterText"),
@@ -414,7 +414,7 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) {
switch {
- case path == "/login" && !config.AuthEnabled:
+ case path == "/login" && !config.GetAuthEnabled():
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
@@ -468,7 +468,7 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) {
default:
data.Title = localization.GetString("UIText_Login_Title")
- data.HeaderTitle = localization.GetString("UIText_Login_HeaderTitle") + config.AdditionalLoginHeaderText
+ data.HeaderTitle = localization.GetString("UIText_Login_HeaderTitle") + config.GetSSUIIdentifier()
data.PrimaryLabel = localization.GetString("UIText_Login_PrimaryLabel")
data.SecondaryLabel = localization.GetString("UIText_Login_SecondaryLabel")
data.PrimaryPlaceholderText = localization.GetString("UIText_Login_PrimaryPlaceholder")
diff --git a/src/web/commands.go b/src/web/commands.go
index 30523f8b..64a5c1c7 100644
--- a/src/web/commands.go
+++ b/src/web/commands.go
@@ -26,7 +26,7 @@ func HandleCommand(w http.ResponseWriter, r *http.Request) {
return
}
- if !config.IsSSCMEnabled {
+ if !config.GetIsSSCMEnabled() {
sendErrorResponse(w, http.StatusForbidden, "SSCM is disabled, cannot execute commands")
return
}
diff --git a/src/web/configpage.go b/src/web/configpage.go
index f66e65ef..705b45ea 100644
--- a/src/web/configpage.go
+++ b/src/web/configpage.go
@@ -29,7 +29,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
// Determine selected attributes for boolean fields
upnpTrueSelected := ""
upnpFalseSelected := ""
- if config.UPNPEnabled {
+ if config.GetUPNPEnabled() {
upnpTrueSelected = "selected"
} else {
upnpFalseSelected = "selected"
@@ -37,7 +37,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
discordTrueSelected := ""
discordFalseSelected := ""
- if config.IsDiscordEnabled {
+ if config.GetIsDiscordEnabled() {
discordTrueSelected = "selected"
} else {
discordFalseSelected = "selected"
@@ -45,7 +45,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
autoSaveTrueSelected := ""
autoSaveFalseSelected := ""
- if config.AutoSave {
+ if config.GetAutoSave() {
autoSaveTrueSelected = "selected"
} else {
autoSaveFalseSelected = "selected"
@@ -53,7 +53,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
autoPauseTrueSelected := ""
autoPauseFalseSelected := ""
- if config.AutoPauseServer {
+ if config.GetAutoPauseServer() {
autoPauseTrueSelected = "selected"
} else {
autoPauseFalseSelected = "selected"
@@ -61,7 +61,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
startLocalTrueSelected := ""
startLocalFalseSelected := ""
- if config.StartLocalHost {
+ if config.GetStartLocalHost() {
startLocalTrueSelected = "selected"
} else {
startLocalFalseSelected = "selected"
@@ -69,7 +69,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
serverVisibleTrueSelected := ""
serverVisibleFalseSelected := ""
- if config.ServerVisible {
+ if config.GetServerVisible() {
serverVisibleTrueSelected = "selected"
} else {
serverVisibleFalseSelected = "selected"
@@ -78,7 +78,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
isNewTerrainAndSaveSystemTrueSelected := ""
isNewTerrainAndSaveSystemFalseSelected := ""
- if config.IsNewTerrainAndSaveSystem {
+ if config.GetIsNewTerrainAndSaveSystem() {
isNewTerrainAndSaveSystemTrueSelected = "selected"
} else {
isNewTerrainAndSaveSystemFalseSelected = "selected"
@@ -86,7 +86,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
autoStartServerTrueSelected := ""
autoStartServerFalseSelected := ""
- if config.AutoStartServerOnStartup {
+ if config.GetAutoStartServerOnStartup() {
autoStartServerTrueSelected = "selected"
} else {
autoStartServerFalseSelected = "selected"
@@ -94,7 +94,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
steamP2PTrueSelected := ""
steamP2PFalseSelected := ""
- if config.UseSteamP2P {
+ if config.GetUseSteamP2P() {
steamP2PTrueSelected = "selected"
} else {
steamP2PFalseSelected = "selected"
@@ -102,57 +102,57 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) {
data := ConfigTemplateData{
// Config values
- DiscordToken: config.DiscordToken,
- ControlChannelID: config.ControlChannelID,
- StatusChannelID: config.StatusChannelID,
- ConnectionListChannelID: config.ConnectionListChannelID,
- LogChannelID: config.LogChannelID,
- SaveChannelID: config.SaveChannelID,
- ControlPanelChannelID: config.ControlPanelChannelID,
- BlackListFilePath: config.BlackListFilePath,
- ErrorChannelID: config.ErrorChannelID,
- IsDiscordEnabled: fmt.Sprintf("%v", config.IsDiscordEnabled),
+ DiscordToken: config.GetDiscordToken(),
+ ControlChannelID: config.GetControlChannelID(),
+ StatusChannelID: config.GetStatusChannelID(),
+ ConnectionListChannelID: config.GetConnectionListChannelID(),
+ LogChannelID: config.GetLogChannelID(),
+ SaveChannelID: config.GetSaveChannelID(),
+ ControlPanelChannelID: config.GetControlPanelChannelID(),
+ BlackListFilePath: config.GetBlackListFilePath(),
+ ErrorChannelID: config.GetErrorChannelID(),
+ IsDiscordEnabled: fmt.Sprintf("%v", config.GetIsDiscordEnabled()),
IsDiscordEnabledTrueSelected: discordTrueSelected,
IsDiscordEnabledFalseSelected: discordFalseSelected,
- GameBranch: config.GameBranch,
- Difficulty: config.Difficulty,
- StartCondition: config.StartCondition,
- StartLocation: config.StartLocation,
- ServerName: config.ServerName,
- SaveInfo: config.SaveInfo,
- ServerMaxPlayers: config.ServerMaxPlayers,
- ServerPassword: config.ServerPassword,
- ServerAuthSecret: config.ServerAuthSecret,
- AdminPassword: config.AdminPassword,
- GamePort: config.GamePort,
- UpdatePort: config.UpdatePort,
- UPNPEnabled: fmt.Sprintf("%v", config.UPNPEnabled),
+ GameBranch: config.GetGameBranch(),
+ Difficulty: config.GetDifficulty(),
+ StartCondition: config.GetStartCondition(),
+ StartLocation: config.GetStartLocation(),
+ ServerName: config.GetServerName(),
+ SaveInfo: config.GetSaveInfo(),
+ ServerMaxPlayers: config.GetServerMaxPlayers(),
+ ServerPassword: config.GetServerPassword(),
+ ServerAuthSecret: config.GetServerAuthSecret(),
+ AdminPassword: config.GetAdminPassword(),
+ GamePort: config.GetGamePort(),
+ UpdatePort: config.GetUpdatePort(),
+ UPNPEnabled: fmt.Sprintf("%v", config.GetUPNPEnabled()),
UPNPEnabledTrueSelected: upnpTrueSelected,
UPNPEnabledFalseSelected: upnpFalseSelected,
- AutoSave: fmt.Sprintf("%v", config.AutoSave),
+ AutoSave: fmt.Sprintf("%v", config.GetAutoSave()),
AutoSaveTrueSelected: autoSaveTrueSelected,
AutoSaveFalseSelected: autoSaveFalseSelected,
- SaveInterval: config.SaveInterval,
- AutoPauseServer: fmt.Sprintf("%v", config.AutoPauseServer),
+ SaveInterval: config.GetSaveInterval(),
+ AutoPauseServer: fmt.Sprintf("%v", config.GetAutoPauseServer()),
AutoPauseServerTrueSelected: autoPauseTrueSelected,
AutoPauseServerFalseSelected: autoPauseFalseSelected,
- LocalIpAddress: config.LocalIpAddress,
- StartLocalHost: fmt.Sprintf("%v", config.StartLocalHost),
+ LocalIpAddress: config.GetLocalIpAddress(),
+ StartLocalHost: fmt.Sprintf("%v", config.GetStartLocalHost()),
StartLocalHostTrueSelected: startLocalTrueSelected,
StartLocalHostFalseSelected: startLocalFalseSelected,
- ServerVisible: fmt.Sprintf("%v", config.ServerVisible),
+ ServerVisible: fmt.Sprintf("%v", config.GetServerVisible()),
ServerVisibleTrueSelected: serverVisibleTrueSelected,
ServerVisibleFalseSelected: serverVisibleFalseSelected,
- UseSteamP2P: fmt.Sprintf("%v", config.UseSteamP2P),
+ UseSteamP2P: fmt.Sprintf("%v", config.GetUseSteamP2P()),
UseSteamP2PTrueSelected: steamP2PTrueSelected,
UseSteamP2PFalseSelected: steamP2PFalseSelected,
- ExePath: config.ExePath,
- AdditionalParams: config.AdditionalParams,
- AutoRestartServerTimer: config.AutoRestartServerTimer,
- IsNewTerrainAndSaveSystem: fmt.Sprintf("%v", config.IsNewTerrainAndSaveSystem),
+ ExePath: config.GetExePath(),
+ AdditionalParams: config.GetAdditionalParams(),
+ AutoRestartServerTimer: config.GetAutoRestartServerTimer(),
+ IsNewTerrainAndSaveSystem: fmt.Sprintf("%v", config.GetIsNewTerrainAndSaveSystem()),
IsNewTerrainAndSaveSystemTrueSelected: isNewTerrainAndSaveSystemTrueSelected,
IsNewTerrainAndSaveSystemFalseSelected: isNewTerrainAndSaveSystemFalseSelected,
- AutoStartServerOnStartup: fmt.Sprintf("%v", config.AutoStartServerOnStartup),
+ AutoStartServerOnStartup: fmt.Sprintf("%v", config.GetAutoStartServerOnStartup()),
AutoStartServerOnStartupTrueSelected: autoStartServerTrueSelected,
AutoStartServerOnStartupFalseSelected: autoStartServerFalseSelected,
diff --git a/src/web/connectedplayers.go b/src/web/connectedplayers.go
new file mode 100644
index 00000000..295a8585
--- /dev/null
+++ b/src/web/connectedplayers.go
@@ -0,0 +1,53 @@
+package web
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr"
+)
+
+// PrintConnectedPlayersHandler handles HTTP requests to list connected players.
+func HandleConnectedPlayersList(w http.ResponseWriter, r *http.Request) {
+ // Only allow GET requests
+ if r.Method != http.MethodGet {
+ http.Error(w, "Only GET requests are allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ detector := detectionmgr.GetDetector()
+ players := detectionmgr.GetPlayers(detector)
+
+ // if players is empty, return an empty list
+ if len(players) == 0 {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(players); err != nil {
+ http.Error(w, "Failed to encode player list", http.StatusInternalServerError)
+ }
+ return
+ }
+
+ // Create a comma-separated string of SteamIDs
+ steamIDs := make([]string, 0, len(players))
+ for steamID := range players {
+ steamIDs = append(steamIDs, steamID)
+ }
+
+ // Build the response player list
+ playerList := make([]map[string]map[string]string, 0, len(players))
+ for steamID, username := range players {
+ playerInfo := map[string]string{
+ "username": username,
+ "steamID": steamID,
+ }
+ nestedPlayer := map[string]map[string]string{
+ username: playerInfo,
+ }
+ playerList = append(playerList, nestedPlayer)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(playerList); err != nil {
+ http.Error(w, "Failed to encode player list", http.StatusInternalServerError)
+ }
+}
diff --git a/src/web/http-sse.go b/src/web/http-sse.go
new file mode 100644
index 00000000..56fc3f23
--- /dev/null
+++ b/src/web/http-sse.go
@@ -0,0 +1,67 @@
+package web
+
+import (
+ "net/http"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/ssestream"
+)
+
+// handler for the /console endpoint
+func GetLogOutput(w http.ResponseWriter, r *http.Request) {
+ StartConsoleStream()(w, r)
+}
+
+// handler for the /console endpoint
+func GetEventOutput(w http.ResponseWriter, r *http.Request) {
+ StartDetectionEventStream()(w, r)
+}
+
+func GetDebugLogOutput(w http.ResponseWriter, r *http.Request) {
+ StartDebugLogStream()(w, r)
+}
+
+func GetInfoLogOutput(w http.ResponseWriter, r *http.Request) {
+ StartInfoLogStream()(w, r)
+}
+
+func GetWarnLogOutput(w http.ResponseWriter, r *http.Request) {
+ StartWarnLogStream()(w, r)
+}
+
+func GetErrorLogOutput(w http.ResponseWriter, r *http.Request) {
+ StartErrorLogStream()(w, r)
+}
+
+func GetBackendLogOutput(w http.ResponseWriter, r *http.Request) {
+ StartBackendLogStream()(w, r)
+}
+
+// StartConsoleStream creates an HTTP handler for console log SSE streaming
+func StartConsoleStream() http.HandlerFunc {
+ return ssestream.ConsoleStreamManager.CreateStreamHandler("Console")
+}
+
+// StartDetectionEventStream creates an HTTP handler for detection event SSE streaming
+func StartDetectionEventStream() http.HandlerFunc {
+ return ssestream.EventStreamManager.CreateStreamHandler("Event")
+}
+
+func StartDebugLogStream() http.HandlerFunc {
+ return ssestream.DebugLogStreamManager.CreateStreamHandler("Debug Log")
+}
+
+func StartInfoLogStream() http.HandlerFunc {
+ return ssestream.InfoLogStreamManager.CreateStreamHandler("Info Log")
+}
+
+func StartWarnLogStream() http.HandlerFunc {
+ return ssestream.WarnLogStreamManager.CreateStreamHandler("Warn Log")
+}
+
+func StartErrorLogStream() http.HandlerFunc {
+ return ssestream.ErrorLogStreamManager.CreateStreamHandler("Error Log")
+}
+
+func StartBackendLogStream() http.HandlerFunc {
+ return ssestream.BackendLogStreamManager.CreateStreamHandler("Full Backend Log")
+}
diff --git a/src/web/http.go b/src/web/http.go
index b246b933..1eb4a324 100644
--- a/src/web/http.go
+++ b/src/web/http.go
@@ -10,10 +10,10 @@ import (
"time"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/ssestream"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/commandmgr"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup"
)
@@ -23,11 +23,11 @@ func StartServer(w http.ResponseWriter, r *http.Request) {
logger.Web.Debug("Received start request from API")
if err := gamemgr.InternalStartServer(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
- logger.Web.Core("Error starting server: " + err.Error())
+ logger.Web.Error("Error starting server: " + err.Error())
return
}
fmt.Fprint(w, localization.GetString("BackendText_ServerStarted"))
- logger.Web.Core("Server started.")
+ logger.Web.Info("Server started.")
}
// StopServer HTTP handler
@@ -36,22 +36,23 @@ func StopServer(w http.ResponseWriter, r *http.Request) {
if err := gamemgr.InternalStopServer(); err != nil {
if err.Error() == "server not running" {
fmt.Fprint(w, localization.GetString("BackendText_ServerNotRunningOrAlreadyStopped"))
- logger.Web.Core("Server not running or was already stopped")
+ logger.Web.Warn("Server not running or was already stopped")
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
- logger.Web.Core("Error stopping server: " + err.Error())
+ logger.Web.Error("Error stopping server: " + err.Error())
return
}
+ detectionmgr.ClearPlayers(detectionmgr.GetDetector())
fmt.Fprint(w, localization.GetString("BackendText_ServerStopped"))
- logger.Web.Core("Server stopped.")
+ logger.Web.Info("Server stopped.")
}
func GetGameServerRunState(w http.ResponseWriter, r *http.Request) {
runState := gamemgr.InternalIsServerRunning()
response := map[string]interface{}{
"isRunning": runState,
- "uuid": config.GameServerUUID.String(),
+ "uuid": gamemgr.GameServerUUID.String(),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
@@ -60,26 +61,6 @@ func GetGameServerRunState(w http.ResponseWriter, r *http.Request) {
}
}
-// handler for the /console endpoint
-func GetLogOutput(w http.ResponseWriter, r *http.Request) {
- StartConsoleStream()(w, r)
-}
-
-// handler for the /console endpoint
-func GetEventOutput(w http.ResponseWriter, r *http.Request) {
- StartDetectionEventStream()(w, r)
-}
-
-// StartConsoleStream creates an HTTP handler for console log SSE streaming
-func StartConsoleStream() http.HandlerFunc {
- return ssestream.ConsoleStreamManager.CreateStreamHandler("Console")
-}
-
-// StartDetectionEventStream creates an HTTP handler for detection event SSE streaming
-func StartDetectionEventStream() http.HandlerFunc {
- return ssestream.EventStreamManager.CreateStreamHandler("Event")
-}
-
// CommandHandler handles POST requests to execute commands via commandmgr.
// Expects a command in the request body. Returns 204 on success or error details.
func CommandHandler(w http.ResponseWriter, r *http.Request) {
@@ -128,7 +109,7 @@ func HandleIsSSCMEnabled(w http.ResponseWriter, r *http.Request) {
}
// Check if SSCM is enabled
- if !config.IsSSCMEnabled {
+ if !config.GetIsSSCMEnabled() {
http.Error(w, "SSCM is disabled", http.StatusForbidden)
return
}
diff --git a/src/web/indexpage.go b/src/web/indexpage.go
index 1741493d..81d72180 100644
--- a/src/web/indexpage.go
+++ b/src/web/indexpage.go
@@ -24,20 +24,31 @@ func ServeIndex(w http.ResponseWriter, r *http.Request) {
return
}
+ var Identifier string
+
+ if config.SSUIIdentifier == "" {
+ Identifier = " (" + config.GetBranch() + ")"
+ } else {
+ Identifier = ": " + config.GetSSUIIdentifier()
+ }
+
data := IndexTemplateData{
- Version: config.Version,
- Branch: config.Branch,
- UIText_StartButton: localization.GetString("UIText_StartButton"),
- UIText_StopButton: localization.GetString("UIText_StopButton"),
- UIText_Settings: localization.GetString("UIText_Settings"),
- UIText_Update_SteamCMD: localization.GetString("UIText_Update_SteamCMD"),
- UIText_Console: localization.GetString("UIText_Console"),
- UIText_Detection_Events: localization.GetString("UIText_Detection_Events"),
- UIText_Backup_Manager: localization.GetString("UIText_Backup_Manager"),
- UIText_Discord_Info: localization.GetString("UIText_Discord_Info"),
- UIText_API_Info: localization.GetString("UIText_API_Info"),
- UIText_Copyright1: localization.GetString("UIText_Copyright1"),
- UIText_Copyright2: localization.GetString("UIText_Copyright2"),
+ Version: config.GetVersion(),
+ Branch: config.GetBranch(),
+ SSUIIdentifier: Identifier,
+ UIText_StartButton: localization.GetString("UIText_StartButton"),
+ UIText_StopButton: localization.GetString("UIText_StopButton"),
+ UIText_Settings: localization.GetString("UIText_Settings"),
+ UIText_Update_SteamCMD: localization.GetString("UIText_Update_SteamCMD"),
+ UIText_Console: localization.GetString("UIText_Console"),
+ UIText_Detection_Events: localization.GetString("UIText_Detection_Events"),
+ UIText_Backend_Log: localization.GetString("UIText_Backend_Log"),
+ UIText_Backup_Manager: localization.GetString("UIText_Backup_Manager"),
+ UIText_Connected_PlayersHeader: localization.GetString("UIText_Connected_PlayersHeader"),
+ UIText_Discord_Info: localization.GetString("UIText_Discord_Info"),
+ UIText_API_Info: localization.GetString("UIText_API_Info"),
+ UIText_Copyright1: localization.GetString("UIText_Copyright1"),
+ UIText_Copyright2: localization.GetString("UIText_Copyright2"),
}
if data.Version == "" {
data.Version = "unknown"
diff --git a/src/web/login.go b/src/web/login.go
index 84883b1a..e2c8f0db 100644
--- a/src/web/login.go
+++ b/src/web/login.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config/configchanger"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/security"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
@@ -56,7 +57,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "AuthToken",
Value: tokenString,
- Expires: time.Now().Add(time.Duration(config.AuthTokenLifetime) * time.Minute),
+ Expires: time.Now().Add(time.Duration(config.GetAuthTokenLifetime()) * time.Minute),
HttpOnly: true,
Secure: true,
Path: "/",
@@ -75,7 +76,7 @@ func AuthMiddleware(next http.Handler) http.Handler {
//logger.Web.Debug("Request Path:" + r.URL.Path) //very spammy
// Check for first-time setup redirect
- if config.IsFirstTimeSetup {
+ if config.GetIsFirstTimeSetup() {
totalSetupReminderCount := 3 // Defines how often we redirect the users reqests to the setup page
if setupReminderCount < totalSetupReminderCount {
if r.URL.Path == "/" && (r.Referer() == "" || r.Referer() != "/setup") {
@@ -89,7 +90,7 @@ func AuthMiddleware(next http.Handler) http.Handler {
}
}
- if !config.AuthEnabled {
+ if !config.GetAuthEnabled() {
next.ServeHTTP(w, r)
return
}
@@ -155,6 +156,13 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
// RegisterUserHandler registers new users
func RegisterUserHandler(w http.ResponseWriter, r *http.Request) {
+
+ // Handle preflight OPTIONS requests
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
var creds security.UserCredentials
err := json.NewDecoder(r.Body).Decode(&creds)
if err != nil {
@@ -173,30 +181,13 @@ func RegisterUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
- // Load existing config to update it
- existingConfig, err := config.LoadConfig()
- if err != nil {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusInternalServerError)
- json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error - Failed to load config"})
- return
- }
-
// Initialize Users map if nil
- if existingConfig.Users == nil {
- existingConfig.Users = make(map[string]string)
+ if config.GetUsers() == nil {
+ config.SetUsers(make(map[string]string))
}
// Add or update the user
- existingConfig.Users[creds.Username] = hashedPassword
-
- // Persist the updated config
- if err := loader.SaveConfig(existingConfig); err != nil {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusInternalServerError)
- json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error - Failed to save config"})
- return
- }
+ config.SetUsers(map[string]string{creds.Username: hashedPassword})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
@@ -210,7 +201,7 @@ func RegisterUserHandler(w http.ResponseWriter, r *http.Request) {
func SetupFinalizeHandler(w http.ResponseWriter, r *http.Request) {
//check if users map is nil or empty
- if len(config.Users) == 0 {
+ if len(config.GetUsers()) == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "No users registered - cannot finalize setup at this time. You should really enable authentication - or click 'Skip authentication'"})
@@ -227,14 +218,12 @@ func SetupFinalizeHandler(w http.ResponseWriter, r *http.Request) {
}
// Mark setup as complete and enable auth
- config.ConfigMu.Lock()
- config.IsFirstTimeSetup = false
- config.ConfigMu.Unlock()
+ config.SetIsFirstTimeSetup(false)
isTrue := true
newConfig.AuthEnabled = &isTrue // Set the pointer to true
// Save the updated config
- err = loader.SaveConfig(newConfig)
+ err = configchanger.SaveConfig(newConfig)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
@@ -242,7 +231,7 @@ func SetupFinalizeHandler(w http.ResponseWriter, r *http.Request) {
return
}
- logger.Web.Core("User Setup finalized successfully")
+ logger.Web.Info("User Setup finalized successfully")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
diff --git a/src/web/routes.go b/src/web/routes.go
new file mode 100644
index 00000000..9b7a45eb
--- /dev/null
+++ b/src/web/routes.go
@@ -0,0 +1,83 @@
+package web
+
+import (
+ "io/fs"
+ "net/http"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config/configchanger"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/backupmgr"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr"
+)
+
+func SetupRoutes() (*http.ServeMux, *http.ServeMux) {
+
+ // Set up handlers with auth middleware
+ mux := http.NewServeMux() // Use a mux to apply middleware globally
+
+ // Unprotected auth routes
+ twoboxformAssetsFS, _ := fs.Sub(config.GetV1UIFS(), "UIMod/onboard_bundled/twoboxform")
+ mux.Handle("/twoboxform/", http.StripPrefix("/twoboxform/", http.FileServer(http.FS(twoboxformAssetsFS))))
+ mux.HandleFunc("/auth/login", LoginHandler) // Token issuer
+ mux.HandleFunc("/auth/logout", LogoutHandler)
+ mux.HandleFunc("/login", ServeTwoBoxFormTemplate)
+
+ // Protected routes (wrapped with middleware)
+ protectedMux := http.NewServeMux()
+
+ legacyAssetsFS, _ := fs.Sub(config.GetV1UIFS(), "UIMod/onboard_bundled/assets")
+ protectedMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(legacyAssetsFS))))
+
+ protectedMux.HandleFunc("/config", ServeConfigPage)
+ protectedMux.HandleFunc("/detectionmanager", ServeDetectionManager)
+ protectedMux.HandleFunc("/", ServeIndex)
+
+ // --- SVELTE UI ---
+ protectedMux.HandleFunc("/v2", ServeSvelteUI)
+ svelteAssetsFS, _ := fs.Sub(config.V1UIFS, "UIMod/onboard_bundled/v2/assets")
+ protectedMux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(svelteAssetsFS))))
+ protectedMux.HandleFunc("/api/v2/loader/reloadbackend", HandleReloadAll)
+
+ // SSE routes
+ protectedMux.HandleFunc("/console", GetLogOutput)
+ protectedMux.HandleFunc("/events", GetEventOutput)
+ protectedMux.HandleFunc("/logs/debug", GetDebugLogOutput)
+ protectedMux.HandleFunc("/logs/info", GetInfoLogOutput)
+ protectedMux.HandleFunc("/logs/warn", GetWarnLogOutput)
+ protectedMux.HandleFunc("/logs/error", GetErrorLogOutput)
+ protectedMux.HandleFunc("/logs/backend", GetBackendLogOutput)
+
+ // Server Control
+ protectedMux.HandleFunc("/start", StartServer)
+ protectedMux.HandleFunc("/stop", StopServer)
+ protectedMux.HandleFunc("/api/v2/server/start", StartServer)
+ protectedMux.HandleFunc("/api/v2/server/stop", StopServer)
+ protectedMux.HandleFunc("/api/v2/server/status", GetGameServerRunState)
+ protectedMux.HandleFunc("/api/v2/server/status/connectedplayers", HandleConnectedPlayersList)
+
+ backupHandler := backupmgr.NewHTTPHandler(backupmgr.GlobalBackupManager)
+ protectedMux.HandleFunc("/api/v2/backups", backupHandler.ListBackupsHandler)
+ protectedMux.HandleFunc("/api/v2/backups/restore", backupHandler.RestoreBackupHandler)
+
+ // Configuration
+ protectedMux.HandleFunc("/saveconfigasjson", configchanger.SaveConfigForm) // legacy, used on config page
+ protectedMux.HandleFunc("/api/v2/saveconfig", configchanger.SaveConfigRestful) // used on twoboxform
+ protectedMux.HandleFunc("/api/v2/SSCM/run", HandleCommand) // Command execution via SSCM (needs to be enable, config.IsSSCMEnabled)
+ protectedMux.HandleFunc("/api/v2/SSCM/enabled", HandleIsSSCMEnabled) // Check if SSCM is enabled
+ protectedMux.HandleFunc("/api/v2/steamcmd/run", HandleRunSteamCMD) // Run SteamCMD
+
+ // Custom Detections
+ protectedMux.HandleFunc("/api/v2/custom-detections", detectionmgr.HandleCustomDetection)
+ protectedMux.HandleFunc("/api/v2/custom-detections/delete/", detectionmgr.HandleDeleteCustomDetection)
+ // Authentication
+ protectedMux.HandleFunc("/changeuser", ServeTwoBoxFormTemplate)
+ protectedMux.HandleFunc("/api/v2/auth/adduser", RegisterUserHandler) // user registration and change password
+ protectedMux.HandleFunc("/api/v2/auth/whoami", WhoAmIHandler)
+
+ // Setup
+ protectedMux.HandleFunc("/setup", ServeTwoBoxFormTemplate)
+ protectedMux.HandleFunc("/api/v2/auth/setup/register", RegisterUserHandler) // user registration
+ protectedMux.HandleFunc("/api/v2/auth/setup/finalize", SetupFinalizeHandler)
+
+ return mux, protectedMux
+}
diff --git a/src/web/start.go b/src/web/start.go
index 95476f93..3b52b85b 100644
--- a/src/web/start.go
+++ b/src/web/start.go
@@ -2,102 +2,58 @@
package web
import (
- "io/fs"
+ "log"
"net/http"
"net/http/pprof"
"sync"
- terminal "github.com/JacksonTheMaster/StationeersServerUI/v5/src/cli"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config/configchanger"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/security"
"github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/backupmgr"
- "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr"
)
-func StartWebServer(wg *sync.WaitGroup) {
-
- logger.Web.Info("Starting API services...")
- // Set up handlers with auth middleware
- mux := http.NewServeMux() // Use a mux to apply middleware globally
-
- // Unprotected auth routes
- twoboxformAssetsFS, _ := fs.Sub(config.GetV1UIFS(), "UIMod/onboard_bundled/twoboxform")
- mux.Handle("/twoboxform/", http.StripPrefix("/twoboxform/", http.FileServer(http.FS(twoboxformAssetsFS))))
- mux.HandleFunc("/auth/login", LoginHandler) // Token issuer
- mux.HandleFunc("/auth/logout", LogoutHandler)
- mux.HandleFunc("/login", ServeTwoBoxFormTemplate)
-
- // Protected routes (wrapped with middleware)
- protectedMux := http.NewServeMux()
-
- legacyAssetsFS, _ := fs.Sub(config.GetV1UIFS(), "UIMod/onboard_bundled/assets")
- protectedMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(legacyAssetsFS))))
-
- protectedMux.HandleFunc("/config", ServeConfigPage)
- protectedMux.HandleFunc("/detectionmanager", ServeDetectionManager)
- protectedMux.HandleFunc("/", ServeIndex)
-
- protectedMux.HandleFunc("/saveconfigasjson", configchanger.SaveConfigForm)
-
- // SSE routes
- protectedMux.HandleFunc("/console", GetLogOutput)
- protectedMux.HandleFunc("/events", GetEventOutput)
-
- // Server Control
- protectedMux.HandleFunc("/start", StartServer)
- protectedMux.HandleFunc("/stop", StopServer)
- protectedMux.HandleFunc("/api/v2/server/start", StartServer)
- protectedMux.HandleFunc("/api/v2/server/stop", StopServer)
- protectedMux.HandleFunc("/api/v2/server/status", GetGameServerRunState)
+type webServerLogger struct{}
- backupHandler := backupmgr.NewHTTPHandler(backupmgr.GlobalBackupManager)
- protectedMux.HandleFunc("/api/v2/backups", backupHandler.ListBackupsHandler)
- protectedMux.HandleFunc("/api/v2/backups/restore", backupHandler.RestoreBackupHandler)
-
- // Configuration
- protectedMux.HandleFunc("/api/v2/saveconfig", configchanger.SaveConfigRestful)
- protectedMux.HandleFunc("/api/v2/SSCM/run", HandleCommand) // Command execution via SSCM (needs to be enable, config.IsSSCMEnabled)
- protectedMux.HandleFunc("/api/v2/SSCM/enabled", HandleIsSSCMEnabled) // Check if SSCM is enabled
- protectedMux.HandleFunc("/api/v2/steamcmd/run", HandleRunSteamCMD) // Run SteamCMD
+func (cl *webServerLogger) Write(p []byte) (n int, err error) {
+ // Redirect HTTP server logs (like TLS handshake errors) to logger.Web.Debug
+ logger.Web.Debug(string(p))
+ return len(p), nil
+}
- // Custom Detections
- protectedMux.HandleFunc("/api/v2/custom-detections", detectionmgr.HandleCustomDetection)
- protectedMux.HandleFunc("/api/v2/custom-detections/delete/", detectionmgr.HandleDeleteCustomDetection)
- // Authentication
- protectedMux.HandleFunc("/changeuser", ServeTwoBoxFormTemplate)
- protectedMux.HandleFunc("/api/v2/auth/adduser", RegisterUserHandler) // user registration and change password
+func StartWebServer(wg *sync.WaitGroup) {
- // Setup
- protectedMux.HandleFunc("/setup", ServeTwoBoxFormTemplate)
- protectedMux.HandleFunc("/api/v2/auth/setup/register", RegisterUserHandler) // user registration
- protectedMux.HandleFunc("/api/v2/auth/setup/finalize", SetupFinalizeHandler)
+ logger.Web.Info("Starting API services...")
+ mux, protectedMux := SetupRoutes()
// Apply middleware only to protected routes
mux.Handle("/", AuthMiddleware(protectedMux)) // Wrap protected routes under root
+ httpLogger := log.New(&webServerLogger{}, "", 0)
// Start HTTP server
wg.Add(1)
go func() {
defer wg.Done()
- terminal.PrintStartupMessage()
- if config.IsFirstTimeSetup {
- terminal.PrintFirstTimeSetupMessage()
- }
// Ensure TLS certs are ready
if err := security.EnsureTLSCerts(); err != nil {
logger.Web.Error("Error setting up TLS certificates: " + err.Error())
- //os.Exit(1)
+ return
}
- err := http.ListenAndServeTLS("0.0.0.0:"+config.SSUIWebPort, config.TLSCertPath, config.TLSKeyPath, mux)
+
+ // Create an HTTP server with a custom logger
+ server := &http.Server{
+ Addr: "0.0.0.0:" + config.GetSSUIWebPort(),
+ Handler: mux,
+ ErrorLog: httpLogger,
+ }
+
+ err := server.ListenAndServeTLS(config.GetTLSCertPath(), config.GetTLSKeyPath())
if err != nil {
logger.Web.Error("Error starting HTTPS server: " + err.Error())
}
}()
// Start the pprof server if debug mode is enabled (HTTP/1.1)
- if config.IsDebugMode && config.LogLevel < 20 { // if debug mode is enabled and log level is lower than 20 (if this triggers LogLevel is probably 10 and probably debug, but who knows), start pprof server
+ if config.GetIsDebugMode() { // if debug mode is enabled, start pprof server
wg.Add(1)
go func() {
defer wg.Done()
@@ -111,8 +67,4 @@ func StartWebServer(wg *sync.WaitGroup) {
}
}()
}
-
- // Wait for both servers to be running
- wg.Wait()
-
}
diff --git a/src/web/svelteui.go b/src/web/svelteui.go
new file mode 100644
index 00000000..c563df63
--- /dev/null
+++ b/src/web/svelteui.go
@@ -0,0 +1,59 @@
+package web
+
+import (
+ "encoding/json"
+ "io"
+ "io/fs"
+ "net/http"
+ "sync"
+
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader"
+ "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger"
+)
+
+var reloadMu sync.Mutex
+
+func ServeSvelteUI(w http.ResponseWriter, r *http.Request) {
+ htmlFS, err := fs.Sub(config.V1UIFS, "UIMod/onboard_bundled/v2")
+ if err != nil {
+ http.Error(w, "Error accessing Svelte UI: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ htmlFile, err := htmlFS.Open("index.html")
+ if err != nil {
+ http.Error(w, "Error reading Svelte UI: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer htmlFile.Close()
+
+ // Stream the file content to the response
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, err = io.Copy(w, htmlFile)
+ if err != nil {
+ http.Error(w, "Error writing Svelte UI: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func HandleReloadAll(w http.ResponseWriter, r *http.Request) {
+ logger.Web.Debug("Received reloadbackend request from API")
+ reloadMu.Lock()
+ defer reloadMu.Unlock()
+ // accept only GET requests
+ if r.Method != http.MethodGet {
+ http.Error(w, "Only GET requests are allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ // Reload all loaders
+ loader.ReloadBackend()
+
+ // Set response headers and write JSON response
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ if err := json.NewEncoder(w).Encode(map[string]string{"status": "OK"}); err != nil {
+ http.Error(w, "Failed to write response", http.StatusInternalServerError)
+ return
+ }
+}
diff --git a/src/web/templatevars.go b/src/web/templatevars.go
index 5b02f10e..a3eb24ba 100644
--- a/src/web/templatevars.go
+++ b/src/web/templatevars.go
@@ -2,19 +2,22 @@ package web
// TemplateData holds data to be passed to templates
type IndexTemplateData struct {
- Version string
- Branch string
- UIText_StartButton string
- UIText_StopButton string
- UIText_Settings string
- UIText_Update_SteamCMD string
- UIText_Console string
- UIText_Detection_Events string
- UIText_Backup_Manager string
- UIText_Discord_Info string
- UIText_API_Info string
- UIText_Copyright1 string
- UIText_Copyright2 string
+ Version string
+ Branch string
+ SSUIIdentifier string
+ UIText_StartButton string
+ UIText_StopButton string
+ UIText_Settings string
+ UIText_Update_SteamCMD string
+ UIText_Console string
+ UIText_Detection_Events string
+ UIText_Backend_Log string
+ UIText_Backup_Manager string
+ UIText_Connected_PlayersHeader string
+ UIText_Discord_Info string
+ UIText_API_Info string
+ UIText_Copyright1 string
+ UIText_Copyright2 string
}
// ConfigTemplateData holds data for the config page template
diff --git a/src/web/whoami.go b/src/web/whoami.go
new file mode 100644
index 00000000..0baa5cc3
--- /dev/null
+++ b/src/web/whoami.go
@@ -0,0 +1,17 @@
+package web
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+func WhoAmIHandler(w http.ResponseWriter, r *http.Request) {
+
+ // Set response headers and write JSON response
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(w).Encode(map[string]string{"username": "SSUI", "accessLevel": "SSUI-Admin"}); err != nil {
+ http.Error(w, "Failed to write response", http.StatusInternalServerError)
+ return
+ }
+}