Initial commit: Brass & Sigil monorepo
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.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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")}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user