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:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+260
View File
@@ -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")}";
}
}