using BrassAndSigil.Server.Models;
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
///
/// 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.
///
public sealed class UpdaterService
{
private readonly ServerConfig _config;
private readonly string _configPath;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action _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 log)
{
_config = config;
_configPath = configPath;
_proc = proc;
_broadcast = broadcast;
_log = log;
}
/// Lightweight read: compare local pack-version.json to remote manifest.
public async Task 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;
}
///
/// Run the full update flow. Single-flight -- returns false if one is already running.
///
public async Task 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(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- 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")}";
}
}