diff --git a/UIMod/onboard_bundled/assets/apiinfo.html b/UIMod/onboard_bundled/assets/apiinfo.html index fdcdf826..18500dc9 100644 --- a/UIMod/onboard_bundled/assets/apiinfo.html +++ b/UIMod/onboard_bundled/assets/apiinfo.html @@ -206,7 +206,7 @@

Information Streams

\ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/credits.html b/UIMod/onboard_bundled/assets/credits.html index 6c9a2bfb..631f27f1 100644 --- a/UIMod/onboard_bundled/assets/credits.html +++ b/UIMod/onboard_bundled/assets/credits.html @@ -132,6 +132,13 @@ margin: 3em 0 1em 0; text-shadow: 0 0 20px rgba(72, 187, 120, 0.6); } + + .donors { + font-size: 2em; + color: #008cff; + margin: 3em 0 1em 0; + text-shadow: 0 0 20px rgba(72, 187, 120, 0.6); + } .tech-stack { font-size: 1.5em; @@ -242,6 +249,11 @@ +
+
Donors
+
Musashi
+
+
Created for the game "Stationeers" which is made by
RocketWerkz, Dean Hall, New Zealand
diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index 9262c3cd..1c734b22 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -54,7 +54,7 @@ width: 24px; height: 24px; margin-right: 10px; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 64 64' id='wizard' xmlns='http://www.w3.org/2000/svg' fill='%23000000'%3E%3C!-- SVG content unchanged, but stroke/fill colours are part of the icon design --%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 64 64' id='wizard' xmlns='http://www.w3.org/2000/svg' fill='%23000000'%3E%3Cg id='SVGRepo_bgCarrier' stroke-width='0'%3E%3C/g%3E%3Cg id='SVGRepo_tracerCarrier' stroke-linecap='round' stroke-linejoin='round'%3E%3C/g%3E%3Cg id='SVGRepo_iconCarrier'%3E%3Ctitle%3Ewizard%3C/title%3E%3Ccircle cx='33' cy='23' r='23' style='fill:%23edebdc'%3E%3C/circle%3E%3Cline x1='7' y1='17' x2='7' y2='19' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Cline x1='7' y1='23' x2='7' y2='25' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Cpath d='M21.778,47H47.222A8.778,8.778,0,0,1,56,55.778V61a0,0,0,0,1,0,0H13a0,0,0,0,1,0,0V55.778A8.778,8.778,0,0,1,21.778,47Z' style='fill:%239dc1e4;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Cpolygon points='32 61 28 61 34 49 38 49 32 61' style='fill:%23ffffff;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/polygon%3E%3Cpath d='M59,39H11v4.236A5.763,5.763,0,0,0,16.764,49L34,55l19.236-6A5.763,5.763,0,0,0,59,43.236Z' style='fill:%239dc1e4;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Cline x1='3' y1='21' x2='5' y2='21' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Cline x1='9' y1='21' x2='11' y2='21' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Ccircle cx='55.5' cy='6.5' r='2.5' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/circle%3E%3Ccircle cx='13.984' cy='6.603' r='1.069' style='fill:%234c241d'%3E%3C/circle%3E%3Cellipse cx='35' cy='39' rx='24' ry='6' style='fill:%236b4f5b;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/ellipse%3E%3Ccircle cx='5.984' cy='30.603' r='1.069' style='fill:%234c241d'%3E%3C/circle%3E%3Cpath d='M48,13V10.143A6.143,6.143,0,0,0,41.857,4H27.143A6.143,6.143,0,0,0,21,10.143V13' style='fill:%239dc1e4;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Crect x='20' y='17.81' width='29' height='14.19' style='fill:%23ffe8dc;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/rect%3E%3Cpath d='M41.972,13H48a4,4,0,0,1,4,4h0a4,4,0,0,1-4,4H21a4,4,0,0,1-4-4h0a4,4,0,0,1,4-4H37' style='fill:%23ffffff;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Ccircle cx='39.5' cy='25.5' r='1.136' style='fill:%234c241d'%3E%3C/circle%3E%3Ccircle cx='29.5' cy='25.5' r='1.136' style='fill:%234c241d'%3E%3C/circle%3E%3Cpath d='M43.875,32a6.472,6.472,0,0,0-5.219-2.2A5.2,5.2,0,0,0,35,31.974,5.2,5.2,0,0,0,31.344,29.8,6.472,6.472,0,0,0,26.125,32H20v4.5a14.5,14.5,0,0,0,29,0V32Z' style='fill:%23ffffff;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/path%3E%3Cline x1='33' y1='36' x2='37' y2='36' style='fill:none;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/line%3E%3Crect x='32' y='10' width='5' height='5' transform='translate(1.266 28.056) rotate(-45)' style='fill:%23bd53b5;stroke:%234c241d;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px'%3E%3C/rect%3E%3C/g%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; } @@ -121,30 +121,125 @@ display: inline-block; width: 24px; height: 24px; - margin-right: 10px; + padding: 2px; vertical-align: middle; + background-size: contain; } -/* Icons – colours are baked in as white; keep as-is */ -.server-icon, -.discord-icon, -.detection-icon { +/* Server Icon - gear/cog icon */ +.server-icon { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; } +/* Discord Icon */ .discord-icon { - background-size: 20px 20px; + background-image: url("/static/icons/discord.webp"); + background-repeat: no-repeat; + background-position: center; } -.tab-button { +/* Detection Manager Icon - radar/search icon */ +.detection-icon { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z'/%3E%3Cpath d='M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm-1 5h2v2h-2v-2zm0-2h2v1h-2v-1zm0-1h2v1h-2v-1z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; +} +.slp-icon { + background-image: url("/static/icons/launchpad.webp"); + background-repeat: no-repeat; + background-position: center; + border-radius: 5px; +} +.sliding-tabs { display: flex; + gap: 8px; + padding: 16px 0px; + min-width: 70px; +} + +.sliding-tab-button { + display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 8px; + padding: 16px 12px; + background: var(--tab-bg); + border: 2px solid var(--primary-dim); + border-radius: 8px; + color: var(--primary); + cursor: pointer; + transition: all var(--transition-normal); + opacity: 0.7; + min-height: 80px; + width: 56px; + overflow: hidden; + position: relative; + font-size: 0.9rem; + font-family: 'Press Start 2P', cursive; + box-shadow: 0 0 10px var(--button-glow-soft); +} + +.sliding-tab-button .icon { + opacity: 1; + transition: opacity 0.2s ease; +} + +.sliding-tab-button .tab-text { + opacity: 0; + transition: opacity 0.2s ease; + white-space: nowrap; + font-size: 0.8rem; + position: absolute; } -.tab-button.active .icon { - filter: brightness(1.2); +.sliding-tab-button:hover{ + width: 100%; + opacity: 1; + transform: translateY(0px) !important; /* Overrides the default transform on global button hover */ +} +.sliding-tab-button.active { + width: 100%; + opacity: 1; + box-shadow: 0 0 15px var(--button-glow), 0 0 25px var(--button-glow-soft); + background-color: var(--tab-active-bg); +} + +.sliding-tab-button:hover .icon, +.sliding-tab-button.active .icon { + opacity: 0; +} + +.sliding-tab-button:hover .tab-text, +.sliding-tab-button.active .tab-text { + opacity: 1; +} + + +@media (max-width: 768px) { + +.sliding-tab-button:hover .icon, +.sliding-tab-button.active .icon { + opacity: 1; +} + +.sliding-tab-button:hover .tab-text, +.sliding-tab-button.active .tab-text { + opacity: 0; +} + +} + +.sliding-tab-button.active { + box-shadow: 0 0 20px var(--tab-active-glow); + border-color: var(--primary); +} + +.config-tabs{ + display: flex; + flex-direction: column; } .fill-hint-wraper { @@ -311,9 +406,31 @@ select option { background-color: rgba(0, 0, 0, 0.4); } -/* Responsive & Animation unchanged */ @media (max-width: 768px) { - /* ... unchanged ... */ + .section-navigation { + flex-wrap: wrap; + justify-content: flex-start; + } + + .section-nav-button { + padding: 8px 12px; + font-size: 0.85em; + } + + .wizard-button { + width: 100%; + justify-content: center; + padding: 10px 15px; + } + + .section-title { + font-size: 0.9rem; + } + + .sliding-tab-button { + width: 100%; + padding: 10px 15px; + } } @keyframes fadeIn { diff --git a/UIMod/onboard_bundled/assets/css/detectionmanager.css b/UIMod/onboard_bundled/assets/css/detectionmanager.css index 2bbd84dd..6bf80a88 100644 --- a/UIMod/onboard_bundled/assets/css/detectionmanager.css +++ b/UIMod/onboard_bundled/assets/css/detectionmanager.css @@ -1,6 +1,6 @@ @import '/static/css/variables.css'; -#detection-list-tab { +#detection-list-container { background: rgba(114, 137, 218, 0.1); } @@ -190,6 +190,10 @@ input:checked+.slider:before { margin-top: 10px; } +#add-detection-container { + margin-top: 2rem; +} + /* Responsive design */ @media (max-width: 768px) { .detection-list-header, diff --git a/UIMod/onboard_bundled/assets/css/home.css b/UIMod/onboard_bundled/assets/css/home.css index 2b3e2d68..3b0862a5 100644 --- a/UIMod/onboard_bundled/assets/css/home.css +++ b/UIMod/onboard_bundled/assets/css/home.css @@ -20,6 +20,7 @@ border-left: 3px solid var(--primary); transition: opacity var(--transition-fast); } + .status-indicator { width: 16px; height: 16px; @@ -52,9 +53,11 @@ height: 2px; background-color: #FFFFFF; } + .status-indicator.offline::before { transform: translate(-50%, -50%) rotate(45deg); } + .status-indicator.offline::after { transform: translate(-50%, -50%) rotate(-45deg); } @@ -70,10 +73,12 @@ transform: scale(1); box-shadow: 0 0 10px rgba(76, 175, 80, 0.7); } + 50% { transform: scale(1.2); box-shadow: 0 0 14px rgba(76, 175, 80, 0.9); } + 100% { transform: scale(1); box-shadow: 0 0 10px rgba(76, 175, 80, 0.7); @@ -81,12 +86,16 @@ } @keyframes shake { - 0%, 100% { + + 0%, + 100% { transform: translateX(0); } + 25% { transform: translateX(-2px); } + 75% { transform: translateX(2px); } @@ -175,6 +184,7 @@ .log-console-element-warn { border-left-color: var(--warning) } + .log-console-element-error { border-left-color: var(--danger); } @@ -255,16 +265,16 @@ } #playerListRefreshButton { - position: absolute; - top: 20px; - right: 20px; - padding: 5px 10px; - font-size: 1.3rem; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; + position: absolute; + top: 20px; + right: 20px; + padding: 5px 10px; + font-size: 1.3rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; } .player-item { @@ -280,7 +290,8 @@ line-height: 1.6; } -.player-item:hover, .player-item.animate-in:hover { +.player-item:hover, +.player-item.animate-in:hover { background-color: rgba(0, 0, 0, 0.6); border-color: var(--primary); transform: translateX(5px); @@ -319,142 +330,445 @@ #backupRefreshButton { - padding: 5px 10px; - font-size: 1.3rem; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; + padding: 5px 10px; + font-size: 1.3rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; } .backup-controls { - position: absolute; - top: 30px; - right: 20px; - display: flex; - gap: 10px; - align-items: center; + position: absolute; + top: 30px; + right: 20px; + display: flex; + gap: 10px; + align-items: center; } @media (max-width: 767px) { - .backup-controls { - position: unset; - } + .backup-controls { + position: unset; + } } #backupLimit { - padding: 8px 12px; - background-color: rgba(0, 0, 0, 0.6); - color: var(--text-bright); - border: 1px solid rgba(0, 255, 171, 0.5); - border-radius: 4px; - font-family: 'Press Start 2P', cursive; - font-size: 0.7rem; + padding: 8px 12px; + background-color: rgba(0, 0, 0, 0.6); + color: var(--text-bright); + border: 1px solid rgba(0, 255, 171, 0.5); + border-radius: 4px; + font-family: 'Press Start 2P', cursive; + font-size: 0.7rem; } .backup-item { - background-color: rgba(0, 0, 0, 0.4); - padding: 20px; - margin-bottom: 15px; - border-radius: 12px; - display: flex; - justify-content: space-between; - align-items: center; - border: 2px solid rgba(0, 255, 171, 0.3); - transition: all var(--transition-normal); - position: relative; - overflow: hidden; + background-color: rgba(0, 0, 0, 0.4); + padding: 20px; + margin-bottom: 15px; + border-radius: 12px; + display: flex; + justify-content: space-between; + align-items: center; + border: 2px solid rgba(0, 255, 171, 0.3); + transition: all var(--transition-normal); + position: relative; + overflow: hidden; } .backup-item.animate-in { - animation: slideIn 0.6s ease-out forwards; + animation: slideIn 0.6s ease-out forwards; } -.backup-item:hover, .backup-item.animate-in:hover { +.backup-item:hover, +.backup-item.animate-in:hover { background-color: rgba(0, 0, 0, 0.6); border-color: var(--primary); transform: translateX(5px); } .backup-info { - flex: 1; + flex: 1; } .backup-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 8px; + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; } .backup-name { - font-weight: bold; - color: var(--text-bright); - font-size: 0.9rem; + font-weight: bold; + color: var(--text-bright); + font-size: 0.9rem; } .backup-type { - padding: 4px 8px; - border-radius: 12px; - font-size: 0.6rem; - text-transform: uppercase; - font-weight: bold; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.6rem; + text-transform: uppercase; + font-weight: bold; } .backup-type.preterrain-trio { - background-color: rgba(255, 165, 0, 0.2); - color: #ffa500; - border: 1px solid rgba(255, 165, 0, 0.4); + background-color: rgba(255, 165, 0, 0.2); + color: #ffa500; + border: 1px solid rgba(255, 165, 0, 0.4); } .backup-type.dotsave { - background-color: rgba(0, 255, 171, 0.2); - color: var(--primary); - border: 1px solid rgba(0, 255, 171, 0.4); + background-color: rgba(0, 255, 171, 0.2); + color: var(--primary); + border: 1px solid rgba(0, 255, 171, 0.4); } .backup-date { - color: var(--text-dim); - font-size: 0.7rem; - opacity: 0.8; + color: var(--text-dim); + font-size: 0.7rem; + opacity: 0.8; } .restore-btn { - padding: 10px 18px; - background-color: rgba(0, 255, 171, 0.1); - color: var(--text-bright); - border: 2px solid var(--primary); - border-radius: 8px; - cursor: pointer; - font-family: 'Press Start 2P', cursive; - font-size: 0.7rem; - transition: all var(--transition-normal); - text-transform: uppercase; + padding: 10px 18px; + background-color: rgba(0, 255, 171, 0.1); + color: var(--text-bright); + border: 2px solid var(--primary); + border-radius: 8px; + cursor: pointer; + font-family: 'Press Start 2P', cursive; + font-size: 0.7rem; + transition: all var(--transition-normal); + text-transform: uppercase; } .restore-btn:hover { - background-color: var(--primary); - color: #000; + background-color: var(--primary); + color: #000; } -.no-backups, .backuperror { - text-align: center; - padding: 40px; - color: var(--text-dim); - font-style: italic; - background-color: rgba(0, 0, 0, 0.2); - border-radius: 8px; - border: 1px dashed rgba(0, 255, 171, 0.3); +.no-backups, +.backuperror { + text-align: center; + padding: 40px; + color: var(--text-dim); + font-style: italic; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 8px; + border: 1px dashed rgba(0, 255, 171, 0.3); } @keyframes slideIn { - 0% { - opacity: 0; - transform: scale(0.9) translateX(-30px) rotateX(10deg); - } - 100% { - opacity: 1; - transform: scale(1) translateX(0) rotateX(0deg); - } + 0% { + opacity: 0; + transform: scale(0.9) translateX(-30px) rotateX(10deg); + } + + 100% { + opacity: 1; + transform: scale(1) translateX(0) rotateX(0deg); + } +} + +/* Update */ +/* Floating Update Indicator */ +#update-indicator-float { + position: fixed; + top: 20px; + left: 20px; + width: 60px; + height: 60px; + background: linear-gradient(135deg, #ff6b6b, #ff4444); + border-radius: 50%; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + box-shadow: 0 4px 20px rgba(255, 68, 68, 0.5); + z-index: 9999; + animation: bounce 2s ease-in-out infinite; + transition: transform 0.2s ease; +} + +#update-indicator-float:hover { + transform: scale(1.1); +} + +#update-indicator-float::before { + content: "↑"; + font-size: 32px; + color: white; + font-weight: bold; + animation: pulse-icon 1.5s ease-in-out infinite; +} + +#update-indicator-float::after { + content: ""; + position: absolute; + top: -5px; + right: -5px; + width: 20px; + height: 20px; + background: #fff; + border-radius: 50%; + animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes bounce { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-10px); + } +} + +@keyframes pulse-icon { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.2); + } +} + +@keyframes ping { + 0% { + transform: scale(1); + opacity: 1; + } + + 75%, + 100% { + transform: scale(2); + opacity: 0; + } +} + +/* Update Modal */ +#update-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 10000; + align-items: center; + justify-content: center; +} + +#update-modal.show { + display: flex; +} + +.update-modal-content { + background: linear-gradient(135deg, #2d2d44, #1a1a2e); + border: 3px solid #ff4444; + border-radius: 16px; + padding: 40px; + max-width: 500px; + width: 90%; + box-shadow: 0 8px 40px rgba(255, 68, 68, 0.3); + position: relative; + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.9) translateY(-20px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.update-modal-close { + position: absolute; + top: 15px; + right: 15px; + background: transparent; + border: none; + color: #888; + font-size: 28px; + cursor: pointer; + width: 35px; + height: 35px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; +} + +.update-modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.update-modal-icon { + width: 80px; + height: 80px; + margin: 0 auto 20px; + background: linear-gradient(135deg, #ff6b6b, #ff4444); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + color: white; +} + +.update-modal-content h2 { + color: #fff; + margin: 0 0 15px 0; + text-align: center; + font-size: 24px; +} + +.update-modal-content p { + color: #ccc; + text-align: center; + margin: 0 0 10px 0; + line-height: 1.6; +} + +.update-version { + color: #ff6b6b; + font-weight: bold; + font-size: 20px; + text-align: center; + margin: 10px 0 25px 0; +} + +.update-modal-buttons { + display: flex; + gap: 15px; + margin-top: 30px; +} + +.update-modal-buttons button { + flex: 1; + padding: 15px 25px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; +} + +#update-now-btn { + background: linear-gradient(135deg, #4CAF50, #45a049); + color: white; +} + +#update-now-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4); +} + +#update-later-btn { + background: rgba(255, 255, 255, 0.1); + color: #ccc; + border: 2px solid rgba(255, 255, 255, 0.2); +} + +#update-later-btn:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +/* Update Status Messages in Modal */ +.update-status-message { + display: none; + text-align: center; + margin-top: 20px; + padding: 15px; + border-radius: 8px; + font-weight: bold; +} + +.update-status-message.running { + display: block; + background: rgba(33, 150, 243, 0.2); + border: 2px solid #2196F3; + color: #2196F3; +} + +.update-status-message.success { + display: block; + background: rgba(76, 175, 80, 0.2); + border: 2px solid #4CAF50; + color: #4CAF50; +} + +.update-status-message.failed { + display: block; + background: rgba(255, 68, 68, 0.2); + border: 2px solid #ff4444; + color: #ff4444; +} + +#update-button { + display: none; + animation: none; +} + +#update-button.bounce { + animation: bounce 2s infinite; + font-weight: bold; +} + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-10px); } + 60% { transform: translateY(-5px); } +} + +.update-status-message { + display: none; + margin-top: 20px; + font-weight: bold; + color: #fff; +} + +.update-status-message.running { + display: block; +} + + +.update-icon { + position: absolute; + top: 20px; + right: 45px; + width: 32px; + height: 32px; + background: #F44336; + box-shadow: 0 0 10px rgba(244, 67, 54, 0.7); + border: none; + cursor: pointer; + padding: 0; + margin: 0; + transition: transform 0.2s ease; + font-size: 1.3rem; + +} + +.update-icon:hover { + background: #F44336; + transform: scale(1.1); } \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/icons/discord.webp b/UIMod/onboard_bundled/assets/icons/discord.webp new file mode 100644 index 00000000..587be942 Binary files /dev/null and b/UIMod/onboard_bundled/assets/icons/discord.webp differ diff --git a/UIMod/onboard_bundled/assets/icons/launchpad.webp b/UIMod/onboard_bundled/assets/icons/launchpad.webp new file mode 100644 index 00000000..cd67c0b4 Binary files /dev/null and b/UIMod/onboard_bundled/assets/icons/launchpad.webp differ diff --git a/UIMod/onboard_bundled/assets/js/detectionmanager.js b/UIMod/onboard_bundled/assets/js/detectionmanager.js index 9d1a3702..c13430f7 100644 --- a/UIMod/onboard_bundled/assets/js/detectionmanager.js +++ b/UIMod/onboard_bundled/assets/js/detectionmanager.js @@ -1,16 +1,3 @@ -// Show active tab -function showTab(tabId) { - document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active')); - document.querySelectorAll('.tab-button').forEach(button => button.classList.remove('active')); - - document.getElementById(tabId).classList.add('active'); - document.querySelector(`.tab-button[data-tab="${tabId}"]`).classList.add('active'); - - if (tabId === 'detection-list-tab') { - loadDetections(); - } -} - // Toggle detection type function setupDetectionTypeToggle() { const toggle = document.getElementById('detection-type-toggle'); @@ -50,7 +37,7 @@ function loadDetections() { loader.style.display = 'none'; if (detections.length === 0) { - detectionItems.innerHTML = '
No custom detections found. Add one to get started.
'; + detectionItems.innerHTML = '
No custom detections found. Add one below to get started.
'; return; } @@ -163,8 +150,5 @@ function escapeHtml(unsafe) { document.addEventListener('DOMContentLoaded', () => { loadDetections(); setupDetectionTypeToggle(); - document.querySelectorAll('.tab-button').forEach(button => { - button.addEventListener('click', () => showTab(button.getAttribute('data-tab'))); - }); document.querySelector('.add-button').addEventListener('click', submitDetection); }); \ No newline at end of file diff --git a/UIMod/onboard_bundled/assets/js/dynamic-announcement.js b/UIMod/onboard_bundled/assets/js/dynamic-announcement.js index 73b75665..477e1755 100644 --- a/UIMod/onboard_bundled/assets/js/dynamic-announcement.js +++ b/UIMod/onboard_bundled/assets/js/dynamic-announcement.js @@ -1,21 +1,18 @@ // dynamic-announcement.js (function () { - // Configuration - change only these values if needed - const ANNOUNCEMENT_ID = 'dynamic-announcement'; - const JSON_URL = 'https://steamserverui.github.io/StationeersServerUI/dynamic-announcement.json'; + // Configuration + const CONTAINER_ID = 'dynamic-announcement-list'; + const JSON_URL = 'https://steamserverui.github.io/StationeersServerUI/dynamic-announcement-list.json'; const FETCH_TIMEOUT = 8000; // ms - // Find the announcement container - const container = document.getElementById(ANNOUNCEMENT_ID); + const container = document.getElementById(CONTAINER_ID); if (!container) { - console.warn(`[Dynamic Announcement] Element #${ANNOUNCEMENT_ID} not found on page.`); + console.warn(`[Dynamic Announcement] Container #${CONTAINER_ID} not found on page.`); return; } - // Hide it initially (in case CSS shows it by default) - container.style.display = 'none'; + container.innerHTML = ''; - // Helper: simple timeout for fetch const fetchWithTimeout = (url, options = {}, timeout = FETCH_TIMEOUT) => { return Promise.race([ fetch(url, options), @@ -25,12 +22,24 @@ ]); }; - // Main logic + function attachCollapsibleHandlers() { + document.querySelectorAll('.info-notice h3').forEach(header => { + // Avoid adding multiple listeners if called repeatedly + header.removeEventListener('click', handleClick); + header.addEventListener('click', handleClick); + }); + } + + function handleClick(event) { + const notice = event.currentTarget.parentElement; + notice.classList.toggle('open'); + } + fetchWithTimeout(JSON_URL, { method: 'GET', cache: 'no-cache' }) .then(response => { if (!response.ok) { if (response.status === 404) { - console.info('[Dynamic Announcement] No announcement (404) - staying hidden.'); + console.info('[Dynamic Announcement] No announcement file (404).'); return null; } throw new Error(`HTTP ${response.status}`); @@ -38,73 +47,87 @@ return response.json(); }) .then(data => { - if (!data) return; // 404 or empty - - // Validate required fields - if (!data.headline || !data.bodyHtml) { - console.warn('[Dynamic Announcement] JSON is missing required fields.'); + if (!data || (Array.isArray(data) && data.length === 0)) { + console.info('[Dynamic Announcement] No announcements defined.'); return; } - // Optional date range check + const announcements = Array.isArray(data) ? data : [data]; + const now = Date.now(); - const start = data.validFrom ? new Date(data.validFrom).getTime() : null; - const end = data.validUntil ? new Date(data.validUntil).getTime() : null; + const validAnnouncements = announcements.filter(ann => { + if (!ann.headline || !ann.bodyHtml) return false; - if ((start !== null && now < start) || (end !== null && now > end)) { - console.info('[Dynamic Announcement] Current date is outside the valid range.'); + const start = ann.validFrom ? new Date(ann.validFrom).getTime() : null; + const end = ann.validUntil ? new Date(ann.validUntil).getTime() : null; + + if ((start !== null && now < start) || (end !== null && now > end)) { + return false; + } + + return true; + }); + + if (validAnnouncements.length === 0) { + console.info('[Dynamic Announcement] No currently valid announcements.'); return; } - // Fill the template - const headerElement = container.querySelector('h3'); - if (headerElement) { - headerElement.innerHTML = ` - 📢 - ${escapeHtml(data.headline)} - `; - } + validAnnouncements.sort((a, b) => { + const timeA = a.validFrom ? new Date(a.validFrom).getTime() : 0; + const timeB = b.validFrom ? new Date(b.validFrom).getTime() : 0; + return timeB - timeA; + }); - const contentDiv = container.querySelector('.collapsible-content'); + // Clear container + container.innerHTML = ''; - // Short description (optional) - let shortHtml = ''; - if (data.shortDescription) { - shortHtml = `

${escapeHtml(data.shortDescription)}

`; - } + validAnnouncements.forEach(ann => { + const notice = document.createElement('div'); + notice.className = 'info-notice'; - // Warning (optional) - let warningHtml = ''; - if (data.warningHtml) { - warningHtml = `

${data.warningHtml}

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

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

`; - } + if (ann.shortDescription) { + contentHTML += `

${escapeHtml(ann.shortDescription)}

`; + } + + contentHTML += `

${ann.bodyHtml}

`; + + if (ann.warningHtml) { + contentHTML += `

${ann.warningHtml}

`; + } + + contentHTML += `

`; // spacer + + if (ann.author || ann.authorRole) { + const author = ann.author ? escapeHtml(ann.author) : ''; + const role = ann.authorRole ? escapeHtml(ann.authorRole) : ''; + contentHTML += `

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

`; + } + + notice.innerHTML = ` +

+ 📢 + ${escapeHtml(ann.headline)} +

+
+ ${contentHTML} +
+ `; + + container.appendChild(notice); + }); + + attachCollapsibleHandlers(); - contentDiv.innerHTML = ` - ${shortHtml} -

${data.bodyHtml}

- ${warningHtml} -

- ${signatureHtml} - `; - - // Show the announcement - container.style.display = ''; // revert to CSS default (usually block) - console.info('[Dynamic Announcement] Announcement loaded and displayed.'); + console.info(`[Dynamic Announcement] ${validAnnouncements.length} announcement(s) loaded and collapsible handlers attached.`); }) .catch(err => { - // On any error (network, timeout, JSON parse, etc.) just keep it hidden - console.info('[Dynamic Announcement] Failed to load announcement:', err.message); + console.info('[Dynamic Announcement] Failed to load announcements:', err.message); }); - // Simple HTML escape utility (prevents XSS if you trust the JSON source) function escapeHtml(text) { if (typeof text !== 'string') return text; const div = document.createElement('div'); diff --git a/UIMod/onboard_bundled/assets/js/update-ssui.js b/UIMod/onboard_bundled/assets/js/update-ssui.js new file mode 100644 index 00000000..b4f21770 --- /dev/null +++ b/UIMod/onboard_bundled/assets/js/update-ssui.js @@ -0,0 +1,95 @@ +// Current known update info (populated from polling) +let currentUpdateVersion = null; + +// Poll for update status every 60 seconds +function pollUpdateStatus() { + fetch('/api/v2/update/check') + .then(response => response.json()) + .then(data => { + if (data.updateAvailable === "true" && data.version) { + // Update available! + currentUpdateVersion = data.version; + + // Show update button with bounce animation + const updateBtn = document.getElementById('update-button'); + updateBtn.style.display = 'block'; + updateBtn.classList.add('bounce'); + + // Update modal text when opened + document.getElementById('modal-version-text').textContent = data.version; + } else { + // No update — hide button and reset bounce + document.getElementById('update-button').style.display = 'none'; + document.getElementById('update-button').classList.remove('bounce'); + currentUpdateVersion = null; + } + }) + .catch(err => { + console.warn('Failed to check for updates:', err); + }); +} + +// Open update modal +function openUpdateModal() { + if (currentUpdateVersion) { + document.getElementById('modal-version-text').textContent = currentUpdateVersion; + } + document.getElementById('update-modal').classList.add('show'); + + // Reset state in case it was left in "running" + document.getElementById('update-status-running').classList.remove('running'); + document.getElementById('update-now-btn').style.display = ''; + document.getElementById('update-later-btn').style.display = ''; +} + +// Close update modal +function closeUpdateModal() { + document.getElementById('update-modal').classList.remove('show'); +} + +// Start the actual update +function startUpdate() { + // Hide buttons + document.getElementById('update-now-btn').style.display = 'none'; + document.getElementById('update-later-btn').style.display = 'none'; + + // Show running status + document.getElementById('update-status-running').classList.add('running'); + + // Send request to trigger update + fetch('/api/v2/update/trigger', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ allowUpdate: true }) + }) + .then(response => response.json()) + .then(data => { + console.log('Update triggered:', data); + // Even if success/fail, we assume update is running + }) + .catch(err => { + console.error('Failed to trigger update:', err); + // Optional: show failure message + document.getElementById('update-status-failed').classList.add('running'); + }); + + // Auto-refresh after 40 seconds (gives time for update to download/apply) + setTimeout(() => { + location.reload(); + }, 40000); +} + +// Close modal when clicking outside +document.getElementById('update-modal').addEventListener('click', function (e) { + if (e.target === this) { + closeUpdateModal(); + } +}); + +// Start polling when page loads +document.addEventListener('DOMContentLoaded', () => { + pollUpdateStatus(); // Immediate check + setInterval(pollUpdateStatus, 60000); // Every minute after +}); \ No newline at end of file diff --git a/UIMod/onboard_bundled/detectionmanager/detectionmanager.html b/UIMod/onboard_bundled/detectionmanager/detectionmanager.html deleted file mode 100644 index e298d02e..00000000 --- a/UIMod/onboard_bundled/detectionmanager/detectionmanager.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - Custom Detection Manager - - - - - - - - - - -
-
- -
-
-

Custom Detection Manager

-
-
- - - -
-
- -
-
-
-
-
Type
-
Pattern
-
Message
-
Actions
-
-
-
No custom detections found. Add one to get started.
-
-
-
- -
-
-
-
- Change Detection Mode - - Keyword -
- -
- - -
Text to match exactly (case-sensitive)
-
- -
- - -
Message to display when pattern is detected
-
- - - -
-
- -
-
-
- -
-

Custom Detection Patterns

-

Custom detections allow you to create custom patterns for detection. These patterns can be used to detect specific events in the server logs.

-

To create a custom detection, you can use the "Add Detection Tab" to define a regex pattern or alternatively a simple string match ("keyword") and a message that will be logged in the Events and if enabled in Discord when the pattern is detected. It is not possible to create patterns that have faulty regex.

-
-

Creating Effective Detections

-
-
-

Keyword Detection:

-

For example, to detect the "Unsupported shader" message that unity logs when a shader is not supported, you would use the following pattern:

- Pattern: "Unsupported shader" - Message: "Unity detected an unsupported shader. This may cause unexpected behavior." -
-
-

Regex Detection:

-

For example, to detect (fictional) the "Player (.+) has reached level (\d+)" message that is logged when a player reaches a certain level in an elevator, you would use the following pattern:

- Pattern: "Player (.+) has reached level (\d+)" - Message: "Player {1} has reached level {2}" -

The AI of your choise will be more than happy to help you create effective detections. You can also use the Regex101 tool to test your patterns.

-
-
-

For more information, visit the GitHub Wiki

-
-
- -
- -
- -
-
- - - - - - - \ 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 3cd789de..a79e9f24 100644 --- a/UIMod/onboard_bundled/localization/de-DE.json +++ b/UIMod/onboard_bundled/localization/de-DE.json @@ -14,11 +14,17 @@ "UIText_API_Info": "API-Endpunktdokumentation", "UIText_Copyright": "Urheberrecht", "UIText_Copyright1": "Lizenziert unter", - "UIText_Copyright2": "Proprietärer Lizenz" + "UIText_Copyright2": "Proprietärer Lizenz", + "UIText_UpdateAvailable": "Eine neue Version von SSUI ist bereit zu installieren und wird beim nächsten Neustart installiert.", + "UIText_UpdateLater": "Später", + "UIText_UpdateNow": "Jetzt aktualisieren", + "UIText_UpdateInstalling": "Aktualisierung wird installiert, bitte warten...", + "UIText_UpdateFailed": "Aktualisierung fehlgeschlagen. Bitte versuche es später erneut." }, "config": { "UIText_ServerConfig": "Server Konfiguration", "UIText_DiscordIntegration": "Discord-Integration", + "UIText_SLPModIntegration": "Launchpad Mods", "UIText_DetectionManager": "Erkennungsmanager", "UIText_ConfigurationWizard": "Konfigurations-Assistent", "UIText_PleaseSelectSection": "Bitte wähle oben eine Konfigurationssektion aus", @@ -68,7 +74,7 @@ "UIText_UseSteamP2PInfo": "Steam Peer-to-Peer Netzwerk aktivieren" }, "advanced": { - "UIText_AdvancedConfiguration": "Erweiterte Konfiguration", + "UIText_AdvancedConfiguration": "Erweiterte Funktionen", "UIText_ServerAuthSecret": "Server Auth Geheimnis", "UIText_ServerAuthSecretInfo": "Authentifizierungsgeheimnis für Server (optional)", "UIText_ServerExePath": "Server Ausführungspfad", @@ -219,7 +225,7 @@ "UIText_UPnPEnabled_SkipButton": "Überspringen", "UIText_LocalIPAddress_Title": "Stationeers Server UI", "UIText_LocalIPAddress_HeaderTitle": "Netzwerk (4/4)", - "UIText_LocalIPAddress_StepMessage": "Lokale IP-Adresse des Servers im Format 0.0.0.0 eingeben (keine CIDR Notation)", + "UIText_LocalIPAddress_StepMessage": "Gib die IP-Adresse ein, auf der der Server auf eine Verbindung hören soll - In den meisten fällen kann man den Wert auf 0.0.0.0 belassen", "UIText_LocalIPAddress_PrimaryPlaceholder": "0.0.0.0", "UIText_LocalIPAddress_PrimaryLabel": "Lokale IP-Adresse", "UIText_LocalIPAddress_SubmitButton": "Speichern & Weiter", @@ -265,7 +271,8 @@ "UIText_WorldID_SecondaryLabel": "Wähle aus dem Dropdown-Menü unten aus", "UIText_WorldID_SecondaryPlaceholder": "Wähle eine Karte aus", "UIText_WorldID_SubmitButton": "Speichern & Weiter", - "UIText_WorldID_SkipButton": "Überspringen" + "UIText_WorldID_SkipButton": "Überspringen", + "UIText_FinalizeSubmitButtonText": "Setup abschließen" } }, "BackendText": { diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json index adbbd138..8e1e6090 100644 --- a/UIMod/onboard_bundled/localization/en-US.json +++ b/UIMod/onboard_bundled/localization/en-US.json @@ -14,18 +14,24 @@ "UIText_API_Info": "API Endpoint Reference", "UIText_Copyright": "Copyright", "UIText_Copyright1": "Licensed under", - "UIText_Copyright2": "Proprietary License" + "UIText_Copyright2": "Proprietary License", + "UIText_UpdateAvailable": "A new version of SSUI is ready to install and will be installed the next time you restart the application.", + "UIText_UpdateLater": "Later", + "UIText_UpdateNow": "Update Now", + "UIText_UpdateInstalling": "Installing update, please wait...", + "UIText_UpdateFailed": "Update failed. Please try again later." }, "config": { "UIText_ServerConfig": "Server Configuration", "UIText_DiscordIntegration": "Discord Integration", + "UIText_SLPModIntegration": "Launchpad Mods", "UIText_DetectionManager": "Detection Manager", "UIText_ConfigurationWizard": "Configuration Wizard", "UIText_PleaseSelectSection": "Please select a configuration section above", "UIText_UseWizardAlternative": "Alternatively, use the Configuration Wizard to configure the server.", "UIText_BasicSettings": "Basic Settings", "UIText_NetworkSettings": "Network Settings", - "UIText_AdvancedSettings": "Advanced Settings", + "UIText_AdvancedSettings": "Advanced Features", "UIText_TerrainSettings": "World generation", "basic": { "UIText_BasicServerSettings": "Basic Server Settings", @@ -37,9 +43,9 @@ "UIText_MaxPlayers": "Max Players", "UIText_MaxPlayersInfo": "Maximum number of players allowed", "UIText_ServerPassword": "Server Password", - "UIText_ServerPasswordInfo": "Leave empty for no password", + "UIText_ServerPasswordInfo": "Password needed to connect to the server. Leave empty for no password", "UIText_AdminPassword": "Admin Password", - "UIText_AdminPasswordInfo": "Server Admin Password", + "UIText_AdminPasswordInfo": "Server Admin Password. VERY Legacy, unused in current Stationeers versions (as far as we know) - Leavy empty unless you know what this parameter does (and let us know if you do!)", "UIText_AutoSave": "Auto Save", "UIText_AutoSaveInfo": "Set to TRUE to enable automatic saving", "UIText_SaveInterval": "Save Interval", @@ -71,7 +77,7 @@ "advanced": { "UIText_AdvancedConfiguration": "Advanced Configuration", "UIText_ServerAuthSecret": "Server Auth Secret", - "UIText_ServerAuthSecretInfo": "Authentication secret for the server (optional)", + "UIText_ServerAuthSecretInfo": "Authentication secret for the server. Needed to run console commands from the client (optional). SSUI also allows console commands from the WebUI and Discord.", "UIText_ServerExePath": "Server Executable Path", "UIText_ServerExePathInfo": "System path to server executable", "UIText_ServerExePathInfo2": "Not editable from the UI for security reasons, but you can edit it manually in the config.json file.", @@ -80,7 +86,7 @@ "UIText_AutoRestartServerTimer": "Scheduled Gameserver Restart", "UIText_AutoRestartServerTimerInfo": "

Timeframe in minutes or time format (e.g., 15:04 or 03:04PM) to schedule an automatic gameserver restart. 0 = disabled, 1440 = 24 hours, etc. You will see 'Attention, server is restarting in 30/20/10/5 seconds!' messages ingame before the restart.

", "UIText_GameBranch": "Game Branch", - "UIText_GameBranchInfo": "Branch of the game to use. When changed, requires to restart SSUI!", + "UIText_GameBranchInfo": "Branch of the game to use. When changed, requires to restart SSUI or press the Update Server button on the Main dashboard!", "UIText_AllowAutoGameServerUpdates": "Enable Auto Game Server Updates", "UIText_AllowAutoGameServerUpdatesInfo": "Allow the gameserver to automatically query for and update to the latest version. Attention: Restarts the server when a new version was found and installed. Will send multiple warning messages to the sever with SAY commands 60-10 seconds before the restart.", "UIText_AutoStartServerOnStartup": "Auto Start Server on Startup", @@ -226,7 +232,7 @@ "UIText_UPnPEnabled_SkipButton": "Skip", "UIText_LocalIPAddress_Title": "Stationeers Server UI", "UIText_LocalIPAddress_HeaderTitle": "Network (4/4)", - "UIText_LocalIPAddress_StepMessage": "Enter server's local IP address in format 0.0.0.0 (no CIDR notation)", + "UIText_LocalIPAddress_StepMessage": "Enter the IP address to bind to. Recommended to leave at 0.0.0.0 unless you really know what you're doing.", "UIText_LocalIPAddress_PrimaryPlaceholder": "0.0.0.0", "UIText_LocalIPAddress_PrimaryLabel": "Local IP Address", "UIText_LocalIPAddress_SubmitButton": "Save & Continue", @@ -257,7 +263,8 @@ "UIText_ChangeUser_PrimaryLabel": "Username to Add/Update", "UIText_ChangeUser_SecondaryLabel": "New Password", "UIText_ChangeUser_SecondaryPlaceholder": "Password", - "UIText_ChangeUser_SubmitButton": "Add/Update User" + "UIText_ChangeUser_SubmitButton": "Add/Update User", + "UIText_FinalizeSubmitButtonText": "Finalize Setup" } }, "BackendText": { diff --git a/UIMod/onboard_bundled/localization/sv-SE.json b/UIMod/onboard_bundled/localization/sv-SE.json index c54d5890..36177311 100644 --- a/UIMod/onboard_bundled/localization/sv-SE.json +++ b/UIMod/onboard_bundled/localization/sv-SE.json @@ -14,11 +14,17 @@ "UIText_API_Info": "API-slutpunktsreferens", "UIText_Copyright": "Upphovsrätt", "UIText_Copyright1": "Licensierad under", - "UIText_Copyright2": "Proprietär licens" + "UIText_Copyright2": "Proprietär licens", + "UIText_UpdateAvailable": "En ny version av SSUI är redo att installera och kommer att installeras nästa gång du startar applikationen.", + "UIText_UpdateLater": "Senare", + "UIText_UpdateNow": "Uppdatera nu", + "UIText_UpdateInstalling": "Uppdatering installeras, vänligen vänta...", + "UIText_UpdateFailed": "Uppdateringen misslyckades. Försök igen senare." }, "config": { "UIText_ServerConfig": "Konfiguration", "UIText_DiscordIntegration": "Discord-integration", + "UIText_SLPModIntegration": "Launchpad Mods", "UIText_DetectionManager": "Detektering", "UIText_ConfigurationWizard": "Konfigurationsguide", "UIText_PleaseSelectSection": "Välj en konfigurationssektion ovan", @@ -68,7 +74,7 @@ "UIText_UseSteamP2PInfo": "Aktivera Steam Peer-to-Peer-nätverk" }, "advanced": { - "UIText_AdvancedConfiguration": "Avancerad konfiguration", + "UIText_AdvancedConfiguration": "Avancerad funktioner", "UIText_ServerAuthSecret": "Serverautentiseringshemlighet", "UIText_ServerAuthSecretInfo": "Autentiseringshemlighet för servern (valfritt)", "UIText_ServerExePath": "Sökväg till serverprogram", @@ -217,7 +223,7 @@ "UIText_UPnPEnabled_SkipButton": "Hoppa över", "UIText_LocalIPAddress_Title": "Stationeers Server UI", "UIText_LocalIPAddress_HeaderTitle": "Nätverk (4/4)", - "UIText_LocalIPAddress_StepMessage": "Ange serverns lokala IP-adress i formatet 0.0.0.0 (ingen CIDR-notation)", + "UIText_LocalIPAddress_StepMessage": "Ange den IP-adress som servern ska lyssna på - I de flesta fall kan du lämna värdet på 0.0.0.0.", "UIText_LocalIPAddress_PrimaryPlaceholder": "0.0.0.0", "UIText_LocalIPAddress_PrimaryLabel": "Lokal IP-adress", "UIText_LocalIPAddress_SubmitButton": "Spara & fortsätt", @@ -263,7 +269,8 @@ "UIText_WorldID_SecondaryLabel": "Välj från rullgardinsmenyn nedan", "UIText_WorldID_SecondaryPlaceholder": "Välj en karta", "UIText_WorldID_SubmitButton": "Spara & fortsätt", - "UIText_WorldID_SkipButton": "Hoppa över" + "UIText_WorldID_SkipButton": "Hoppa över", + "UIText_FinalizeSubmitButtonText": "Slutför konfiguration" } }, "BackendText": { diff --git a/UIMod/onboard_bundled/twoboxform/twoboxform.html b/UIMod/onboard_bundled/twoboxform/twoboxform.html index 984c9c0f..54d2fde9 100644 --- a/UIMod/onboard_bundled/twoboxform/twoboxform.html +++ b/UIMod/onboard_bundled/twoboxform/twoboxform.html @@ -47,9 +47,8 @@

{{.HeaderTitle}}

-

Your server is ready to be launched!

- +
{{end}} @@ -119,8 +118,10 @@

{{.HeaderTitle}}

diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 5672510b..0d00d920 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -12,6 +12,7 @@ + @@ -29,16 +30,28 @@

{{.UIText_ServerConfig}}

-
-
- - -
@@ -46,19 +59,23 @@

{{.UIText_ServerConfig}}

-
- + {{if eq .IsNewTerrainAndSaveSystemTrueSelected "selected"}} - + {{end}} - - + +
@@ -88,7 +105,7 @@

{{.UIText_BasicServerSettings}}

{{.UIText_WorldIDInfo}}
- +
{{.UIText_BasicServerSettings}}
{{.UIText_ServerPasswordInfo}}
- +
{{.UIText_NetworkConfiguration}}
{{.UIText_LocalIpAddressInfo}}
+
@@ -204,27 +223,6 @@

{{.UIText_NetworkConfiguration}}

{{.UIText_AdvancedConfiguration}}

-
- - -
{{.UIText_AdminPasswordInfo}}
-
- -
- - -
{{.UIText_ServerExePathInfo}}
-
{{.UIText_ServerExePathInfo2}}
-
- -
- - -
{{.UIText_AdditionalParamsInfo}}
-
-
{{.UIText_AdvancedConfiguration}}
{{.UIText_AutoRestartServerTimerInfo}}
-
- - -
{{.UIText_GameBranchInfo}}
-
-
- +
{{.UIText_AllowAutoGameServerUpdatesInfo}}
@@ -274,7 +267,36 @@

{{.UIText_AdvancedConfiguration}}

{{.UIText_CreateGameServerLogFileInfo}}
- + +
+ + +
{{.UIText_GameBranchInfo}}
+
+ +
+ + +
{{.UIText_ServerExePathInfo}}
+
{{.UIText_ServerExePathInfo2}}
+
+ +
+ + +
{{.UIText_AdminPasswordInfo}}
+
+ +
+ + +
{{.UIText_AdditionalParamsInfo}}
+
+ + +
@@ -284,16 +306,16 @@

{{.UIText_TerrainSettingsHeader}}


-
+
{{.UIText_WorldIDInfo}}
-
+
- +
✅{{.UIText_DifficultyInfo}}
@@ -311,7 +333,8 @@

{{.UIText_TerrainSettingsHeader}}

✅{{.UIText_StartLocationInfo}}
- @@ -321,7 +344,7 @@

{{.UIText_TerrainSettingsHeader}}

- +
@@ -414,29 +437,130 @@

{{.UIText_DiscordIntegrationBenefits}}

  • {{.UIText_DiscordBenefit4}}
  • {{.UIText_DiscordBenefit5}}
  • -

    {{.UIText_DiscordSetupInstructions}} GitHub repository

    +

    {{.UIText_DiscordSetupInstructions}} GitHub + repository

    + + + +
    +
    + +
    +

    Custom Detection Manager

    +

    Custom detections allow you to create custom patterns for event detection. These patterns can be used to + detect specific events in the server log and send alerts to the "Status" channel in Discord and will be shown in the Events tab on the main dashboard.

    +

    To create a custom detection, you can either define a regex pattern or + alternatively a simple string match ("keyword") and a message that will be broadcasted to Discord and the Events log

    + +
    +
    +
    +
    +
    Type
    +
    Pattern
    +
    Message
    +
    Actions
    +
    +
    +
    No custom detections found. Add one to get started.
    +
    +
    +
    + +
    +
    +
    +
    + Change Detection Mode + + Keyword +
    + +
    + + +
    Text to match exactly (case-sensitive)
    +
    + +
    + + +
    Message to display when pattern is detected
    +
    + + + +
    + +
    +
    +
    +
    + +
    +
    +

    Creating Effective Detections

    +
    +
    +

    Keyword Detection:

    +

    For example, to detect the "Unsupported shader" message that unity logs when a shader + is not supported, you would use the following pattern:

    + Pattern: "Unsupported shader" + Message: "Unity detected an unsupported shader. This may cause unexpected behavior." +
    +
    +

    Regex Detection:

    +

    For example, to detect (fictional) the "Player (.+) has reached level (\d+)" message + that is logged when a player reaches a certain level in an elevator, you would use the + following pattern:

    + Pattern: "Player (.+) has reached level (\d+)" + Message: "Player {1} has reached level {2}" +

    The AI of your choise will be more than happy to help you create effective detections. + You can also use the Regex101 tool to + test your patterns.

    +
    +
    +

    For more information, visit the GitHub Wiki

    +
    + +
    +
    + diff --git a/UIMod/onboard_bundled/ui/index.html b/UIMod/onboard_bundled/ui/index.html index 71339142..19b65dd0 100644 --- a/UIMod/onboard_bundled/ui/index.html +++ b/UIMod/onboard_bundled/ui/index.html @@ -28,15 +28,43 @@

    + + + +
    +
    + +
    + +

    {{.UIText_UpdateAvailable}}

    + + +
    + + +
    + + +
    + ⏳ {{.UIText_UpdateInstalling}} +
    +
    + ✗ {{.UIText_UpdateFailed}} +
    +
    +
    +
    - + + title="Save GPU Power by disabling background Animations. Persistent until toggled off. Options: Focus (Default), Always, Disabled. If unsure, check developer tools -> Application -> Local Storage -> animationState"> +
    +

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

    @@ -63,22 +91,8 @@

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

    -
    + - [![UI Overview](media/SSUI_AD.gif)](https://jacksonthemaster.github.io/StationeersServerUI/) Manage your Stationeers server with style - Retro interface, modern capabilities.
    -### New Power Features - -- 🛠️ **Stationeers Server Command Manager Mod** - Execute server commands directly through the UI or API -- 🧩 **BepInEx Integration** - Automatic setup of the popular modding framework ## TL;DR - Get Started Fast -1. 📦 Download latest executable from [here](https://github.com/JacksonTheMaster/StationeersServerUI/releases) -2. 📁 Place in empty folder and run it on Linux or Windows (chmod +x on linux) -3. 🌐 Access UI at `https://<>:8443` -4. 📚 See [First-Time Setup](https://github.com/JacksonTheMaster/StationeersServerUI/wiki/First-Time-Setup) in the wiki -5. 📖 Read the [Wiki](https://github.com/JacksonTheMaster/StationeersServerUI/wiki) and follow the chained pages (links at bottom of page)! +📚 Visit the [Quick-Start-Guide](https://github.com/SteamServerUI/StationeersServerUI/wiki/Quick-Start-Guide) in the [Wiki](https://github.com/JacksonTheMaster/StationeersServerUI/wiki) +⛓️‍💥 Follow the chained pages (links at bottom of page)! +📖 Full Documentation is provided in the [Wiki](https://github.com/JacksonTheMaster/StationeersServerUI/wiki). ## What is This? @@ -67,6 +66,8 @@ A sleek, retro-themed web UI to manage your Stationeers dedicated server. No mor - 🛠️ **Command Manager** - Execute server commands directly from the UI or from Discord commands - 🧩 **Mod Support** - Support for BepInEx mods - 📦 **Docker Support** - Runs in Docker containers +- 🛠️ **Stationeers Server Command Manager** - Execute server commands directly through the UI, API, or Discord commands +- 🧩 **BepInEx Integration** - Automatic setup of the popular modding framework ## Detailed Documentation diff --git a/src/cli/runtimecommands.go b/src/cli/runtimecommands.go index 1e282b82..a9924f60 100644 --- a/src/cli/runtimecommands.go +++ b/src/cli/runtimecommands.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -163,6 +164,8 @@ func init() { 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") } func startServer() { @@ -221,6 +224,25 @@ func testLocalization() { logger.Core.Info("Start Server Button text (current language: " + currentLanguageSetting + "): " + s) } +func triggerUpdateCheck() { + err, newVersion := update.Update(false) + if err != nil { + logger.Install.Warn("⚠️ Update check failed: " + err.Error()) + return + } + if newVersion != "" { + logger.Install.Infof("✅ Update to %s available, Trigger update from WebUI or with applyupdate command", newVersion) + } +} + +func applyUpdate() { + err, _ := update.Update(true) + if err != nil { + logger.Install.Warn("⚠️ Update failed: " + err.Error()) + return + } +} + func supportMode() { if isSupportMode { diff --git a/src/config/config.go b/src/config/config.go index 57d23568..7d2fb51d 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.9.1" + Version = "5.10.0" Branch = "release" ) diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index ca2c3810..8d150485 100644 --- a/src/core/loader/loader.go +++ b/src/core/loader/loader.go @@ -29,6 +29,7 @@ func InitBackend() { ReloadDiscordBot() InitDetector() StartIsGameServerRunningCheck() + StartUpdateCheckLoop() LoadAdvertiser() } @@ -107,6 +108,12 @@ func LoadAdvertiser() { } } +func StartUpdateCheckLoop() { + if config.GetIsUpdateEnabled() { + go update.StartUpdateCheckLoop() + } +} + // InitBundler initialized the onboard bundled assets for the web UI func InitVirtFS(v1uiFS embed.FS) { config.SetV1UIFS(v1uiFS) diff --git a/src/managers/detectionmgr/detector.go b/src/managers/detectionmgr/detector.go index 24e8c6df..544b4f72 100644 --- a/src/managers/detectionmgr/detector.go +++ b/src/managers/detectionmgr/detector.go @@ -171,19 +171,14 @@ func (d *Detector) processRegexPatterns(logMessage string) { }, }, { - // World saved pattern - pattern: regexp.MustCompile(`World Saved:\s.*,\sBackupIndex:\s(\d+)`), + // World saved pattern (simpy detects "Saving - file created and zipped in" since preterrain detection using "World Saved:\s.*,\sBackupIndex:\s(\d+)" is no longer possible) + pattern: regexp.MustCompile(`Saving\s*-\s*file created and zipped in`), handler: func(matches []string, logMessage string) { - backupIndex := matches[1] - d.triggerEvent(Event{ Type: EventWorldSaved, Message: "World saved", RawLog: logMessage, - Timestamp: time.Now().Format(time.RFC3339), - BackupInfo: &BackupInfo{ - BackupIndex: backupIndex, - }, + Timestamp: time.Now().Format("Jan 02 15:04"), }) }, }, diff --git a/src/managers/detectionmgr/handlers.go b/src/managers/detectionmgr/handlers.go index d8ff75f3..d2da296f 100644 --- a/src/managers/detectionmgr/handlers.go +++ b/src/managers/detectionmgr/handlers.go @@ -19,6 +19,8 @@ Event Handler Subsystem - SSE stream for web UI */ +var lastWorldSavedTime time.Time // zero value means never saved + // DefaultHandlers returns a map of event types to default handlers func DefaultHandlers() map[EventType]Handler { return map[EventType]Handler{ @@ -106,14 +108,23 @@ func DefaultHandlers() map[EventType]Handler { } }, EventWorldSaved: func(event Event) { - if event.BackupInfo != nil { - timeStr := time.Now().UTC().Format(time.RFC3339) - message := fmt.Sprintf("🎮 [Gameserver] 💾 World Saved: BackupIndex: %s UTC Time: %s", - event.BackupInfo.BackupIndex, timeStr) - logger.Detection.Info(message) - ssestream.BroadcastDetectionEvent(message) - discordbot.SendMessageToSavesChannel(message) + const debounceDuration = 15 * time.Second // since SSCM triggers a HEAD save after an autosave is detected by the Backup Manager, we debounce save messages here to prevent spamming and user confusion. + + now := time.Now() + + // Check if we handled a world save recently + if now.Sub(lastWorldSavedTime) < debounceDuration { + return } + + lastWorldSavedTime = now + + timeStr := event.Timestamp + message := fmt.Sprintf("🎮 [Gameserver] 💾 World Saved: ServerTime: %s", timeStr) + + logger.Detection.Info(message) + ssestream.BroadcastDetectionEvent(message) + discordbot.SendMessageToSavesChannel(message) }, EventException: func(event Event) { // Initial alert message diff --git a/src/managers/detectionmgr/types.go b/src/managers/detectionmgr/types.go index efa327d2..020d8efa 100644 --- a/src/managers/detectionmgr/types.go +++ b/src/managers/detectionmgr/types.go @@ -44,7 +44,6 @@ type Event struct { RawLog string Timestamp string PlayerInfo *PlayerInfo - BackupInfo *BackupInfo ExceptionInfo *ExceptionInfo } @@ -54,11 +53,6 @@ type PlayerInfo struct { SteamID string } -// BackupInfo contains information about a world save/backup -type BackupInfo struct { - BackupIndex string -} - // ExceptionInfo contains information about a server exception type ExceptionInfo struct { StackTrace string diff --git a/src/setup/install.go b/src/setup/install.go index 99e1d82d..5343f60c 100644 --- a/src/setup/install.go +++ b/src/setup/install.go @@ -28,7 +28,7 @@ func Install(wg *sync.WaitGroup) { defer wg.Done() // Signal that installation is complete // Step 0: Check for updates - if err := update.UpdateExecutable(); err != nil { + if err, _ := update.Update(true); err != nil { logger.Install.Error("❌Update check went sideways: " + err.Error()) } diff --git a/src/setup/update/progressbar.go b/src/setup/update/progressbar.go new file mode 100644 index 00000000..07a362c0 --- /dev/null +++ b/src/setup/update/progressbar.go @@ -0,0 +1,72 @@ +package update + +import ( + "fmt" + "strconv" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// writeCounter tracks download progress +type writeCounter struct { + Total int64 + count int64 +} + +func (wc *writeCounter) Write(p []byte) (int, error) { + n := len(p) + wc.count += int64(n) + wc.printProgress() + return n, nil +} + +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))) + return + } + + // Calculate percentage with bounds checking + percent := float64(wc.count) / float64(wc.Total) * 100 + if percent > 100 { + percent = 100 + } + + // Create simple progress bar + width := 20 + complete := int(percent / 100 * float64(width)) + + progressBar := "[" + for i := 0; i < width; i++ { + if i < complete { + progressBar += "=" + } else if i == complete && complete < width { + progressBar += ">" + } else { + progressBar += " " + } + } + progressBar += "]" + + // Print progress and erase to end of line + logger.Backup.Info(fmt.Sprintf("\r%s %.1f%% (%s/%s)", + progressBar, + percent, + bytesToHuman(wc.count), + bytesToHuman(wc.Total))) +} + +// bytesToHuman converts bytes to human readable format +func bytesToHuman(bytes int64) string { + const unit = 1024 + if bytes < unit { + return strconv.FormatInt(bytes, 10) + " B" + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/src/setup/update/runandexit.go b/src/setup/update/runandexit.go new file mode 100644 index 00000000..51f9d88c --- /dev/null +++ b/src/setup/update/runandexit.go @@ -0,0 +1,83 @@ +package update + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// runAndExit launches the new executable and terminates the current process +func runAndExit(newExe string) error { + // Resolve absolute path + absPath, err := filepath.Abs(newExe) + if err != nil { + return fmt.Errorf("❌ Couldn’t resolve path to %s: %v", newExe, err) + } + + // Prepare the new process + cmd := exec.Command(absPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Set SysProcAttr based on OS using the OS-specific implementation + setSysProcAttr(cmd) + + // Start the new process + if err := cmd.Start(); err != nil { + return fmt.Errorf("❌ Failed to start new executable: %v", err) + } + + // Exit gracefully + logger.Install.Warn("✨ New version’s live! Catch you on the flip side!") + time.Sleep(500 * time.Millisecond) // Dramatic pause + os.Exit(0) + return nil +} + +func runAndExitLinux(newExe string) error { + absPath, err := filepath.Abs(newExe) + if err != nil { + return fmt.Errorf("❌ Couldn’t resolve path to %s: %v", newExe, err) + } + + // Use syscall.Exec to replace the current process + logger.Install.Warn("✨ New version’s live! Catch you on the flip side!") + time.Sleep(500 * time.Millisecond) + + // Replace the current process with the new executable + err = syscall.Exec(absPath, []string{absPath}, os.Environ()) + if err != nil { + return fmt.Errorf("❌ Failed to exec new executable: %v", err) + } + + // This line is never reached if Exec succeeds + return nil +} + +func RestartMySelf() { + currentExe, err := os.Executable() + if err != nil { + logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t get current executable path: %v. Keeping version %s.", err, config.GetVersion())) + return + } + + if runtime.GOOS == "windows" { + if err := runAndExit(currentExe); err != nil { + logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.GetVersion())) + return + } + } + if runtime.GOOS == "linux" { + if err := runAndExitLinux(currentExe); err != nil { + logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.GetVersion())) + return + } + } +} diff --git a/src/setup/update/update-hepers.go b/src/setup/update/update-hepers.go new file mode 100644 index 00000000..d38742c1 --- /dev/null +++ b/src/setup/update/update-hepers.go @@ -0,0 +1,142 @@ +package update + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// parseVersion parses a version string (e.g., "4.6.10") into a Version struct and tries to handle a few culprits too +func parseVersion(v string) (Version, error) { + v = strings.TrimPrefix(v, "v") + if idx := strings.Index(v, "-"); idx != -1 { + v = v[:idx] + } + + var ver Version + _, err := fmt.Sscanf(v, "%d.%d.%d", &ver.Major, &ver.Minor, &ver.Patch) + if err != nil { + return Version{}, fmt.Errorf("no valid X.Y.Z in tag: %s", v) + } + return ver, nil +} + +// shouldUpdate determines if an update should proceed, returning reason if not +func shouldUpdate(current, latest Version, isInUpdateableState bool) (string, bool) { + // Check if already up-to-date or older + if latest.Major < current.Major || + (latest.Major == current.Major && latest.Minor < current.Minor) || + (latest.Major == current.Major && latest.Minor == current.Minor && latest.Patch <= current.Patch) { + return "up-to-date", false + } + + // Check if it’s a major update and not allowed + if current.Major != latest.Major && !config.GetAllowMajorUpdates() { + return "major-update", false + } + + if !isInUpdateableState { + return "not-in-updateable-state", false + } + + return "", true +} + +// getLatestRelease fetches the most recent release (or prerelease) from GitHub API +func getLatestRelease() (*githubRelease, error) { + url := "https://api.github.com/repos/JacksonTheMaster/StationeersServerUI/releases" + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad response from GitHub API: %s", resp.Status) + } + + var releases []githubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("failed to parse GitHub API response: %v", err) + } + + if len(releases) == 0 { + return nil, fmt.Errorf("no releases found") + } + + // Find the most recent release + var latestRelease *githubRelease + var latestVersion Version + for i, release := range releases { + version, err := parseVersion(release.TagName) + if err != nil { + logger.Install.Warn(fmt.Sprintf("Skipping invalid version tag %s: %v", release.TagName, err)) + continue + } + if i == 0 || isReleaseNewerVersion(version, latestVersion) { + currentVersion, err := parseVersion(config.GetVersion()) + if err == nil && isReleaseNewerVersion(currentVersion, version) { + if release.Prerelease { + logger.Install.Warn("Found a prerelease, but it is older than the running version. Skipping...") + continue + } + continue + } + latestVersion = version + latestRelease = &releases[i] + } + } + + if latestRelease == nil { + return nil, fmt.Errorf("no suitable releases found") + } + + // Log warning if the latest release is a prerelease + if latestRelease.Prerelease && !config.GetAllowPrereleaseUpdates() { + logger.Install.Warn(fmt.Sprintf("⚠️ Pre-release Update found: Latest version %s is a pre-release. Enable 'AllowPrereleaseUpdates' in config.json to update to it.", latestRelease.TagName)) + } + + // If prerelease and AllowPrereleaseUpdates is false, find the latest stable release + if latestRelease.Prerelease && !config.GetAllowPrereleaseUpdates() { + var stableRelease *githubRelease + var stableVersion Version + for i, release := range releases { + if release.Prerelease { + continue + } + version, err := parseVersion(release.TagName) + if err != nil { + logger.Install.Warn(fmt.Sprintf("Skipping invalid version tag %s: %v", release.TagName, err)) + continue + } + if i == 0 || isReleaseNewerVersion(version, stableVersion) { + stableVersion = version + stableRelease = &releases[i] + } + } + if stableRelease == nil { + return nil, fmt.Errorf("no stable releases found") + } + return stableRelease, nil + } + + return latestRelease, nil +} + +// isNewerVersion compares two versions to determine if the first is newer +func isReleaseNewerVersion(v1, v2 Version) bool { + if v1.Major != v2.Major { + return v1.Major > v2.Major + } + if v1.Minor != v2.Minor { + return v1.Minor > v2.Minor + } + if v1.Patch == v2.Patch { + return false + } + return v1.Patch > v2.Patch +} diff --git a/src/setup/update/updatecheckloop.go b/src/setup/update/updatecheckloop.go new file mode 100644 index 00000000..16ce93c5 --- /dev/null +++ b/src/setup/update/updatecheckloop.go @@ -0,0 +1,44 @@ +package update + +import ( + "sync" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// UpdateInfo holds the shared state about available updates +var UpdateInfo struct { + sync.RWMutex + + Available bool // true if an update is available + Version string // the available version string +} + +func init() { + UpdateInfo.Available = false + UpdateInfo.Version = "" +} + +// StartUpdateCheckLoop runs in the background and checks for updates every 6 hours +func StartUpdateCheckLoop() { + for { + // Acquire write lock for the duration of the check + UpdateInfo.Lock() + + err, newVersion := Update(false) + if err != nil { + logger.Install.Warn("⚠️ Automatic SSUI Update check failed: " + err.Error()) + } else if newVersion != "" { + UpdateInfo.Available = true + UpdateInfo.Version = newVersion + } else { + UpdateInfo.Available = false + UpdateInfo.Version = "" + } + + UpdateInfo.Unlock() + + time.Sleep(6 * time.Hour) + } +} diff --git a/src/setup/update/updater.go b/src/setup/update/updater.go index f5ef8b7c..f5d6a826 100644 --- a/src/setup/update/updater.go +++ b/src/setup/update/updater.go @@ -1,18 +1,11 @@ package update import ( - "encoding/json" "fmt" "io" "net/http" "os" - "os/exec" - "path/filepath" "runtime" - "strconv" - "strings" - "syscall" - "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" @@ -35,23 +28,16 @@ type Version struct { Patch int } -// UpdateExecutable checks for and applies the latest release from GitHub -func UpdateExecutable() error { +// CheckForUpdates checks for the latest release from GitHub +func Update(isInUpdateableState bool) (err error, newVersion string) { if !config.GetIsUpdateEnabled() { logger.Install.Warn("⚠️ Update check is disabled. Skipping update check. Change 'IsUpdateEnabled' in config.json to true to re-enable update checks.") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 3 seconds...") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 2 seconds...") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 1 seconds...") - time.Sleep(1000 * time.Millisecond) - return nil + return nil, "" } if config.GetBranch() != "release" { logger.Install.Warn("⚠️ You are running a development build. Skipping update check.") - return nil + return nil, "" } if config.GetAllowPrereleaseUpdates() { @@ -61,38 +47,35 @@ func UpdateExecutable() error { } latestRelease, err := getLatestRelease() if err != nil { - return fmt.Errorf("❌ Failed to fetch latest release: %v", err) + return fmt.Errorf("❌ Failed to fetch latest release: %v", err), "" } // Parse current and latest versions currentVer, err := parseVersion(config.GetVersion()) if err != nil { - return fmt.Errorf("❌ Failed to parse current version %s: %v", config.GetVersion(), err) + return fmt.Errorf("❌ Failed to parse current version %s: %v", config.GetVersion(), err), "" } latestVer, err := parseVersion(latestRelease.TagName) if err != nil { - return fmt.Errorf("❌ Failed to parse latest version %s: %v", latestRelease.TagName, err) + return fmt.Errorf("❌ Failed to parse latest version %s: %v", latestRelease.TagName, err), "" } - logger.Install.Info(fmt.Sprintf("Current version: %s, Latest version: %s", config.GetVersion(), latestRelease.TagName)) + logger.Install.Debug(fmt.Sprintf("Current version: %s, Latest version: %s", config.GetVersion(), latestRelease.TagName)) // Check if we should update - updateReason, shouldUpdate := shouldUpdate(currentVer, latestVer) + updateReason, shouldUpdate := shouldUpdate(currentVer, latestVer, isInUpdateableState) if !shouldUpdate { switch updateReason { case "up-to-date": logger.Install.Info("🎉 No update needed: you’re already on the latest version.") case "major-update": logger.Install.Warn(fmt.Sprintf("⚠️ Update found: Latest version %s is a major update from %s. Major Updates include Breaking changes in this project. Read the release notes and backup your Server folder before updating. Enable 'AllowMajorUpdates' in config to proceed.", latestRelease.TagName, config.Version)) - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 3 seconds...") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 2 seconds...") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 1 seconds...") - time.Sleep(1000 * time.Millisecond) + return nil, latestRelease.TagName + case "not-in-updateable-state": + logger.Install.Debug("⚠️ Update found but SSUI is not in an updatable state.") + return nil, latestRelease.TagName } - return nil + return nil, "" } // Proceed with update @@ -111,21 +94,21 @@ func UpdateExecutable() error { } } if downloadURL == "" { - return fmt.Errorf("❌ No matching asset found for %s", expectedExe) + return fmt.Errorf("❌ No matching asset found for %s", expectedExe), latestRelease.TagName } // Download and replace logger.Install.Info(fmt.Sprintf("📡 Updating from %s to %s...", config.GetVersion(), latestRelease.TagName)) if err := downloadNewExecutable(expectedExe, downloadURL); err != nil { logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: %v. Keeping version %s.", err, config.GetVersion())) - return err + return err, "" } // Set executable permissions on Linux if runtime.GOOS != "windows" { if err := os.Chmod(expectedExe, 0755); err != nil { logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t make %s executable: %v. Keeping version %s.", expectedExe, err, config.GetVersion())) - return err + return err, "" } } @@ -134,172 +117,17 @@ func UpdateExecutable() error { if runtime.GOOS == "windows" { if err := runAndExit(expectedExe); err != nil { logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t launch %s: %v. Keeping version %s.", expectedExe, err, config.GetVersion())) - return err + return err, "" } } if runtime.GOOS == "linux" { if err := runAndExitLinux(expectedExe); err != nil { logger.Install.Warn(fmt.Sprintf("⚠️ Update failed: couldn’t launch %s: %v. Keeping version %s.", expectedExe, err, config.GetVersion())) - return err + return err, "" } } - return nil -} - -func RestartMySelf() { - currentExe, err := os.Executable() - if err != nil { - logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t get current executable path: %v. Keeping version %s.", err, config.GetVersion())) - return - } - - if runtime.GOOS == "windows" { - if err := runAndExit(currentExe); err != nil { - logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.GetVersion())) - return - } - } - if runtime.GOOS == "linux" { - if err := runAndExitLinux(currentExe); err != nil { - logger.Install.Warn(fmt.Sprintf("⚠️ Restart failed: couldn’t launch %s: %v. Keeping version %s.", currentExe, err, config.GetVersion())) - return - } - } -} - -// parseVersion parses a version string (e.g., "4.6.10") into a Version struct and tries to handle a few culprits too -func parseVersion(v string) (Version, error) { - v = strings.TrimPrefix(v, "v") - if idx := strings.Index(v, "-"); idx != -1 { - v = v[:idx] - } - - var ver Version - _, err := fmt.Sscanf(v, "%d.%d.%d", &ver.Major, &ver.Minor, &ver.Patch) - if err != nil { - return Version{}, fmt.Errorf("no valid X.Y.Z in tag: %s", v) - } - return ver, nil -} - -// shouldUpdate determines if an update should proceed, returning reason if not -func shouldUpdate(current, latest Version) (string, bool) { - // Check if already up-to-date or older - if latest.Major < current.Major || - (latest.Major == current.Major && latest.Minor < current.Minor) || - (latest.Major == current.Major && latest.Minor == current.Minor && latest.Patch <= current.Patch) { - return "up-to-date", false - } - - // Check if it’s a major update and not allowed - if current.Major != latest.Major && !config.GetAllowMajorUpdates() { - return "major-update", false - } - - return "", true -} - -// getLatestRelease fetches the most recent release (or prerelease) from GitHub API -func getLatestRelease() (*githubRelease, error) { - url := "https://api.github.com/repos/JacksonTheMaster/StationeersServerUI/releases" - resp, err := http.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad response from GitHub API: %s", resp.Status) - } - - var releases []githubRelease - if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { - return nil, fmt.Errorf("failed to parse GitHub API response: %v", err) - } - - if len(releases) == 0 { - return nil, fmt.Errorf("no releases found") - } - - // Find the most recent release - var latestRelease *githubRelease - var latestVersion Version - for i, release := range releases { - version, err := parseVersion(release.TagName) - if err != nil { - logger.Install.Warn(fmt.Sprintf("Skipping invalid version tag %s: %v", release.TagName, err)) - continue - } - if i == 0 || isReleaseNewerVersion(version, latestVersion) { - currentVersion, err := parseVersion(config.GetVersion()) - if err == nil && isReleaseNewerVersion(currentVersion, version) { - if release.Prerelease { - logger.Install.Warn("Found a prerelease, but it is older than the running version. Skipping...") - continue - } - continue - } - latestVersion = version - latestRelease = &releases[i] - } - } - - if latestRelease == nil { - return nil, fmt.Errorf("no suitable releases found") - } - - // Log warning if the latest release is a prerelease - if latestRelease.Prerelease && !config.GetAllowPrereleaseUpdates() { - logger.Install.Warn(fmt.Sprintf("⚠️ Pre-release Update found: Latest version %s is a pre-release. Enable 'AllowPrereleaseUpdates' in config.json to update to it.", latestRelease.TagName)) - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 3 seconds...") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 2 seconds...") - time.Sleep(1000 * time.Millisecond) - logger.Install.Info("⚠️ Continuing in 1 seconds...") - time.Sleep(1000 * time.Millisecond) - } - - // If prerelease and AllowPrereleaseUpdates is false, find the latest stable release - if latestRelease.Prerelease && !config.GetAllowPrereleaseUpdates() { - var stableRelease *githubRelease - var stableVersion Version - for i, release := range releases { - if release.Prerelease { - continue - } - version, err := parseVersion(release.TagName) - if err != nil { - logger.Install.Warn(fmt.Sprintf("Skipping invalid version tag %s: %v", release.TagName, err)) - continue - } - if i == 0 || isReleaseNewerVersion(version, stableVersion) { - stableVersion = version - stableRelease = &releases[i] - } - } - if stableRelease == nil { - return nil, fmt.Errorf("no stable releases found") - } - return stableRelease, nil - } - - return latestRelease, nil -} - -// isNewerVersion compares two versions to determine if the first is newer -func isReleaseNewerVersion(v1, v2 Version) bool { - if v1.Major != v2.Major { - return v1.Major > v2.Major - } - if v1.Minor != v2.Minor { - return v1.Minor > v2.Minor - } - if v1.Patch == v2.Patch { - return false - } - return v1.Patch > v2.Patch + return nil, "" } // downloadNewExecutable downloads the new executable with a progress bar @@ -346,115 +174,3 @@ func downloadNewExecutable(filename, url string) error { logger.Install.Info("✅ Downloaded " + filename) return nil } - -// runAndExit launches the new executable and terminates the current process -func runAndExit(newExe string) error { - // Resolve absolute path - absPath, err := filepath.Abs(newExe) - if err != nil { - return fmt.Errorf("❌ Couldn’t resolve path to %s: %v", newExe, err) - } - - // Prepare the new process - cmd := exec.Command(absPath) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Set SysProcAttr based on OS using the OS-specific implementation - setSysProcAttr(cmd) - - // Start the new process - if err := cmd.Start(); err != nil { - return fmt.Errorf("❌ Failed to start new executable: %v", err) - } - - // Exit gracefully - logger.Install.Warn("✨ New version’s live! Catch you on the flip side!") - time.Sleep(500 * time.Millisecond) // Dramatic pause - os.Exit(0) - return nil -} - -func runAndExitLinux(newExe string) error { - absPath, err := filepath.Abs(newExe) - if err != nil { - return fmt.Errorf("❌ Couldn’t resolve path to %s: %v", newExe, err) - } - - // Use syscall.Exec to replace the current process - logger.Install.Warn("✨ New version’s live! Catch you on the flip side!") - time.Sleep(500 * time.Millisecond) - - // Replace the current process with the new executable - err = syscall.Exec(absPath, []string{absPath}, os.Environ()) - if err != nil { - return fmt.Errorf("❌ Failed to exec new executable: %v", err) - } - - // This line is never reached if Exec succeeds - return nil -} - -// writeCounter tracks download progress -type writeCounter struct { - Total int64 - count int64 -} - -func (wc *writeCounter) Write(p []byte) (int, error) { - n := len(p) - wc.count += int64(n) - wc.printProgress() - return n, nil -} - -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))) - return - } - - // Calculate percentage with bounds checking - percent := float64(wc.count) / float64(wc.Total) * 100 - if percent > 100 { - percent = 100 - } - - // Create simple progress bar - width := 20 - complete := int(percent / 100 * float64(width)) - - progressBar := "[" - for i := 0; i < width; i++ { - if i < complete { - progressBar += "=" - } else if i == complete && complete < width { - progressBar += ">" - } else { - progressBar += " " - } - } - progressBar += "]" - - // Print progress and erase to end of line - logger.Backup.Info(fmt.Sprintf("\r%s %.1f%% (%s/%s)", - progressBar, - percent, - bytesToHuman(wc.count), - bytesToHuman(wc.Total))) -} - -// bytesToHuman converts bytes to human readable format -func bytesToHuman(bytes int64) string { - const unit = 1024 - if bytes < unit { - return strconv.FormatInt(bytes, 10) + " B" - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} diff --git a/src/steamcmd/steamcmd.go b/src/steamcmd/steamcmd.go index 7312e714..8554657d 100644 --- a/src/steamcmd/steamcmd.go +++ b/src/steamcmd/steamcmd.go @@ -74,7 +74,7 @@ func runSteamCMD(steamCMDDir string) (int, error) { } else { // Another goroutine holds the lock; log and wait. logger.Core.Warn("🔄 SteamMu is currently locked, waiting for it to be unlocked and then continuing...") - steamMu.Lock() // Block until steamMu becomes available, then snack it and lock it again + steamMu.Lock() // Block until steamMu becomes available, then snag it and lock it again logger.Core.Debug("🔄 Locking SteamMu for SteamCMD execution..") } defer steamMu.Unlock() @@ -94,16 +94,16 @@ func runSteamCMD(steamCMDDir string) (int, error) { } } - // Build SteamCMD command + // Build the initial SteamCMD command cmd := buildSteamCMDCommand(steamCMDDir, currentDir) // Set output to stdout and stderr cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + // Apply Linux-specific HOME environment variable override if runtime.GOOS == "linux" { env := os.Environ() - // Replace or set HOME newEnv := make([]string, 0, len(env)+1) foundHome := false for _, e := range env { @@ -120,24 +120,84 @@ func runSteamCMD(steamCMDDir string) (int, error) { cmd.Env = newEnv } - // Run the command if config.GetLogLevel() == 10 { cmdString := strings.Join(cmd.Args, " ") logger.Install.Info("🕑 Running SteamCMD: " + cmdString) } else { logger.Install.Info("🕑 Running SteamCMD...") } - err = cmd.Run() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - logger.Install.Error("❌ SteamCMD exited unsuccessfully: " + err.Error() + "\n") - return exitErr.ExitCode(), err + + // Retry loop: maximum 2 attempts, with retry only on exit status 8 + var exitCode int = -1 + var runErr error + + for attempt := 1; attempt <= 2; attempt++ { + runErr = cmd.Run() + + if runErr == nil { + // Success! + logger.Install.Info("✅ SteamCMD executed successfully.\n") + return 0, nil } - logger.Install.Error("❌ Error running SteamCMD: " + err.Error() + "\n") - return -1, err + + // Check if it's an ExitError so we can inspect the code + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + logger.Install.Error("❌ SteamCMD exited unsuccessfully: " + runErr.Error() + "\n") + + if exitCode == 8 && attempt == 1 { + logger.Install.Warn("⚠️ SteamCMD failed with exit status 8 on first attempt. Retrying once...") + // Rebuild a fresh command for the retry + cmd = buildSteamCMDCommand(steamCMDDir, currentDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Re-apply Linux env modifications + 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 + } + + // Log the retry + if config.GetLogLevel() == 10 { + cmdString := strings.Join(cmd.Args, " ") + logger.Install.Info("🕑 Retrying SteamCMD: " + cmdString) + } else { + logger.Install.Info("🕑 Retrying SteamCMD...") + } + + continue // Go to next attempt + } + + // If we get here: either not exit 8, or it was exit 8 on the second attempt + if exitCode == 8 { + logger.Install.Error(" ⚠️ Exit status 8 persisted after retry. Please restart SSUI and try again. If the issue persists, feel free to ask for help on the SSUI Discord server or GitHub issues page.") + } + } else { + // Not an ExitError (e.g., command not found, permission denied, etc.) + logger.Install.Error("❌ Error running SteamCMD: " + runErr.Error() + "\n") + exitCode = -1 + } + + // If we reach here, the command failed and we're not retrying + break } - logger.Install.Info("✅ SteamCMD executed successfully.\n") - return 0, nil + + // Final return after failure (with or without retry) + return exitCode, runErr } // buildSteamCMDCommand constructs the SteamCMD command based on the OS. diff --git a/src/web/TwoBoxForm.go b/src/web/TwoBoxForm.go index c5b88773..17bfe149 100644 --- a/src/web/TwoBoxForm.go +++ b/src/web/TwoBoxForm.go @@ -50,6 +50,7 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) { ShowExtraButtons bool FooterText string FooterTextInfo string + FinalizeSubmitButtonText string Step string ConfigField string NextStep string @@ -316,11 +317,12 @@ func ServeTwoBoxFormTemplate(w http.ResponseWriter, r *http.Request) { } data := TemplateData{ - IsFirstTimeSetup: config.GetIsFirstTimeSetup(), - Path: path, - Step: stepID, - FooterText: localization.GetString("UIText_FooterText"), - FooterTextInfo: localization.GetString("UIText_FooterTextInfo"), + IsFirstTimeSetup: config.GetIsFirstTimeSetup(), + Path: path, + Step: stepID, + FooterText: localization.GetString("UIText_FooterText"), + FooterTextInfo: localization.GetString("UIText_FooterTextInfo"), + FinalizeSubmitButtonText: localization.GetString("UIText_FinalizeSubmitButtonText"), } switch { diff --git a/src/web/configpage.go b/src/web/configpage.go index a03598fb..839f3fdd 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -189,6 +189,7 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { // Localized UI text UIText_ServerConfig: localization.GetString("UIText_ServerConfig"), UIText_DiscordIntegration: localization.GetString("UIText_DiscordIntegration"), + UIText_SLPModIntegration: localization.GetString("UIText_SLPModIntegration"), UIText_DetectionManager: localization.GetString("UIText_DetectionManager"), UIText_ConfigurationWizard: localization.GetString("UIText_ConfigurationWizard"), UIText_PleaseSelectSection: localization.GetString("UIText_PleaseSelectSection"), diff --git a/src/web/indexpage.go b/src/web/indexpage.go index 81d72180..f702d8c6 100644 --- a/src/web/indexpage.go +++ b/src/web/indexpage.go @@ -33,6 +33,11 @@ func ServeIndex(w http.ResponseWriter, r *http.Request) { } data := IndexTemplateData{ + UIText_UpdateAvailable: localization.GetString("UIText_UpdateAvailable"), + UIText_UpdateLater: localization.GetString("UIText_UpdateLater"), + UIText_UpdateNow: localization.GetString("UIText_UpdateNow"), + UIText_UpdateInstalling: localization.GetString("UIText_UpdateInstalling"), + UIText_UpdateFailed: localization.GetString("UIText_UpdateFailed"), Version: config.GetVersion(), Branch: config.GetBranch(), SSUIIdentifier: Identifier, diff --git a/src/web/routes.go b/src/web/routes.go index 394858ce..f3e6b3cd 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -80,6 +80,10 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { protectedMux.HandleFunc("/api/v2/auth/setup/apikey", RegisterAPIKeyHandler) // API Key registration protectedMux.HandleFunc("/api/v2/auth/setup/finalize", SetupFinalizeHandler) + // Update + protectedMux.HandleFunc("/api/v2/update/trigger", TriggerUpdateHandler) + protectedMux.HandleFunc("/api/v2/update/check", CheckUpdateHandler) + // Monitoring protectedMux.HandleFunc("/api/v2/monitor/gameserver/status", HandleMonitorStatus) diff --git a/src/web/templatevars.go b/src/web/templatevars.go index ad5d5a87..c61e94b8 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -2,6 +2,11 @@ package web // TemplateData holds data to be passed to templates type IndexTemplateData struct { + UIText_UpdateAvailable string + UIText_UpdateLater string + UIText_UpdateNow string + UIText_UpdateInstalling string + UIText_UpdateFailed string Version string Branch string SSUIIdentifier string @@ -89,6 +94,7 @@ type ConfigTemplateData struct { UIText_ServerConfig string UIText_DiscordIntegration string + UIText_SLPModIntegration string UIText_DetectionManager string UIText_ConfigurationWizard string UIText_PleaseSelectSection string diff --git a/src/web/update.go b/src/web/update.go new file mode 100644 index 00000000..e626e74f --- /dev/null +++ b/src/web/update.go @@ -0,0 +1,131 @@ +package web + +import ( + "encoding/json" + "net/http" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" +) + +type UpdateTriggerRequest struct { + AllowUpdate bool `json:"allowUpdate"` +} + +func CheckUpdateHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + update.UpdateInfo.RLock() + if update.UpdateInfo.Available { + version := update.UpdateInfo.Version + update.UpdateInfo.RUnlock() + + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "updateAvailable": "true", + "version": version, + "message": "Update to " + version + " available", + }) + return + } + update.UpdateInfo.RUnlock() + // no update available + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "updateAvailable": "false", + "message": "No update available", + }) +} + +func TriggerUpdateHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != http.MethodPost { + http.Error(w, `{"status":"error","message":"Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + applyUpdate := false + if r.Body != http.NoBody { + var req UpdateTriggerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + applyUpdate = req.AllowUpdate + } + } + + // Prevent concurrent runs + if !update.UpdateInfo.TryLock() { + json.NewEncoder(w).Encode(map[string]string{ + "status": "busy", + "message": "An update operation is already in progress — please try again later", + }) + return + } + + // If we're applying the update, do it in background (may restart process) + if applyUpdate { + go func() { + defer update.UpdateInfo.Unlock() + + err, newVersion := update.Update(true) + if err != nil { + logger.Install.Error("Manual update (apply) failed: " + err.Error()) + } else { + logger.Install.Info("Manual update (apply) completed successfully") + // Note: if it replaced the binary and restarts, we won't get here reliably + } + + // Still try to update state if we're still running + if newVersion != "" { + update.UpdateInfo.Available = true + update.UpdateInfo.Version = newVersion + } else { + update.UpdateInfo.Available = false + update.UpdateInfo.Version = "" + } + }() + + json.NewEncoder(w).Encode(map[string]string{ + "status": "running", + "updateAvailable": "true", + "message": "Update started — applying in background. Check logs for progress.", + }) + return + } + + // --- Check-only mode: do synchronously --- + err, newVersion := update.Update(false) + + if err == nil && newVersion != "" { + update.UpdateInfo.Available = true + update.UpdateInfo.Version = newVersion + } else { + update.UpdateInfo.Available = false + update.UpdateInfo.Version = "" + } + + update.UpdateInfo.Unlock() + + if err != nil { + json.NewEncoder(w).Encode(map[string]string{ + "status": "error", + "message": "Update check failed: " + err.Error(), + }) + return + } + + if newVersion != "" { + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "updateAvailable": "true", + "version": newVersion, + "message": "Update to " + newVersion + " available", + }) + } else { + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "updateAvailable": "false", + "message": "No update available — you are up to date", + }) + } +} diff --git a/sscm/LICENSE b/sscm/LICENSE index 48aadff9..183f6612 100644 --- a/sscm/LICENSE +++ b/sscm/LICENSE @@ -2,7 +2,7 @@ Version 1.1, Effective April 14, 2025 -_Copyright © 2025 J. Langisch "JacksonTheMaster". All rights reserved._ +_Copyright © 2026 J. Langisch "JacksonTheMaster". All rights reserved._ PREAMBLE The Stationeers Server Command Manager (“SSCM”) is a proprietary utility designed to enhance server administration for Stationeers, exclusively in conjunction with the Stationeers Server UI (“SSUI”), available at [Github](https://github.com/JacksonTheMaster/StationeersServerUI/) under the License. This License Agreement governs your use of SSCM to ensure its intended functionality and compatibility with SSUI.