Server updates from the web panel with progress screen
- Updater checks git.mbm.wtf every 6 h (and on demand) for a newer <Version> on main, shows the new commits and the required mod version - Install: download the source, build it with dotnet publish into data/app/current, warn players and restart (exit code 75 for Docker/systemd, self-relaunch on Windows). At startup a newer installed update is loaded in its own AssemblyLoadContext; after 3 failed starts the built-in version runs - Panel: Update page, sidebar badge, full-screen progress overlay (download, build, install, restart) that survives the restart and reloads the panel - Panel sessions survive restarts (hashed tokens in data/panel_sessions.json) - Docker image is based on the .NET SDK so it can build updates - Version 2.9.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+65
-5
@@ -60,6 +60,7 @@ public sealed class AdminPanel
|
||||
this.dataDir = dataDir;
|
||||
this.config = config;
|
||||
users = new PanelUsers(Path.Combine(dataDir, "panel_users.json"));
|
||||
LoadSessions();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Start
|
||||
@@ -357,7 +358,7 @@ public sealed class AdminPanel
|
||||
{
|
||||
if (!CsrfOk(ctx)) return Forbidden("Missing header");
|
||||
string token = ctx.Request.Cookies[CookieName];
|
||||
if (token != null) sessions.TryRemove(token, out _);
|
||||
if (token != null && sessions.TryRemove(TokenKey(token), out _)) SaveSessions();
|
||||
ctx.Response.Cookies.Delete(CookieName, CookieOptions(ctx));
|
||||
return Results.Json(new { ok = true });
|
||||
});
|
||||
@@ -527,6 +528,25 @@ public sealed class AdminPanel
|
||||
};
|
||||
});
|
||||
|
||||
// --- Updates (prüfen, installieren, Fortschritt)
|
||||
app.MapGet("/api/update", (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
|
||||
return Results.Json(host.Updater.Status());
|
||||
});
|
||||
app.MapPost("/api/update/check", async (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
await host.Updater.CheckAsync();
|
||||
return Results.Json(host.Updater.Status());
|
||||
});
|
||||
app.MapPost("/api/update/install", (HttpContext ctx) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
string problem = host.Updater.Install(By(user));
|
||||
return problem != null ? Error(409, problem) : Ok("Update started");
|
||||
});
|
||||
|
||||
// --- Einstellungen (server.json)
|
||||
app.MapGet("/api/config", async (HttpContext ctx) =>
|
||||
{
|
||||
@@ -740,11 +760,14 @@ public sealed class AdminPanel
|
||||
PanelUsers.User CurrentUser(HttpContext ctx)
|
||||
{
|
||||
string token = ctx.Request.Cookies[CookieName];
|
||||
if (token == null || !sessions.TryGetValue(token, out var session)) return null;
|
||||
if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(token, out _); return null; }
|
||||
if (token == null) return null;
|
||||
string key = TokenKey(token);
|
||||
if (!sessions.TryGetValue(key, out var session)) return null;
|
||||
if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(key, out _); SaveSessions(); return null; }
|
||||
var user = users.Find(session.User);
|
||||
if (user == null) { sessions.TryRemove(token, out _); return null; }
|
||||
if (user == null) { sessions.TryRemove(key, out _); SaveSessions(); return null; }
|
||||
session.Expires = DateTime.UtcNow + SessionLifetime; // gleitend verlängern
|
||||
if (DateTime.UtcNow - lastSessionSave > TimeSpan.FromMinutes(5)) SaveSessions();
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -761,16 +784,53 @@ public sealed class AdminPanel
|
||||
void StartSession(HttpContext ctx, string name)
|
||||
{
|
||||
string token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
sessions[token] = new Session { User = name, Expires = DateTime.UtcNow + SessionLifetime };
|
||||
sessions[TokenKey(token)] = new Session { User = name, Expires = DateTime.UtcNow + SessionLifetime };
|
||||
ctx.Response.Cookies.Append(CookieName, token, CookieOptions(ctx));
|
||||
// Alte Sitzungen aufräumen
|
||||
foreach (var kv in sessions) if (kv.Value.Expires < DateTime.UtcNow) sessions.TryRemove(kv.Key, out _);
|
||||
SaveSessions();
|
||||
}
|
||||
|
||||
void EndSessionsOf(string name)
|
||||
{
|
||||
foreach (var kv in sessions)
|
||||
if (string.Equals(kv.Value.User, name, StringComparison.OrdinalIgnoreCase)) sessions.TryRemove(kv.Key, out _);
|
||||
SaveSessions();
|
||||
}
|
||||
|
||||
// Sitzungen überstehen einen Neustart (z.B. nach einem Update). Gespeichert wird nur ein Hash des Cookies.
|
||||
static readonly JsonSerializerOptions SessionJson = new() { IncludeFields = true };
|
||||
readonly object sessionFileLock = new();
|
||||
DateTime lastSessionSave = DateTime.MinValue;
|
||||
string SessionsPath => Path.Combine(dataDir, "panel_sessions.json");
|
||||
|
||||
static string TokenKey(string token) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
||||
|
||||
void LoadSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(SessionsPath)) return;
|
||||
var saved = JsonSerializer.Deserialize<Dictionary<string, Session>>(File.ReadAllText(SessionsPath), SessionJson);
|
||||
foreach (var kv in saved ?? new())
|
||||
if (kv.Value?.User != null && kv.Value.Expires > DateTime.UtcNow) sessions[kv.Key] = kv.Value;
|
||||
}
|
||||
catch { } // kaputte Datei: alle melden sich neu an
|
||||
}
|
||||
|
||||
void SaveSessions()
|
||||
{
|
||||
lock (sessionFileLock)
|
||||
{
|
||||
lastSessionSave = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
string tmp = SessionsPath + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
|
||||
File.Move(tmp, SessionsPath, true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
static CookieOptions CookieOptions(HttpContext ctx) => new()
|
||||
|
||||
+4
-2
@@ -8,13 +8,15 @@ WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app
|
||||
|
||||
# aspnet: enthält Kestrel für das Web-Panel
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
# sdk (enthält auch ASP.NET/Kestrel fürs Web-Panel): damit sich der Server im Panel selbst updaten kann
|
||||
# (lädt den Quellcode, baut ihn und legt ihn nach /data/app)
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
ENV MSCO_DATA=/data
|
||||
# läuft als beliebige Benutzer-ID (docker compose "user:") - dann gibt es kein eigenes Home
|
||||
ENV HOME=/tmp
|
||||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
|
||||
# Befehle an den laufenden Server: docker exec msco-server msco players
|
||||
RUN printf '#!/bin/sh\nexec dotnet /app/MSCO.Server.dll --cmd "$@"\n' > /usr/local/bin/msco && chmod +x /usr/local/bin/msco
|
||||
VOLUME /data
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ public sealed class GameServer
|
||||
|
||||
public void EnqueueCommand(string line) => commands.Enqueue(line);
|
||||
|
||||
int JoinedCount => clients.Values.Count(c => c.State == ClientState.Joined && !c.Conn.IsClosing); // gerade getrennte zählen nicht mehr
|
||||
internal int JoinedCount => clients.Values.Count(c => c.State == ClientState.Joined && !c.Conn.IsClosing); // gerade getrennte zählen nicht mehr
|
||||
|
||||
// ---------------------------------------------------------------- Start / Schleife
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MSCO.Server</RootNamespace>
|
||||
<AssemblyName>MSCO.Server</AssemblyName>
|
||||
<Version>2.8.0</Version>
|
||||
<Version>2.9.0</Version>
|
||||
<Company>MBM</Company>
|
||||
<Product>MSCO Dedicated Server</Product>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
|
||||
@@ -231,3 +231,64 @@ input[type=file]::file-selector-button { background: var(--panel); color: var(--
|
||||
th, td { padding: 10px 12px; }
|
||||
.console { height: 55vh; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Updates */
|
||||
.nav-badge { margin-left: auto; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px;
|
||||
border-radius: 999px; background: var(--accent); color: #fff; box-shadow: 0 0 0 3px rgba(242, 107, 29, .18); }
|
||||
.card.accent { border-color: rgba(242, 107, 29, .45); background: linear-gradient(180deg, rgba(242, 107, 29, .10), transparent 70%), var(--panel); }
|
||||
.notice.ok { background: rgba(61, 214, 140, .07); border-color: rgba(61, 214, 140, .3); }
|
||||
.notice.update-notice { background: rgba(242, 107, 29, .08); border-color: rgba(242, 107, 29, .35); }
|
||||
.warn-text { color: var(--pending); margin-top: 4px; }
|
||||
.update-body > * + * { margin-top: 14px; }
|
||||
.small-print { font-size: 12.5px; margin-bottom: 0; }
|
||||
.changes { list-style: none; margin: 0; padding: 0; display: grid; }
|
||||
.changes li { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: baseline; padding: 8px 4px; border-bottom: 1px solid var(--border); }
|
||||
.changes li:last-child { border-bottom: 0; }
|
||||
.change-sha { color: var(--accent); font-size: 12px; font-family: var(--mono); }
|
||||
.change-title { overflow-wrap: anywhere; }
|
||||
@media (max-width: 560px) { .changes li { grid-template-columns: auto 1fr; } .changes li small { grid-column: 2; } }
|
||||
|
||||
.update-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 16px;
|
||||
background: radial-gradient(1200px 600px at 50% 0%, rgba(242, 107, 29, .14), transparent 60%), rgba(10, 10, 13, .92);
|
||||
backdrop-filter: blur(6px); animation: us-in .2s ease-out; }
|
||||
@keyframes us-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
.update-card { width: min(640px, 100%); max-height: calc(100vh - 32px); overflow: auto; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 24px; box-shadow: 0 30px 80px rgba(0, 0, 0, .5); display: grid; gap: 16px; }
|
||||
.update-card.success { border-color: rgba(61, 214, 140, .45); }
|
||||
.us-head { display: flex; align-items: center; gap: 14px; }
|
||||
.us-head img { width: 44px; height: 44px; border-radius: 10px; }
|
||||
.us-versions { color: var(--muted); margin-top: 2px; }
|
||||
.us-versions .mono { color: var(--text); font-family: var(--mono); }
|
||||
.us-steps { list-style: none; margin: 4px 0 0; padding: 0; display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
|
||||
.us-steps li { display: grid; justify-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); text-align: center; position: relative; }
|
||||
.us-steps li::before { content: ""; position: absolute; top: 11px; left: calc(-50% + 16px); right: calc(50% + 16px); height: 2px; background: var(--border); }
|
||||
.us-steps li:first-child::before { display: none; }
|
||||
.us-dot { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border); background: var(--panel-2); display: grid; place-items: center;
|
||||
font-size: 12px; font-weight: 800; color: #fff; position: relative; z-index: 1; }
|
||||
.us-steps li.done { color: var(--text); }
|
||||
.us-steps li.done .us-dot { background: var(--online); border-color: var(--online); }
|
||||
.us-steps li.done .us-dot::after { content: "✓"; }
|
||||
.us-steps li.done::before, .us-steps li.active::before, .us-steps li.failed::before { background: var(--online); }
|
||||
.us-steps li.active { color: #fff; font-weight: 700; }
|
||||
.us-steps li.active .us-dot { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(242, 107, 29, .2); animation: us-pulse 1.4s ease-in-out infinite; }
|
||||
.us-steps li.active .us-dot::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.us-steps li.failed { color: #ff8a8d; font-weight: 700; }
|
||||
.us-steps li.failed .us-dot { background: var(--danger); border-color: var(--danger); }
|
||||
.us-steps li.failed .us-dot::after { content: "!"; }
|
||||
@keyframes us-pulse { 50% { box-shadow: 0 0 0 7px rgba(242, 107, 29, .08); } }
|
||||
.us-bar { height: 10px; border-radius: 999px; background: var(--panel-2); overflow: hidden; border: 1px solid var(--border); }
|
||||
.us-fill { height: 100%; width: 0; border-radius: 999px; transition: width .6s ease;
|
||||
background: linear-gradient(90deg, #e5561a, #ffa24d, #e5561a); background-size: 200% 100%; animation: us-flow 2s linear infinite; }
|
||||
.update-card.success .us-fill { background: var(--online); animation: none; }
|
||||
@keyframes us-flow { to { background-position: -200% 0; } }
|
||||
.us-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.us-msg { min-width: 0; overflow-wrap: anywhere; }
|
||||
.us-pct, .us-clock { font-variant-numeric: tabular-nums; }
|
||||
.us-log { height: 240px; min-height: 0; }
|
||||
.update-card .buttons { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.update-card .buttons:empty { display: none; }
|
||||
@media (max-width: 560px) {
|
||||
.update-card { padding: 18px; }
|
||||
.us-steps li { font-size: 11px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .us-fill, .us-steps li.active .us-dot, .update-overlay { animation: none; } }
|
||||
|
||||
+212
-1
@@ -172,6 +172,7 @@ const PAGES = [
|
||||
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
|
||||
{ id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld },
|
||||
{ id: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
|
||||
{ id: "update", label: "Update", icon: "⇪", role: "Admin", group: "Management", render: renderUpdate },
|
||||
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers },
|
||||
{ id: "account", label: "My Account", icon: "☻", role: "Moderator", group: null, render: renderAccount },
|
||||
];
|
||||
@@ -183,7 +184,8 @@ function renderNav() {
|
||||
for (const p of PAGES.filter((x) => can(x.role) && x.group)) {
|
||||
if (p.group !== group) { group = p.group; items.push(h("div", { class: "nav-group" }, group)); }
|
||||
items.push(h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) },
|
||||
h("span", { class: "ico" }, p.icon), h("span", { class: "nav-label" }, p.label)));
|
||||
h("span", { class: "ico" }, p.icon), h("span", { class: "nav-label" }, p.label),
|
||||
p.id === "update" && state.updateAvailable ? h("span", { class: "nav-badge", title: "Update available" }, "new") : null));
|
||||
}
|
||||
nav.replaceChildren(...items);
|
||||
$("meButton").classList.toggle("active", state.page === "account");
|
||||
@@ -198,6 +200,7 @@ function go(pageId) {
|
||||
document.querySelector(".sidebar").classList.remove("open");
|
||||
clearTimers();
|
||||
every(5000, refreshTopbar);
|
||||
every(30000, refreshUpdateState);
|
||||
renderNav();
|
||||
const page = $("page");
|
||||
page.replaceChildren();
|
||||
@@ -812,6 +815,214 @@ function renderAccount(page) {
|
||||
h("span", { class: "muted" }, `${state.user.name} · ${state.user.role}`)), form));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Updates
|
||||
|
||||
const UPDATE_STEPS = [["downloading", "Download"], ["building", "Build"], ["installing", "Install"], ["restarting", "Restart"], ["done", "Done"]];
|
||||
const UPDATE_BUSY = ["downloading", "building", "installing", "restarting"];
|
||||
|
||||
function shortDate(iso) {
|
||||
if (!iso) return "–";
|
||||
return new Date(iso).toLocaleString(window.MSCO_LANG === "de" ? "de-DE" : "en-GB", { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/** Sidebar-Hinweis "Update verfügbar" und laufendes Update (auch von einem anderen Admin gestartet) anzeigen. */
|
||||
async function refreshUpdateState() {
|
||||
if (!can("Admin")) return;
|
||||
try {
|
||||
const u = await api("GET", "update");
|
||||
const available = !!u.available;
|
||||
if (available !== !!state.updateAvailable) { state.updateAvailable = available; renderNav(); }
|
||||
if (UPDATE_BUSY.includes(u.step)) openUpdateOverlay(u);
|
||||
} catch { /* Topbar zeigt Fehler */ }
|
||||
}
|
||||
|
||||
function renderUpdate(page) {
|
||||
const body = h("div", { class: "section-body update-body" });
|
||||
const changes = h("div", { class: "section-body" });
|
||||
const changesBox = h("div", { class: "section hidden" }, h("div", { class: "section-head" }, h("h2", {}, "What's new")), changes);
|
||||
page.append(
|
||||
h("div", { class: "section" },
|
||||
h("div", { class: "section-head" }, h("h2", {}, "Server update"),
|
||||
h("button", { class: "btn small", type: "button", onclick: () => load(true) }, "⟳ Check for updates")),
|
||||
body),
|
||||
changesBox);
|
||||
load(false);
|
||||
|
||||
async function load(check) {
|
||||
let u;
|
||||
try { u = check ? await api("POST", "update/check") : await api("GET", "update"); }
|
||||
catch (e) { toast(e.message, true); return; }
|
||||
if (check) toast(u.checkError ? u.checkError : u.available ? "Update available: " + u.latest : "The server is up to date", !!u.checkError);
|
||||
state.updateAvailable = !!u.available;
|
||||
renderNav();
|
||||
if (UPDATE_BUSY.includes(u.step)) openUpdateOverlay(u);
|
||||
|
||||
const tile = (label, value, sub, cls) => h("div", { class: "card" + (cls ? " " + cls : "") },
|
||||
h("div", { class: "stat-label" }, label), h("div", { class: "stat-value" }, value), sub ? h("div", { class: "stat-sub" }, sub) : null);
|
||||
const tiles = h("div", { class: "cards" },
|
||||
tile("Installed", u.current, u.currentCommit ? "commit " + u.currentCommit.slice(0, 8) : "for MSCO mod " + u.modVersion),
|
||||
tile("Newest", u.latest || "–", u.latestDate ? shortDate(u.latestDate) : "not checked yet", u.available ? "accent" : null),
|
||||
tile("Last check", u.lastCheck ? since(u.lastCheck) : "–", "automatically every 6 hours"));
|
||||
|
||||
const notes = [];
|
||||
if (u.checkError) notes.push(h("div", { class: "notice bad" }, h("div", {}, h("b", {}, "Check failed"), h("div", {}, u.checkError))));
|
||||
if (u.step === "failed" && u.error) notes.push(h("div", { class: "notice bad" }, h("div", {}, h("b", {}, "The last update failed"), h("div", {}, u.error)),
|
||||
h("button", { class: "btn small ghost", type: "button", onclick: () => openUpdateOverlay(u, true) }, "Show log")));
|
||||
if (u.available) {
|
||||
const modChange = u.latestModVersion && u.latestModVersion !== u.modVersion;
|
||||
notes.push(h("div", { class: "notice update-notice" },
|
||||
h("div", {}, h("b", {}, "Version " + u.latest + " is available"),
|
||||
h("div", {}, u.latestMessage || ""),
|
||||
modChange ? h("div", { class: "warn-text" }, "Players then need MSCO mod " + u.latestModVersion + " (currently " + u.modVersion + ")") : null),
|
||||
u.canInstall === false ? null : h("button", { class: "btn primary", type: "button", onclick: () => install(u) }, "⇪ Install update")));
|
||||
if (u.canInstall === false) notes.push(h("div", { class: "notice" }, h("div", {}, h("b", {}, "Update from the panel not possible"), h("div", {}, u.cannotInstall))));
|
||||
} else if (u.latest) {
|
||||
notes.push(h("div", { class: "notice ok" }, h("div", {}, h("b", {}, "The server is up to date"), h("div", {}, "Version " + u.current))));
|
||||
}
|
||||
body.replaceChildren(tiles, ...notes,
|
||||
h("p", { class: "muted small-print" }, "Source: ", h("a", { href: u.source, target: "_blank", rel: "noopener" }, u.source.replace(/^https?:\/\//, "")), " (" + u.branch + ")"));
|
||||
|
||||
const list = u.changes || [];
|
||||
changesBox.classList.toggle("hidden", !u.available || list.length === 0);
|
||||
changes.replaceChildren(h("ul", { class: "changes" }, list.map((c) =>
|
||||
h("li", {}, h("span", { class: "mono change-sha" }, c.sha), h("span", { class: "change-title mono" }, c.title), h("small", { class: "muted" }, shortDate(c.date))))));
|
||||
}
|
||||
|
||||
async function install(u) {
|
||||
const ok = await modal({ title: "Install update " + u.latest + "?",
|
||||
text: "The server downloads and builds the new version (takes a minute or two), then restarts. Players are warned and reconnect automatically. The panel shows the progress and reloads itself.",
|
||||
confirm: "Update now" });
|
||||
if (!ok) return;
|
||||
const r = await act(api("POST", "update/install"));
|
||||
if (r) openUpdateOverlay({ ...u, step: "downloading", progress: 0, target: u.latest, log: [] });
|
||||
}
|
||||
}
|
||||
|
||||
let updateOverlay = null;
|
||||
|
||||
/** Vollbild-Fortschritt: Laden, Bauen, Installieren, Neustart - übersteht den Neustart des Panels und lädt danach neu. */
|
||||
function openUpdateOverlay(initial, showLogOnly) {
|
||||
if (updateOverlay) return;
|
||||
const stepEls = {};
|
||||
const list = h("ol", { class: "us-steps" }, UPDATE_STEPS.map(([id, label]) => (stepEls[id] = h("li", {}, h("span", { class: "us-dot" }), h("span", {}, label)))));
|
||||
const fill = h("div", { class: "us-fill" });
|
||||
const pct = h("b", { class: "us-pct" }, "0%");
|
||||
const msg = h("div", { class: "us-msg" }, "…");
|
||||
const clock = h("small", { class: "muted us-clock" }, "");
|
||||
const logBox = h("div", { class: "console us-log hidden" });
|
||||
const logToggle = h("button", { class: "btn small ghost", type: "button", onclick: () => {
|
||||
logBox.classList.toggle("hidden");
|
||||
logToggle.textContent = logBox.classList.contains("hidden") ? "Show build log" : "Hide build log";
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
} }, "Show build log");
|
||||
const errorBox = h("div", { class: "notice bad hidden" });
|
||||
const actions = h("div", { class: "buttons" });
|
||||
const versions = h("div", { class: "us-versions" });
|
||||
const card = h("div", { class: "update-card" },
|
||||
h("div", { class: "us-head" }, h("img", { src: "logo.png", alt: "" }), h("div", {}, h("h2", {}, "Updating the server"), versions)),
|
||||
list, h("div", { class: "us-bar" }, fill), h("div", { class: "us-row" }, msg, pct), h("div", { class: "us-row" }, logToggle, clock),
|
||||
logBox, errorBox, actions);
|
||||
const back = h("div", { class: "update-overlay" }, card);
|
||||
document.body.append(back);
|
||||
updateOverlay = back;
|
||||
|
||||
const from = initial.current;
|
||||
let target = initial.target || initial.latest;
|
||||
let sawRestart = initial.step === "restarting", offlineSince = null, done = false, timer = null, lastLog = "";
|
||||
const started = Date.now();
|
||||
const close = () => { clearTimeout(timer); back.remove(); updateOverlay = null; };
|
||||
const showVersions = (now) => versions.replaceChildren(h("span", { class: "mono" }, from || now || "?"), " → ", h("span", { class: "mono" }, target || "?"));
|
||||
showVersions();
|
||||
|
||||
const mark = (active, failed) => {
|
||||
const idx = UPDATE_STEPS.findIndex(([id]) => id === active);
|
||||
UPDATE_STEPS.forEach(([id], i) => { stepEls[id].className = i < idx ? "done" : i === idx ? (failed ? "failed" : "active") : ""; });
|
||||
};
|
||||
const setBar = (p) => { const v = Math.max(0, Math.min(100, p || 0)); fill.style.width = v + "%"; pct.textContent = v + "%"; };
|
||||
const fmt = (s) => Math.floor(s / 60) + ":" + String(Math.floor(s % 60)).padStart(2, "0");
|
||||
const showLog = (lines) => {
|
||||
const text = (lines || []).join("\n");
|
||||
if (text === lastLog) return;
|
||||
lastLog = text;
|
||||
const atEnd = logBox.scrollTop + logBox.clientHeight >= logBox.scrollHeight - 30;
|
||||
logBox.replaceChildren(...(lines || []).map((l) => h("div", { class: /error|FAILED/i.test(l) ? "l-warn" : l.startsWith("==") ? "l-cmd" : "" }, l)));
|
||||
if (atEnd) logBox.scrollTop = logBox.scrollHeight;
|
||||
};
|
||||
const fail = (text, lines) => {
|
||||
done = true;
|
||||
const active = UPDATE_STEPS.find(([id]) => stepEls[id].className === "active");
|
||||
mark(active ? active[0] : "downloading", true);
|
||||
errorBox.replaceChildren(h("div", {}, h("b", {}, "The update failed - the server keeps running the old version"), h("div", {}, text)));
|
||||
errorBox.classList.remove("hidden");
|
||||
if (lines) showLog(lines);
|
||||
logBox.classList.remove("hidden");
|
||||
logToggle.textContent = "Hide build log";
|
||||
msg.textContent = "Update failed";
|
||||
actions.replaceChildren(h("button", { class: "btn primary", type: "button", onclick: () => { close(); go("update"); } }, "Close"));
|
||||
};
|
||||
const finish = (version) => {
|
||||
done = true;
|
||||
mark("done");
|
||||
stepEls.done.className = "done";
|
||||
setBar(100);
|
||||
msg.textContent = "Server updated to " + version + " - reloading the panel …";
|
||||
card.classList.add("success");
|
||||
setTimeout(() => location.reload(), 2500);
|
||||
};
|
||||
|
||||
if (showLogOnly) { fail(initial.error || "", initial.log); return; }
|
||||
mark(initial.step || "downloading");
|
||||
setBar(initial.progress);
|
||||
|
||||
const poll = async () => {
|
||||
if (done) return;
|
||||
clock.textContent = fmt((Date.now() - started) / 1000);
|
||||
let u = null, unauthorized = false;
|
||||
try {
|
||||
const res = await fetch("api/update", { credentials: "same-origin", headers: { "X-MSCO-Panel": "1" }, cache: "no-store" });
|
||||
if (res.status === 401) unauthorized = true;
|
||||
else if (res.ok) u = await res.json();
|
||||
} catch { /* während des Neustarts normal */ }
|
||||
|
||||
if (unauthorized && sawRestart) { finish(target); return; } // nach dem Neuladen neu anmelden
|
||||
if (!u) {
|
||||
offlineSince = offlineSince || Date.now();
|
||||
const away = (Date.now() - offlineSince) / 1000;
|
||||
if (away > 15) sawRestart = true; // Verbindung länger weg = Neustart läuft
|
||||
if (sawRestart) {
|
||||
mark("restarting");
|
||||
setBar(97);
|
||||
msg.textContent = away > 180
|
||||
? "The server has not come back yet. Docker/systemd restart it automatically - otherwise start it again."
|
||||
: "Waiting for the server to come back …";
|
||||
}
|
||||
timer = setTimeout(poll, 1000);
|
||||
return;
|
||||
}
|
||||
offlineSince = null;
|
||||
if (u.target) target = u.target;
|
||||
showVersions(u.current);
|
||||
if (u.log && u.log.length) showLog(u.log);
|
||||
|
||||
if (u.step === "failed") { fail(u.error || "Unknown error", u.log); return; }
|
||||
if (UPDATE_BUSY.includes(u.step)) {
|
||||
if (u.step === "restarting") sawRestart = true;
|
||||
mark(u.step);
|
||||
setBar(u.progress);
|
||||
msg.textContent = u.message || "…";
|
||||
} else if (target && u.current === target) {
|
||||
finish(u.current); return;
|
||||
} else if (sawRestart) {
|
||||
fail("The server restarted but still runs version " + u.current + ". The update could not be started - see the console.");
|
||||
return;
|
||||
} else {
|
||||
close(); return; // nichts (mehr) zu tun
|
||||
}
|
||||
timer = setTimeout(poll, 1000);
|
||||
};
|
||||
poll();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Start
|
||||
|
||||
async function boot() {
|
||||
|
||||
@@ -13,6 +13,53 @@
|
||||
window.MSCO_LANG = lang;
|
||||
|
||||
const DE = {
|
||||
// Updates
|
||||
"Update": "Update",
|
||||
"Update available": "Update verfügbar",
|
||||
"new": "neu",
|
||||
"Server update": "Server-Update",
|
||||
"⟳ Check for updates": "⟳ Nach Updates suchen",
|
||||
"What's new": "Neuerungen",
|
||||
"Installed": "Installiert",
|
||||
"Newest": "Neueste",
|
||||
"Last check": "Letzte Prüfung",
|
||||
"not checked yet": "noch nicht geprüft",
|
||||
"automatically every 6 hours": "automatisch alle 6 Stunden",
|
||||
"Check failed": "Prüfung fehlgeschlagen",
|
||||
"The last update failed": "Das letzte Update ist fehlgeschlagen",
|
||||
"Show log": "Log anzeigen",
|
||||
"⇪ Install update": "⇪ Update installieren",
|
||||
"Update from the panel not possible": "Update über das Panel nicht möglich",
|
||||
"The server is up to date": "Der Server ist aktuell",
|
||||
"Source: ": "Quelle: ",
|
||||
"The server downloads and builds the new version (takes a minute or two), then restarts. Players are warned and reconnect automatically. The panel shows the progress and reloads itself.":
|
||||
"Der Server lädt die neue Version herunter, baut sie (dauert ein, zwei Minuten) und startet dann neu. Spieler werden vorgewarnt und verbinden sich automatisch neu. Das Panel zeigt den Fortschritt und lädt sich danach selbst neu.",
|
||||
"Update now": "Jetzt updaten",
|
||||
"Update started": "Update gestartet",
|
||||
"Updating the server": "Server wird aktualisiert",
|
||||
"Download": "Download",
|
||||
"Build": "Bauen",
|
||||
"Install": "Installieren",
|
||||
"Show build log": "Build-Log anzeigen",
|
||||
"Hide build log": "Build-Log ausblenden",
|
||||
"Update failed": "Update fehlgeschlagen",
|
||||
"The update failed - the server keeps running the old version": "Das Update ist fehlgeschlagen - der Server läuft mit der alten Version weiter",
|
||||
"Close": "Schließen",
|
||||
"Waiting for the server to come back …": "Warte, bis der Server wieder da ist …",
|
||||
"The server has not come back yet. Docker/systemd restart it automatically - otherwise start it again.":
|
||||
"Der Server ist noch nicht wieder da. Docker/systemd starten ihn automatisch neu - sonst bitte von Hand starten.",
|
||||
"Checking the newest version": "Prüfe die neueste Version",
|
||||
"Download complete": "Download fertig",
|
||||
"Building (dotnet publish) - this takes a minute": "Wird gebaut (dotnet publish) - das dauert etwa eine Minute",
|
||||
"Building - packages restored": "Wird gebaut - Pakete geladen",
|
||||
"Building - compiled": "Wird gebaut - kompiliert",
|
||||
"Building - published": "Wird gebaut - veröffentlicht",
|
||||
"Build complete": "Build fertig",
|
||||
"Installing": "Wird installiert",
|
||||
"An update is already running": "Es läuft schon ein Update",
|
||||
"No update available - check again first": "Kein Update verfügbar - bitte zuerst erneut prüfen",
|
||||
"No update available anymore": "Kein Update mehr verfügbar",
|
||||
|
||||
// Anmeldung
|
||||
"Sign in": "Anmelden",
|
||||
"Set up the panel": "Panel einrichten",
|
||||
@@ -337,6 +384,21 @@
|
||||
"Noch kein Spielstand - lade einen als ZIP/RAR im Panel hoch (Welt & Backups) oder kopiere ihn nach data/save"],
|
||||
[/^No license key set - (.+)$/, "Kein Lizenzschlüssel gesetzt - hol dir einen unter msco.mbm.wtf/keys und trag ihn in den Einstellungen ein"],
|
||||
[/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"],
|
||||
[/^Update available: (.+)$/, "Update verfügbar: $1"],
|
||||
[/^Version (.+) is available$/, "Version $1 ist verfügbar"],
|
||||
[/^Version ([0-9.]+)$/, "Version $1"],
|
||||
[/^Install update (.+)\?$/, "Update $1 installieren?"],
|
||||
[/^Players then need MSCO mod (.+) \(currently (.+)\)$/, "Spieler brauchen danach die MSCO-Mod $1 (aktuell $2)"],
|
||||
[/^for MSCO mod (.+)$/, "für MSCO-Mod $1"],
|
||||
[/^Downloading MSCO server (.+)$/, "Lade MSCO-Server $1 herunter"],
|
||||
[/^Downloading … (.+)$/, "Lade herunter … $1"],
|
||||
[/^Restarting with version (.+)$/, "Neustart mit Version $1"],
|
||||
[/^Server updated to (.+) - reloading the panel …$/, "Server auf $1 aktualisiert - Panel wird neu geladen …"],
|
||||
[/^The server restarted but still runs version (.+)\. The update could not be started - see the console\.$/,
|
||||
"Der Server wurde neu gestartet, läuft aber noch mit Version $1. Das Update konnte nicht gestartet werden - siehe Konsole."],
|
||||
[/^Could not check for updates: (.+)$/, "Konnte nicht nach Updates suchen: $1"],
|
||||
[/^No \.NET (\d+) SDK found - (.+)$/, "Kein .NET-$1-SDK gefunden - es wird zum Bauen des Updates gebraucht. Mit Docker: einmal die neue Version holen (git pull && docker compose up -d --build), deren Image enthält das SDK."],
|
||||
[/^The dotnet command was not found (.+)$/, "Der Befehl dotnet wurde nicht gefunden $1 - installiere das .NET SDK, um über das Panel zu updaten."],
|
||||
[/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"],
|
||||
[/^Crashed: (.+)$/, "Abgestürzt: $1"],
|
||||
[/^The master server (rejects the license key|is not reachable) \(last confirmed (.+) ago\) - the server keeps running until the next restart$/,
|
||||
|
||||
+5
-2
@@ -6,6 +6,8 @@ using MSCO.Server;
|
||||
string dataDir = ArgValue("--data") ?? Environment.GetEnvironmentVariable("MSCO_DATA") ?? AppContext.BaseDirectory;
|
||||
dataDir = Path.GetFullPath(dataDir);
|
||||
Directory.CreateDirectory(dataDir);
|
||||
// Im Panel installiertes, neueres Update (DATA/app/current)? Dann läuft stattdessen das.
|
||||
if (Array.IndexOf(args, "--cmd") < 0 && Updater.RunInstalled(args, dataDir) is int updatedExit) return updatedExit;
|
||||
string configPath = Path.Combine(dataDir, "server.json");
|
||||
string commandFile = Path.Combine(dataDir, "commands.in");
|
||||
|
||||
@@ -22,7 +24,7 @@ if (Array.IndexOf(args, "--cmd") >= 0)
|
||||
}
|
||||
|
||||
try { Console.Title = "MSCO Server"; } catch { } // nicht jedes Terminal unterstützt einen Titel
|
||||
GameServer.Log("MSCO dedicated server " + GameServer.ModVersion + " (" + RuntimeInformation.OSDescription + ")");
|
||||
GameServer.Log("MSCO dedicated server " + Updater.CurrentVersion + " for MSCO mod " + GameServer.ModVersion + " (" + RuntimeInformation.OSDescription + ")");
|
||||
GameServer.Log("Data folder: " + dataDir);
|
||||
GameServer.Log("Master server: " + MasterServer.Url);
|
||||
|
||||
@@ -138,7 +140,8 @@ using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => {
|
||||
using var sigquit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, ctx => { ctx.Cancel = true; host.RequestExit(); });
|
||||
|
||||
int exitCode = host.Run();
|
||||
if (exitCode != 0) Pause();
|
||||
if (exitCode == Updater.RestartExitCode) Updater.Relaunch(args);
|
||||
else if (exitCode != 0) Pause();
|
||||
return exitCode;
|
||||
|
||||
string ArgValue(string name)
|
||||
|
||||
@@ -166,6 +166,14 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms
|
||||
- **Keymaster nicht erreichbar oder lehnt den Schlüssel ab?** Ein laufender Server läuft bis zum nächsten Neustart weiter
|
||||
und warnt im Log/Panel. Ein Neustart klappt noch bis 72 Stunden nach der letzten Bestätigung (gespeichert in
|
||||
`data/license.json`); danach startet der Server von selbst, sobald der Master den Schlüssel wieder bestätigt.
|
||||
- **Updates im Panel** (Seite „Update“): Der Server prüft alle 6 Stunden, ob auf git.mbm.wtf eine neuere Version liegt
|
||||
(Hinweis im Log und in der Seitenleiste). „Update installieren“ lädt den Quellcode, baut ihn mit dem .NET SDK und
|
||||
startet neu – mit Fortschrittsanzeige (Download, Bauen, Installieren, Neustart), die auch den Neustart des Panels
|
||||
übersteht; man bleibt angemeldet und Spieler verbinden sich automatisch neu. Das Update liegt in `data/app/current`
|
||||
und wird beim Start statt der eingebauten Version geladen; startet es dreimal nicht, läuft wieder die eingebaute.
|
||||
Ein neueres Docker-Image (`git pull && docker compose up -d --build`) ersetzt ein älteres Panel-Update automatisch.
|
||||
Voraussetzung: .NET SDK (im Docker-Image ab 2.9 enthalten) und ein Dienst, der nach dem Beenden neu startet
|
||||
(Docker `restart: unless-stopped`, systemd) – unter Windows startet sich der Server selbst neu.
|
||||
- Passwörter werden nur als PBKDF2-Hash in `data/panel_users.json` gespeichert, Login mit Sperre nach
|
||||
Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF.
|
||||
|
||||
|
||||
+14
-1
@@ -36,6 +36,10 @@ public sealed class ServerHost
|
||||
public ServerConfig Config { get; private set; }
|
||||
public GameServer Server { get; private set; }
|
||||
public AdminPanel Panel { get; private set; }
|
||||
/// <summary>Server-Updates (Prüfen/Installieren im Panel).</summary>
|
||||
public Updater Updater { get; }
|
||||
/// <summary>Rückgabewert von Run - Updater.RestartExitCode nach einem installierten Update.</summary>
|
||||
public int ExitCode { get; private set; }
|
||||
public HostState State { get; private set; } = HostState.Stopped;
|
||||
public string LastError { get; private set; }
|
||||
public DateTime StateSince { get; private set; } = DateTime.UtcNow;
|
||||
@@ -50,6 +54,7 @@ public sealed class ServerHost
|
||||
this.dataDir = dataDir;
|
||||
this.configPath = configPath;
|
||||
this.offline = offline;
|
||||
Updater = new Updater(this, dataDir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Steuerung (Konsole, Panel, Signale)
|
||||
@@ -120,6 +125,13 @@ public sealed class ServerHost
|
||||
return "Restarting - the server is back in a few seconds";
|
||||
}
|
||||
|
||||
/// <summary>Update installiert: Weltzustand speichern, beenden und neu starten (Docker/systemd bzw. selbst).</summary>
|
||||
public void ExitForUpdate()
|
||||
{
|
||||
ExitCode = Updater.RestartExitCode;
|
||||
RequestExit();
|
||||
}
|
||||
|
||||
/// <summary>Ganzes Programm beenden (Strg+C, docker stop, "exit").</summary>
|
||||
public void RequestExit()
|
||||
{
|
||||
@@ -136,6 +148,7 @@ public sealed class ServerHost
|
||||
if (!Listen(Config.Port)) return 1;
|
||||
if (Panel == null)
|
||||
GameServer.Log("Web panel is disabled (PanelEnabled in server.json)");
|
||||
if (!offline) Updater.StartBackground();
|
||||
|
||||
while (!exitRequested)
|
||||
{
|
||||
@@ -185,7 +198,7 @@ public sealed class ServerHost
|
||||
try { listener?.Stop(); } catch { }
|
||||
Panel?.Stop();
|
||||
GameServer.Log("Bye.");
|
||||
return 0;
|
||||
return ExitCode;
|
||||
}
|
||||
|
||||
void HandleHostCommands()
|
||||
|
||||
@@ -486,6 +486,17 @@ async Task PanelTests()
|
||||
using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip)))
|
||||
Check(down.IsSuccessStatusCode && da.GetEntry("defaultES2File.txt") != null, "The current save can be downloaded as ZIP");
|
||||
|
||||
// Updates: Versionen vergleichen, Status im Panel, Installieren nur mit verfügbarem Update
|
||||
Check(Updater.ParseCsprojVersion("<Project><PropertyGroup><Version>2.10.1</Version></PropertyGroup></Project>") == "2.10.1"
|
||||
&& Updater.ParseModVersion("public const string ModVersion = \"2.9\";") == "2.9", "Update versions are read from the source");
|
||||
Check(Updater.IsNewer("2.10.0", "2.9.0") && !Updater.IsNewer("2.9.0", "2.9.0") && !Updater.IsNewer("2.8.5", "2.9.0") && !Updater.IsNewer("x", "2.9.0"),
|
||||
"Versions are compared numerically (2.10 > 2.9)");
|
||||
var upd = await Call(owner, "GET", "api/update");
|
||||
Check(upd.status == 200 && upd.json.GetProperty("current").GetString() == Updater.CurrentVersion && upd.json.GetProperty("step").GetString() == "idle",
|
||||
"The panel shows the installed server version: " + upd.json);
|
||||
Check((await Call(owner, "POST", "api/update/install")).status == 409, "Installing without an available update is refused");
|
||||
Check(Updater.RunInstalled(Array.Empty<string>(), dir) == null, "Without an installed update the built-in version runs");
|
||||
|
||||
// Lizenz: Master fällt aus -> der laufende Server läuft bis zum nächsten Neustart weiter
|
||||
LicenseCache.Save(dir, "test", "Tester", "Test", DateTime.UtcNow.AddHours(-5));
|
||||
var cachedLicense = LicenseCache.Load(dir, "test");
|
||||
|
||||
+640
@@ -0,0 +1,640 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MSCO.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Server-Updates aus dem Git-Repository (git.mbm.wtf). Prüft regelmäßig, ob auf "main" eine neuere Version
|
||||
/// (<Version> in MSCO.Server.csproj) liegt. Installieren im Panel: Quellcode laden, mit dem .NET SDK bauen
|
||||
/// (dotnet publish) und nach DATA/app/current legen, dann neu starten. Beim Start lädt das Programm von dort die neuere
|
||||
/// Version (siehe RunInstalled) - so klappt es auch in Docker, wo /app zum Image gehört. Ein neueres Image
|
||||
/// (docker compose up --build) gewinnt wieder gegen ein älteres installiertes Update.
|
||||
/// </summary>
|
||||
public sealed class Updater
|
||||
{
|
||||
/// <summary>Exit-Code "neu starten für das Update" - Docker (restart: unless-stopped) und systemd starten neu.</summary>
|
||||
public const int RestartExitCode = 75;
|
||||
const int MaxStartAttempts = 3;
|
||||
static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||
static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(15);
|
||||
const int MaxLogLines = 400;
|
||||
|
||||
/// <summary>Quelle der Updates (für Forks/Tests per MSCO_UPDATE_SOURCE änderbar).</summary>
|
||||
public static string Source => (Environment.GetEnvironmentVariable("MSCO_UPDATE_SOURCE") ?? "https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server").TrimEnd('/');
|
||||
public static string Branch => Environment.GetEnvironmentVariable("MSCO_UPDATE_BRANCH") ?? "main";
|
||||
static string Api
|
||||
{
|
||||
get
|
||||
{
|
||||
var u = new Uri(Source);
|
||||
return $"{u.Scheme}://{u.Authority}/api/v1/repos{u.AbsolutePath.TrimEnd('/')}";
|
||||
}
|
||||
}
|
||||
|
||||
static readonly HttpClient Http = CreateHttp();
|
||||
|
||||
static HttpClient CreateHttp()
|
||||
{
|
||||
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("MSCO-Server/" + CurrentVersion);
|
||||
return http;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- eigene Version
|
||||
|
||||
static string Informational =>
|
||||
typeof(Updater).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0";
|
||||
|
||||
/// <summary>Version dieses Programms (aus MSCO.Server.csproj), z.B. "2.9.0".</summary>
|
||||
public static string CurrentVersion => Informational.Split('+')[0];
|
||||
|
||||
/// <summary>Git-Commit dieses Builds (nur bei per Update gebauten Versionen bekannt).</summary>
|
||||
public static string CurrentCommit
|
||||
{
|
||||
get
|
||||
{
|
||||
int plus = Informational.IndexOf('+');
|
||||
return plus < 0 ? null : Informational[(plus + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsNewer(string candidate, string than) =>
|
||||
Version.TryParse(candidate, out var a) && Version.TryParse(than, out var b) && a > b;
|
||||
|
||||
/// <summary><Version> aus einer .csproj-Datei.</summary>
|
||||
public static string ParseCsprojVersion(string csproj)
|
||||
{
|
||||
var m = Regex.Match(csproj ?? "", @"<Version>\s*([0-9][0-9.]*)\s*</Version>");
|
||||
return m.Success ? m.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
/// <summary>Mod-Version (Protokoll) aus GameServer.cs.</summary>
|
||||
public static string ParseModVersion(string source)
|
||||
{
|
||||
var m = Regex.Match(source ?? "", @"ModVersion\s*=\s*""([^""]+)""");
|
||||
return m.Success ? m.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zustand fürs Panel
|
||||
|
||||
public enum Step { Idle, Downloading, Building, Installing, Restarting, Failed }
|
||||
|
||||
public sealed class Commit
|
||||
{
|
||||
public string Sha, Title, Date;
|
||||
}
|
||||
|
||||
sealed class Latest
|
||||
{
|
||||
public string Version, Commit, Date, Message, ModVersion;
|
||||
public List<Commit> Changes = new();
|
||||
}
|
||||
|
||||
readonly ServerHost host;
|
||||
readonly string dataDir;
|
||||
readonly object gate = new();
|
||||
readonly List<string> log = new();
|
||||
Latest latest;
|
||||
DateTime? lastCheckUtc;
|
||||
string checkError;
|
||||
Step step = Step.Idle;
|
||||
int progress;
|
||||
string message, error, target, startedBy;
|
||||
DateTime stepSinceUtc = DateTime.UtcNow;
|
||||
bool notified;
|
||||
(bool ok, string reason)? sdk;
|
||||
|
||||
public Updater(ServerHost host, string dataDir)
|
||||
{
|
||||
this.host = host;
|
||||
this.dataDir = dataDir;
|
||||
}
|
||||
|
||||
string AppDir => Path.Combine(dataDir, "app");
|
||||
string WorkDir => Path.Combine(dataDir, "update");
|
||||
|
||||
/// <summary>Hintergrund: kurz nach dem Start und dann alle 6 Stunden prüfen; neu installierte Version als gut markieren.</summary>
|
||||
public void StartBackground()
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(60));
|
||||
MarkHealthy(); // eine Minute ohne Absturz: das installierte Update läuft
|
||||
while (true)
|
||||
{
|
||||
try { await CheckAsync(); } catch { }
|
||||
await Task.Delay(CheckInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public object Status()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var sdkInfo = sdk;
|
||||
return new
|
||||
{
|
||||
current = CurrentVersion,
|
||||
currentCommit = CurrentCommit,
|
||||
modVersion = GameServer.ModVersion,
|
||||
source = Source,
|
||||
branch = Branch,
|
||||
latest = latest?.Version,
|
||||
latestCommit = latest?.Commit,
|
||||
latestDate = latest?.Date,
|
||||
latestMessage = latest?.Message,
|
||||
latestModVersion = latest?.ModVersion,
|
||||
available = latest != null && IsNewer(latest.Version, CurrentVersion),
|
||||
changes = latest?.Changes.Select(c => new { sha = c.Sha, title = c.Title, date = c.Date }),
|
||||
lastCheck = lastCheckUtc?.ToString("o"),
|
||||
checkError,
|
||||
canInstall = sdkInfo?.ok,
|
||||
cannotInstall = sdkInfo is { ok: false } ? sdkInfo.Value.reason : null,
|
||||
supervised = Supervised,
|
||||
step = step.ToString().ToLowerInvariant(),
|
||||
progress,
|
||||
message,
|
||||
error,
|
||||
target,
|
||||
startedBy,
|
||||
stepSince = stepSinceUtc.ToString("o"),
|
||||
log = log.ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool Busy
|
||||
{
|
||||
get { lock (gate) return step is Step.Downloading or Step.Building or Step.Installing or Step.Restarting; }
|
||||
}
|
||||
|
||||
void SetStep(Step s, int percent, string text)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (s != step) stepSinceUtc = DateTime.UtcNow;
|
||||
step = s;
|
||||
progress = percent;
|
||||
message = text;
|
||||
}
|
||||
AddLog("== " + text);
|
||||
}
|
||||
|
||||
void SetProgress(int percent, string text = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
progress = Math.Max(progress, Math.Clamp(percent, 0, 100));
|
||||
if (text != null) message = text;
|
||||
}
|
||||
}
|
||||
|
||||
void AddLog(string line)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
log.Add(line);
|
||||
if (log.Count > MaxLogLines) log.RemoveRange(0, log.Count - MaxLogLines);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Prüfen
|
||||
|
||||
/// <summary>Neueste Version auf dem Branch abfragen.</summary>
|
||||
public async Task CheckAsync()
|
||||
{
|
||||
CheckSdk();
|
||||
try
|
||||
{
|
||||
using var branchDoc = JsonDocument.Parse(await Http.GetStringAsync($"{Api}/branches/{Branch}"));
|
||||
var commit = branchDoc.RootElement.GetProperty("commit");
|
||||
string sha = commit.GetProperty("id").GetString();
|
||||
string msg = commit.TryGetProperty("message", out var m) ? m.GetString() : "";
|
||||
string date = commit.TryGetProperty("timestamp", out var t) ? t.GetString() : null;
|
||||
|
||||
string version = ParseCsprojVersion(await Http.GetStringAsync($"{Source}/raw/commit/{sha}/MSCO.Server.csproj"))
|
||||
?? throw new Exception("No <Version> in MSCO.Server.csproj");
|
||||
string modVersion = null;
|
||||
try { modVersion = ParseModVersion(await Http.GetStringAsync($"{Source}/raw/commit/{sha}/GameServer.cs")); } catch { }
|
||||
|
||||
var found = new Latest
|
||||
{
|
||||
Version = version, Commit = sha, Date = date, ModVersion = modVersion,
|
||||
Message = (msg ?? "").Split('\n')[0].Trim()
|
||||
};
|
||||
if (IsNewer(version, CurrentVersion))
|
||||
{
|
||||
try { found.Changes = await ChangesAsync(sha); } catch { }
|
||||
}
|
||||
|
||||
bool newlyAvailable;
|
||||
lock (gate)
|
||||
{
|
||||
newlyAvailable = IsNewer(version, CurrentVersion) && (latest == null || latest.Version != version || !notified);
|
||||
latest = found;
|
||||
lastCheckUtc = DateTime.UtcNow;
|
||||
checkError = null;
|
||||
if (newlyAvailable) notified = true;
|
||||
}
|
||||
if (newlyAvailable)
|
||||
GameServer.Log($"Update available: MSCO server {version} (you have {CurrentVersion}) - install it in the web panel (Update)" +
|
||||
(modVersion != null && modVersion != GameServer.ModVersion ? $". Players then need MSCO mod {modVersion}." : ""));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
lastCheckUtc = DateTime.UtcNow;
|
||||
checkError = "Could not check for updates: " + Short(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Commits seit der eigenen Version (bzw. die letzten, wenn der eigene Commit unbekannt ist).</summary>
|
||||
async Task<List<Commit>> ChangesAsync(string sha)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(await Http.GetStringAsync(
|
||||
$"{Api}/commits?sha={sha}&limit=30&stat=false&verification=false&files=false"));
|
||||
var list = new List<Commit>();
|
||||
string own = CurrentCommit;
|
||||
foreach (var c in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
string id = c.GetProperty("sha").GetString();
|
||||
if (own != null && id.StartsWith(own, StringComparison.OrdinalIgnoreCase)) break;
|
||||
var inner = c.GetProperty("commit");
|
||||
string text = inner.GetProperty("message").GetString() ?? "";
|
||||
string date = inner.TryGetProperty("author", out var a) && a.TryGetProperty("date", out var d) ? d.GetString() : null;
|
||||
list.Add(new Commit { Sha = id[..Math.Min(8, id.Length)], Title = text.Split('\n')[0].Trim(), Date = date });
|
||||
if (list.Count >= (own != null ? 30 : 10)) break;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Ist ein .NET SDK zum Bauen da? (Docker-Image ab 2.9 enthält es.)</summary>
|
||||
void CheckSdk()
|
||||
{
|
||||
if (sdk != null) return;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(DotnetPath(), "--list-sdks") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
using var p = Process.Start(psi);
|
||||
string output = p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit(15000);
|
||||
int need = Environment.Version.Major;
|
||||
bool ok = output.Split('\n').Any(l => int.TryParse(l.Split('.')[0].Trim(), out int major) && major >= need);
|
||||
lock (gate) sdk = ok ? (true, null) : (false, $"No .NET {need} SDK found - it is needed to build the update. " +
|
||||
"With Docker: pull the new version once (git pull && docker compose up -d --build), its image contains the SDK.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
lock (gate) sdk = (false, "The dotnet command was not found (" + Short(e) + ") - install the .NET SDK to update from the panel.");
|
||||
}
|
||||
}
|
||||
|
||||
static string DotnetPath()
|
||||
{
|
||||
string self = Environment.ProcessPath;
|
||||
string name = Path.GetFileNameWithoutExtension(self ?? "");
|
||||
return name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? self : "dotnet";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Installieren
|
||||
|
||||
/// <summary>Update im Hintergrund laden, bauen, installieren und neu starten. null = gestartet, sonst Fehlertext.</summary>
|
||||
public string Install(string by)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (step is Step.Downloading or Step.Building or Step.Installing or Step.Restarting) return "An update is already running";
|
||||
if (latest == null || !IsNewer(latest.Version, CurrentVersion)) return "No update available - check again first";
|
||||
if (sdk is { ok: false }) return sdk.Value.reason;
|
||||
log.Clear();
|
||||
error = null;
|
||||
target = latest.Version;
|
||||
startedBy = by;
|
||||
step = Step.Downloading;
|
||||
progress = 0;
|
||||
stepSinceUtc = DateTime.UtcNow;
|
||||
}
|
||||
GameServer.Log($"Server update to {target} started by {by}");
|
||||
_ = Task.Run(InstallAsync);
|
||||
return null;
|
||||
}
|
||||
|
||||
async Task InstallAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1) Quellcode des neuesten Commits laden
|
||||
SetStep(Step.Downloading, 2, "Checking the newest version");
|
||||
await CheckAsync();
|
||||
Latest want;
|
||||
lock (gate) want = latest;
|
||||
if (want == null || !IsNewer(want.Version, CurrentVersion)) throw new Exception(checkError ?? "No update available anymore");
|
||||
lock (gate) target = want.Version;
|
||||
|
||||
Directory.CreateDirectory(WorkDir);
|
||||
string zip = Path.Combine(WorkDir, "source.zip"), src = Path.Combine(WorkDir, "src");
|
||||
SetStep(Step.Downloading, 5, $"Downloading MSCO server {want.Version} ({want.Commit[..8]})");
|
||||
await DownloadAsync($"{Source}/archive/{want.Commit}.zip", zip);
|
||||
if (Directory.Exists(src)) Directory.Delete(src, true);
|
||||
ZipFile.ExtractToDirectory(zip, src);
|
||||
string csproj = Directory.GetFiles(src, "MSCO.Server.csproj", SearchOption.AllDirectories)
|
||||
.OrderBy(p => p.Length).FirstOrDefault()
|
||||
?? throw new Exception("MSCO.Server.csproj not found in the download");
|
||||
SetProgress(25, "Download complete");
|
||||
|
||||
// 2) Bauen
|
||||
string build = Path.Combine(AppDir, "build");
|
||||
if (Directory.Exists(build)) Directory.Delete(build, true);
|
||||
SetStep(Step.Building, 27, "Building (dotnet publish) - this takes a minute");
|
||||
await BuildAsync(csproj, build, want.Commit);
|
||||
string dll = Path.Combine(build, "MSCO.Server.dll");
|
||||
if (!File.Exists(dll)) throw new Exception("The build did not produce MSCO.Server.dll");
|
||||
|
||||
// 3) Installieren: build -> current (die bisherige bleibt als previous)
|
||||
SetStep(Step.Installing, 92, "Installing");
|
||||
WriteInfo(build, new InstallInfo { Version = want.Version, Commit = want.Commit, InstalledUtc = DateTime.UtcNow, By = startedBy });
|
||||
string current = Path.Combine(AppDir, "current"), previous = Path.Combine(AppDir, "previous");
|
||||
if (Directory.Exists(previous)) Directory.Delete(previous, true);
|
||||
if (Directory.Exists(current)) Directory.Move(current, previous);
|
||||
Directory.Move(build, current);
|
||||
try { Directory.Delete(src, true); File.Delete(zip); } catch { }
|
||||
AddLog("Installed to " + current);
|
||||
|
||||
// 4) Neu starten - die Spieler verbinden sich danach automatisch wieder
|
||||
SetStep(Step.Restarting, 96, $"Restarting with version {want.Version}");
|
||||
var server = host.Server;
|
||||
if (server != null && host.State == HostState.Running)
|
||||
{
|
||||
// im Server-Thread: Spieler zählen und vorwarnen
|
||||
var warn = server.Invoke(() => server.JoinedCount > 0
|
||||
&& server.PanelSay("Server update - restarting in 5 seconds, you reconnect automatically", "Server") != null);
|
||||
if (await Task.WhenAny(warn, Task.Delay(3000)) == warn && warn.Result) await Task.Delay(5000);
|
||||
}
|
||||
await Task.Delay(1500); // Panel: "Neustart" noch anzeigen lassen
|
||||
GameServer.Log($"Update {want.Version} installed - restarting");
|
||||
host.ExitForUpdate();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
step = Step.Failed;
|
||||
error = Short(e);
|
||||
message = "Update failed";
|
||||
stepSinceUtc = DateTime.UtcNow;
|
||||
}
|
||||
AddLog("FAILED: " + Short(e));
|
||||
GameServer.Log("Server update failed: " + Short(e) + " - the server keeps running the old version");
|
||||
}
|
||||
}
|
||||
|
||||
async Task DownloadAsync(string url, string file)
|
||||
{
|
||||
using var res = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
res.EnsureSuccessStatusCode();
|
||||
long? total = res.Content.Headers.ContentLength;
|
||||
await using var input = await res.Content.ReadAsStreamAsync();
|
||||
await using var output = File.Create(file);
|
||||
var buffer = new byte[81920];
|
||||
long done = 0;
|
||||
int read;
|
||||
while ((read = await input.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
await output.WriteAsync(buffer.AsMemory(0, read));
|
||||
done += read;
|
||||
string mb = (done / 1048576.0).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (total > 0) SetProgress(5 + (int)(18 * done / total.Value), $"Downloading … {mb} MB");
|
||||
else SetProgress(Math.Min(22, 5 + (int)(done / 100_000)), $"Downloading … {mb} MB");
|
||||
}
|
||||
AddLog($"Downloaded {done / 1024} KB");
|
||||
}
|
||||
|
||||
async Task BuildAsync(string csproj, string output, string commit)
|
||||
{
|
||||
var psi = new ProcessStartInfo(DotnetPath())
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(csproj)
|
||||
};
|
||||
foreach (string a in new[] { "publish", csproj, "-c", "Release", "-o", output, "--nologo", "--disable-build-servers",
|
||||
"-p:SourceRevisionId=" + commit, "-p:UseAppHost=" + (OperatingSystem.IsWindows() ? "true" : "false") })
|
||||
psi.ArgumentList.Add(a);
|
||||
// Alles Schreibbare in den Datenordner (in Docker ist HOME nur /tmp)
|
||||
psi.Environment["DOTNET_CLI_HOME"] = Path.Combine(WorkDir, "home");
|
||||
psi.Environment["NUGET_PACKAGES"] = Path.Combine(WorkDir, "nuget");
|
||||
psi.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
|
||||
psi.Environment["DOTNET_NOLOGO"] = "1";
|
||||
psi.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1";
|
||||
psi.Environment["MSBUILDDISABLENODEREUSE"] = "1";
|
||||
|
||||
using var p = new Process { StartInfo = psi };
|
||||
void OnLine(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) return;
|
||||
AddLog(line.TrimEnd());
|
||||
if (line.Contains("Restored ") || line.Contains("All projects are up-to-date")) SetProgress(45, "Building - packages restored");
|
||||
else if (line.Contains(" -> ") && line.Contains(".dll")) SetProgress(80, "Building - compiled");
|
||||
else if (line.Contains(" -> ")) SetProgress(90, "Building - published");
|
||||
}
|
||||
p.OutputDataReceived += (_, e) => { if (e.Data != null) OnLine(e.Data); };
|
||||
p.ErrorDataReceived += (_, e) => { if (e.Data != null) OnLine(e.Data); };
|
||||
p.Start();
|
||||
p.BeginOutputReadLine();
|
||||
p.BeginErrorReadLine();
|
||||
|
||||
// Fortschritt langsam weiterlaufen lassen, solange das Bauen dauert
|
||||
var started = DateTime.UtcNow;
|
||||
while (!p.HasExited)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
if (DateTime.UtcNow - started > BuildTimeout)
|
||||
{
|
||||
try { p.Kill(true); } catch { }
|
||||
throw new Exception("The build took longer than " + BuildTimeout.TotalMinutes + " minutes");
|
||||
}
|
||||
lock (gate) if (progress < 88) progress++;
|
||||
}
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
{
|
||||
string lastError;
|
||||
lock (gate) lastError = log.LastOrDefault(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)) ?? "see the log";
|
||||
throw new Exception($"The build failed (exit code {p.ExitCode}): {lastError}");
|
||||
}
|
||||
SetProgress(91, "Build complete");
|
||||
}
|
||||
|
||||
static string Short(Exception e)
|
||||
{
|
||||
while (e.InnerException != null && e is AggregateException or TargetInvocationException) e = e.InnerException;
|
||||
return e.Message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Neu starten
|
||||
|
||||
/// <summary>Läuft das Programm unter Docker/systemd (die starten nach dem Beenden neu)?</summary>
|
||||
public static bool Supervised =>
|
||||
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"
|
||||
|| Environment.GetEnvironmentVariable("INVOCATION_ID") != null // systemd
|
||||
|| Environment.GetEnvironmentVariable("MSCO_SUPERVISED") == "1";
|
||||
|
||||
/// <summary>Nach dem Beenden für ein Update: ohne Docker/systemd startet sich das Programm selbst neu.</summary>
|
||||
public static void Relaunch(string[] args)
|
||||
{
|
||||
if (Supervised)
|
||||
{
|
||||
GameServer.Log("Exiting for the update - Docker/systemd starts the server again");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
string self = Environment.ProcessPath;
|
||||
var all = new List<string>();
|
||||
if (Path.GetFileNameWithoutExtension(self).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
all.Add(Environment.GetCommandLineArgs()[0]); // die gestartete MSCO.Server.dll
|
||||
all.AddRange(args);
|
||||
var psi = new ProcessStartInfo(self)
|
||||
{
|
||||
Arguments = string.Join(" ", all.Select(Quote)),
|
||||
WorkingDirectory = Environment.CurrentDirectory,
|
||||
UseShellExecute = OperatingSystem.IsWindows() // Windows: neues Konsolenfenster
|
||||
};
|
||||
Process.Start(psi);
|
||||
GameServer.Log("Started the updated server");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
GameServer.Log("Could not restart automatically (" + e.Message + ") - please start the server again");
|
||||
}
|
||||
}
|
||||
|
||||
static string Quote(string a) => a.Length > 0 && a.IndexOfAny(new[] { ' ', '"', '\t' }) < 0 ? a : "\"" + a.Replace("\"", "\\\"") + "\"";
|
||||
|
||||
// ---------------------------------------------------------------- installierte Version starten
|
||||
|
||||
sealed class InstallInfo
|
||||
{
|
||||
public string Version { get; set; }
|
||||
public string Commit { get; set; }
|
||||
public DateTime InstalledUtc { get; set; }
|
||||
public string By { get; set; }
|
||||
public int Attempts { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
static InstallInfo ReadInfo(string dir)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<InstallInfo>(File.ReadAllText(Path.Combine(dir, "msco-update.json"))); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
static void WriteInfo(string dir, InstallInfo info) =>
|
||||
File.WriteAllText(Path.Combine(dir, "msco-update.json"), JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
/// <summary>Läuft gerade eine installierte Update-Version? Dann nach einer Minute als funktionierend merken.</summary>
|
||||
static void MarkHealthy()
|
||||
{
|
||||
string dir = Environment.GetEnvironmentVariable("MSCO_UPDATE_DIR");
|
||||
if (dir == null) return;
|
||||
var info = ReadInfo(dir);
|
||||
if (info == null || info.Verified) return;
|
||||
info.Verified = true;
|
||||
info.Attempts = 0;
|
||||
try { WriteInfo(dir, info); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ganz am Anfang von Program: liegt in DATA/app/current eine neuere Version, diese statt der eigenen ausführen
|
||||
/// (im selben Prozess, eigener AssemblyLoadContext - Signale, Konsole und Docker bleiben gleich). Gibt den Exit-Code
|
||||
/// zurück oder null, wenn die eigene Version laufen soll.
|
||||
/// </summary>
|
||||
public static int? RunInstalled(string[] args, string dataDir)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("MSCO_UPDATE_DIR") != null) return null; // wir SIND die installierte Version
|
||||
if (Environment.GetEnvironmentVariable("MSCO_NO_UPDATES") == "1") return null;
|
||||
string dir = Path.Combine(dataDir, "app", "current");
|
||||
var info = ReadInfo(dir);
|
||||
if (info == null) return null;
|
||||
string dll = Path.Combine(dir, "MSCO.Server.dll");
|
||||
if (!File.Exists(dll)) return null;
|
||||
if (!IsNewer(info.Version, CurrentVersion))
|
||||
{
|
||||
// Das Programm selbst ist gleich neu oder neuer (z.B. neues Docker-Image) - altes Update aufräumen
|
||||
Console.WriteLine($"Installed update {info.Version} is not newer than this version ({CurrentVersion}) - removing it");
|
||||
try { Directory.Delete(Path.Combine(dataDir, "app"), true); } catch { }
|
||||
return null;
|
||||
}
|
||||
if (!info.Verified && info.Attempts >= MaxStartAttempts)
|
||||
{
|
||||
Console.WriteLine($"The installed update {info.Version} failed to start {info.Attempts} times - running the built-in version {CurrentVersion}");
|
||||
try
|
||||
{
|
||||
string failed = Path.Combine(dataDir, "app", "failed");
|
||||
if (Directory.Exists(failed)) Directory.Delete(failed, true);
|
||||
Directory.Move(dir, failed);
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
if (!info.Verified)
|
||||
{
|
||||
info.Attempts++;
|
||||
try { WriteInfo(dir, info); } catch { }
|
||||
}
|
||||
|
||||
MethodInfo entry;
|
||||
try
|
||||
{
|
||||
var context = new UpdateLoadContext(dll);
|
||||
entry = context.LoadFromAssemblyPath(dll).EntryPoint ?? throw new Exception("no entry point");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine($"Could not load the installed update {info.Version}: {e.Message} - running the built-in version {CurrentVersion}");
|
||||
return null;
|
||||
}
|
||||
Console.WriteLine($"Starting installed update {info.Version} (built-in version {CurrentVersion})");
|
||||
Environment.SetEnvironmentVariable("MSCO_UPDATE_DIR", dir);
|
||||
try
|
||||
{
|
||||
object result = entry.Invoke(null, entry.GetParameters().Length == 0 ? null : new object[] { args });
|
||||
if (result is Task<int> task) return task.GetAwaiter().GetResult();
|
||||
if (result is Task plain) { plain.GetAwaiter().GetResult(); return 0; }
|
||||
return result is int code ? code : 0;
|
||||
}
|
||||
catch (TargetInvocationException e) when (e.InnerException != null)
|
||||
{
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lädt die installierte Version samt ihren Abhängigkeiten (z.B. SharpCompress) aus ihrem Ordner.</summary>
|
||||
sealed class UpdateLoadContext : AssemblyLoadContext
|
||||
{
|
||||
readonly AssemblyDependencyResolver resolver;
|
||||
|
||||
public UpdateLoadContext(string mainAssembly) : base("msco-update") => resolver = new AssemblyDependencyResolver(mainAssembly);
|
||||
|
||||
protected override Assembly Load(AssemblyName name)
|
||||
{
|
||||
string path = resolver.ResolveAssemblyToPath(name);
|
||||
return path != null ? LoadFromAssemblyPath(path) : null; // Framework (ASP.NET ...) aus dem Standard-Kontext
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string name)
|
||||
{
|
||||
string path = resolver.ResolveUnmanagedDllToPath(name);
|
||||
return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user