a1331212cb
Self-hosted Minecraft modpack distribution + administration system.
- launcher/ Avalonia 12 desktop client; single-file win-x64 publish.
Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
portable install path, sidecar settings.
- server/ brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
MC subprocess, embedded Kestrel admin panel with cookie
auth + rate limiting, RCON bridge, scheduled backups,
BlueMap CLI integration with player markers + skin proxy,
friend-side whitelist request flow, world wipe with seed
selection (keep current / random / custom).
- pack/ pack.lock.json (Modrinth + manual CurseForge entries),
data-only tweak source under tweaks/, build outputs in
overrides/ (gitignored).
- scripts/ Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
plus Deploy-Brass.ps1 unified one-shot deploy with
version-bump pre-flight and daemon-state detection.
261 lines
10 KiB
C#
261 lines
10 KiB
C#
using BrassAndSigil.Server.Models;
|
|
using System.Text.Json;
|
|
|
|
namespace BrassAndSigil.Server.Services;
|
|
|
|
/// <summary>
|
|
/// Drives a "fetch new manifest, drain players, swap mods, restart MC" workflow.
|
|
/// Single-flight: one update at a time, guarded by a semaphore. State is exposed
|
|
/// so the panel can poll progress; logs go through the existing OnLogLine event
|
|
/// (re-streamed via SSE) so they show up in the live console too.
|
|
/// </summary>
|
|
public sealed class UpdaterService
|
|
{
|
|
private readonly ServerConfig _config;
|
|
private readonly string _configPath;
|
|
private readonly ServerProcess _proc;
|
|
private readonly Broadcaster _broadcast;
|
|
private readonly Action<string> _log;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
|
|
private CancellationTokenSource? _cts;
|
|
public UpdateState State { get; private set; } = new();
|
|
|
|
public sealed class UpdateState
|
|
{
|
|
public bool InProgress { get; set; }
|
|
public string Phase { get; set; } = "idle";
|
|
// "idle" | "countdown" | "stopping" | "syncing" | "installing_loader" | "starting" | "complete" | "failed" | "cancelled"
|
|
public int CountdownTotal { get; set; }
|
|
public int CountdownRemaining { get; set; }
|
|
public string? CurrentVersion { get; set; }
|
|
public string? AvailableVersion { get; set; }
|
|
public string? Error { get; set; }
|
|
public DateTimeOffset? LastFinishedAt { get; set; }
|
|
}
|
|
|
|
public sealed record CheckResult(string? Current, string? Available, bool NeedsUpdate, string? Error);
|
|
|
|
public UpdaterService(ServerConfig config, string configPath,
|
|
ServerProcess proc, Broadcaster broadcast,
|
|
Action<string> log)
|
|
{
|
|
_config = config;
|
|
_configPath = configPath;
|
|
_proc = proc;
|
|
_broadcast = broadcast;
|
|
_log = log;
|
|
}
|
|
|
|
/// <summary>Lightweight read: compare local pack-version.json to remote manifest.</summary>
|
|
public async Task<CheckResult> CheckAsync(CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var sync = new ManifestSync();
|
|
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
|
|
var local = ReadLocalPackVersion(_config.ServerDir);
|
|
var current = local;
|
|
var available = manifest.Version;
|
|
var needs = !string.Equals(current, available, StringComparison.Ordinal);
|
|
State.CurrentVersion = current;
|
|
State.AvailableVersion = available;
|
|
return new CheckResult(current, available, needs, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new CheckResult(State.CurrentVersion, State.AvailableVersion, false, ex.Message);
|
|
}
|
|
}
|
|
|
|
public bool TryCancel()
|
|
{
|
|
if (!State.InProgress || _cts is null) return false;
|
|
// Only meaningful during countdown phase -- a sync mid-flight isn't safely abortable.
|
|
if (State.Phase != "countdown") return false;
|
|
_cts.Cancel();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run the full update flow. Single-flight -- returns false if one is already running.
|
|
/// </summary>
|
|
public async Task<bool> StartAsync(int delaySeconds)
|
|
{
|
|
if (!await _gate.WaitAsync(0)) return false;
|
|
_cts = new CancellationTokenSource();
|
|
var ct = _cts.Token;
|
|
|
|
State = new UpdateState
|
|
{
|
|
InProgress = true,
|
|
Phase = "countdown",
|
|
CountdownTotal = delaySeconds,
|
|
CountdownRemaining = delaySeconds,
|
|
CurrentVersion = State.CurrentVersion,
|
|
AvailableVersion = State.AvailableVersion,
|
|
};
|
|
|
|
try
|
|
{
|
|
// ── 1. Player-facing countdown ──
|
|
if (delaySeconds > 0 && _proc.IsRunning)
|
|
{
|
|
_log($"[update] Announcing restart in {delaySeconds}s.");
|
|
await _broadcast.SayAsync($"Server will restart in {FormatDuration(delaySeconds)} for an update to v{State.AvailableVersion}.", ct);
|
|
|
|
// Run the action-bar countdown + periodic chat warnings + UI ticker
|
|
// in parallel. Action bar (instead of boss bar) avoids stacking on
|
|
// top of real boss fight UIs (Ender Dragon, raids, mod bosses).
|
|
var actionBar = _broadcast.ActionBarCountdownAsync(
|
|
"Server restart for update", delaySeconds, ct);
|
|
|
|
var warnings = WarnDuringCountdownAsync(delaySeconds, ct);
|
|
|
|
// Drive State.CountdownRemaining for the UI poller.
|
|
var ticker = TickCountdownStateAsync(delaySeconds, ct);
|
|
|
|
await Task.WhenAll(actionBar, warnings, ticker);
|
|
}
|
|
|
|
// ── 2. Stop MC ──
|
|
ct.ThrowIfCancellationRequested();
|
|
State.Phase = "stopping";
|
|
_log("[update] Stopping Minecraft for update...");
|
|
if (_proc.IsRunning)
|
|
{
|
|
await _broadcast.SayAsync("Server is restarting now.");
|
|
await _proc.StopAsync(TimeSpan.FromSeconds(30));
|
|
}
|
|
|
|
// ── 3. Sync mods from manifest ──
|
|
ct.ThrowIfCancellationRequested();
|
|
State.Phase = "syncing";
|
|
_log("[update] Syncing mods from manifest...");
|
|
var sync = new ManifestSync();
|
|
var progress = new Progress<string>(msg => _log($"[update] {msg}"));
|
|
var result = await sync.SyncAsync(_config.ManifestUrl, _config.ServerDir, progress, ct);
|
|
_log($"[update] Sync complete: {result.Downloaded} downloaded, {result.Removed} removed.");
|
|
|
|
// ── 4. Update NeoForge if loader version changed ──
|
|
ct.ThrowIfCancellationRequested();
|
|
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
|
|
if (manifest.Loader is { } loader &&
|
|
loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase) &&
|
|
LoaderVersionChanged(_config.ServerDir, loader.Version))
|
|
{
|
|
State.Phase = "installing_loader";
|
|
_log($"[update] Reinstalling NeoForge {loader.Version}...");
|
|
var nf = new NeoForgeInstaller();
|
|
var ok = await nf.InstallAsync(loader.Version, _config.ServerDir, _config.JavaPath, progress, ct);
|
|
if (!ok) throw new InvalidOperationException("NeoForge installer failed.");
|
|
}
|
|
|
|
// ── 5. Start MC ──
|
|
ct.ThrowIfCancellationRequested();
|
|
State.Phase = "starting";
|
|
_log("[update] Starting Minecraft...");
|
|
_proc.Start();
|
|
State.CurrentVersion = manifest.Version;
|
|
|
|
State.Phase = "complete";
|
|
State.InProgress = false;
|
|
State.LastFinishedAt = DateTimeOffset.UtcNow;
|
|
_log("[update] Update complete.");
|
|
return true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
State.Phase = "cancelled";
|
|
State.InProgress = false;
|
|
State.LastFinishedAt = DateTimeOffset.UtcNow;
|
|
_log("[update] Update cancelled.");
|
|
// If we cancelled during countdown, MC is still running -- leave it alone.
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
State.Phase = "failed";
|
|
State.Error = ex.Message;
|
|
State.InProgress = false;
|
|
State.LastFinishedAt = DateTimeOffset.UtcNow;
|
|
_log($"[update] Failed: {ex.Message}");
|
|
// Try to bring MC back up if we stopped it but never restarted.
|
|
if (!_proc.IsRunning)
|
|
{
|
|
try { _proc.Start(); _log("[update] Restored Minecraft after failure."); }
|
|
catch (Exception startEx) { _log($"[update] Restore failed too: {startEx.Message}"); }
|
|
}
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_cts?.Dispose();
|
|
_cts = null;
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
private async Task TickCountdownStateAsync(int total, CancellationToken ct)
|
|
{
|
|
for (int sec = total; sec > 0; sec--)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
State.CountdownRemaining = sec;
|
|
try { await Task.Delay(1000, ct); }
|
|
catch (OperationCanceledException) { throw; }
|
|
}
|
|
State.CountdownRemaining = 0;
|
|
}
|
|
|
|
private async Task WarnDuringCountdownAsync(int total, CancellationToken ct)
|
|
{
|
|
// Periodic chat warnings -- independent of the boss bar (visual-but-missable).
|
|
// Each milestone fires at an absolute time computed from the start, so the
|
|
// delays don't accumulate sequentially across the loop iterations.
|
|
var startUtc = DateTime.UtcNow;
|
|
var milestones = new[] { 300, 60, 30, 10 };
|
|
foreach (var m in milestones)
|
|
{
|
|
if (m >= total) continue;
|
|
var fireAt = startUtc.AddSeconds(total - m);
|
|
var wait = fireAt - DateTime.UtcNow;
|
|
if (wait > TimeSpan.Zero)
|
|
{
|
|
try { await Task.Delay(wait, ct); }
|
|
catch (OperationCanceledException) { throw; }
|
|
}
|
|
try { await _broadcast.SayAsync($"Server restart in {FormatDuration(m)}."); }
|
|
catch { /* don't bring down the whole update for one failed broadcast */ }
|
|
}
|
|
}
|
|
|
|
private static bool LoaderVersionChanged(string serverDir, string newVersion)
|
|
{
|
|
// Look at the libraries dir for an existing neoforge-<version> path.
|
|
// If absent or different version, we should re-install.
|
|
var libsRoot = Path.Combine(serverDir, "libraries", "net", "neoforged", "neoforge");
|
|
if (!Directory.Exists(libsRoot)) return true;
|
|
var versions = Directory.EnumerateDirectories(libsRoot).Select(Path.GetFileName).ToList();
|
|
return !versions.Contains(newVersion);
|
|
}
|
|
|
|
private static string? ReadLocalPackVersion(string serverDir)
|
|
{
|
|
var path = Path.Combine(serverDir, "pack-version.json");
|
|
if (!File.Exists(path)) return null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
|
return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private static string FormatDuration(int seconds)
|
|
{
|
|
if (seconds >= 60) return $"{seconds / 60} minute{(seconds / 60 == 1 ? "" : "s")}";
|
|
return $"{seconds} second{(seconds == 1 ? "" : "s")}";
|
|
}
|
|
}
|