diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0ed4e03b..c1439a5d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,6 @@ "extensions": [ "golang.go", "svelte.svelte-vscode", - "supermaven.supermaven", "eamodio.gitlens" ], "settings": { diff --git a/.gitignore b/.gitignore index ca2aa0b9..59e620c0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ frontend/node_modules frontend/dist frontend/build UIMod/onboard_bundled/v2 -UIMod/logs/* \ No newline at end of file +UIMod/logs/* +.agent-context/** +.github/copilot-instructions.md +mods/** \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/credits.html b/UIMod/onboard_bundled/assets/credits.html index 631f27f1..7113d2a9 100644 --- a/UIMod/onboard_bundled/assets/credits.html +++ b/UIMod/onboard_bundled/assets/credits.html @@ -18,6 +18,24 @@ overflow: hidden; position: relative; } + .nebula { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + background: + radial-gradient(ellipse at 20% 80%, rgba(102, 126, 234, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(118, 75, 162, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 50% 50%, rgba(240, 147, 251, 0.08) 0%, transparent 60%); + animation: nebula-drift 30s ease-in-out infinite alternate; + } + + @keyframes nebula-drift { + 0% { transform: scale(1) rotate(0deg); } + 100% { transform: scale(1.1) rotate(5deg); } + } .stars { position: fixed; @@ -35,6 +53,26 @@ animation: twinkle 3s infinite; } + .star.bright { + box-shadow: 0 0 6px 2px rgba(255, 255, 255, 0.6); + } + + .shooting-star { + position: absolute; + width: 100px; + height: 2px; + background: linear-gradient(90deg, rgba(255,255,255,0.8), transparent); + opacity: 0; + animation: shoot 4s linear infinite; + } + + @keyframes shoot { + 0% { transform: translateX(0) translateY(0); opacity: 0; } + 5% { opacity: 1; } + 20% { transform: translateX(300px) translateY(150px); opacity: 0; } + 100% { opacity: 0; } + } + @keyframes twinkle { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } @@ -53,15 +91,27 @@ width: 80%; max-width: 900px; left: 50%; - bottom: 0; - transform: translateX(-50%) translateY(100%); - animation: scroll-up 90s linear forwards; + top: 100%; + transform: translateX(-50%); text-align: center; + will-change: transform; + transition: none; + } + + .credits-scroll.animating { + animation: scroll-up 90s linear forwards; + } + + .credits-scroll.paused { + animation-play-state: paused; } @keyframes scroll-up { + from { + top: 100%; + } to { - transform: translateX(-50%) translateY(-100%); + top: -100%; } } @@ -69,15 +119,28 @@ font-size: 3.5em; font-weight: bold; margin-bottom: 2em; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + background-size: 200% 200%; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; - text-shadow: 0 0 30px rgba(102, 126, 234, 0.5); + animation: gradient-shift 5s ease infinite; + filter: drop-shadow(0 0 30px rgba(102, 126, 234, 0.5)); + } + + @keyframes gradient-shift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } } .section { margin: 4em 0; + padding: 2em; + transition: all 0.4s ease; + } + + .section:hover { + transform: scale(1.02); } .role { @@ -87,6 +150,17 @@ letter-spacing: 3px; margin-bottom: 0.5em; text-shadow: 0 0 20px rgba(255, 215, 0, 0.6); + position: relative; + } + + .role::after { + content: ''; + display: block; + width: 60px; + height: 3px; + background: linear-gradient(90deg, transparent, #ffd700, transparent); + margin: 0.5em auto; + border-radius: 2px; } .name { @@ -110,11 +184,16 @@ color: #60a5fa; text-decoration: none; font-size: 1em; - transition: color 0.3s; + transition: all 0.3s; + padding: 0.3em 1em; + border-radius: 20px; + display: inline-block; } .link:hover { color: #93c5fd; + background: rgba(96, 165, 250, 0.1); + box-shadow: 0 0 20px rgba(96, 165, 250, 0.3); } .description { @@ -137,7 +216,7 @@ font-size: 2em; color: #008cff; margin: 3em 0 1em 0; - text-shadow: 0 0 20px rgba(72, 187, 120, 0.6); + text-shadow: 0 0 20px rgba(0, 140, 255, 0.6); } .tech-stack { @@ -167,13 +246,101 @@ .glow { text-shadow: 0 0 10px currentColor; } + + /* Scroll hint indicator */ + .scroll-hint { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.5); + font-size: 0.9em; + text-align: center; + z-index: 100; + transition: opacity 0.5s; + pointer-events: none; + } + + .scroll-hint .mouse-icon { + width: 26px; + height: 40px; + border: 2px solid rgba(255, 255, 255, 0.5); + border-radius: 13px; + margin: 0 auto 10px; + position: relative; + } + + .scroll-hint .wheel { + width: 4px; + height: 8px; + background: rgba(255, 255, 255, 0.5); + border-radius: 2px; + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + animation: scroll-wheel 2s infinite; + } + + @keyframes scroll-wheel { + 0%, 100% { transform: translateX(-50%) translateY(0); opacity: 1; } + 50% { transform: translateX(-50%) translateY(10px); opacity: 0.3; } + } + + /* Control buttons */ + .controls { + position: fixed; + top: 20px; + right: 20px; + z-index: 100; + display: flex; + gap: 10px; + } + + .control-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 10px 20px; + border-radius: 25px; + cursor: pointer; + font-size: 0.9em; + transition: all 0.3s; + backdrop-filter: blur(10px); + } + + .control-btn:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); + transform: scale(1.05); + } + + .control-btn.active { + background: rgba(102, 126, 234, 0.3); + border-color: rgba(102, 126, 234, 0.6); + } +
-
-
+ +
+ + +
+ + +
+
+
+
+ Scroll to navigate +
+ +
+
StationeersServerUI
@@ -252,6 +419,8 @@
Donors
Musashi
+
Stealthbob
+
Sumisukyo
@@ -281,11 +450,11 @@ \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index 1c734b22..13de4173 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -436,4 +436,573 @@ select option { @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } +} + +.slp-section { + padding: 25px; + border-radius: 12px; + margin: 15px 0; + border: 1px solid var(--primary-dim); + transition: all var(--transition-normal); +} + +.slp-section:hover { + border-color: var(--primary); + box-shadow: 0 0 15px rgba(0, 255, 150, 0.1); +} + +.slp-section p { + color: var(--text-normal, #aaa); + font-family: 'Share Tech Mono', monospace; + font-size: 0.9rem; + line-height: 1.6; + margin: 0 0 15px 0; +} + +.slp-section h3 { + color: var(--text-bright); + font-family: 'Press Start 2P', cursive; + font-size: 0.85rem; + margin: 0 0 20px 0; + padding-bottom: 12px; + border-bottom: 2px solid var(--primary-dim); + letter-spacing: 1px; +} + +.slp-install-section { + background: linear-gradient(135deg, rgba(0, 255, 150, 0.08), rgba(0, 150, 255, 0.08)); + border: 2px solid var(--primary); + box-shadow: 0 0 25px rgba(0, 255, 150, 0.15); + text-align: center; + padding: 40px; +} + +.slp-install-section:hover { + box-shadow: 0 0 35px rgba(0, 255, 150, 0.25); +} + +.slp-install-header { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + margin-bottom: 20px; +} + +.slp-install-icon { + font-size: 52px; + filter: drop-shadow(0 0 10px rgba(0, 255, 150, 0.5)); +} + +.slp-install-header h3 { + color: var(--text-bright); + font-family: 'Press Start 2P', cursive; + font-size: 1.1rem; + margin: 0; + padding: 0; + border: none; + text-shadow: 0 0 10px rgba(0, 255, 150, 0.3); +} + +.slp-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 200px; + padding: 14px 28px; + background-color: var(--primary); + border: 2px solid var(--primary); + color: var(--bg-dark); + border-radius: 8px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + font-family: 'Share Tech Mono', monospace; + font-size: 0.85rem; + box-shadow: 0 4px 15px rgba(0, 255, 150, 0.25); + margin: 8px 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.slp-button:hover:not(:disabled) { + transform: translateY(-3px); + box-shadow: 0 6px 25px rgba(0, 255, 150, 0.4); +} + +.slp-button:active:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 3px 15px rgba(0, 255, 150, 0.3); +} + +.slp-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.slp-button.loading { + opacity: 0.7; + cursor: wait; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 0.9; } +} + +.slp-button-large { + min-width: 320px; + padding: 18px 36px; + font-size: 0.9rem; + box-shadow: 0 4px 20px rgba(0, 255, 150, 0.35); +} + +.slp-button-large:hover:not(:disabled) { + box-shadow: 0 8px 35px rgba(0, 255, 150, 0.5); +} + +.slp-button-small { + min-width: 160px; + padding: 10px 20px; + font-size: 0.75rem; +} + +.slp-button.danger { + background-color: #e53935; + border-color: #e53935; + box-shadow: 0 4px 15px rgba(229, 57, 53, 0.25); +} + +.slp-button.danger:hover:not(:disabled) { + background-color: #ff5252; + border-color: #ff5252; + box-shadow: 0 6px 25px rgba(255, 82, 82, 0.4); +} + +.slp-manage-section { + border: 1px solid rgba(0, 255, 150, 0.2); +} + +.manage-buttons { + display: flex; + gap: 30px; + align-items: stretch; +} + +.manage-left, +.manage-right { + flex: 1; + display: flex; + flex-direction: column; + padding: 15px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.manage-left p, +.manage-right p { + flex: 1; + margin-bottom: 15px; + font-size: 0.85rem; +} + +.manage-left .slp-button, +.manage-right .slp-button { + width: 100%; + margin-top: auto; +} + +@media (max-width: 768px) { + .manage-buttons { + flex-direction: column; + } +} + +#modPackageUploadZone { + border: 2px dashed var(--primary-dim); + border-radius: 12px; + padding: 50px 40px; + text-align: center; + cursor: pointer; + transition: all 0.25s ease; + background: linear-gradient(145deg, rgba(0, 255, 150, 0.03), rgba(0, 150, 255, 0.03)); + position: relative; + margin: 20px 0; +} + +#modPackageUploadZone:hover:not(.upload-zone-disabled) { + border-color: var(--primary); + background: linear-gradient(145deg, rgba(0, 255, 150, 0.08), rgba(0, 150, 255, 0.08)); + box-shadow: 0 0 25px rgba(0, 255, 150, 0.15); + transform: translateY(-2px); +} + +#modPackageUploadZone.highlight { + border-color: var(--primary); + border-style: solid; + background: linear-gradient(145deg, rgba(0, 255, 150, 0.12), rgba(0, 150, 255, 0.12)); + box-shadow: 0 0 30px rgba(0, 255, 150, 0.25); +} + +.upload-zone-disabled { + cursor: not-allowed !important; + opacity: 0.5; + border-color: rgba(150, 150, 150, 0.3) !important; + background: rgba(50, 50, 50, 0.2) !important; +} + +.upload-zone-disabled:hover { + transform: none !important; + box-shadow: none !important; +} + +.upload-icon { + font-size: 56px; + margin-bottom: 18px; + display: block; + filter: drop-shadow(0 0 8px rgba(0, 255, 150, 0.3)); +} + +.upload-text { + color: var(--text-bright); + font-family: 'Share Tech Mono', monospace; + font-size: 1rem; + font-weight: bold; + margin: 12px 0; + letter-spacing: 0.5px; +} + +.upload-subtext { + color: var(--text-dim); + font-family: 'Share Tech Mono', monospace; + font-size: 0.85rem; + margin-top: 8px; + opacity: 0.8; +} + +#modPackageUpload { + display: none; +} + +#modPackageUploadProgress { + display: none; + height: 4px; + background-color: var(--primary); + border-radius: 2px; + width: 0%; + transition: width 0.3s ease; + margin-top: 10px; + box-shadow: 0 0 10px var(--primary); +} + +#modPackageUploadProgress.active { + display: block; +} + +/* SLP Beta Disclaimer */ +.slp-disclaimer { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + padding: 40px; +} + +.slp-disclaimer-content { + text-align: center; + max-width: 500px; + padding: 50px; + background: linear-gradient(135deg, rgba(255, 170, 0, 0.08), rgba(255, 100, 0, 0.08)); + border: 2px solid rgba(255, 170, 0, 0.5); + border-radius: 16px; + box-shadow: 0 0 30px rgba(255, 170, 0, 0.15); +} + +.slp-disclaimer-icon { + font-size: 64px; + display: block; + margin-bottom: 20px; + filter: drop-shadow(0 0 15px rgba(255, 170, 0, 0.5)); +} + +.slp-disclaimer-content h2 { + color: #ffaa00; + font-family: 'Press Start 2P', cursive; + font-size: 1.2rem; + margin: 0 0 25px 0; + text-shadow: 0 0 15px rgba(255, 170, 0, 0.4); +} + +.slp-disclaimer-content p { + color: var(--text-normal, #ccc); + font-family: 'Share Tech Mono', monospace; + font-size: 1rem; + line-height: 1.8; + margin: 0 0 15px 0; +} + +.slp-disclaimer-content .slp-button { + margin-top: 25px; +} + +.slp-content-hidden { + display: none; +} + +.slp-disclaimer-hidden { + display: none; +} + +.notification { + display: none; + padding: 16px 20px; + margin: 15px 0; + border-radius: 8px; + font-family: 'Share Tech Mono', monospace; + font-size: 0.9rem; + animation: slideIn 0.3s ease-out; + position: relative; + border-left: 5px solid transparent; +} + +.notification.success { + background-color: rgba(0, 255, 150, 0.1); + border-color: #00ff96; + color: #00ff96; +} + +.notification.error { + background-color: rgba(255, 68, 68, 0.1); + border-color: #ff4444; + color: #ff6666; +} + +.notification.info { + background-color: rgba(0, 200, 255, 0.1); + border-color: #00c8ff; + color: #00d4ff; +} + +.notification.warning { + background-color: rgba(255, 200, 0, 0.1); + border-color: #ffc800; + color: #ffd700; +} + +@keyframes slideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.mods-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.mod-card { + background: #11111142; + border: 2px solid var(--primary-dim); + border-radius: 12px; + padding: 20px; + transition: all var(--transition-normal); + box-shadow: 0 0 15px var(--button-glow-soft); +} + +.mod-card:hover { + border-color: var(--primary); + box-shadow: 0 0 25px var(--button-glow); + transform: translateY(-5px); +} + +.mod-image-container { + width: 100%; + height: 250px; + background: var(--input-bg, #0f0f1e); + border: 2px solid var(--primary-dim); + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + position: relative; +} + +.mod-image-container img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + padding: 5px; +} + +.mod-image-container.no-image { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim, #888); + font-size: 3rem; +} + + + +.mod-title { + font-family: 'Press Start 2P', cursive; + font-size: 0.95rem; + color: var(--text-bright); + margin-bottom: 5px; + word-break: break-word; +} + +.mod-author { + color: var(--primary); + font-size: 0.85rem; + margin-bottom: 3px; + font-family: 'Share Tech Mono', monospace; +} + +.mod-version { + color: var(--text-dim, #888); + font-size: 0.8rem; + margin-bottom: 10px; + font-family: 'Share Tech Mono', monospace; +} + +.mod-description { + color: var(--text-normal, #ccc); + font-size: 0.85rem; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; + max-height: 250px; + overflow: hidden; + position: relative; + transition: max-height 0.3s ease; +} + +.mod-description.expanded { + max-height: none; + overflow: visible; +} + +.mod-description::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.mod-description.expanded::after { + opacity: 0; + pointer-events: none; +} + +.mod-description h1 { + font-family: 'Press Start 2P', cursive; + font-size: 1rem; + color: var(--primary); + margin: 12px 0 8px 0; + padding-left: 20px; +} + +.mod-description h2 { + font-family: 'Press Start 2P', cursive; + font-size: 0.95rem; + color: var(--primary); + margin: 10px 0 6px 0; +} + +.mod-description h3, +.mod-description h4 { + font-family: 'Press Start 2P', cursive; + font-size: 0.9rem; + color: var(--primary); + margin: 8px 0 5px 0; +} + +.mod-description b { + color: var(--primary); + font-weight: bold; +} + +.mod-description i { + font-style: italic; + color: var(--primary-glow); +} + +.mod-description u { + text-decoration: underline; +} + +.mod-description ul { + margin: 8px 0; + padding-left: 20px; +} + +.mod-description li { + margin: 4px 0; +} + +.mod-description hr { + border: none; + border-top: 1px solid var(--primary-dim); + margin: 12px 0; +} + +.mod-description a { + color: var(--primary); + text-decoration: underline; + cursor: pointer; +} + +.mod-description a:hover { + color: var(--primary-glow); +} + +.mod-expand-button { + box-shadow: none !important; + display: inline-block; + margin-top: 10px; + background: none; + border: none; + color: var(--primary); + cursor: pointer; + font-size: 1.2rem; + padding: 0; + transition: transform 0.3s ease; + font-weight: bold; +} + +.mod-expand-button:hover { + color: var(--primary-glow); +} + +.mod-expand-button.expanded { + transform: rotate(180deg); +} + +.mods-empty { + text-align: center; + color: var(--text-dim, #888); + padding: 60px 30px; + font-family: 'Share Tech Mono', monospace; + font-size: 0.95rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 12px; + border: 2px dashed var(--primary-dim); + margin: 10px 0; } \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/css/popup.css b/UIMod/onboard_bundled/assets/css/popup.css index 6b304c2a..cdf64cb2 100644 --- a/UIMod/onboard_bundled/assets/css/popup.css +++ b/UIMod/onboard_bundled/assets/css/popup.css @@ -19,9 +19,10 @@ padding: 20px; border-radius: 10px; text-align: center; - max-width: 30vw; + max-width: 60vw; + max-height: 60vh; + overflow-y: auto; box-shadow: 0 5px 30px var(--primary-glow); - overflow: hidden; } .popup-content h2 { margin: 10px 0; @@ -30,6 +31,8 @@ .popup-content p { margin: 10px 0; + text-align: left; + white-space: pre-wrap; } .popup-content button { diff --git a/UIMod/onboard_bundled/assets/js/popup.js b/UIMod/onboard_bundled/assets/js/popup.js index 9b018c86..a64ac7e0 100644 --- a/UIMod/onboard_bundled/assets/js/popup.js +++ b/UIMod/onboard_bundled/assets/js/popup.js @@ -5,7 +5,7 @@ function showPopup(status, message) { popup.className = 'popup'; popupTitle.textContent = ''; - popupMessage.textContent = message; + popupMessage.innerHTML = message.replace(/\n/g, '
'); switch(status.toLowerCase()) { case 'error': diff --git a/UIMod/onboard_bundled/assets/js/slp.js b/UIMod/onboard_bundled/assets/js/slp.js new file mode 100644 index 00000000..8f9ea091 --- /dev/null +++ b/UIMod/onboard_bundled/assets/js/slp.js @@ -0,0 +1,486 @@ +// SLP Beta Disclaimer Handler +function acknowledgeSLPDisclaimer() { + const disclaimer = document.getElementById('slp-disclaimer'); + const content = document.getElementById('slp-content'); + + if (disclaimer && content) { + disclaimer.classList.add('slp-disclaimer-hidden'); + content.classList.remove('slp-content-hidden'); + + // Store acknowledgment in session storage (resets on browser close) + sessionStorage.setItem('slp-disclaimer-acknowledged', 'true'); + } +} + +// Check if disclaimer was already acknowledged this session +function checkSLPDisclaimerState() { + const acknowledged = sessionStorage.getItem('slp-disclaimer-acknowledged'); + if (acknowledged === 'true') { + const disclaimer = document.getElementById('slp-disclaimer'); + const content = document.getElementById('slp-content'); + + if (disclaimer && content) { + disclaimer.classList.add('slp-disclaimer-hidden'); + content.classList.remove('slp-content-hidden'); + } + } +} + +// Initialize disclaimer state on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', checkSLPDisclaimerState); +} else { + checkSLPDisclaimerState(); +} + +function showNotification(message, type = 'info') { + const notification = document.getElementById('notification'); + notification.textContent = message; + notification.className = 'notification ' + type; + notification.style.display = 'block'; + + // Auto-hide after 5 seconds for success, keep longer for errors + const duration = type === 'success' ? 5000 : 7000; + setTimeout(() => { + notification.style.display = 'none'; + }, duration); +} + +function setButtonLoading(buttonId, isLoading) { + const button = document.getElementById(buttonId); + if (!button) return; + + if (isLoading) { + button.disabled = true; + button.dataset.originalText = button.textContent; + button.textContent = '⏳ Please wait...'; + button.classList.add('loading'); + } else { + button.disabled = false; + button.textContent = button.dataset.originalText || button.textContent; + button.classList.remove('loading'); + } +} + +function installSLP() { + setButtonLoading('installSLPBtn', true); + showPopup('info', 'Installing Stationeers Launch Pad...'); + + fetch('/api/v2/slp/install') + .then(response => response.json()) + .then(data => { + if (data.success) { + showPopup('success', 'Stationeers Launch Pad installed successfully! The page will refresh automatically.'); + setButtonLoading('installSLPBtn', false); + // Reload after 3 seconds to show the success message + setTimeout(() => window.location.reload(), 3000); + } else { + showPopup('error', 'Failed to install SLP:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('installSLPBtn', false); + } + }) + .catch(error => { + showPopup('error', 'Failed to install SLP:\n\n' + (error.message || 'Network error')); + setButtonLoading('installSLPBtn', false); + }); +} + +function uninstallSLP() { + if (!confirm('Are you sure you want to uninstall SLP? This will DELETE all mods too (you can always reinstall later).')) { + return; + } + setButtonLoading('uninstallSLPBtn', true); + showPopup('info', 'Uninstalling Stationeers Launch Pad...'); + + fetch('/api/v2/slp/uninstall') + .then(response => response.json()) + .then(data => { + if (data.success) { + showPopup('success', 'Stationeers Launch Pad uninstalled successfully! The page will refresh automatically.'); + setButtonLoading('uninstallSLPBtn', false); + setTimeout(() => window.location.reload(), 3000); + } else { + showPopup('error', 'Failed to uninstall SLP:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('uninstallSLPBtn', false); + } + }) + .catch(error => { + showPopup('error', 'Failed to uninstall SLP:\n\n' + (error.message || 'Network error')); + setButtonLoading('uninstallSLPBtn', false); + }); +} + +function updateWorkshopMods() { + setButtonLoading('updateWorkshopModsBtn', true); + showPopup('info', 'Updating workshop mods...\n\nThis may take some time depending on the number of mods. Please wait.'); + + fetch('/api/v2/steamcmd/updatemods') + .then(response => response.json()) + .then(data => { + setButtonLoading('updateWorkshopModsBtn', false); + if (data.success) { + const logsText = data.logs && data.logs.length > 0 ? '\n\n' + data.logs.join('\n') : ''; + showPopup('success', 'Workshop mods updated successfully!' + logsText); + } else { + const logsText = data.logs && data.logs.length > 0 ? '\n\n' + data.logs.join('\n') : ''; + showPopup('error', 'Failed to update workshop mods:\n\n' + (data.error || 'Unknown error') + logsText); + } + }) + .catch(error => { + showPopup('error', 'Failed to update workshop mods:\n\n' + (error.message || 'Network error')); + setButtonLoading('updateWorkshopModsBtn', false); + }); +} + +let selectedModFile = null; + +function handleModPackageSelection(files) { + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + + // Validate file is a zip + if (!file.name.endsWith('.zip')) { + showPopup('error', 'Invalid file format\n\nPlease select a .zip file'); + document.getElementById('modPackageUpload').value = ''; + selectedModFile = null; + updateFileDisplay(); + return; + } + + // Validate filename starts with modpkg_ + if (!file.name.startsWith('modpkg_')) { + showPopup('error', 'Invalid mod package name: Mod package filename must start with "modpkg_" Example: modpkg_2026-01-09_12-33-01-670.zip'); + document.getElementById('modPackageUpload').value = ''; + selectedModFile = null; + updateFileDisplay(); + return; + } + + // Check file size (limit to 500MB) + const maxSize = 500 * 1024 * 1024; + if (file.size > maxSize) { + showPopup('error', 'File too large: Maximum file size is 500MB'); + document.getElementById('modPackageUpload').value = ''; + selectedModFile = null; + updateFileDisplay(); + return; + } + + // Store the file for later upload + selectedModFile = file; + updateFileDisplay(); + showNotification('✓ File selected: ' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + 'MB)', 'success'); +} + +function updateFileDisplay() { + const uploadZone = document.getElementById('modPackageUploadZone'); + const uploadBtn = document.getElementById('uploadModPackageBtn'); + + if (!uploadZone || !uploadBtn) return; + + if (selectedModFile) { + uploadZone.innerHTML = '' + + '
File Selected
' + + '
' + selectedModFile.name + '
' + + '
' + (selectedModFile.size / 1024 / 1024).toFixed(2) + ' MB
'; + uploadBtn.disabled = false; + uploadBtn.style.opacity = '1'; + } else { + uploadZone.innerHTML = '📦' + + '
Drag & Drop Mod Package Here
' + + '
or click to select a .zip file
'; + uploadBtn.disabled = true; + uploadBtn.style.opacity = '0.5'; + } +} + +function uploadModPackage() { + if (!selectedModFile) { + showPopup('error', 'No file selected'); + return; + } + + setButtonLoading('uploadModPackageBtn', true); + showPopup('info', 'Uploading mod package...\n\n' + selectedModFile.name + ' (' + (selectedModFile.size / 1024 / 1024).toFixed(2) + 'MB)'); + updateUploadProgress(0); + + const reader = new FileReader(); + reader.onload = function(e) { + const zipData = e.target.result; + + fetch('/api/v2/slp/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/zip' + }, + body: zipData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showPopup('success', 'Mod package uploaded successfully!\n\n' + (data.message || 'The mods have been extracted and are ready to use.')); + selectedModFile = null; + document.getElementById('modPackageUpload').value = ''; + updateFileDisplay(); + updateUploadProgress(100); + setTimeout(() => updateUploadProgress(0), 2000); + setButtonLoading('uploadModPackageBtn', false); + } else { + showPopup('error', 'Failed to upload mod package:\n\n' + (data.error || 'Unknown error')); + setButtonLoading('uploadModPackageBtn', false); + } + }) + .catch(error => { + showPopup('error', 'Upload failed:\n\n' + (error.message || 'Network error')); + setButtonLoading('uploadModPackageBtn', false); + }); + }; + + reader.onerror = function() { + showPopup('error', 'Failed to read file'); + setButtonLoading('uploadModPackageBtn', false); + }; + + reader.readAsArrayBuffer(selectedModFile); +} + +function updateUploadProgress(percent) { + const progressBar = document.getElementById('modPackageUploadProgress'); + if (progressBar) { + progressBar.style.width = percent + '%'; + if (percent > 0) { + progressBar.classList.add('active'); + } else { + progressBar.classList.remove('active'); + } + } +} + +// Drag and drop support +function initializeDragAndDrop() { + const uploadZone = document.getElementById('modPackageUploadZone'); + if (!uploadZone) return; + + // Click to open file selector + uploadZone.addEventListener('click', function() { + document.getElementById('modPackageUpload').click(); + }); + + // Drag and drop events + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadZone.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ['dragenter', 'dragover'].forEach(eventName => { + uploadZone.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadZone.addEventListener(eventName, unhighlight, false); + }); + + function highlight(e) { + uploadZone.classList.add('highlight'); + } + + function unhighlight(e) { + uploadZone.classList.remove('highlight'); + } + + uploadZone.addEventListener('drop', handleDrop, false); + + function handleDrop(e) { + const dt = e.dataTransfer; + const files = dt.files; + handleModPackageSelection(files); + } + + // Initialize file display on load + updateFileDisplay(); +} + +// Initialize on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDragAndDrop); +} else { + initializeDragAndDrop(); +} + +// Mods List Management +let modsData = []; + +function loadInstalledMods() { + const container = document.getElementById('mods-list-container'); + const loader = document.getElementById('mods-loader'); + const modsList = document.getElementById('mods-list'); + + if (!container) return; + + // Show loader + if (loader) loader.style.display = 'block'; + if (modsList) modsList.innerHTML = ''; + + fetch('/api/v2/slp/mods') + .then(response => response.json()) + .then(data => { + if (loader) loader.style.display = 'none'; + + if (data.success && data.mods && data.mods.length > 0) { + modsData = data.mods; + renderModsList(data.mods); + } else { + if (modsList) modsList.innerHTML = '
No mods installed yet. Upload a mod package to get started!
'; + } + }) + .catch(error => { + if (loader) loader.style.display = 'none'; + console.error('Failed to load mods:', error); + if (modsList) modsList.innerHTML = '
Failed to load mods. Check console for details.
'; + }); +} + +function renderModsList(mods) { + const modsList = document.getElementById('mods-list'); + if (!modsList) return; + + modsList.innerHTML = ''; + + if (!mods || mods.length === 0) { + modsList.innerHTML = '
No mods installed yet. Upload a mod package to get started!
'; + return; + } + + mods.forEach((mod, index) => { + const modCard = createModCard(mod, index); + modsList.appendChild(modCard); + }); +} + +function createModCard(mod, index) { + const card = document.createElement('div'); + card.className = 'mod-card'; + + const images = mod.Images || {}; + const imageArray = Object.entries(images); + + let imageHtml = ''; + if (imageArray.length > 0) { + const firstImageData = imageArray[0][1]; + imageHtml = ` +
+ Mod image +
+ `; + } else { + imageHtml = ` +
+ 📷 +
+ `; + } + + const parsedDescription = mod.Description ? parseSteamMarkup(mod.Description) : ''; + + let descriptionHtml = ''; + if (parsedDescription) { + descriptionHtml = ` +
+ ${parsedDescription} +
+ + `; + } + + card.innerHTML = ` + ${imageHtml} +
${escapeHtml(mod.Name || 'Unknown Mod')}
+ ${mod.Author ? `
By ${escapeHtml(mod.Author)}
` : ''} + ${mod.Version ? `
v${escapeHtml(mod.Version)}
` : ''} + ${descriptionHtml} + `; + + return card; +} + +function parseSteamMarkup(text) { + if (!text) return ''; + + // Escape HTML first + let html = text + .replace(/&/g, '&') + .replace(//g, '>'); + + // Replace Steam markup tags + // Headers + html = html.replace(/\[h1\](.*?)\[\/h1\]/g, '

$1

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

$1

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

$1

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

$1

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

    '); + html = html.replace(/\n/g, '
    '); + + return html; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function toggleModDescription(index) { + const descElement = document.getElementById(`desc-${index}`); + const btnElement = document.getElementById(`expand-btn-${index}`); + + if (descElement && btnElement) { + descElement.classList.toggle('expanded'); + btnElement.classList.toggle('expanded'); + } +} + +// Initialize mods list when mods section is visible +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + const modsContainer = document.getElementById('mods-list-container'); + if (modsContainer) { + loadInstalledMods(); + } + }); +} else { + const modsContainer = document.getElementById('mods-list-container'); + if (modsContainer) { + loadInstalledMods(); + } +} \ No newline at end of file diff --git a/UIMod/onboard_bundled/localization/de-DE.json b/UIMod/onboard_bundled/localization/de-DE.json index a79e9f24..bdca48cf 100644 --- a/UIMod/onboard_bundled/localization/de-DE.json +++ b/UIMod/onboard_bundled/localization/de-DE.json @@ -137,6 +137,27 @@ "UIText_DiscordBenefit5": "Echtzeit Fehlerbenachrichtigungen", "UIText_DiscordSetupInstructions": "Für Setup-Anweisungen besuche die" }, + "slp": { + "UIText_SLP_Title": "Stationeers Launch Pad", + "UIText_SLP_Description": "Stationeers Launch Pad ist ein einfacher Mod-Loader für Stationeers. Es aktualisiert sich automatisch und macht Mod-Verwaltung einfach.", + "UIText_SLP_ReadyToInstall": "Bereit zur Installation? Klicke auf den Button unten, um zu starten.", + "UIText_SLP_InstallButton": "Stationeers Launch Pad installieren", + "UIText_SLP_UploadModPackage": "Mod-Paket hochladen", + "UIText_SLP_UploadDescription": "Wähle eine Mod-Paket (modpkg) ZIP-Datei oder ziehe sie per Drag & Drop in das Feld unten, um Mods hochzuladen und zu extrahieren.", + "UIText_SLP_UploadDescriptionLink": "Mod-Pakete müssen von deinem Spiel-Client exportiert werden. Siehe ", + "UIText_SLP_InstallFirst": "Zuerst SLP installieren", + "UIText_SLP_InstallFirstSubtext": "SLP muss installiert sein, um Mod-Pakete hochzuladen", + "UIText_SLP_DragDropHere": "Mod-Paket hierher ziehen", + "UIText_SLP_OrClickToSelect": "oder klicken, um eine Mod-Paket Datei auszuwählen", + "UIText_SLP_UploadButton": "Mod-Paket hochladen", + "UIText_SLP_ManageInstallation": "Installation verwalten", + "UIText_SLP_UninstallWarning": "Deinstallation LÖSCHT alle Mods im Mods-Ordner.", + "UIText_SLP_UninstallButton": "SLP deinstallieren", + "UIText_SLP_UpdateWorkshopMods": "Workshop-Mods aktualisieren", + "UIText_SLP_UpdateWorkshopModsDesc": "Aktualisiert alle installierten Workshop-Mods auf die neueste Version. Dieser Vorgang kann je nach Anzahl der Mods einige Zeit dauern.", + "UIText_SLP_UpdateButton": "Workshop-Mods aktualisieren", + "UIText_SLP_InstalledMods": "Installierte Mods" + }, "UIText_TerrainSettings": "Weltgenerierung" }, "setup": { diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json index 8e1e6090..7cf978d3 100644 --- a/UIMod/onboard_bundled/localization/en-US.json +++ b/UIMod/onboard_bundled/localization/en-US.json @@ -136,6 +136,27 @@ "UIText_DiscordBenefit4": "Community management options", "UIText_DiscordBenefit5": "Real-time error notifications", "UIText_DiscordSetupInstructions": "For setup instructions, visit the" + }, + "slp": { + "UIText_SLP_Title": "Stationeers Launch Pad", + "UIText_SLP_Description": "Stationeers Launch Pad is a simple mod loader for Stationeers. It automatically updates itself and makes mod management easy.", + "UIText_SLP_ReadyToInstall": "Ready to install? Click the button below to get started.", + "UIText_SLP_InstallButton": "Install Stationeers Launch Pad", + "UIText_SLP_UploadModPackage": "Upload Mod Package", + "UIText_SLP_UploadDescription": "Select a mod package (modpkg) zip file or drag and drop it into the box below to upload and extract mods.", + "UIText_SLP_UploadDescriptionLink": "Mod packages need to be exported from your game client. See the", + "UIText_SLP_InstallFirst": "Install SLP First", + "UIText_SLP_InstallFirstSubtext": "SLP must be installed to upload mod packages", + "UIText_SLP_DragDropHere": "Drag & Drop Mod Package Here", + "UIText_SLP_OrClickToSelect": "or click to select a mod package file", + "UIText_SLP_UploadButton": "Upload Mod Package", + "UIText_SLP_ManageInstallation": "Manage Installation", + "UIText_SLP_UninstallWarning": "Uninstalling will DELETE all mods in the Mods folder.", + "UIText_SLP_UninstallButton": "Uninstall SLP", + "UIText_SLP_UpdateWorkshopMods": "Update Workshop Mods", + "UIText_SLP_UpdateWorkshopModsDesc": "Update all installed workshop mods to their latest versions. This process may take some time depending on the number of mods.", + "UIText_SLP_UpdateButton": "Update Workshop Mods", + "UIText_SLP_InstalledMods": "Installed Mods" } }, "setup": { diff --git a/UIMod/onboard_bundled/localization/sv-SE.json b/UIMod/onboard_bundled/localization/sv-SE.json index 36177311..63865092 100644 --- a/UIMod/onboard_bundled/localization/sv-SE.json +++ b/UIMod/onboard_bundled/localization/sv-SE.json @@ -135,6 +135,27 @@ "UIText_DiscordBenefit5": "Felnotiser i realtid", "UIText_DiscordSetupInstructions": "För installationsinstruktioner, besök" }, + "slp": { + "UIText_SLP_Title": "Stationeers Launch Pad", + "UIText_SLP_Description": "Stationeers Launch Pad är en enkel mod-loader för Stationeers. Den uppdateras automatiskt och gör mod-hantering enkel.", + "UIText_SLP_ReadyToInstall": "Redo att installera? Klicka på knappen nedan för att börja.", + "UIText_SLP_InstallButton": "Installera Stationeers Launch Pad", + "UIText_SLP_UploadModPackage": "Ladda upp Mod-paket", + "UIText_SLP_UploadDescription": "Välj en mod-paket (modpkg) zip-fil eller dra och släpp den i rutan nedan för att ladda upp och extrahera mods.", + "UIText_SLP_UploadDescriptionLink": "Mod-paket måste exporteras från din spelklient. Se", + "UIText_SLP_InstallFirst": "Installera SLP först", + "UIText_SLP_InstallFirstSubtext": "SLP måste installeras för att ladda upp mod-paket", + "UIText_SLP_DragDropHere": "Dra & Släpp Mod-paket här", + "UIText_SLP_OrClickToSelect": "eller klicka för att välja en mod-paketfil", + "UIText_SLP_UploadButton": "Ladda upp Mod-paket", + "UIText_SLP_ManageInstallation": "Hantera Installation", + "UIText_SLP_UninstallWarning": "Avinstallation kommer att RADERA alla mods i Mods-mappen.", + "UIText_SLP_UninstallButton": "Avinstallera SLP", + "UIText_SLP_UpdateWorkshopMods": "Uppdatera Workshop-mods", + "UIText_SLP_UpdateWorkshopModsDesc": "Uppdatera alla installerade workshop-mods till deras senaste versioner. Denna process kan ta lite tid beroende på antalet mods.", + "UIText_SLP_UpdateButton": "Uppdatera Workshop-mods", + "UIText_SLP_InstalledMods": "Installerade Mods" + }, "UIText_TerrainSettings": "Världsgenerering" }, "setup": { diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 7be7498b..206182a6 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -14,11 +14,19 @@ +
    +
    @@ -40,15 +48,10 @@

    {{.UIText_ServerConfig}}

    {{.UIText_DiscordIntegration}} - -
    + +
    +
    + ⚠️ +

    Beta Feature

    +

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

    +

    Handle with care and report any issues you encounter.

    + +
    +
    + + +
    + {{if eq .IsStationeersLaunchPadEnabled "false"}} +
    +
    + 🚀 +

    {{.UIText_SLP_Title}}

    +
    +

    {{.UIText_SLP_Description}}

    +

    {{.UIText_SLP_ReadyToInstall}}

    + +
    + +
    +

    {{.UIText_SLP_UploadModPackage}}

    +
    + 🔒 +
    {{.UIText_SLP_InstallFirst}}
    +
    {{.UIText_SLP_InstallFirstSubtext}}
    +
    +
    + {{end}} + + {{if eq .IsStationeersLaunchPadEnabled "true"}} +
    +

    {{.UIText_SLP_UploadModPackage}}

    +

    {{.UIText_SLP_UploadDescription}}

    +

    {{.UIText_SLP_UploadDescriptionLink}} wiki.

    + +
    + 📦 +
    {{.UIText_SLP_DragDropHere}}
    +
    {{.UIText_SLP_OrClickToSelect}}
    +
    +
    + + +
    + +
    +

    {{.UIText_SLP_ManageInstallation}}

    +
    +
    +

    ⚠️ {{.UIText_SLP_UninstallWarning}}

    + +
    +
    +

    {{.UIText_SLP_UpdateWorkshopModsDesc}}

    + +
    +
    +
    + +
    +

    {{.UIText_SLP_InstalledMods}}

    +
    +
    +
    +
    +
    + {{end}} +
    @@ -616,6 +692,8 @@

    Regex Detection:

    + + \ No newline at end of file diff --git a/src/cli/runtimecommands.go b/src/cli/commands.go similarity index 59% rename from src/cli/runtimecommands.go rename to src/cli/commands.go index a9924f60..c00b426f 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/commands.go @@ -1,152 +1,25 @@ -// Package misc provides a non-blocking command-line interface for entering commands -// while allowing the application to continue its operations normally. package cli import ( "archive/zip" - "bufio" "encoding/json" - "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" - "sort" "strings" - "sync" "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" ) -// ANSI escape codes for green text and reset -const ( - cliPrompt = "\033[32m" + "SSUICLI" + " » " + "\033[0m" -) - -var isSupportMode bool - -// CommandFunc defines the signature for command handler functions. -type CommandFunc func(args []string) error - -// commandRegistry holds the map of command names to their handler functions. -var commandRegistry = make(map[string]CommandFunc) -var mu sync.Mutex - -var commandAliases = make(map[string][]string) - -// RegisterCommand adds a new command and its handler to the registry. -func RegisterCommand(name string, handler CommandFunc, aliases ...string) { - mu.Lock() - defer mu.Unlock() - commandRegistry[name] = handler - if len(aliases) > 0 { - commandAliases[name] = append(commandAliases[name], aliases...) - for _, alias := range aliases { - commandRegistry[alias] = handler - } - } -} - -// StartConsole starts a non-blocking console input loop in a separate goroutine. -func StartConsole(wg *sync.WaitGroup) { - if !config.GetIsConsoleEnabled() { - logger.Core.Info("SSUICLI runtime console is disabled in config, skipping...") - return - } - wg.Add(1) - go func() { - defer wg.Done() - scanner := bufio.NewScanner(os.Stdin) - logger.Core.Info("SSUICLI runtime console started. Type 'help' for commands.") - time.Sleep(10 * time.Millisecond) - - for { - fmt.Print(cliPrompt) - os.Stdout.Sync() // Force flush the output buffer - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - continue - } - ProcessCommand(input) - } - - if err := scanner.Err(); err != nil { - logger.Core.Error("SSUICLI input error:" + err.Error()) - } - logger.Core.Info("SSUICLI runtime console stopped.") - }() -} - -// ProcessCommand parses and executes a command from the input string. -func ProcessCommand(input string) { - args := strings.Fields(input) - if len(args) == 0 { - return - } - - commandName := strings.ToLower(args[0]) - args = args[1:] // Remove command name from args - - mu.Lock() - handler, exists := commandRegistry[commandName] - mu.Unlock() - - if !exists { - logger.Core.Error("Unknown command:" + commandName + ". Type 'help' for available commands.") - return - } - - if err := handler(args); err != nil { - logger.Core.Error("Command " + commandName + " failed:" + err.Error()) - } -} - -// WrapNoReturn wraps a function with no return value to match CommandFunc. -func WrapNoReturn(fn func()) CommandFunc { - return func(args []string) error { - if len(args) > 0 { - return errors.New("command does not accept arguments") - } - fn() - logger.Core.Info("Runtime CLI Command executed successfully") - return nil - } -} - -// helpCommand displays available commands along with their aliases. -func helpCommand(args []string) error { - mu.Lock() - defer mu.Unlock() - logger.Core.Info("Available commands:") - // Collect primary commands (those in commandAliases keys) - primaryCommands := make([]string, 0, len(commandAliases)) - for cmd := range commandAliases { - primaryCommands = append(primaryCommands, cmd) - } - sort.Strings(primaryCommands) - for _, cmd := range primaryCommands { - aliases := commandAliases[cmd] - if len(aliases) > 0 { - logger.Core.Info("- " + cmd + " (aliases: " + strings.Join(aliases, ", ") + ")") - } else { - logger.Core.Info("- %s" + cmd) - } - } - return nil -} - // init registers default cli commands and their aliases. func init() { RegisterCommand("help", helpCommand, "h") @@ -162,10 +35,22 @@ func init() { RegisterCommand("supportmode", WrapNoReturn(supportMode), "sm") RegisterCommand("supportpackage", WrapNoReturn(supportPackage), "sp") RegisterCommand("getbuildid", WrapNoReturn(getBuildID), "gbid") - RegisterCommand("setdummybuildid", WrapNoReturn(setDummyBuildID), "sdbid") RegisterCommand("printconfig", WrapNoReturn(printConfig), "pc") RegisterCommand("update", WrapNoReturn(triggerUpdateCheck), "u") RegisterCommand("applyupdate", WrapNoReturn(applyUpdate), "au") + RegisterCommand("listmods", WrapNoReturn(listmods), "lm") + RegisterCommand("listworkshophandles", WrapNoReturn(listworkshophandles), "lwh") + RegisterCommand("downloadworkshopupdates", WrapNoReturn(downloadWorkshopUpdates), "dwu") + RegisterCommand("downloadworkshopitemtest", WrapNoReturn(downloadWorkshopItemTest), "dwmodcon") +} + +// COMMAND HANDLERS WITH COMMANDS USEFUL FOR USERS + +func downloadWorkshopUpdates() { + _, err := steamcmd.UpdateWorkshopItems() + if err != nil { + logger.Core.Error("Error downloading workshop updates: " + err.Error()) + } } func startServer() { @@ -213,17 +98,6 @@ func getBuildID() { logger.Core.Info("Build ID: " + buildID) } -func setDummyBuildID() { - config.SetCurrentBranchBuildID("dummy") - logger.Core.Info("Dummy build ID set") -} - -func testLocalization() { - currentLanguageSetting := config.GetLanguageSetting() - s := localization.GetString("UIText_StartButton") - logger.Core.Info("Start Server Button text (current language: " + currentLanguageSetting + "): " + s) -} - func triggerUpdateCheck() { err, newVersion := update.Update(false) if err != nil { diff --git a/src/cli/devcommands.go b/src/cli/devcommands.go new file mode 100644 index 00000000..f0aa5f7e --- /dev/null +++ b/src/cli/devcommands.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" +) + +// COMMAND HANDLERS WITH COMMANDS USEFUL FOR DEVELOPMENT AND DEBUGGING + +func downloadWorkshopItemTest() { + workshopHandles := []string{"3505169479"} + _, err := steamcmd.DownloadWorkshopItems(workshopHandles) + if err != nil { + logger.Core.Error("Error downloading workshop items: " + err.Error()) + } +} + +func listworkshophandles() { + handles := modding.GetModWorkshopHandles() + if len(handles) == 0 { + logger.Core.Info("No mods with Workshop handles found.") + return + } + logger.Core.Info(fmt.Sprintf("Installed Mod Workshop Handles: (%d):", len(handles))) + logger.Modding.Info(fmt.Sprintf("%v", handles)) + +} + +func listmods() { + mods := modding.GetModList() + if len(mods) == 0 { + logger.Core.Info("No mods installed.") + return + } + logger.Core.Info(fmt.Sprintf("Installed Mods (%d):", len(mods))) + for _, mod := range mods { + + // print mod details in one logger call but with /n for new lines + logger.Modding.Info("Mod Details:\n" + + fmt.Sprintf("Modname: %s\n", mod.Name) + + fmt.Sprintf(" Version: %s\n", mod.Version) + + fmt.Sprintf(" Author: %s\n", mod.Author) + + fmt.Sprintf(" Workshop Handle: %s\n", mod.WorkshopHandle)) + } +} + +func testLocalization() { + currentLanguageSetting := config.GetLanguageSetting() + s := localization.GetString("UIText_StartButton") + logger.Core.Info("Start Server Button text (current language: " + currentLanguageSetting + "): " + s) +} diff --git a/src/cli/ssuicli.go b/src/cli/ssuicli.go new file mode 100644 index 00000000..f343f5fe --- /dev/null +++ b/src/cli/ssuicli.go @@ -0,0 +1,137 @@ +// Package misc provides a non-blocking command-line interface for entering commands +// while allowing the application to continue its operations normally. +package cli + +import ( + "bufio" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// ANSI escape codes for green text and reset +const ( + cliPrompt = "\033[32m" + "SSUICLI" + " » " + "\033[0m" +) + +var isSupportMode bool + +// CommandFunc defines the signature for command handler functions. +type CommandFunc func(args []string) error + +// commandRegistry holds the map of command names to their handler functions. +var commandRegistry = make(map[string]CommandFunc) +var mu sync.Mutex + +var commandAliases = make(map[string][]string) + +// RegisterCommand adds a new command and its handler to the registry. +func RegisterCommand(name string, handler CommandFunc, aliases ...string) { + mu.Lock() + defer mu.Unlock() + commandRegistry[name] = handler + if len(aliases) > 0 { + commandAliases[name] = append(commandAliases[name], aliases...) + for _, alias := range aliases { + commandRegistry[alias] = handler + } + } +} + +// StartConsole starts a non-blocking console input loop in a separate goroutine. +func StartConsole(wg *sync.WaitGroup) { + if !config.GetIsConsoleEnabled() { + logger.Core.Info("SSUICLI runtime console is disabled in config, skipping...") + return + } + wg.Add(1) + go func() { + defer wg.Done() + scanner := bufio.NewScanner(os.Stdin) + logger.Core.Info("SSUICLI runtime console started. Type 'help' for commands.") + time.Sleep(10 * time.Millisecond) + + for { + fmt.Print(cliPrompt) + os.Stdout.Sync() // Force flush the output buffer + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + ProcessCommand(input) + } + + if err := scanner.Err(); err != nil { + logger.Core.Error("SSUICLI input error:" + err.Error()) + } + logger.Core.Info("SSUICLI runtime console stopped.") + }() +} + +// ProcessCommand parses and executes a command from the input string. +func ProcessCommand(input string) { + args := strings.Fields(input) + if len(args) == 0 { + return + } + + commandName := strings.ToLower(args[0]) + args = args[1:] // Remove command name from args + + mu.Lock() + handler, exists := commandRegistry[commandName] + mu.Unlock() + + if !exists { + logger.Core.Error("Unknown command:" + commandName + ". Type 'help' for available commands.") + return + } + + if err := handler(args); err != nil { + logger.Core.Error("Command " + commandName + " failed:" + err.Error()) + } +} + +// WrapNoReturn wraps a function with no return value to match CommandFunc. +func WrapNoReturn(fn func()) CommandFunc { + return func(args []string) error { + if len(args) > 0 { + return errors.New("command does not accept arguments") + } + fn() + logger.Core.Info("Runtime CLI Command executed successfully") + return nil + } +} + +// helpCommand displays available commands along with their aliases. +func helpCommand(args []string) error { + mu.Lock() + defer mu.Unlock() + logger.Core.Info("Available commands:") + // Collect primary commands (those in commandAliases keys) + primaryCommands := make([]string, 0, len(commandAliases)) + for cmd := range commandAliases { + primaryCommands = append(primaryCommands, cmd) + } + sort.Strings(primaryCommands) + for _, cmd := range primaryCommands { + aliases := commandAliases[cmd] + if len(aliases) > 0 { + logger.Core.Info("- " + cmd + " (aliases: " + strings.Join(aliases, ", ") + ")") + } else { + logger.Core.Info("- %s" + cmd) + } + } + return nil +} diff --git a/src/config/config.go b/src/config/config.go index 9fb6f37d..edce2ecf 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -11,7 +11,7 @@ import ( var ( // All configuration variables can be found in vars.go - Version = "5.10.1" + Version = "5.11.0" Branch = "release" ) @@ -82,6 +82,10 @@ type JsonConfig struct { AllowMajorUpdates *bool `json:"AllowMajorUpdates"` AllowAutoGameServerUpdates *bool `json:"AllowAutoGameServerUpdates"` + // SLP Modding Settings + IsStationeersLaunchPadEnabled *bool `json:"IsStationeersLaunchPadEnabled"` + IsStationeersLaunchPadAutoUpdatesEnabled *bool `json:"IsStationeersLaunchPadAutoUpdatesEnabled"` + // Discord Settings DiscordToken string `json:"discordToken"` ControlChannelID string `json:"controlChannelID"` @@ -253,6 +257,14 @@ func applyConfig(cfg *JsonConfig) { AllowAutoGameServerUpdates = allowAutoGameServerUpdatesVal cfg.AllowAutoGameServerUpdates = &allowAutoGameServerUpdatesVal + isStationeersLaunchPadEnabledVal := getBool(cfg.IsStationeersLaunchPadEnabled, "IS_SLP_MODDING_ENABLED", false) + IsStationeersLaunchPadEnabled = isStationeersLaunchPadEnabledVal + cfg.IsStationeersLaunchPadEnabled = &isStationeersLaunchPadEnabledVal + + isStationeersLaunchPadAutoUpdatesEnabledVal := getBool(cfg.IsStationeersLaunchPadAutoUpdatesEnabled, "IS_SLP_MODDING_AUTO_UPDATES_ENABLED", true) + IsStationeersLaunchPadAutoUpdatesEnabled = isStationeersLaunchPadAutoUpdatesEnabledVal + cfg.IsStationeersLaunchPadAutoUpdatesEnabled = &isStationeersLaunchPadAutoUpdatesEnabledVal + SubsystemFilters = getStringSlice(cfg.SubsystemFilters, "SUBSYSTEM_FILTERS", []string{}) AutoRestartServerTimer = getString(cfg.AutoRestartServerTimer, "AUTO_RESTART_SERVER_TIMER", "0") isSSCMEnabledVal := getBool(cfg.IsSSCMEnabled, "IS_SSCM_ENABLED", true) @@ -326,70 +338,72 @@ func applyConfig(cfg *JsonConfig) { // M U S T be called while holding a lock on ConfigMu! func safeSaveConfig() error { cfg := JsonConfig{ - DiscordToken: DiscordToken, - ControlChannelID: ControlChannelID, - StatusChannelID: StatusChannelID, - ConnectionListChannelID: ConnectionListChannelID, - LogChannelID: LogChannelID, - SaveChannelID: SaveChannelID, - ControlPanelChannelID: ControlPanelChannelID, - DiscordCharBufferSize: DiscordCharBufferSize, - BlackListFilePath: BlackListFilePath, - IsDiscordEnabled: &IsDiscordEnabled, - ErrorChannelID: ErrorChannelID, - BackupKeepLastN: BackupKeepLastN, - IsCleanupEnabled: &IsCleanupEnabled, - BackupKeepDailyFor: int(BackupKeepDailyFor / time.Hour), // Convert to hours - BackupKeepWeeklyFor: int(BackupKeepWeeklyFor / time.Hour), // Convert to hours - BackupKeepMonthlyFor: int(BackupKeepMonthlyFor / time.Hour), // Convert to hours - BackupCleanupInterval: int(BackupCleanupInterval / time.Hour), // Convert to hours - BackupWaitTime: int(BackupWaitTime / time.Second), // Convert to seconds - IsNewTerrainAndSaveSystem: &IsNewTerrainAndSaveSystem, - GameBranch: GameBranch, - Difficulty: Difficulty, - StartCondition: StartCondition, - StartLocation: StartLocation, - ServerName: ServerName, - SaveName: SaveName, - WorldID: WorldID, - ServerMaxPlayers: ServerMaxPlayers, - ServerPassword: ServerPassword, - ServerAuthSecret: ServerAuthSecret, - AdminPassword: AdminPassword, - GamePort: GamePort, - UpdatePort: UpdatePort, - UPNPEnabled: &UPNPEnabled, - AutoSave: &AutoSave, - SaveInterval: SaveInterval, - AutoPauseServer: &AutoPauseServer, - LocalIpAddress: LocalIpAddress, - StartLocalHost: &StartLocalHost, - ServerVisible: &ServerVisible, - UseSteamP2P: &UseSteamP2P, - ExePath: ExePath, - AdditionalParams: AdditionalParams, - Users: Users, - AuthEnabled: &AuthEnabled, - JwtKey: JwtKey, - AuthTokenLifetime: AuthTokenLifetime, - Debug: &IsDebugMode, - CreateSSUILogFile: &CreateSSUILogFile, - CreateGameServerLogFile: &CreateGameServerLogFile, - LogLevel: LogLevel, - LogClutterToConsole: &LogClutterToConsole, - SubsystemFilters: SubsystemFilters, - IsUpdateEnabled: &IsUpdateEnabled, - IsSSCMEnabled: &IsSSCMEnabled, - AutoRestartServerTimer: AutoRestartServerTimer, - AllowPrereleaseUpdates: &AllowPrereleaseUpdates, - AllowMajorUpdates: &AllowMajorUpdates, - AllowAutoGameServerUpdates: &AllowAutoGameServerUpdates, - IsConsoleEnabled: &IsConsoleEnabled, - LanguageSetting: LanguageSetting, - AutoStartServerOnStartup: &AutoStartServerOnStartup, - SSUIIdentifier: SSUIIdentifier, - SSUIWebPort: SSUIWebPort, - AdvertiserOverride: AdvertiserOverride, + DiscordToken: DiscordToken, + ControlChannelID: ControlChannelID, + StatusChannelID: StatusChannelID, + ConnectionListChannelID: ConnectionListChannelID, + LogChannelID: LogChannelID, + SaveChannelID: SaveChannelID, + ControlPanelChannelID: ControlPanelChannelID, + DiscordCharBufferSize: DiscordCharBufferSize, + BlackListFilePath: BlackListFilePath, + IsDiscordEnabled: &IsDiscordEnabled, + ErrorChannelID: ErrorChannelID, + BackupKeepLastN: BackupKeepLastN, + IsCleanupEnabled: &IsCleanupEnabled, + BackupKeepDailyFor: int(BackupKeepDailyFor / time.Hour), // Convert to hours + BackupKeepWeeklyFor: int(BackupKeepWeeklyFor / time.Hour), // Convert to hours + BackupKeepMonthlyFor: int(BackupKeepMonthlyFor / time.Hour), // Convert to hours + BackupCleanupInterval: int(BackupCleanupInterval / time.Hour), // Convert to hours + BackupWaitTime: int(BackupWaitTime / time.Second), // Convert to seconds + IsNewTerrainAndSaveSystem: &IsNewTerrainAndSaveSystem, + GameBranch: GameBranch, + Difficulty: Difficulty, + StartCondition: StartCondition, + StartLocation: StartLocation, + ServerName: ServerName, + SaveName: SaveName, + WorldID: WorldID, + ServerMaxPlayers: ServerMaxPlayers, + ServerPassword: ServerPassword, + ServerAuthSecret: ServerAuthSecret, + AdminPassword: AdminPassword, + GamePort: GamePort, + UpdatePort: UpdatePort, + UPNPEnabled: &UPNPEnabled, + AutoSave: &AutoSave, + SaveInterval: SaveInterval, + AutoPauseServer: &AutoPauseServer, + LocalIpAddress: LocalIpAddress, + StartLocalHost: &StartLocalHost, + ServerVisible: &ServerVisible, + UseSteamP2P: &UseSteamP2P, + ExePath: ExePath, + AdditionalParams: AdditionalParams, + Users: Users, + AuthEnabled: &AuthEnabled, + JwtKey: JwtKey, + AuthTokenLifetime: AuthTokenLifetime, + Debug: &IsDebugMode, + CreateSSUILogFile: &CreateSSUILogFile, + CreateGameServerLogFile: &CreateGameServerLogFile, + LogLevel: LogLevel, + LogClutterToConsole: &LogClutterToConsole, + SubsystemFilters: SubsystemFilters, + IsUpdateEnabled: &IsUpdateEnabled, + IsSSCMEnabled: &IsSSCMEnabled, + AutoRestartServerTimer: AutoRestartServerTimer, + AllowPrereleaseUpdates: &AllowPrereleaseUpdates, + AllowMajorUpdates: &AllowMajorUpdates, + AllowAutoGameServerUpdates: &AllowAutoGameServerUpdates, + IsStationeersLaunchPadEnabled: &IsStationeersLaunchPadEnabled, + IsStationeersLaunchPadAutoUpdatesEnabled: &IsStationeersLaunchPadAutoUpdatesEnabled, + IsConsoleEnabled: &IsConsoleEnabled, + LanguageSetting: LanguageSetting, + AutoStartServerOnStartup: &AutoStartServerOnStartup, + SSUIIdentifier: SSUIIdentifier, + SSUIWebPort: SSUIWebPort, + AdvertiserOverride: AdvertiserOverride, } file, err := os.Create(ConfigPath) diff --git a/src/config/getters.go b/src/config/getters.go index cd637768..16a12724 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -536,3 +536,15 @@ func GetStationeersServerPingEndpoint() string { defer ConfigMu.RUnlock() return StationeersServerPingEndpoint } + +func GetIsStationeersLaunchPadEnabled() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsStationeersLaunchPadEnabled +} + +func GetIsStationeersLaunchPadAutoUpdatesEnabled() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsStationeersLaunchPadAutoUpdatesEnabled +} diff --git a/src/config/setters.go b/src/config/setters.go index 3f8b603b..3e838375 100644 --- a/src/config/setters.go +++ b/src/config/setters.go @@ -710,3 +710,19 @@ func SetAdvertiserOverride(value string) error { AdvertiserOverride = value return safeSaveConfig() } + +func SetIsStationeersLaunchPadEnabled(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsStationeersLaunchPadEnabled = value + return safeSaveConfig() +} + +func SetIsStationeersLaunchPadAutoUpdatesEnabled(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsStationeersLaunchPadAutoUpdatesEnabled = value + return safeSaveConfig() +} diff --git a/src/config/vars.go b/src/config/vars.go index 3aafbbff..e9f6f57b 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -45,24 +45,26 @@ var ( // Logging, debugging and misc var ( - IsDebugMode bool //only used for pprof server, keep it like this and check the log level instead. Debug = 10 - CreateSSUILogFile bool - CreateGameServerLogFile bool - LogLevel int - IsFirstTimeSetup bool - SSEMessageBufferSize = 2000 - MaxSSEConnections = 20 - GameServerAppID = "600760" - ExePath string - GameBranch string - SubsystemFilters []string - AutoRestartServerTimer string - IsConsoleEnabled bool - LogClutterToConsole bool // surpresses clutter mono logs from the gameserver - LanguageSetting string - AutoStartServerOnStartup bool - SSUIIdentifier string - AdvertiserOverride string + IsDebugMode bool //only used for pprof server, keep it like this and check the log level instead. Debug = 10 + CreateSSUILogFile bool + CreateGameServerLogFile bool + LogLevel int + IsFirstTimeSetup bool + SSEMessageBufferSize = 2000 + MaxSSEConnections = 20 + GameServerAppID = "600760" + ExePath string + GameBranch string + SubsystemFilters []string + AutoRestartServerTimer string + IsConsoleEnabled bool + LogClutterToConsole bool // surpresses clutter mono logs from the gameserver + LanguageSetting string + AutoStartServerOnStartup bool + SSUIIdentifier string + AdvertiserOverride string + IsStationeersLaunchPadEnabled bool + IsStationeersLaunchPadAutoUpdatesEnabled bool ) // Runtime only variables diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index 8d150485..0fa3253f 100644 --- a/src/core/loader/loader.go +++ b/src/core/loader/loader.go @@ -14,6 +14,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/backupmgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" @@ -27,6 +28,7 @@ func InitBackend() { ReloadLocalizer() ReloadAppInfoPoller() ReloadDiscordBot() + EnsureSLPAutoUpdates() InitDetector() StartIsGameServerRunningCheck() StartUpdateCheckLoop() @@ -119,6 +121,26 @@ func InitVirtFS(v1uiFS embed.FS) { config.SetV1UIFS(v1uiFS) } +func InstallSLP() { + version, err := modding.InstallSLP() + if err != nil { + logger.Install.Error("SLP installation failed: " + err.Error()) + return + } + logger.Install.Infof("SLP %s installed successfully", version) +} + +func EnsureSLPAutoUpdates() { + modified, err := modding.ToggleSLPAutoUpdates(config.GetIsStationeersLaunchPadAutoUpdatesEnabled()) + if err != nil { + logger.Install.Error("Failed to toggle SLP auto-updates: " + err.Error()) + return + } + if modified { + logger.Install.Infof("StationeersLaunchPad auto-updates toggled to %t", config.GetIsStationeersLaunchPadAutoUpdatesEnabled()) + } +} + func SanityCheck() { err := runSanityCheck() if err != nil { diff --git a/src/logger/logger.go b/src/logger/logger.go index a505e154..719c08c3 100644 --- a/src/logger/logger.go +++ b/src/logger/logger.go @@ -25,6 +25,7 @@ var ( Security = &Logger{suffix: SYS_SECURITY} Localization = &Logger{suffix: SYS_LOCALIZATION} Advertiser = &Logger{suffix: SYS_ADVERTISER} + Modding = &Logger{suffix: SYS_MODDING} ) // Severity Levels @@ -50,6 +51,7 @@ const ( SYS_SECURITY = "SECURITY" SYS_LOCALIZATION = "LOCALIZATION" SYS_ADVERTISER = "ADVERTISER" + SYS_MODDING = "MODDING" ) const ( @@ -76,6 +78,7 @@ var subsystemColors = map[string]string{ SYS_SECURITY: colorRed, // Screams "pay attention" SYS_LOCALIZATION: colorCyan, // Matches WEB, localization-related SYS_ADVERTISER: colorYellow, // Matches Config, advanced feature + SYS_MODDING: colorCyan, // } // Global channels and mutex for all loggers diff --git a/src/modding/launchpad-config.go b/src/modding/launchpad-config.go new file mode 100644 index 00000000..0daccd55 --- /dev/null +++ b/src/modding/launchpad-config.go @@ -0,0 +1,86 @@ +package modding + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// ToggleSLPAutoUpdates enables or disables the SLP built-in auto-updater +// by setting CheckForUpdate and AutoUpdateOnStart to true/false in +// BepInEx/config/stationeers.launchpad.cfg +// +// If the file doesn't exist → does nothing (returns nil) +// If the file exists but keys are missing → adds them under [Startup] +// Returns true if the file was modified, false if no change/no file, error on failure +func ToggleSLPAutoUpdates(enable bool) (modified bool, err error) { + const configPath = "BepInEx/config/stationeers.launchpad.cfg" + + // Check if config exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + logger.Install.Debug("SLP config not found, skipping auto-update flag setup") + return false, nil + } + + // Read existing content + data, err := os.ReadFile(configPath) + if err != nil { + return false, fmt.Errorf("failed to read SLP config: %w", err) + } + + original := string(data) + content := original + + value := "true" + if !enable { + value = "false" + } + + // Regex to match lines like: CheckForUpdate = false (any whitespace, case insensitive) + reCheck := regexp.MustCompile(`(?mi)^\s*CheckForUpdate\s*=\s*(true|false)\s*(?:#.*)?$`) + reAuto := regexp.MustCompile(`(?mi)^\s*AutoUpdateOnStart\s*=\s*(true|false)\s*(?:#.*)?$`) + + // Replace existing values + newContent := reCheck.ReplaceAllString(content, fmt.Sprintf("CheckForUpdate = %s", value)) + newContent = reAuto.ReplaceAllString(newContent, fmt.Sprintf("AutoUpdateOnStart = %s", value)) + + modified = (newContent != content) + + // If any key is missing → append them under [Startup] section + if !reCheck.MatchString(content) || !reAuto.MatchString(content) { + // Look for [Startup] section + startupSectionRe := regexp.MustCompile(`(?m)^\[Startup\]\s*$`) + + if startupSectionRe.MatchString(newContent) { + // Append to existing [Startup] section + newContent = startupSectionRe.ReplaceAllStringFunc(newContent, func(match string) string { + return match + fmt.Sprintf("\nCheckForUpdate = %s\nAutoUpdateOnStart = %s", value, value) + }) + } else { + // No [Startup] section → add it at the end + newContent = strings.TrimRight(newContent, "\r\n") + fmt.Sprintf("\n\n[Startup]\nCheckForUpdate = %s\nAutoUpdateOnStart = %s\n", value, value) + } + modified = true + } + + if !modified { + logger.Install.Debug("SLP auto-update flags already set correctly, no change needed") + return false, nil + } + + // Write back + if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { + return false, fmt.Errorf("failed to write updated SLP config: %w", err) + } + + action := "enabled" + if !enable { + action = "disabled" + } + logger.Install.Info(fmt.Sprintf("SLP auto-updater %s (CheckForUpdate & AutoUpdateOnStart = %s)", action, value)) + + return true, nil +} diff --git a/src/modding/launchpad.go b/src/modding/launchpad.go new file mode 100644 index 00000000..4e946460 --- /dev/null +++ b/src/modding/launchpad.go @@ -0,0 +1,259 @@ +package modding + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" +) + +// InstallSLP downloads the latest StationeersLaunchPad-server zip from GitHub +// and extracts it into BepInEx/plugins/StationeersLaunchPad +// Returns: (installed version tag or "", error) +func InstallSLP() (string, error) { + const repoOwner = "StationeersLaunchPad" + const repoName = "StationeersLaunchPad" + const slpAssetPattern = "StationeersLaunchPad-server-" + + // Prepare target folder + pluginsDir := "BepInEx/plugins" + slpDir := filepath.Join(pluginsDir, "StationeersLaunchPad") + + baseURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", repoOwner, repoName) + logger.Install.Info("📡 Fetching latest Stationeers Launch Pad release...") + + resp, err := http.Get(baseURL) + if err != nil { + return "", fmt.Errorf("failed to query GitHub API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var releases []struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", fmt.Errorf("failed to parse GitHub releases: %w", err) + } + + if len(releases) == 0 { + return "", fmt.Errorf("no releases found in %s/%s", repoOwner, repoName) + } + + // Find newest non-prerelease release with server zip + var selectedRelease *struct { + TagName string + URL string + } + + for _, rel := range releases { + if rel.Prerelease { + continue // skip prereleases for now (can be made configurable later) + } + + for _, asset := range rel.Assets { + if strings.HasPrefix(asset.Name, slpAssetPattern) && + strings.HasSuffix(asset.Name, ".zip") { + selectedRelease = &struct { + TagName string + URL string + }{rel.TagName, asset.URL} + break + } + } + if selectedRelease != nil { + break + } + } + + if selectedRelease == nil { + return "", fmt.Errorf("no suitable StationeersLaunchPad-server-*.zip found in latest releases") + } + + zipName := fmt.Sprintf("StationeersLaunchPad-server-%s.zip", selectedRelease.TagName) + downloadURL := selectedRelease.URL + + logger.Install.Info(fmt.Sprintf("Found SLP %s → downloading %s...", selectedRelease.TagName, zipName)) + + // Download to temp file + tmpZip := zipName + ".tmp" + if err := downloadFile(tmpZip, downloadURL); err != nil { + return "", fmt.Errorf("failed to download SLP zip: %w", err) + } + defer os.Remove(tmpZip) + + // Clean previous installation if exists + if err := os.RemoveAll(slpDir); err != nil { + logger.Install.Warn(fmt.Sprintf("Could not clean old SLP folder: %v", err)) + } + + // Make sure parent exists + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + return "", fmt.Errorf("failed to create BepInEx/plugins directory: %w", err) + } + + // Extract + logger.Install.Info("📦 Extracting Stationeers Launch Pad...") + if err := unzipTo(tmpZip, slpDir, func(name string) bool { + // Only process files inside the StationeersLaunchPad folder + return strings.HasPrefix(name, "StationeersLaunchPad/") + }); err != nil { + return "", fmt.Errorf("failed to extract SLP: %w", err) + } + + logger.Install.Info(fmt.Sprintf("✅ Stationeers Launch Pad %s installed to %s", selectedRelease.TagName, slpDir)) + logger.Install.Info("💡 SLP contains its own auto-updater — future updates should happen automatically.") + config.SetIsStationeersLaunchPadEnabled(true) + + return selectedRelease.TagName, nil +} + +// UninstallSLP removes the SLP folder from BepInEx/plugins +// Returns: ("success" or "failed", error) +func UninstallSLP() (string, error) { + pluginsDir := "BepInEx/plugins" + slpDir := filepath.Join(pluginsDir, "StationeersLaunchPad") + + // stat the folder to see if it exists, if not skip removal + if _, err := os.Stat(slpDir); os.IsNotExist(err) { + logger.Install.Info("SLP is not installed; nothing to uninstall") + config.SetIsStationeersLaunchPadEnabled(false) + return "not_installed", nil + } + + if err := os.RemoveAll(slpDir); err != nil { + logger.Install.Error("Failed to remove SLP folder: " + err.Error()) + return "failed", fmt.Errorf("failed to remove SLP folder: %w", err) + } + + // stat the current directory to see if mod files exist, if not skip removal + if _, err := os.Stat(filepath.Join(".", "mods")); os.IsNotExist(err) { + logger.Install.Info("No mods folder found; skipping mods removal") + } else { + + // remove the ./mods folder and the modconfig.xml file too + if err := os.RemoveAll(filepath.Join(".", "mods")); err != nil { + logger.Install.Error("Failed to remove the mods folder: " + err.Error()) + return "failed", fmt.Errorf("failed to remove mods folder: %w", err) + } + } + + // stat the modconfig.xml file to see if it exists, if not skip removal + if _, err := os.Stat(filepath.Join(".", "modconfig.xml")); os.IsNotExist(err) { + logger.Install.Info("No modconfig.xml file found; skipping its removal") + } else { + + if err := os.Remove(filepath.Join(".", "modconfig.xml")); err != nil { + logger.Install.Error("Failed to remove modconfig.xml file: " + err.Error()) + return "failed", fmt.Errorf("failed to remove modconfig.xml file: %w", err) + } + } + + config.SetIsStationeersLaunchPadEnabled(false) + logger.Install.Info("SLP uninstalled successfully") + + return "success", nil +} + +func downloadFile(destPath, url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + out, err := os.Create(destPath) + if err != nil { + return err + } + defer out.Close() + + counter := &update.WriteCounter{Total: resp.ContentLength} + _, err = io.Copy(out, io.TeeReader(resp.Body, counter)) + if err != nil { + return err + } + + return nil +} + +// unzipTo extracts zip contents into destDir +// Only extracts files where shouldExtract returns true +func unzipTo(zipPath, destDir string, shouldExtract func(fileName string) bool) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + if !shouldExtract(f.Name) { + continue + } + + // Remove the leading "StationeersLaunchPad/" from the path + // so BepInEx/plugins/StationeersLaunchPad/core.dll etc. + relPath := strings.TrimPrefix(f.Name, "StationeersLaunchPad/") + if relPath == f.Name { // safety check + logger.Install.Warn("Unexpected file outside StationeersLaunchPad/: " + f.Name) + continue + } + + // Build full target path + fpath := filepath.Join(destDir, relPath) + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(fpath, f.Mode()); err != nil { + return err + } + continue + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + return err + } + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return err + } + + _, err = io.Copy(outFile, rc) + rc.Close() + outFile.Close() + + if err != nil { + return err + } + } + + return nil +} diff --git a/src/modding/modlist.go b/src/modding/modlist.go new file mode 100644 index 00000000..c13bfa9a --- /dev/null +++ b/src/modding/modlist.go @@ -0,0 +1,155 @@ +package modding + +import ( + "encoding/base64" + "encoding/xml" + "os" + "path/filepath" + "strings" +) + +// ModMetadata represents a parsed mod from About.xml +type ModMetadata struct { + Name string + Author string + Version string + Description string + WorkshopHandle string + Images map[string]string // filename -> base64 encoded image data +} + +// aboutXML is the structure for parsing About.xml files +type aboutXML struct { + Name string `xml:"Name"` + Author string `xml:"Author"` + Version string `xml:"Version"` + Description string `xml:"Description"` + WorkshopHandle string `xml:"WorkshopHandle"` +} + +// loadModImages loads all images from the About folder and converts them to base64 +func loadModImages(aboutPath string) map[string]string { + images := make(map[string]string) + + // List all files in the About folder + entries, err := os.ReadDir(aboutPath) + if err != nil { + return images // Return empty map if we can't read the directory + } + + // Common image extensions (case-insensitive) + imageExtensions := map[string]bool{ + ".png": true, + ".jpg": true, + ".jpeg": true, + ".webp": true, + } + + // Iterate over files and look for images + for _, entry := range entries { + if entry.IsDir() { + continue // Skip directories + } + + filename := entry.Name() + ext := strings.ToLower(filepath.Ext(filename)) + + // Check if file has an image extension + if !imageExtensions[ext] { + continue + } + + // Read the image file + imagePath := filepath.Join(aboutPath, filename) + data, err := os.ReadFile(imagePath) + if err != nil { + continue // Skip if we can't read the file + } + + // Convert to base64 + encoded := base64.StdEncoding.EncodeToString(data) + images[filename] = encoded + } + + return images +} + +// GetModList returns an array of installed mods and their details +func GetModList() []ModMetadata { + var mods []ModMetadata + + // Check if ./mods folder exists + modsPath := "./mods" + info, err := os.Stat(modsPath) + if err != nil || !info.IsDir() { + return mods // Return empty slice if folder doesn't exist or isn't a directory + } + + // Read all entries in the mods folder + entries, err := os.ReadDir(modsPath) + if err != nil { + return mods // Return empty slice if we can't read the directory + } + + // Iterate over each entry in the mods folder + for _, entry := range entries { + if !entry.IsDir() { + continue // Skip if not a directory + } + + dirName := entry.Name() + aboutPath := filepath.Join(modsPath, dirName, "About") + aboutXMLPath := filepath.Join(aboutPath, "About.xml") + + // Check if About.xml exists + if _, err := os.Stat(aboutXMLPath); os.IsNotExist(err) { + continue // Skip if About.xml doesn't exist + } + + // Try to parse the XML file + data, err := os.ReadFile(aboutXMLPath) + if err != nil { + continue // Skip if we can't read the file + } + + var xmlData aboutXML + err = xml.Unmarshal(data, &xmlData) + if err != nil { + continue // Skip if we can't parse the XML + } + + // Load images from the About folder + images := loadModImages(aboutPath) + + // Use WorkshopHandle from XML if available + workshopHandle := xmlData.WorkshopHandle + + // Build the ModMetadata struct + mod := ModMetadata{ + Name: xmlData.Name, + Author: xmlData.Author, + Version: xmlData.Version, + Description: xmlData.Description, + WorkshopHandle: workshopHandle, + Images: images, + } + + mods = append(mods, mod) + } + + return mods +} + +// GetModWorkshopHandles returns an array of workshop handles for installed mods that have one +func GetModWorkshopHandles() []string { + var handles []string + mods := GetModList() + + for _, mod := range mods { + if mod.WorkshopHandle != "" { + handles = append(handles, mod.WorkshopHandle) + } + } + + return handles +} diff --git a/src/modding/modpackages.go b/src/modding/modpackages.go new file mode 100644 index 00000000..65e9dabd --- /dev/null +++ b/src/modding/modpackages.go @@ -0,0 +1,177 @@ +package modding + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// ProcessModPackageUpload handles the upload and extraction of a mod package zip file +func ProcessModPackageUpload(r io.Reader) error { + logger.Modding.Info("Starting mod package upload process") + + const maxZipSize = 500 * 1024 * 1024 // 500 MB + sizeLimitedReader := io.LimitReader(r, maxZipSize+1) + + // Read the entire zip file into memory + zipBytes, err := io.ReadAll(sizeLimitedReader) + if err != nil { + logger.Modding.Errorf("Failed to read mod package zip file, filesize might exceed 500mb: %v", err) + return fmt.Errorf("failed to read mod package zip file, filesize might exceed 500mb: %w", err) + } + + if len(zipBytes) == 0 { + logger.Modding.Error("Received empty mod package") + return fmt.Errorf("mod package is empty") + } + + logger.Modding.Debugf("Received Modpackage: %d bytes", len(zipBytes)) + + // Create temporary file with timestamp + timestamp := time.Now().Unix() + tempFilename := fmt.Sprintf("tmp-uploaded-modpackage-%d.zip", timestamp) + tempFilepath := filepath.Join(".", tempFilename) + + logger.Modding.Debugf("Saving temporary mod package: %s", tempFilename) + if err := os.WriteFile(tempFilepath, zipBytes, 0644); err != nil { + logger.Modding.Errorf("Failed to write temporary mod package: %v", err) + return fmt.Errorf("failed to save temporary mod package: %w", err) + } + defer func() { + if err := os.Remove(tempFilepath); err != nil && !os.IsNotExist(err) { + logger.Modding.Warnf("Failed to clean up temporary mod package: %v", err) + } + }() + + // Clear ./mods directory if it exists + modsDir := filepath.Join(".", "mods") + if err := clearDirectory(modsDir); err != nil { + logger.Modding.Warnf("Ran into an issue while clearing mods directory: %v", err) + } + + // Remove modconfig.xml if it exists + modconfigPath := filepath.Join(".", "modconfig.xml") + if err := os.Remove(modconfigPath); err != nil && !os.IsNotExist(err) { + logger.Modding.Warnf("Failed to remove existing modconfig.xml: %v", err) + } + if !os.IsNotExist(err) { + logger.Modding.Debug("Removed existing modconfig.xml") + } + + // Extract the zip file to current working directory + if err := extractZip(tempFilepath, "."); err != nil { + logger.Modding.Errorf("Failed to extract mod package: %v", err) + return fmt.Errorf("failed to extract mod package: %w", err) + } + + // Call ImportModPackage with the zip bytes + if err := ImportModPackage(zipBytes); err != nil { + logger.Modding.Errorf("ImportModPackage failed: %v", err) + return fmt.Errorf("import mod package failed: %w", err) + } + + logger.Modding.Info("Mod package upload process completed successfully") + return nil +} + +// clearDirectory removes all files and subdirectories in a directory +func clearDirectory(dirPath string) error { + if _, err := os.Stat(dirPath); err != nil { + if os.IsNotExist(err) { + return nil // Directory doesn't exist, nothing to clear + } + return err + } + + logger.Modding.Debugf("Clearing directory: %s", dirPath) + + entries, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("failed to read directory: %w", err) + } + + for _, entry := range entries { + path := filepath.Join(dirPath, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + } + + logger.Modding.Debug("Directory cleared successfully") + return nil +} + +// extractZip extracts all files from a zip archive to the destination directory +func extractZip(zipPath string, destDir string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer reader.Close() + + logger.Modding.Debugf("Extracting %d files from zip", len(reader.File)) + + for i, file := range reader.File { + filePath := filepath.Join(destDir, file.Name) + + // Prevent path traversal attacks + if !filepath.IsLocal(filepath.Join(filepath.Dir(filePath), filepath.Base(filePath))) { + return fmt.Errorf("invalid file path in archive: %s", file.Name) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(filePath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } else { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + rc, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open file in archive: %w", err) + } + + outFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + rc.Close() + return fmt.Errorf("failed to create output file: %w", err) + } + + if _, err := io.Copy(outFile, rc); err != nil { + outFile.Close() + rc.Close() + return fmt.Errorf("failed to write file: %w", err) + } + + outFile.Close() + rc.Close() + + if (i+1)%10 == 0 || i == len(reader.File)-1 { + logger.Modding.Debugf("Extracted %d/%d files", i+1, len(reader.File)) + } + } + } + + return nil +} + +func ImportModPackage(zipData []byte) error { + if len(zipData) == 0 { + return fmt.Errorf("empty zip data") + } + + // Validate it's a valid zip file + if _, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))); err != nil { + return fmt.Errorf("invalid zip file: %w", err) + } + + return nil +} diff --git a/src/setup/update/progressbar.go b/src/setup/update/progressbar.go index 07a362c0..1431253c 100644 --- a/src/setup/update/progressbar.go +++ b/src/setup/update/progressbar.go @@ -8,19 +8,19 @@ import ( ) // writeCounter tracks download progress -type writeCounter struct { +type WriteCounter struct { Total int64 count int64 } -func (wc *writeCounter) Write(p []byte) (int, error) { +func (wc *WriteCounter) Write(p []byte) (int, error) { n := len(p) wc.count += int64(n) wc.printProgress() return n, nil } -func (wc *writeCounter) printProgress() { +func (wc *WriteCounter) printProgress() { // If we don't know the total size, just show downloaded bytes if wc.Total <= 0 { logger.Backup.Info(fmt.Sprintf("\r%s downloaded", bytesToHuman(wc.count))) diff --git a/src/setup/update/updater.go b/src/setup/update/updater.go index f5d6a826..0c5f13fd 100644 --- a/src/setup/update/updater.go +++ b/src/setup/update/updater.go @@ -154,7 +154,7 @@ func downloadNewExecutable(filename, url string) error { } // Show progress - counter := &writeCounter{Total: resp.ContentLength} + counter := &WriteCounter{Total: resp.ContentLength} _, err = io.Copy(out, io.TeeReader(resp.Body, counter)) if err != nil { out.Close() diff --git a/src/steamcmd/workshop.go b/src/steamcmd/workshop.go new file mode 100644 index 00000000..c28d1dec --- /dev/null +++ b/src/steamcmd/workshop.go @@ -0,0 +1,247 @@ +package steamcmd + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" +) + +// DownloadWorkshopItems downloads all installed workshop mods using SteamCMD +func UpdateWorkshopItems() ([]string, error) { + var logs []string + workshopHandles := modding.GetModWorkshopHandles() + if len(workshopHandles) == 0 { + logger.Install.Debug("ℹ️ No workshop items to download") + logs = append(logs, "No workshop items to download") + return logs, nil + } + + //logger.Install.Debugf("Workshop handles to update: %v", workshopHandles) + logs2, err := DownloadWorkshopItems(workshopHandles) + logs = append(logs, logs2...) + return logs, err +} + +func DownloadWorkshopItems(workshopHandles []string) ([]string, error) { + var logs []string + logger.Install.Infof("🔄 Downloading %d workshop items...", len(workshopHandles)) + logs = append(logs, fmt.Sprintf("Downloading %d workshop items...", len(workshopHandles))) + + currentDir, err := os.Getwd() + if err != nil { + logger.Install.Error("❌ Error getting current working directory: " + err.Error()) + logs = append(logs, "Error getting current working directory: "+err.Error()) + return logs, err + } + + // Acquire lock for SteamCMD access + if steamMu.TryLock() { + logger.Core.Debug("🔄 Locking SteamMu for SteamCMD Workshop Downloads...") + } else { + logger.Core.Warn("🔄 SteamMu is currently locked, waiting for it to be unlocked and then continuing...") + steamMu.Lock() + logger.Core.Debug("🔄 Locking SteamMu for SteamCMD Workshop Downloads...") + } + defer func() { + steamMu.Unlock() + logger.Core.Debug("🔄 Unlocking SteamMu after SteamCMD Workshop Downloads...") + }() + + steamcmddir := SteamCMDLinuxDir + executable := "steamcmd.sh" + + if runtime.GOOS == "windows" { + executable = "steamcmd.exe" + steamcmddir = SteamCMDWindowsDir + } + + // Download each workshop item + for i, appID := range workshopHandles { + logger.Install.Infof("📦 Downloading workshop item %d/%d: %s", i+1, len(workshopHandles), appID) + + // Build SteamCMD command + cmd := exec.Command( + filepath.Join(steamcmddir, executable), + "+force_install_dir", "../", + "+login", "anonymous", + "+workshop_download_item", "544550", appID, + "validate", + "+quit", + ) + + // Capture output + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Set up environment for Linux + if runtime.GOOS == "linux" { + env := os.Environ() + newEnv := make([]string, 0, len(env)+1) + foundHome := false + for _, e := range env { + if !strings.HasPrefix(e, "HOME=") { + newEnv = append(newEnv, e) + } else { + newEnv = append(newEnv, "HOME="+currentDir) + foundHome = true + } + } + if !foundHome { + newEnv = append(newEnv, "HOME="+currentDir) + } + cmd.Env = newEnv + } + + // Run the command + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + logger.Install.Warnf("⚠️ SteamCMD workshop download failed for %s (code %d): %s", appID, exitErr.ExitCode(), stderr.String()) + logs = append(logs, fmt.Sprintf("SteamCMD workshop download failed for %s (code %d): %s", appID, exitErr.ExitCode(), stderr.String())) + } else { + logger.Install.Warnf("⚠️ Error running SteamCMD for workshop item %s: %s", appID, err.Error()) + logs = append(logs, fmt.Sprintf("Error running SteamCMD for workshop item %s: %s", appID, err.Error())) + } + continue // Continue with next workshop item even if this one fails + } + + logger.Install.Debugf("✅ Successfully downloaded workshop item: %s", appID) + logs = append(logs, fmt.Sprintf("Successfully downloaded workshop item: %s", appID)) + } + + logger.Install.Info("✅ Workshop items download complete") + logs = append(logs, "Workshop items download complete") + + // Copy downloaded items to mods directory + logs2, err := copyDownloadedItemsToMods(workshopHandles) + logs = append(logs, logs2...) + if err != nil { + logger.Install.Error("❌ Error copying workshop items to mods directory: " + err.Error()) + logs = append(logs, "Error copying workshop items to mods directory: "+err.Error()) + return logs, err + } + + return logs, nil +} + +// copyDownloadedItemsToMods copies downloaded workshop items from the Steam directory to ./mods +func copyDownloadedItemsToMods(workshopHandles []string) ([]string, error) { + var logs []string + // Determine the steam content directory based on OS + var steamContentDir string + if runtime.GOOS == "windows" { + steamContentDir = SteamCMDWindowsDir + // Windows SteamCMD dir is C:\SteamCMD, so workshop content is at C:\SteamCMD\steamapps\workshop\content\544550 + steamContentDir = filepath.Join(steamContentDir, "steamapps", "workshop", "content", "544550") + } else { + // Linux: ./steamapps/workshop/content/544550 + steamContentDir = filepath.Join(".", "steamapps", "workshop", "content", "544550") + } + + // Ensure mods directory exists + modsDir := "./mods" + if err := os.MkdirAll(modsDir, 0755); err != nil { + return logs, fmt.Errorf("failed to create mods directory: %w", err) + } + + logger.Install.Infof("📂 Copying %d workshop items to mods directory...", len(workshopHandles)) + logs = append(logs, fmt.Sprintf("Copying %d workshop items to mods directory...", len(workshopHandles))) + + // Copy each workshop item + for i, appID := range workshopHandles { + logger.Install.Infof("📋 Processing workshop item %d/%d: %s", i+1, len(workshopHandles), appID) + + // Source path: steamapps/workshop/content/544550/{appID} + srcPath := filepath.Join(steamContentDir, appID) + + // Check if source directory exists + srcInfo, err := os.Stat(srcPath) + if err != nil || !srcInfo.IsDir() { + logger.Install.Errorf("❌ Workshop item not found at expected path: %s (skipping)", srcPath) + logs = append(logs, fmt.Sprintf("Workshop item not found at expected path: %s (skipping)", srcPath)) + continue + } + + // Destination path: ./mods/Workshop_{appID} + destPath := filepath.Join(modsDir, fmt.Sprintf("Workshop_%s", appID)) + + // Remove existing destination directory if it exists + if _, err := os.Stat(destPath); err == nil { + logger.Install.Debugf("🗑️ Removing existing directory: %s", destPath) + if err := os.RemoveAll(destPath); err != nil { + logger.Install.Warnf("⚠️ Failed to remove existing directory %s: %s (continuing anyway)", destPath, err.Error()) + logs = append(logs, fmt.Sprintf("Failed to remove existing directory %s: %s (continuing anyway)", destPath, err.Error())) + } + } + + // Copy the entire directory + if err := copyDir(srcPath, destPath); err != nil { + logger.Install.Warnf("⚠️ Failed to copy workshop item %s: %s (skipping)", appID, err.Error()) + logs = append(logs, fmt.Sprintf("Failed to copy workshop item %s: %s (skipping)", appID, err.Error())) + continue + } + + logger.Install.Debugf("✅ Successfully copied workshop item to: %s", destPath) + logs = append(logs, fmt.Sprintf("Successfully copied workshop item to: %s", destPath)) + } + + logger.Install.Info("✅ Workshop items copy complete") + logs = append(logs, "Workshop items copy complete") + return logs, nil +} + +// copyDir recursively copies a directory from src to dst +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} + +// copyFile copies a single file from src to dst +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} diff --git a/src/web/TwoBoxForm.go b/src/web/TwoBoxForm.go index 17bfe149..80339d0f 100644 --- a/src/web/TwoBoxForm.go +++ b/src/web/TwoBoxForm.go @@ -85,6 +85,7 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) { {Display: "Pre-terrain rework update", Value: "preterrain"}, {Display: "Pre-rocket refactor update", Value: "prerocket"}, {Display: "Version before the latest update", Value: "previous"}, + {Display: "A slightly rolled back Multiplayer-Safe version", Value: "multiplayersafe"}, } var worldOptions = []struct{ Display, Value string }{ diff --git a/src/web/configpage.go b/src/web/configpage.go index 839f3fdd..c39755bb 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -121,6 +121,11 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { createGameServerLogFileFalseSelected = "selected" } + isStationeersLaunchPadEnabled := "false" + if config.GetIsStationeersLaunchPadEnabled() { + isStationeersLaunchPadEnabled = "true" + } + data := ConfigTemplateData{ // Config values DiscordToken: config.GetDiscordToken(), @@ -293,6 +298,29 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_CopyrightConfig1: localization.GetString("UIText_Copyright1"), UIText_CopyrightConfig2: localization.GetString("UIText_Copyright2"), + + // SLP Section + UIText_SLP_Title: localization.GetString("UIText_SLP_Title"), + UIText_SLP_Description: localization.GetString("UIText_SLP_Description"), + UIText_SLP_ReadyToInstall: localization.GetString("UIText_SLP_ReadyToInstall"), + UIText_SLP_InstallButton: localization.GetString("UIText_SLP_InstallButton"), + UIText_SLP_UploadModPackage: localization.GetString("UIText_SLP_UploadModPackage"), + UIText_SLP_UploadDescription: localization.GetString("UIText_SLP_UploadDescription"), + UIText_SLP_UploadDescriptionLink: localization.GetString("UIText_SLP_UploadDescriptionLink"), + UIText_SLP_InstallFirst: localization.GetString("UIText_SLP_InstallFirst"), + UIText_SLP_InstallFirstSubtext: localization.GetString("UIText_SLP_InstallFirstSubtext"), + UIText_SLP_DragDropHere: localization.GetString("UIText_SLP_DragDropHere"), + UIText_SLP_OrClickToSelect: localization.GetString("UIText_SLP_OrClickToSelect"), + UIText_SLP_UploadButton: localization.GetString("UIText_SLP_UploadButton"), + UIText_SLP_ManageInstallation: localization.GetString("UIText_SLP_ManageInstallation"), + UIText_SLP_UninstallWarning: localization.GetString("UIText_SLP_UninstallWarning"), + UIText_SLP_UninstallButton: localization.GetString("UIText_SLP_UninstallButton"), + UIText_SLP_UpdateWorkshopMods: localization.GetString("UIText_SLP_UpdateWorkshopMods"), + UIText_SLP_UpdateWorkshopModsDesc: localization.GetString("UIText_SLP_UpdateWorkshopModsDesc"), + UIText_SLP_UpdateButton: localization.GetString("UIText_SLP_UpdateButton"), + UIText_SLP_InstalledMods: localization.GetString("UIText_SLP_InstalledMods"), + + IsStationeersLaunchPadEnabled: isStationeersLaunchPadEnabled, } err = tmpl.Execute(w, data) diff --git a/src/web/routes.go b/src/web/routes.go index f3e6b3cd..0011c0ea 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -65,6 +65,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { protectedMux.HandleFunc("/api/v2/SSCM/run", HandleCommand) // Command execution via SSCM (needs to be enable, config.IsSSCMEnabled) protectedMux.HandleFunc("/api/v2/SSCM/enabled", HandleIsSSCMEnabled) // Check if SSCM is enabled protectedMux.HandleFunc("/api/v2/steamcmd/run", HandleRunSteamCMD) // Run SteamCMD + // /api/v2/steamcmd/updatemods is defined in the SLP & Modding section below // Custom Detections protectedMux.HandleFunc("/api/v2/custom-detections", detectionmgr.HandleCustomDetection) @@ -87,5 +88,12 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { // Monitoring protectedMux.HandleFunc("/api/v2/monitor/gameserver/status", HandleMonitorStatus) + // SLP & Modding + protectedMux.HandleFunc("/api/v2/slp/install", InstallSLPHandler) + protectedMux.HandleFunc("/api/v2/slp/uninstall", UninstallSLPHandler) + protectedMux.HandleFunc("/api/v2/slp/upload", UploadModPackageHandler) + protectedMux.HandleFunc("/api/v2/slp/mods", GetInstalledModDetailsHandler) + protectedMux.HandleFunc("/api/v2/steamcmd/updatemods", UpdateWorkshopModsHandler) + return mux, protectedMux } diff --git a/src/web/slp-launchpad.go b/src/web/slp-launchpad.go new file mode 100644 index 00000000..416f5ec2 --- /dev/null +++ b/src/web/slp-launchpad.go @@ -0,0 +1,93 @@ +package web + +import ( + "encoding/json" + "net/http" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/modding" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" +) + +func InstallSLPHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := modding.InstallSLP(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) +} + +func UninstallSLPHandler(w http.ResponseWriter, r *http.Request) { + + w.Header().Set("Content-Type", "application/json") + if _, err := modding.UninstallSLP(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) +} + +func UploadModPackageHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if err := modding.ProcessModPackageUpload(r.Body); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Mod package uploaded and extracted successfully", + }) +} + +func GetInstalledModDetailsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + mods := modding.GetModList() + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "mods": mods, + }) +} + +func UpdateWorkshopModsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + logs, err := steamcmd.UpdateWorkshopItems() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + "logs": logs, + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Workshop mods updated successfully", + "logs": logs, + }) +} diff --git a/src/web/templatevars.go b/src/web/templatevars.go index c61e94b8..dc1a9ea5 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -201,4 +201,26 @@ type ConfigTemplateData struct { UIText_Copyright string UIText_CopyrightConfig1 string UIText_CopyrightConfig2 string + + UIText_SLP_Title string + UIText_SLP_Description string + UIText_SLP_ReadyToInstall string + UIText_SLP_InstallButton string + UIText_SLP_UploadModPackage string + UIText_SLP_UploadDescription string + UIText_SLP_UploadDescriptionLink string + UIText_SLP_InstallFirst string + UIText_SLP_InstallFirstSubtext string + UIText_SLP_DragDropHere string + UIText_SLP_OrClickToSelect string + UIText_SLP_UploadButton string + UIText_SLP_ManageInstallation string + UIText_SLP_UninstallWarning string + UIText_SLP_UninstallButton string + UIText_SLP_UpdateWorkshopMods string + UIText_SLP_UpdateWorkshopModsDesc string + UIText_SLP_UpdateButton string + UIText_SLP_InstalledMods string + + IsStationeersLaunchPadEnabled string }