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")}"; } }