commit a1331212cb61e86271682e4dba543d11b64a21cc Author: Matt Sijbers Date: Tue May 5 00:19:05 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6448d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# ─── .NET build outputs ─────────────────────────────────────────────────── +bin/ +obj/ +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ll]og/ +[Ll]ogs/ +*.pdb +*.user +*.suo +*.userosscache +*.sln.docstates + +# ─── IDE state ──────────────────────────────────────────────────────────── +.vs/ +.vscode/ +.idea/ +*.sln.iml + +# ─── NuGet / package caches ─────────────────────────────────────────────── +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json +artifacts/ + +# ─── Build / publish output (every project) ─────────────────────────────── +publish/ + +# ─── Misc OS junk ───────────────────────────────────────────────────────── +*.swp +.DS_Store +Thumbs.db + +# ─── Local secrets / runtime config (track template, ignore real values) ─ +launcher/launcher-config.json +server/deploy/server-config.json +scripts/deploy.config.ps1 + +# ─── Local AI assistant artifacts (Claude, Cursor, Copilot) ─────────────── +# Kept out of the public repo so collaborators aren't surprised by tool- +# specific orientation files. Local copies stay usable in the working tree. +CLAUDE.md +.claude/ +.cursor/ +.cursorrules +.aider* +.github/copilot* + +# ─── Build artifacts that get regenerated ───────────────────────────────── +# Tweak jars rebuilt by scripts/Build-Tweaks.ps1 +pack/overrides/ +# manifest.json regenerated by scripts/Build-Pack.ps1 -- produced at scripts/ +# (default OutputPath) and copied to the deploy share by Deploy-Brass.ps1 +scripts/manifest.json +pack/manifest.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b5ccd9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matt Sijbers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d5f3f4 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Brass & Sigil + +Self-hosted Minecraft modpack distribution + administration system. +Three components, one repo: + +``` +brass-and-sigil/ +├── launcher/ ← Avalonia desktop client (Windows .NET 8) +│ Auths with Microsoft, syncs the modpack, launches the game. +├── server/ ← brass-sigil-server daemon (Linux .NET 8) +│ Wraps the MC subprocess, exposes the admin web panel, +│ syncs mods, runs backups + BlueMap, handles whitelist. +├── pack/ ← Modpack content +│ ├── pack.lock.json Source of truth for mod versions / URLs / hashes +│ ├── tweaks/ Hand-written data-only tweak source +│ └── overrides/ Build output / hand-placed local files (gitignored) +├── scripts/ ← Build + deploy entry points (run from repo root) +│ ├── Update-Pack.ps1 Refresh pack.lock.json from Modrinth/CurseForge +│ ├── Check-Updates.ps1 Non-mutating "what's new?" report +│ ├── Build-Tweaks.ps1 Compile tweaks/ into pack/overrides/mods/*.jar +│ ├── Build-Pack.ps1 Generate manifest.json from the lockfile +│ └── Deploy-Brass.ps1 One-shot build + deploy everything +└── docs/ ← Operational notes, deploy runbook, schema docs +``` + +## Quick start + +```powershell +# Pull dependencies, copy and edit the deploy profile +git clone brass-and-sigil +cd brass-and-sigil +Copy-Item scripts\deploy.config.template.ps1 scripts\deploy.config.ps1 +notepad scripts\deploy.config.ps1 # fill in deploy share, SSH host, etc. + +# Build + deploy everything in one go +.\scripts\Deploy-Brass.ps1 +``` + +`deploy.config.ps1` is gitignored -- local values never get committed. + +## Component reference + +| Component | What it does | Technology | +|-----------|--------------|------------| +| `launcher/` | Friend-distributed client. Auths with Microsoft via WebView2 + XboxAuthNet, downloads mods, launches Minecraft. Single self-contained `.exe` published from `publish/`. | Avalonia 12 + CmlLib.Core | +| `server/` | Daemon running alongside the MC process on Linux. Hosts a Kestrel web panel for admin (cookie-auth, rate-limited), bridges RCON, runs scheduled backups + BlueMap renders, handles whitelist requests. | ASP.NET Core minimal APIs | +| `pack/` | Lockfile-driven modpack content. `pack.lock.json` resolves mod slug → URL+SHA-1; `Build-Pack.ps1` walks it and produces `manifest.json` for the launcher and the server-tool to consume. | PowerShell tooling | + +## Workflow + +1. **Bump mod versions:** `.\scripts\Update-Pack.ps1` (queries Modrinth + manual CurseForge entries). +2. **Sanity-check:** `.\scripts\Check-Updates.ps1` for a non-mutating availability report. +3. **Edit tweaks** under `pack/tweaks//` if you're changing recipes / loot / etc. +4. **Deploy:** `.\scripts\Deploy-Brass.ps1` -- builds launcher + server, regenerates manifest, mirrors everything to the deploy share, scp's the server binary. + +## Secrets / config files + +| File | Tracked? | Notes | +|------|----------|-------| +| `launcher/launcher-config.template.json` | ✓ | Empty placeholders, public-safe | +| `launcher/launcher-config.json` | ✗ | Local override; merged into the published exe at build time if present | +| `server/deploy/server-config.example.json` | ✓ | Template; rename to `server-config.json` on the server with real values | +| `server/deploy/server-config.json` | ✗ | Production config; lives on the server only | +| `scripts/deploy.config.ps1` | ✗ | Local deploy profile (paths, SSH host, share location) | + +## License + +See `LICENSE`. diff --git a/launcher/App.axaml b/launcher/App.axaml new file mode 100644 index 0000000..468f2a2 --- /dev/null +++ b/launcher/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/launcher/App.axaml.cs b/launcher/App.axaml.cs new file mode 100644 index 0000000..8372e5e --- /dev/null +++ b/launcher/App.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace ModpackLauncher; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/launcher/Assets/build-icon.ps1 b/launcher/Assets/build-icon.ps1 new file mode 100644 index 0000000..dde57bc --- /dev/null +++ b/launcher/Assets/build-icon.ps1 @@ -0,0 +1,98 @@ +#requires -Version 5 +# One-shot helper: produces a multi-resolution icon.ico from icon.png. +# Run only when the source icon changes; commit the resulting icon.ico. + +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName PresentationCore +Add-Type -AssemblyName WindowsBase + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$srcPath = Join-Path $here 'icon.png' +$icoPath = Join-Path $here 'icon.ico' + +if (-not (Test-Path $srcPath)) { throw "icon.png not found at $srcPath" } + +# Detect if the file is actually a different format renamed to .png (e.g. WebP from AI tools). +# If so, transcode via WPF's WIC pipeline to a real PNG before feeding GDI+. +$head = [System.IO.File]::ReadAllBytes($srcPath)[0..3] +$isPng = $head[0] -eq 0x89 -and $head[1] -eq 0x50 -and $head[2] -eq 0x4E -and $head[3] -eq 0x47 +if (-not $isPng) { + Write-Host "Source file is not a PNG (likely WebP from AI tool). Transcoding via WIC..." + $bytes = [System.IO.File]::ReadAllBytes($srcPath) + $stream = New-Object System.IO.MemoryStream(,$bytes) + $decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create( + $stream, + [System.Windows.Media.Imaging.BitmapCreateOptions]::PreservePixelFormat, + [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad) + $frame = $decoder.Frames[0] + # Force BGRA32 so GDI+ can later handle it cleanly with alpha + $converted = New-Object System.Windows.Media.Imaging.FormatConvertedBitmap( + $frame, + [System.Windows.Media.PixelFormats]::Bgra32, + $null, + 0) + $encoder = New-Object System.Windows.Media.Imaging.PngBitmapEncoder + $encoder.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($converted)) + $outStream = New-Object System.IO.MemoryStream + $encoder.Save($outStream) + [System.IO.File]::WriteAllBytes($srcPath, $outStream.ToArray()) + $outStream.Dispose() + $stream.Dispose() + Write-Host "Transcoded to real PNG ($($outStream.Length) bytes)." +} + +$sizes = 16, 32, 48, 64, 128, 256 +$src = [System.Drawing.Image]::FromFile($srcPath) +$frames = @{} + +foreach ($size in $sizes) { + $bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $g.DrawImage($src, 0, 0, $size, $size) + $g.Dispose() + + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $frames[$size] = $ms.ToArray() + $bmp.Dispose() + $ms.Dispose() +} + +$out = New-Object System.IO.MemoryStream +$bw = New-Object System.IO.BinaryWriter($out) + +# ICONDIR header +$bw.Write([UInt16]0) +$bw.Write([UInt16]1) +$bw.Write([UInt16]$sizes.Count) + +$dataOffset = 6 + (16 * $sizes.Count) +foreach ($size in $sizes) { + $bytes = $frames[$size] + $w = if ($size -ge 256) { [byte]0 } else { [byte]$size } + $h = if ($size -ge 256) { [byte]0 } else { [byte]$size } + $bw.Write([byte]$w) + $bw.Write([byte]$h) + $bw.Write([byte]0) + $bw.Write([byte]0) + $bw.Write([UInt16]1) + $bw.Write([UInt16]32) + $bw.Write([UInt32]$bytes.Length) + $bw.Write([UInt32]$dataOffset) + $dataOffset += $bytes.Length +} + +foreach ($size in $sizes) { + $bw.Write($frames[$size]) +} + +[System.IO.File]::WriteAllBytes($icoPath, $out.ToArray()) +$bw.Dispose() +$out.Dispose() +$src.Dispose() + +"Wrote: $icoPath ($((Get-Item $icoPath).Length) bytes, $($sizes.Count) sizes)" diff --git a/launcher/Assets/build-noise.ps1 b/launcher/Assets/build-noise.ps1 new file mode 100644 index 0000000..2bddeb5 --- /dev/null +++ b/launcher/Assets/build-noise.ps1 @@ -0,0 +1,43 @@ +#requires -Version 5 +# One-shot helper: generates a subtle warm-tinted tileable noise texture +# at Assets/noise.png. Run only when you want to regenerate the texture. + +Add-Type -AssemblyName System.Drawing + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$outPath = Join-Path $here 'noise.png' + +$size = 128 +$bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) +$rng = New-Object System.Random 1337 + +# Lock bits for fast pixel access +$rect = New-Object System.Drawing.Rectangle 0, 0, $size, $size +$data = $bmp.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::WriteOnly, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) +$bytes = New-Object byte[] ($data.Stride * $size) + +function Clamp([double]$v, [double]$lo, [double]$hi) { + if ($v -lt $lo) { return $lo } + if ($v -gt $hi) { return $hi } + return $v +} + +for ($y = 0; $y -lt $size; $y++) { + for ($x = 0; $x -lt $size; $x++) { + $offset = ($y * $data.Stride) + ($x * 4) + # Cool dark grain to overlay on a navy backdrop -- gives subtle metallic noise + $n = ($rng.NextDouble() - 0.5) * 2.0 # -1.0 .. 1.0 + $bytes[$offset] = [byte](Clamp (110 + ($n * 50)) 0 255) # B + $bytes[$offset + 1] = [byte](Clamp (105 + ($n * 50)) 0 255) # G + $bytes[$offset + 2] = [byte](Clamp (95 + ($n * 50)) 0 255) # R + $bytes[$offset + 3] = 28 # A (~11%) + } +} + +[System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $data.Scan0, $bytes.Length) +$bmp.UnlockBits($data) +$bmp.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() + +"Wrote: $outPath ($((Get-Item $outPath).Length) bytes, ${size}x${size})" diff --git a/launcher/Assets/icon.ico b/launcher/Assets/icon.ico new file mode 100644 index 0000000..52f8d21 Binary files /dev/null and b/launcher/Assets/icon.ico differ diff --git a/launcher/Assets/icon.png b/launcher/Assets/icon.png new file mode 100644 index 0000000..969d348 Binary files /dev/null and b/launcher/Assets/icon.png differ diff --git a/launcher/Assets/noise.png b/launcher/Assets/noise.png new file mode 100644 index 0000000..e1136ef Binary files /dev/null and b/launcher/Assets/noise.png differ diff --git a/launcher/MainWindow.axaml b/launcher/MainWindow.axaml new file mode 100644 index 0000000..c5dbf52 --- /dev/null +++ b/launcher/MainWindow.axaml @@ -0,0 +1,395 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/MainWindow.axaml.cs b/launcher/MainWindow.axaml.cs new file mode 100644 index 0000000..bbf42d3 --- /dev/null +++ b/launcher/MainWindow.axaml.cs @@ -0,0 +1,980 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CmlLib.Core.Auth; +using ModpackLauncher.Models; +using ModpackLauncher.Services; + +namespace ModpackLauncher; + +public partial class MainWindow : Window +{ + private enum LauncherState + { + Checking, // initial / fetching manifest + ConfigError, // manifest URL not set or unreachable + nothing local + NeedsDownload, // no local pack, manifest reachable + NeedsUpdate, // local pack version != manifest version + ReadyNotSignedIn,// up-to-date but not signed in + Ready // up-to-date and signed in + } + + private readonly LauncherConfig _config; + private readonly AuthService _auth; + private readonly ManifestSyncService _sync; + private LauncherSettings _settings; + private LaunchService? _launch; + private MSession? _session; + private Manifest? _remoteManifest; + private LauncherState _state = LauncherState.Checking; + private bool _busy; + private bool _suppressAutoSave; + private bool _infoPanelOpen; + // Cached "what the play button should show when not busy". Updated by ApplyState, + // restored by SetBusy(false). Avoids race between login flow's SetBusy and the + // RefreshStateAsync triggered by ApplySession. + private string _playButtonLabel = "Play"; + private bool _playButtonEnabled; + + public MainWindow() + { + InitializeComponent(); + + FileLog.Init(); + _config = LauncherConfig.Load(); + _auth = new AuthService(_config.MsalClientId); + _sync = new ManifestSyncService(); + _sync.SetBasicAuth(_config.HttpUsername, _config.HttpPassword); + + var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + var versionText = version is null ? "" : $" v{version.Major}.{version.Minor}.{version.Build}"; + Title = $"{_config.PackName} Launcher{versionText}"; + PackNameText.Text = _config.PackName; + TitleText.Text = $"{_config.PackName} Launcher{versionText}"; + + _settings = LauncherSettings.Load(); + + // Initialise RAM control without triggering auto-save + _suppressAutoSave = true; + RamBox.Value = _settings.MemoryMB ?? _config.MemoryMB; + _suppressAutoSave = false; + RamBox.ValueChanged += OnRamValueChanged; + + UpdateInstallDirDisplay(); + UpdateRamWarning((int)(RamBox.Value ?? _config.MemoryMB)); + + var localVersion = _sync.GetLocalPackVersion(GetInstallDir()); + PackVersionText.Text = localVersion?.Version is { } v + ? $"Installed: v{v}" + : "No pack synced yet"; + + // Maximize is disabled on this launcher -- the slide-out animation gets unhappy + // when the window can't actually resize. If something (Win+Up, Aero Snap, etc.) + // pushes us to Maximized anyway, snap back to Normal. + PropertyChanged += (_, e) => + { + if (e.Property == WindowStateProperty && WindowState == WindowState.Maximized) + { + WindowState = WindowState.Normal; + } + }; + + Opened += async (_, _) => + { + CheckSystemRequirements(); + await TrySilentSignInAsync(); + await RefreshStateAsync(refetchManifest: true); + }; + + // Auto-refresh manifest when window regains focus (e.g. user alt-tabs back) + Activated += async (_, _) => + { + if (_busy) return; + await RefreshStateAsync(refetchManifest: true); + }; + + } + + private async void OnRefreshClick(object? sender, RoutedEventArgs e) + { + if (_busy) return; + _remoteManifest = null; // force a fresh fetch + await RefreshStateAsync(refetchManifest: true); + } + + /// + /// Compare our running assembly version to the manifest's launcherVersion field. + /// If the manifest reports something newer, surface a non-blocking banner that + /// links to the public download URL. Doesn't auto-update -- friends decide when. + /// + private void CheckLauncherVersion(Manifest manifest) + { + UpdateBanner.IsVisible = false; + if (string.IsNullOrWhiteSpace(manifest.LauncherVersion)) return; + + var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + if (current is null) return; + if (!Version.TryParse(manifest.LauncherVersion, out var advertised)) return; + if (current >= advertised) return; + + UpdateBannerText.Text = $"A newer launcher (v{advertised}) is available -- you're on v{current.Major}.{current.Minor}.{current.Build}."; + UpdateBannerDownloadButton.Tag = manifest.LauncherUrl ?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe"; + UpdateBanner.IsVisible = true; + AppendLog($"[update] Newer launcher available: v{advertised} (running v{current})"); + } + + private void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e) + { + var url = UpdateBannerDownloadButton.Tag as string + ?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe"; + try + { + Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + } + catch (Exception ex) + { + AppendLog($"[update] Couldn't open browser: {ex.Message}"); + } + } + + private const double InfoPanelExtraWidth = 334; // 320 panel + 14 gap + + private void OnInfoClick(object? sender, RoutedEventArgs e) + { + _infoPanelOpen = !_infoPanelOpen; + ApplyInfoPanelState(); + } + + private void ApplyInfoPanelState() + { + var currentContainerWidth = InfoPanelContainer.Width; + var targetContainerWidth = _infoPanelOpen ? InfoPanelExtraWidth : 0; + var deltaWidth = targetContainerWidth - currentContainerWidth; + + AnimateSlideOut(currentContainerWidth, targetContainerWidth, Width, Width + deltaWidth); + } + + private DispatcherTimer? _widthAnimTimer; + + private void AnimateSlideOut(double containerStart, double containerEnd, + double windowStart, double windowEnd, + Action? onComplete = null) + { + _widthAnimTimer?.Stop(); + + var startTime = DateTime.UtcNow; + const double durationMs = 220; + + _widthAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(15) }; + _widthAnimTimer.Tick += (_, _) => + { + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + var t = Math.Min(1.0, elapsed / durationMs); + var eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic + // Animate both in lockstep so the Star column (RootBorder) keeps its width. + InfoPanelContainer.Width = containerStart + (containerEnd - containerStart) * eased; + Width = windowStart + (windowEnd - windowStart) * eased; + if (t >= 1.0) + { + InfoPanelContainer.Width = containerEnd; + Width = windowEnd; + _widthAnimTimer?.Stop(); + _widthAnimTimer = null; + onComplete?.Invoke(); + } + }; + _widthAnimTimer.Start(); + } + + private void PopulateInfoPanel() + { + InfoPanelContent.Children.Clear(); + if (_remoteManifest is null) + { + InfoPanelContent.Children.Add(new TextBlock + { + Text = "Pack info will appear once the manifest has been fetched.", + Foreground = new SolidColorBrush(Color.Parse("#9F8E72")), + TextWrapping = TextWrapping.Wrap, + FontSize = 12 + }); + return; + } + + AddInfoSection("Pack", + ("Name", _remoteManifest.Name ?? "(unnamed)"), + ("Version", _remoteManifest.Version ?? "?")); + + AddInfoSection("Minecraft", + ("Version", _remoteManifest.Minecraft.Version), + ("Loader", _remoteManifest.Loader is null + ? "vanilla" + : $"{_remoteManifest.Loader.Type} {_remoteManifest.Loader.Version}")); + + var modFiles = _remoteManifest.Files + .Where(f => f.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) + .ToList(); + AddModListSection($"Mods ({modFiles.Count})", modFiles); + + var others = _remoteManifest.Files + .Where(f => !f.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (others.Count > 0) + { + AddOtherFilesSection($"Other files ({others.Count})", others); + } + } + + private void AddInfoSection(string title, params (string Label, string Value)[] rows) + { + var stack = new StackPanel { Spacing = 4 }; + stack.Children.Add(new TextBlock + { + Text = title.ToUpper(), + FontSize = 10, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.Parse("#E8B95C")), + Margin = new Thickness(0, 0, 0, 4) + }); + foreach (var (label, value) in rows) + { + var row = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; + row.Children.Add(new TextBlock + { + Text = label, Margin = new Thickness(0, 0, 8, 0), + Foreground = new SolidColorBrush(Color.Parse("#7A8497")), + FontSize = 12, + MinWidth = 60 + }); + var valueBlock = new TextBlock + { + Text = value, + Foreground = new SolidColorBrush(Color.Parse("#E8DFC8")), + FontSize = 12, + TextWrapping = TextWrapping.Wrap + }; + Grid.SetColumn(valueBlock, 1); + row.Children.Add(valueBlock); + stack.Children.Add(row); + } + InfoPanelContent.Children.Add(stack); + } + + private void AddModListSection(string title, System.Collections.Generic.List mods) + { + var stack = new StackPanel { Spacing = 6 }; + stack.Children.Add(new TextBlock + { + Text = title.ToUpper(), + FontSize = 10, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.Parse("#E8B95C")), + Margin = new Thickness(0, 0, 0, 4) + }); + foreach (var mod in mods) + { + var (name, version) = ParseModFilename(System.IO.Path.GetFileName(mod.Path)); + var row = new StackPanel { Spacing = 1 }; + row.Children.Add(new TextBlock + { + Text = name, + Foreground = new SolidColorBrush(Color.Parse("#E8DFC8")), + FontSize = 12, + FontWeight = FontWeight.Medium + }); + row.Children.Add(new TextBlock + { + Text = version, + Foreground = new SolidColorBrush(Color.Parse("#7A8497")), + FontSize = 11 + }); + stack.Children.Add(row); + } + InfoPanelContent.Children.Add(stack); + } + + private void AddOtherFilesSection(string title, System.Collections.Generic.List files) + { + var stack = new StackPanel { Spacing = 4 }; + stack.Children.Add(new TextBlock + { + Text = title.ToUpper(), + FontSize = 10, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.Parse("#E8B95C")), + Margin = new Thickness(0, 0, 0, 4) + }); + foreach (var f in files) + { + stack.Children.Add(new TextBlock + { + Text = f.Path, + Foreground = new SolidColorBrush(Color.Parse("#B7C0D6")), + FontSize = 11, + TextWrapping = TextWrapping.Wrap + }); + } + InfoPanelContent.Children.Add(stack); + } + + private static (string Name, string Version) ParseModFilename(string filename) + { + var stem = System.IO.Path.GetFileNameWithoutExtension(filename); + // Split on both '-' and '_' -- Modrinth filenames mix both conventions + // (e.g. Terralith_1.21.x_v2.5.8.jar uses underscores). + var parts = stem.Split(new[] { '-', '_' }); + + // Recognise version segments that start with a digit OR with 'v' followed + // by a digit (e.g. "v2.5.8"). Find the smallest index that matches so the + // entire trailing version chain is captured. + int versionIdx = -1; + for (int i = parts.Length - 1; i >= 0; i--) + { + var p = parts[i]; + if (p.Length == 0) continue; + var c0 = p[0]; + var startsWithDigit = char.IsDigit(c0); + var startsWithVDigit = (c0 == 'v' || c0 == 'V') && p.Length >= 2 && char.IsDigit(p[1]); + if (startsWithDigit || startsWithVDigit) versionIdx = i; + } + if (versionIdx <= 0) return (stem, ""); + + var skipWords = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase) + { "neoforge", "forge", "fabric", "bundled" }; + var acronyms = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase) + { "ftb", "tfmg", "jei", "rei", "emi", "ae2", "ic2", "kubejs", "rpl", "c2me", "yungs" }; + + var nameParts = parts.Take(versionIdx) + .Where(s => !skipWords.Contains(s)) + .Select(s => acronyms.Contains(s) ? s.ToUpper() : (s.Length > 0 ? char.ToUpper(s[0]) + s.Substring(1) : s)); + var name = string.Join(" ", nameParts); + if (string.IsNullOrWhiteSpace(name)) name = stem; + var version = string.Join("-", parts.Skip(versionIdx)); + return (name, version); + } + + private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + BeginMoveDrag(e); + } + } + + private void OnMinimizeClick(object? sender, RoutedEventArgs e) + { + WindowState = WindowState.Minimized; + } + + private void OnCloseClick(object? sender, RoutedEventArgs e) + { + Close(); + } + + private void CheckSystemRequirements() + { + // WebView2 Runtime is required for the default sign-in flow. Preinstalled on + // most modern Windows but not guaranteed -- surface a helpful message at startup. + if (!_auth.HasCustomClientId && !WebView2Check.IsInstalled()) + { + AppendLog("[system] Microsoft Edge WebView2 Runtime not detected."); + AppendLog($"[system] Sign-in won't work until it's installed: {WebView2Check.DownloadUrl}"); + UpdateStatus("WebView2 Runtime missing", + $"Sign-in needs Microsoft Edge WebView2 Runtime -- install from {WebView2Check.DownloadUrl}"); + } + } + + private async Task TrySilentSignInAsync() + { + var session = await _auth.TryAuthenticateSilentlyAsync(); + if (session != null) + { + _session = session; + ApplySession(session); + } + } + + private void ApplySession(MSession session) + { + _session = session; + UserText.Text = $"Signed in as {session.Username}"; + LoginButton.IsVisible = false; + LogoutButton.IsVisible = true; + _ = RefreshStateAsync(); + _ = RefreshWhitelistStatusAsync(); + } + + private void ClearSession() + { + _session = null; + UserText.Text = "Not signed in"; + LoginButton.IsVisible = true; + LogoutButton.IsVisible = false; + WhitelistStatusText.IsVisible = false; + RequestAccessButton.IsVisible = false; + _ = RefreshStateAsync(); + } + + private async Task RefreshWhitelistStatusAsync() + { + if (_session is null || _remoteManifest is null) return; + var panelUrl = _remoteManifest.PanelUrl; + if (string.IsNullOrWhiteSpace(panelUrl)) + { + // No panel configured in the manifest -- feature disabled. + WhitelistStatusText.IsVisible = false; + RequestAccessButton.IsVisible = false; + return; + } + var status = await new WhitelistRequestService().GetStatusAsync(panelUrl, _session.Username ?? ""); + ApplyWhitelistStatus(status); + } + + private void ApplyWhitelistStatus(string status) + { + // Status values: "pending", "approved", "denied", "unknown" (no record), "" (network error). + switch (status) + { + case "pending": + WhitelistStatusText.Text = "Whitelist request pending"; + WhitelistStatusText.IsVisible = true; + RequestAccessButton.IsVisible = false; + break; + case "approved": + // Once approved, the server removes the record; this branch is rare + // (status returns "unknown" almost immediately after approve fires). + WhitelistStatusText.Text = "Whitelisted ✓"; + WhitelistStatusText.IsVisible = true; + RequestAccessButton.IsVisible = false; + break; + case "denied": + WhitelistStatusText.Text = "Request denied"; + WhitelistStatusText.IsVisible = true; + RequestAccessButton.IsVisible = true; // allow retry + RequestAccessButton.Content = "Request again"; + break; + case "": + // Network error -- hide both, don't claim anything. + WhitelistStatusText.IsVisible = false; + RequestAccessButton.IsVisible = false; + break; + default: // "unknown" -- never requested + WhitelistStatusText.IsVisible = false; + RequestAccessButton.IsVisible = true; + RequestAccessButton.Content = "Request access"; + break; + } + } + + private async void OnRequestAccessClick(object? sender, RoutedEventArgs e) + { + if (_session is null || _remoteManifest?.PanelUrl is not { } panelUrl) return; + RequestAccessButton.IsEnabled = false; + try + { + var resp = await new WhitelistRequestService().SubmitAsync(panelUrl, _session.Username ?? "", null); + if (resp.Ok) + { + AppendLog($"[whitelist] Request sent for {_session.Username}."); + ApplyWhitelistStatus("pending"); + } + else + { + AppendLog($"[whitelist] Request failed: {resp.Error ?? "unknown"}"); + UpdateStatus("Request failed", resp.Error ?? "Couldn't reach server."); + } + } + finally { RequestAccessButton.IsEnabled = true; } + } + + private async Task RefreshStateAsync(bool refetchManifest = false) + { + if (_busy) return; + + if (string.IsNullOrWhiteSpace(_config.ManifestUrl) || + _config.ManifestUrl.Contains("example.com")) + { + ApplyState(LauncherState.ConfigError, "Setup needed", + "Set 'manifestUrl' in launcher-config.json and rebuild."); + return; + } + + if (refetchManifest || _remoteManifest == null) + { + ApplyState(LauncherState.Checking, "Checking...", "Looking for updates"); + try + { + _remoteManifest = await _sync.FetchManifestOnlyAsync(_config.ManifestUrl); + PopulateInfoPanel(); + CheckLauncherVersion(_remoteManifest); + _ = RefreshWhitelistStatusAsync(); + } + catch (Exception ex) + { + AppendLog($"[manifest fetch] {ex.GetType().Name}: {ex.Message}"); + // Offline fallback -- allow play if we have a local pack + var fallback = _sync.GetLocalPackVersion(GetInstallDir()); + if (fallback?.Version != null) + { + if (_session != null) + { + ApplyState(LauncherState.Ready, "Play", + $"Offline: pack server unreachable, using local v{fallback.Version}"); + } + else + { + ApplyState(LauncherState.ReadyNotSignedIn, "Play", + "Offline mode -- sign in to play"); + } + } + else + { + ApplyState(LauncherState.ConfigError, "Connection error", + $"Couldn't reach pack server: {ex.Message}"); + } + return; + } + } + + var local = _sync.GetLocalPackVersion(GetInstallDir()); + var remote = _remoteManifest!; + + if (local == null) + { + ApplyState(LauncherState.NeedsDownload, "Download", + $"{remote.Name ?? "Pack"} v{remote.Version ?? "?"} ready to download"); + return; + } + + if (!string.Equals(local.Version, remote.Version, StringComparison.Ordinal)) + { + ApplyState(LauncherState.NeedsUpdate, "Update", + $"Update available: v{local.Version} → v{remote.Version}"); + return; + } + + // Version matches -- but also verify the actual files are on disk. Catches AV + // quarantines, manual deletions, and interrupted downloads. + var missing = _sync.FindMissingFiles(remote, GetInstallDir()); + if (missing.Count > 0) + { + ApplyState(LauncherState.NeedsUpdate, "Repair", + $"Pack files missing ({missing.Count}). Click to redownload."); + return; + } + + if (_session == null) + { + ApplyState(LauncherState.ReadyNotSignedIn, "Play", + $"Up to date (v{local.Version}). Sign in to play."); + } + else + { + ApplyState(LauncherState.Ready, "Play", + $"Up to date (v{local.Version}). Ready to launch."); + } + } + + private void ApplyState(LauncherState state, string buttonLabel, string subtext) + { + _state = state; + _playButtonLabel = buttonLabel; + _playButtonEnabled = state is LauncherState.NeedsDownload + or LauncherState.NeedsUpdate + or LauncherState.Ready; + if (!_busy) + { + PlayButton.Content = _playButtonLabel; + PlayButton.IsEnabled = _playButtonEnabled; + } + StatusText.Text = state switch + { + LauncherState.Ready or LauncherState.ReadyNotSignedIn => "Ready", + LauncherState.NeedsDownload => "Download required", + LauncherState.NeedsUpdate => "Update available", + LauncherState.Checking => "Checking...", + LauncherState.ConfigError => "Setup needed", + _ => "..." + }; + StatusSubtext.Text = subtext; + } + + private async void OnLoginClick(object? sender, RoutedEventArgs e) + { + if (_busy) return; + try + { + SetBusy(true); + UpdateStatus("Signing in...", "A browser window should appear."); + var session = await _auth.SignInInteractivelyAsync(); + ApplySession(session); + UpdateStatus("Signed in", $"Welcome, {session.Username}."); + } + catch (Exception ex) + { + LogException("auth error", ex); + UpdateStatus("Sign-in failed", ex.Message); + } + finally + { + SetBusy(false); + // ApplySession's fire-and-forget RefreshStateAsync was a no-op because + // _busy was still true. Re-run state now that we're idle so the Play + // button enables based on the new session + current pack state. + await RefreshStateAsync(); + } + } + + private void LogException(string label, Exception ex) + { + AppendLog($"[{label}] {ex.GetType().Name}: {ex.Message}"); + var inner = ex.InnerException; + var depth = 0; + while (inner != null && depth < 5) + { + AppendLog($" ↳ {inner.GetType().Name}: {inner.Message}"); + inner = inner.InnerException; + depth++; + } + // Reflectively surface any Code / XErr / Redirect / StatusCode properties (XboxAuthException, etc.) + foreach (var prop in ex.GetType().GetProperties()) + { + if (prop.Name is "Code" or "XErr" or "ErrorCode" or "StatusCode" or "Redirect" or "Identity" or "Message" or "Source" or "InnerException" or "TargetSite" or "StackTrace" or "Data" or "HelpLink" or "HResult") + { + if (prop.Name is "Message" or "Source" or "InnerException" or "TargetSite" or "StackTrace" or "Data" or "HelpLink" or "HResult") continue; + try + { + var val = prop.GetValue(ex); + if (val != null) AppendLog($" • {prop.Name}: {val}"); + } + catch { } + } + } + } + + private async void OnLogoutClick(object? sender, RoutedEventArgs e) + { + await _auth.SignOutAsync(); + ClearSession(); + UpdateStatus("Signed out", "Click Sign in to authenticate again."); + } + + private async void OnPlayClick(object? sender, RoutedEventArgs e) + { + if (_busy) return; + + switch (_state) + { + case LauncherState.NeedsDownload: + case LauncherState.NeedsUpdate: + await DoSyncAndInstallAsync(); + break; + case LauncherState.Ready: + await DoLaunchAsync(); + break; + // Other states leave the button disabled -- shouldn't reach here. + } + } + + private async Task DoSyncAndInstallAsync() + { + try + { + SetBusy(true); + var installDir = GetInstallDir(); + Directory.CreateDirectory(installDir); + var progress = new Progress(OnProgress); + + UpdateStatus("Syncing pack...", "Fetching manifest and downloading mods."); + var syncResult = await _sync.SyncAsync(_config.ManifestUrl, installDir, progress); + _remoteManifest = syncResult.Manifest; + AppendLog($"Sync: {syncResult.Downloaded} downloaded, {syncResult.Removed} removed."); + + PackVersionText.Text = syncResult.Manifest.Version is { } v + ? $"Installed: v{v}" + : "Installed"; + + // Pre-populate the multiplayer server list so friends don't have to + // hand-type the address. Idempotent -- match-by-IP, won't duplicate. + if (syncResult.Manifest.DefaultServer is { } ds && !string.IsNullOrEmpty(ds.Ip)) + { + try + { + new ServerListService().EnsureServer(installDir, ds.Name, ds.Ip); + AppendLog($"Multiplayer list seeded: {ds.Name} ({ds.Ip})."); + } + catch (Exception ex) + { + AppendLog($"[server-list] Couldn't update servers.dat: {ex.Message}"); + } + } + + // Pre-enable a default shader pack on fresh installs (Iris reads + // config/iris.properties at startup). Does nothing if the user has + // already chosen a different shader. + if (!string.IsNullOrEmpty(syncResult.Manifest.DefaultShader)) + { + try + { + new IrisConfigService().SetDefaultShader(installDir, syncResult.Manifest.DefaultShader); + AppendLog($"Default shader: {syncResult.Manifest.DefaultShader} (only set if no shader was previously chosen)."); + } + catch (Exception ex) + { + AppendLog($"[iris] Couldn't set default shader: {ex.Message}"); + } + } + + _launch ??= new LaunchService(installDir); + UpdateStatus("Installing Minecraft...", + "Downloading client, libraries, assets, and mod loader. First run may take a few minutes."); + var versionId = await _launch.InstallVersionAsync( + syncResult.Manifest, progress, CancellationToken.None); + AppendLog($"Version ready: {versionId}"); + ResetProgress(); + } + catch (Exception ex) + { + AppendLog($"[error] {ex.GetType().Name}: {ex.Message}"); + UpdateStatus("Install failed", ex.Message); + } + finally + { + SetBusy(false); + await RefreshStateAsync(); + } + } + + private async Task DoLaunchAsync() + { + if (_session == null || _remoteManifest == null) return; + + try + { + SetBusy(true); + var progress = new Progress(OnProgress); + var installDir = GetInstallDir(); + _launch ??= new LaunchService(installDir); + + // Pre-launch sanity: any manifest files missing? If so, re-sync. + var missing = _sync.FindMissingFiles(_remoteManifest, installDir); + if (missing.Count > 0) + { + UpdateStatus("Repairing pack...", + $"{missing.Count} file(s) missing -- redownloading before launch."); + AppendLog($"Pre-launch check: {missing.Count} files missing, re-syncing."); + var syncResult = await _sync.SyncAsync(_config.ManifestUrl, installDir, progress); + _remoteManifest = syncResult.Manifest; + AppendLog($"Re-sync: {syncResult.Downloaded} downloaded, {syncResult.Removed} removed."); + } + + UpdateStatus("Verifying installation...", + "Checking Minecraft, mod loader, and libraries."); + // InstallVersionAsync is idempotent + always runs the library verifier afterwards, + // so this catches any post-install gaps (e.g. CmlLib's bootstraplauncher quirk). + var versionId = await _launch.InstallVersionAsync( + _remoteManifest, progress, CancellationToken.None); + + var ram = (int)(RamBox.Value ?? _config.MemoryMB); + var minRam = ram; + var maxRam = ram; + + UpdateStatus("Launching Minecraft...", ""); + ResetProgress(); + + _ = Task.Run(async () => + { + try + { + await _launch.LaunchAsync(versionId, _session!, minRam, maxRam, progress, CancellationToken.None); + } + catch (Exception ex) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + AppendLog($"[launch error] {ex.GetType().Name}: {ex.Message}"); + UpdateStatus("Launch failed", ex.Message); + }); + } + finally + { + await Dispatcher.UIThread.InvokeAsync(async () => + { + SetBusy(false); + await RefreshStateAsync(); + }); + } + }); + } + catch (Exception ex) + { + AppendLog($"[error] {ex.GetType().Name}: {ex.Message}"); + UpdateStatus("Launch failed", ex.Message); + SetBusy(false); + await RefreshStateAsync(); + } + } + + private string GetInstallDir() => _config.GetInstallDir(_settings.InstallDirOverride); + + private void UpdateInstallDirDisplay() + { + InstallDirText.Text = GetInstallDir(); + } + + private void UpdateRamWarning(int ramMb) + { + var totalMb = SystemInfo.TotalPhysicalMemoryMB; + var safeMax = SystemInfo.SafeMaxAllocationMB; + + if (totalMb < 12 * 1024) + { + RamWarningText.Text = $"Only {totalMb / 1024} GB system RAM detected -- pack may not run smoothly (12+ GB recommended)"; + RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#E8B95C")); + } + else if (ramMb > safeMax) + { + RamWarningText.Text = $"Above safe limit for {totalMb / 1024} GB system (max {safeMax} MB recommended)"; + RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#DC6E28")); + } + else if (ramMb < 6144) + { + RamWarningText.Text = "Below recommended (6 GB+ for Distant Horizons)"; + RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#9F8E72")); + } + else + { + RamWarningText.Text = $"OK ({totalMb / 1024} GB system)"; + RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#7A8497")); + } + } + + private void OnRamValueChanged(object? sender, NumericUpDownValueChangedEventArgs e) + { + if (_suppressAutoSave) return; + var ram = (int)(RamBox.Value ?? _config.MemoryMB); + _settings.MemoryMB = ram; + _settings.Save(); + UpdateRamWarning(ram); + } + + private async void OnChangeInstallDirClick(object? sender, RoutedEventArgs e) + { + var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Choose install location", + AllowMultiple = false + }); + if (folders.Count == 0) return; + + var picked = folders[0].TryGetLocalPath(); + if (string.IsNullOrEmpty(picked)) return; + + _settings.InstallDirOverride = picked; + _settings.Save(); + UpdateInstallDirDisplay(); + AppendLog($"Install location changed to: {picked}"); + AppendLog("Pack will need to be re-downloaded at the new location on next Play."); + + _launch = null; // force re-create with new path next time + _remoteManifest = null; + await RefreshStateAsync(refetchManifest: true); + } + + private void OnOpenFolderClick(object? sender, RoutedEventArgs e) + { + var dir = GetInstallDir(); + Directory.CreateDirectory(dir); + try + { + Process.Start(new ProcessStartInfo + { + FileName = dir, + UseShellExecute = true + }); + } + catch (Exception ex) + { + AppendLog($"[open folder] {ex.Message}"); + } + } + + private void OnProgress(ProgressReport report) + { + if (Dispatcher.UIThread.CheckAccess()) + { + ApplyProgress(report); + } + else + { + Dispatcher.UIThread.Post(() => ApplyProgress(report)); + } + } + + private void ApplyProgress(ProgressReport report) + { + switch (report.Kind) + { + case ProgressKind.Status: + StatusSubtext.Text = report.Message; + break; + case ProgressKind.Progress: + if (report.Percent >= 0) ProgressBar.Value = Math.Clamp(report.Percent, 0, 100); + StatusSubtext.Text = report.Message; + break; + case ProgressKind.Log: + AppendLog(report.Message); + break; + case ProgressKind.Error: + AppendLog($"[error] {report.Message}"); + break; + } + } + + private void UpdateStatus(string title, string subtitle) + { + StatusText.Text = title; + StatusSubtext.Text = subtitle; + } + + private void ResetProgress() + { + ProgressBar.Value = 0; + } + + private void AppendLog(string message) + { + if (string.IsNullOrEmpty(message)) return; + LogText.Text += (LogText.Text?.Length > 0 ? "\n" : "") + message; + LogScroll.ScrollToEnd(); + FileLog.Write(message); + } + + private void SetBusy(bool busy) + { + _busy = busy; + if (busy) + { + PlayButton.IsEnabled = false; + PlayButton.Content = "Working..."; + } + else + { + // Restore from the cached state-derived label (avoids races with concurrent state refreshes). + PlayButton.Content = _playButtonLabel; + PlayButton.IsEnabled = _playButtonEnabled; + } + LoginButton.IsEnabled = !busy; + LogoutButton.IsEnabled = !busy; + } +} diff --git a/launcher/Models/LauncherConfig.cs b/launcher/Models/LauncherConfig.cs new file mode 100644 index 0000000..ffea97f --- /dev/null +++ b/launcher/Models/LauncherConfig.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModpackLauncher.Models; + +public sealed class LauncherConfig +{ + [JsonPropertyName("packName")] + public string PackName { get; set; } = "Modpack"; + + [JsonPropertyName("manifestUrl")] + public string ManifestUrl { get; set; } = ""; + + /// + /// Subfolder name appended under the install location (sidecar exe folder + /// by default, or the folder the user picked via "Change..."). Acts as + /// a safety net so picking a generic location like "D:\" doesn't dump + /// thousands of files at the drive root, and signals at a glance that + /// this is the launcher's data, not the launcher itself. + /// + [JsonPropertyName("installDirName")] + public string InstallDirName { get; set; } = "BrassAndSigilData"; + + [JsonPropertyName("memoryMB")] + public int MemoryMB { get; set; } = 8192; + + [JsonPropertyName("msalClientId")] + public string MsalClientId { get; set; } = ""; + + /// Optional HTTP Basic auth username for the manifest URL and mod file URLs. + [JsonPropertyName("httpUsername")] + public string? HttpUsername { get; set; } + + /// Optional HTTP Basic auth password (paired with HttpUsername). + [JsonPropertyName("httpPassword")] + public string? HttpPassword { get; set; } + + public static LauncherConfig Load() + { + // 1. External override beside the exe (dev convenience / per-deploy override) + var sidecar = Path.Combine(AppContext.BaseDirectory, "launcher-config.json"); + if (File.Exists(sidecar)) + { + return ParseSafe(File.ReadAllText(sidecar)); + } + + // 2. Embedded launcher-config.json (set at build time from local copy) + var asm = typeof(LauncherConfig).Assembly; + using (var stream = asm.GetManifestResourceStream("launcher-config.json")) + { + if (stream != null) + { + using var reader = new StreamReader(stream); + return ParseSafe(reader.ReadToEnd()); + } + } + + // 3. Fall back to embedded template (so fresh clones still run, with placeholders) + using (var stream = asm.GetManifestResourceStream("launcher-config.template.json")) + { + if (stream != null) + { + using var reader = new StreamReader(stream); + return ParseSafe(reader.ReadToEnd()); + } + } + + return new LauncherConfig(); + } + + private static LauncherConfig ParseSafe(string json) + { + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + return JsonSerializer.Deserialize(json, opts) ?? new LauncherConfig(); + } + + /// + /// Resolve the absolute install directory. The launcher behaves as a + /// portable app: by default it installs alongside the exe in + /// <exe-folder>/<InstallDirName>/. The user can override + /// via the "Change..." picker, which stores the chosen *parent* folder + /// in InstallDirOverride; we then append + /// to it (same safety reasoning as the default). + /// + /// Smart-skip: if the parent path already ends in InstallDirName, we + /// don't double up. Lets users re-pick their existing install folder + /// (e.g. "D:\Games\BrassAndSigilData") without ending up at + /// "D:\Games\BrassAndSigilData\BrassAndSigilData". + /// + public string GetInstallDir(string? overrideDir = null) + { + var parent = !string.IsNullOrWhiteSpace(overrideDir) + ? overrideDir! + : AppContext.BaseDirectory; + + var trimmed = parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.Equals(Path.GetFileName(trimmed), InstallDirName, StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + return Path.Combine(parent, InstallDirName); + } +} diff --git a/launcher/Models/LauncherSettings.cs b/launcher/Models/LauncherSettings.cs new file mode 100644 index 0000000..17420b7 --- /dev/null +++ b/launcher/Models/LauncherSettings.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModpackLauncher.Models; + +public sealed class LauncherSettings +{ + [JsonPropertyName("memoryMB")] + public int? MemoryMB { get; set; } + + [JsonPropertyName("installDirOverride")] + public string? InstallDirOverride { get; set; } + + /// + /// Settings live next to the launcher exe ("sidecar"), so each copy of + /// the launcher has its own independent state. Drop the launcher in a + /// new folder on a different machine, or alongside the existing one in + /// a separate directory, and they remember their own install paths, + /// memory choices, etc. Matches the portable-app convention. + /// + private static string FilePath + => Path.Combine(AppContext.BaseDirectory, "launcher-settings.json"); + + public static LauncherSettings Load() + { + try + { + if (!File.Exists(FilePath)) return new LauncherSettings(); + return JsonSerializer.Deserialize(File.ReadAllText(FilePath)) + ?? new LauncherSettings(); + } + catch + { + return new LauncherSettings(); + } + } + + public void Save() + { + try + { + File.WriteAllText( + FilePath, + JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }) + ); + } + catch + { + // best-effort + } + } +} diff --git a/launcher/Models/Manifest.cs b/launcher/Models/Manifest.cs new file mode 100644 index 0000000..d1cc985 --- /dev/null +++ b/launcher/Models/Manifest.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ModpackLauncher.Models; + +public sealed class Manifest +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("minecraft")] + public MinecraftSpec Minecraft { get; set; } = new(); + + [JsonPropertyName("loader")] + public LoaderSpec? Loader { get; set; } + + [JsonPropertyName("files")] + public List Files { get; set; } = new(); + + /// + /// Optional. The launcher version that the modpack publisher expects clients + /// to be running. If a client's assembly version is lower than this, the launcher + /// surfaces a "newer version available" banner pointing at . + /// + [JsonPropertyName("launcherVersion")] + public string? LauncherVersion { get; set; } + + /// Public download URL for the latest launcher (shown in the banner). + [JsonPropertyName("launcherUrl")] + public string? LauncherUrl { get; set; } + + /// + /// Optional. If present, the launcher writes this entry into the player's + /// servers.dat on first install so the modpack's server appears in + /// the multiplayer list automatically -- no copy-paste needed. + /// + [JsonPropertyName("defaultServer")] + public DefaultServer? DefaultServer { get; set; } + + /// + /// Optional. Filename (in shaderpacks/) of a shader pack to enable by default + /// for fresh installs. Existing installs with a different shader chosen are + /// left alone -- this is a default, not a forced override. + /// + [JsonPropertyName("defaultShader")] + public string? DefaultShader { get; set; } + + /// + /// Optional. Public base URL of the brass-sigil-server admin panel (e.g. + /// https://bns-admin.sijbers.uk). The launcher uses this to send whitelist + /// requests on the player's behalf -- nothing else. + /// + [JsonPropertyName("panelUrl")] + public string? PanelUrl { get; set; } +} + +public sealed class DefaultServer +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("ip")] + public string Ip { get; set; } = ""; +} + +public sealed class MinecraftSpec +{ + [JsonPropertyName("version")] + public string Version { get; set; } = ""; +} + +public sealed class LoaderSpec +{ + /// "forge" | "fabric" | "neoforge" | "vanilla" (or null) + [JsonPropertyName("type")] + public string Type { get; set; } = "vanilla"; + + [JsonPropertyName("version")] + public string Version { get; set; } = ""; +} + +public sealed class ManifestFile +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("sha1")] + public string? Sha1 { get; set; } + + [JsonPropertyName("size")] + public long? Size { get; set; } +} + +public sealed class PackVersionRecord +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("syncedAt")] + public string? SyncedAt { get; set; } +} diff --git a/launcher/ModpackLauncher.csproj b/launcher/ModpackLauncher.csproj new file mode 100644 index 0000000..250f67d --- /dev/null +++ b/launcher/ModpackLauncher.csproj @@ -0,0 +1,65 @@ + + + WinExe + + net8.0-windows + true + enable + app.manifest + true + ModpackLauncher + ModpackLauncher + 0.4.5 + Assets\icon.ico + + + win-x64 + win-x64 + true + true + true + true + embedded + false + false + + + + + + + + + None + All + + + + + + + + + + + + launcher-config.json + + + launcher-config.template.json + + + + + + + + + + + <_StripPdb Include="$(PublishDir)*.pdb" Exclude="$(PublishDir)$(AssemblyName).pdb" /> + + + + diff --git a/launcher/Program.cs b/launcher/Program.cs new file mode 100644 index 0000000..cc0346e --- /dev/null +++ b/launcher/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using System; + +namespace ModpackLauncher; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..ddd7163 --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,112 @@ +# Brass & Sigil Launcher + +A custom Minecraft Java Edition launcher built for distributing the private +"Brass & Sigil" modpack (Create + aeronautics + tech + magic + Distant +Horizons) to a small friend group. + +> **NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH +> MOJANG OR MICROSOFT.** + +## What it does + +1. Fetches a JSON manifest from a self-hosted server and syncs the modpack + files (mods, configs, resourcepacks) to the player's local install + directory. SHA-1 hashing ensures only changed files are downloaded. +2. Authenticates each player with their own personal Microsoft account via + the standard MSAL OAuth + Xbox Live + Minecraft Services chain, using the + open-source `CmlLib.Core.Auth.Microsoft` library without modification. +3. Installs the appropriate Minecraft Java Edition version and Forge mod + loader, then launches the game with the player's authenticated session. + +## Stack + +- **C# / .NET 8** +- **Avalonia 12** -- desktop UI +- **CmlLib.Core 4.x** -- Minecraft install + launch +- **CmlLib.Core.Auth.Microsoft 3.x + XboxAuthNet.Game.Msal** -- Microsoft auth +- **CmlLib.Core.Installer.Forge** -- Forge support + +## Build + +Requires the .NET 8 SDK. + +```powershell +dotnet build +``` + +To produce the shippable single-file executable (~46 MB): + +```powershell +dotnet publish -c Release -r win-x64 --self-contained true +``` + +Output: `bin/Release/net8.0/win-x64/publish/ModpackLauncher.exe` -- a single +file with no other dependencies, ready to send to a friend. + +## Configuration + +The repo ships with a `launcher-config.template.json`. Copy it to +`launcher-config.json` and fill in real values before building: + +```powershell +Copy-Item launcher-config.template.json launcher-config.json +``` + +`launcher-config.json` is gitignored so local values (manifest URL, Azure +client ID) never get committed. + +### Fields + +| Field | Purpose | +| --- | --- | +| `packName` | Display name shown in the launcher | +| `manifestUrl` | URL of the hosted manifest JSON | +| `installDirName` | Folder name under `%APPDATA%` for game files | +| `memoryMinMB` / `memoryMaxMB` | JVM memory defaults | +| `msalClientId` | Azure App Registration client ID for Microsoft auth | + +The config is **embedded into the exe** at build time, so the launcher ships +as a single self-contained file. A sidecar `launcher-config.json` placed +beside the exe will override the embedded copy at runtime (handy for testing). + +## Manifest format + +See `manifest.example.json` for the schema. Minimum: + +```json +{ + "name": "Brass & Sigil", + "version": "1.0.0", + "minecraft": { "version": "1.20.1" }, + "loader": { "type": "forge", "version": "47.2.0" }, + "files": [ + { "path": "mods/example.jar", "url": "https://...", "sha1": "..." } + ] +} +``` + +The launcher diffs the manifest against the local install dir using SHA-1 +hashes and downloads only what has changed. Files removed from the manifest +are pruned from managed folders (`mods/`, `config/`, `resourcepacks/`, +`shaderpacks/`, `kubejs/`, `defaultconfigs/`). + +## Privacy + +The launcher does not collect, store, or transmit any user data beyond what +the standard Microsoft and Minecraft authentication APIs require. Auth tokens +are cached locally via the MSAL token cache. No telemetry, no analytics, no +third-party services beyond Microsoft and Mojang. + +Local data is written to: + +- `%APPDATA%\BrassAndSigil\` -- launcher settings + log file +- `%APPDATA%\\` -- modpack and Minecraft installation + +## License + +MIT -- see [LICENSE](LICENSE). + +## Author + +Matt Sijbers -- [https://sijbers.uk/matt](https://sijbers.uk/matt) / +[project page](https://sijbers.uk/brass-and-sigil) diff --git a/launcher/Services/AuthService.cs b/launcher/Services/AuthService.cs new file mode 100644 index 0000000..f2f0ba0 --- /dev/null +++ b/launcher/Services/AuthService.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using CmlLib.Core.Auth; +using CmlLib.Core.Auth.Microsoft; +using XboxAuthNet.Game.Msal; +using XboxAuthNet.Game.Msal.OAuth; + +namespace ModpackLauncher.Services; + +/// +/// Microsoft auth wrapper. Two modes: +/// 1. Custom Azure AD client ID (msalClientId set) -> MSAL flow. Requires Microsoft +/// to have approved the app for Minecraft API access. +/// 2. No custom client ID (default) -> CmlLib's BuildDefault() which uses the +/// WebView2-driven Microsoft Live OAuth flow with the Xbox Live SDK client ID. +/// Doesn't require an Azure registration; works out of the box on any Win10/11 +/// machine that has the WebView2 Runtime installed (preinstalled since 2021). +/// +public sealed class AuthService +{ + private readonly string _clientId; + + public AuthService(string clientId) + { + _clientId = clientId; + } + + /// True when the user has provided their own Azure App Registration ID. + public bool HasCustomClientId => !string.IsNullOrWhiteSpace(_clientId) + && _clientId != "00000000-0000-0000-0000-000000000000"; + + /// Auth is always available now (BuildDefault provides a fallback). + public bool IsConfigured => true; + + public async Task AuthenticateAsync() + { + var loginHandler = await BuildLoginHandlerAsync(); + try + { + return await loginHandler.AuthenticateSilently(); + } + catch + { + return await loginHandler.AuthenticateInteractively(); + } + } + + public async Task SignInInteractivelyAsync() + { + var loginHandler = await BuildLoginHandlerAsync(); + return await loginHandler.AuthenticateInteractively(); + } + + public async Task TryAuthenticateSilentlyAsync() + { + try + { + var loginHandler = await BuildLoginHandlerAsync(); + return await loginHandler.AuthenticateSilently(); + } + catch + { + return null; + } + } + + public async Task SignOutAsync() + { + try + { + var loginHandler = await BuildLoginHandlerAsync(); + await loginHandler.Signout(); + } + catch + { + // best-effort + } + } + + private async Task BuildLoginHandlerAsync() + { + if (HasCustomClientId) + { + // Custom Azure AD MSAL flow -- requires the app to be approved by Microsoft. + var app = await MsalClientHelper.BuildApplicationWithCache(_clientId); + return new JELoginHandlerBuilder() + .WithOAuthProvider(new MsalCodeFlowProvider(app)) + .Build(); + } + + // Default path: WebView2 + Xbox Live SDK community client ID. No Azure registration. + // Note: requires WebView2 Runtime on Windows (preinstalled on Win10/11 since 2021). + return JELoginHandlerBuilder.BuildDefault(); + } +} diff --git a/launcher/Services/FileLog.cs b/launcher/Services/FileLog.cs new file mode 100644 index 0000000..f7aa813 --- /dev/null +++ b/launcher/Services/FileLog.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; + +namespace ModpackLauncher.Services; + +public static class FileLog +{ + private static readonly object _lock = new(); + + public static string LogPath { get; } = BuildPath(); + + private static string BuildPath() + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "BrassAndSigil" + ); + Directory.CreateDirectory(dir); + return Path.Combine(dir, "launcher.log"); + } + + public static void Init() + { + try + { + // Truncate per launch so we always have the most recent run. + File.WriteAllText(LogPath, + $"=== ModpackLauncher launched {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); + } + catch { } + } + + public static void Write(string message) + { + try + { + lock (_lock) + { + File.AppendAllText(LogPath, + $"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}"); + } + } + catch { } + } +} diff --git a/launcher/Services/IrisConfigService.cs b/launcher/Services/IrisConfigService.cs new file mode 100644 index 0000000..e75aca4 --- /dev/null +++ b/launcher/Services/IrisConfigService.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace ModpackLauncher.Services; + +/// +/// Pre-seeds Iris's config/iris.properties with a default shader pack +/// for fresh installs, so friends launch the game and the recommended shader +/// is already on rather than them having to dig through Video Settings. +/// +/// Respects user choice: if the file already exists with a non-empty +/// shaderPack=... entry, we leave it alone -- only fresh installs (or +/// installs where Iris has never been opened) get the default. +/// +public sealed class IrisConfigService +{ + public void SetDefaultShader(string gameDir, string shaderPackFilename) + { + if (string.IsNullOrWhiteSpace(shaderPackFilename)) return; + var configDir = Path.Combine(gameDir, "config"); + Directory.CreateDirectory(configDir); + var path = Path.Combine(configDir, "iris.properties"); + + var lines = File.Exists(path) ? File.ReadAllLines(path).ToList() : new List(); + + // If a shaderPack is already chosen and non-empty (the user picked + // something), respect it and bail. + var existingShader = lines + .Select(l => l.TrimStart()) + .Where(l => l.Length > 0 && l[0] != '#') + .Select(l => { var i = l.IndexOf('='); return i < 0 ? null : new { Key = l.Substring(0, i).Trim(), Value = l.Substring(i + 1).Trim() }; }) + .Where(p => p != null && p.Key.Equals("shaderPack", System.StringComparison.OrdinalIgnoreCase)) + .Select(p => p!.Value) + .FirstOrDefault(); + if (!string.IsNullOrEmpty(existingShader)) return; + + // No shader set -- write our defaults. Update existing keys in-place if + // they exist (e.g. shaderPack="" placeholder), append otherwise. + var defaults = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + { "shaderPack", shaderPackFilename }, + { "enableShaders", "true" }, + }; + var seen = new HashSet(System.StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < lines.Count; i++) + { + var trimmed = lines[i].TrimStart(); + if (trimmed.Length == 0 || trimmed[0] == '#') continue; + var idx = trimmed.IndexOf('='); + if (idx < 0) continue; + var key = trimmed.Substring(0, idx).Trim(); + if (defaults.TryGetValue(key, out var val)) + { + lines[i] = $"{key}={val}"; + seen.Add(key); + } + } + foreach (var (k, v) in defaults) + { + if (!seen.Contains(k)) lines.Add($"{k}={v}"); + } + File.WriteAllLines(path, lines); + } +} diff --git a/launcher/Services/LaunchService.cs b/launcher/Services/LaunchService.cs new file mode 100644 index 0000000..48d2fed --- /dev/null +++ b/launcher/Services/LaunchService.cs @@ -0,0 +1,227 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using CmlLib.Core; +using CmlLib.Core.Auth; +using CmlLib.Core.Installers; +using CmlLib.Core.ProcessBuilder; +using CmlLib.Core.Installer.Forge; +using CmlLib.Core.Installer.NeoForge; +using ModpackLauncher.Models; + +namespace ModpackLauncher.Services; + +public sealed class LaunchService +{ + private readonly MinecraftLauncher _launcher; + private readonly ForgeInstaller _forgeInstaller; + private readonly NeoForgeInstaller _neoForgeInstaller; + + private readonly string _installDir; + + public LaunchService(string installDir) + { + _installDir = installDir; + var path = new MinecraftPath(installDir); + _launcher = new MinecraftLauncher(path); + _forgeInstaller = new ForgeInstaller(_launcher); + _neoForgeInstaller = new NeoForgeInstaller(_launcher); + } + + public MinecraftLauncher Launcher => _launcher; + + /// + /// Belt-and-braces check after the loader installer runs: parse the version JSON + /// and download any libraries that are listed but missing on disk. Works around + /// a known CmlLib quirk where libraries with @jar suffix in the Maven coordinate + /// (e.g. "cpw.mods:bootstraplauncher:2.0.2@jar" used by NeoForge 21.1.x) get + /// skipped by the installer's library downloader. + /// + private async Task VerifyVersionLibrariesAsync(string versionId, + IProgress progress, + CancellationToken ct) + { + var versionJsonPath = Path.Combine(_installDir, "versions", versionId, $"{versionId}.json"); + if (!File.Exists(versionJsonPath)) return; + + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(versionJsonPath, ct).ConfigureAwait(false)); + if (!doc.RootElement.TryGetProperty("libraries", out var libsArr)) return; + + var libsDir = Path.Combine(_installDir, "libraries"); + var missing = new System.Collections.Generic.List<(string Path, string Url, string? Sha1)>(); + + foreach (var lib in libsArr.EnumerateArray()) + { + if (!lib.TryGetProperty("downloads", out var dls)) continue; + if (!dls.TryGetProperty("artifact", out var art)) continue; + if (!art.TryGetProperty("path", out var pPath) || !art.TryGetProperty("url", out var pUrl)) continue; + + var relPath = pPath.GetString(); + var url = pUrl.GetString(); + var sha1 = art.TryGetProperty("sha1", out var pSha1) ? pSha1.GetString() : null; + if (string.IsNullOrEmpty(relPath) || string.IsNullOrEmpty(url)) continue; + + var fullPath = Path.Combine(libsDir, relPath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(fullPath)) + { + missing.Add((fullPath, url, sha1)); + } + } + + if (missing.Count == 0) return; + + progress.Report(new ProgressReport( + ProgressKind.Status, + $"Verifying loader libraries: {missing.Count} missing, fetching...")); + + using var http = new HttpClient(); + http.Timeout = TimeSpan.FromMinutes(5); + for (int i = 0; i < missing.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var (path, url, _) = missing[i]; + progress.Report(new ProgressReport( + ProgressKind.Log, + $" Library {i + 1}/{missing.Count}: {Path.GetFileName(path)}")); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var bytes = await http.GetByteArrayAsync(url, ct).ConfigureAwait(false); + await File.WriteAllBytesAsync(path, bytes, ct).ConfigureAwait(false); + } + + progress.Report(new ProgressReport( + ProgressKind.Status, + "Loader libraries verified.")); + } + + public async Task InstallVersionAsync( + Manifest manifest, + IProgress progress, + CancellationToken ct) + { + EventHandler fileProgressHandler = (_, args) => + { + var pct = args.TotalTasks > 0 + ? (args.ProgressedTasks * 100.0 / args.TotalTasks) + : -1; + progress.Report(new ProgressReport( + ProgressKind.Progress, + $"{args.EventType}: {args.Name ?? ""}", + Current: args.ProgressedTasks, + Total: args.TotalTasks, + Percent: pct + )); + }; + + EventHandler byteProgressHandler = (_, args) => + { + if (args.TotalBytes <= 0) return; + var pct = args.ToRatio() * 100.0; + progress.Report(new ProgressReport( + ProgressKind.Progress, + $"{args.ProgressedBytes:N0} / {args.TotalBytes:N0} bytes", + Current: (int)Math.Min(args.ProgressedBytes, int.MaxValue), + Total: (int)Math.Min(args.TotalBytes, int.MaxValue), + Percent: pct + )); + }; + + _launcher.FileProgressChanged += fileProgressHandler; + _launcher.ByteProgressChanged += byteProgressHandler; + + try + { + var mcVersion = manifest.Minecraft.Version; + var loader = manifest.Loader; + + progress.Report(new ProgressReport(ProgressKind.Status, $"Installing Minecraft {mcVersion}...")); + await _launcher.InstallAsync(mcVersion, ct).AsTask().ConfigureAwait(false); + + if (loader == null || string.IsNullOrEmpty(loader.Type) || + loader.Type.Equals("vanilla", StringComparison.OrdinalIgnoreCase)) + { + return mcVersion; + } + + if (loader.Type.Equals("forge", StringComparison.OrdinalIgnoreCase)) + { + progress.Report(new ProgressReport( + ProgressKind.Status, + $"Installing Forge {loader.Version} for {mcVersion}..." + )); + var fid = await _forgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false); + await VerifyVersionLibrariesAsync(fid, progress, ct).ConfigureAwait(false); + return fid; + } + + if (loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase)) + { + progress.Report(new ProgressReport( + ProgressKind.Status, + $"Installing NeoForge {loader.Version} for {mcVersion}..." + )); + var nid = await _neoForgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false); + await VerifyVersionLibrariesAsync(nid, progress, ct).ConfigureAwait(false); + return nid; + } + + throw new NotSupportedException( + $"Loader '{loader.Type}' is not yet supported. Use 'forge', 'neoforge', or 'vanilla'." + ); + } + finally + { + _launcher.FileProgressChanged -= fileProgressHandler; + _launcher.ByteProgressChanged -= byteProgressHandler; + } + } + + public async Task LaunchAsync( + string versionId, + MSession session, + int minMemoryMB, + int maxMemoryMB, + IProgress progress, + CancellationToken ct) + { + var option = new MLaunchOption + { + Session = session, + MaximumRamMb = maxMemoryMB, + MinimumRamMb = minMemoryMB, + // Generational ZGC: low-pause concurrent collector -- recommended by + // Distant Horizons (and broadly better than the default G1 for modded MC). + // Requires Java 21+ which CmlLib auto-installs for MC 1.21.1. + ExtraJvmArguments = new[] + { + new MArgument("-XX:+UseZGC"), + new MArgument("-XX:+ZGenerational"), + } + }; + + progress.Report(new ProgressReport(ProgressKind.Status, "Building launch process...")); + var process = await _launcher.BuildProcessAsync(versionId, option).ConfigureAwait(false); + + var wrapper = new ProcessWrapper(process); + wrapper.OutputReceived += (_, log) => + { + progress.Report(new ProgressReport(ProgressKind.Log, log)); + }; + + wrapper.StartWithEvents(); + progress.Report(new ProgressReport( + ProgressKind.Status, + $"Minecraft launched (PID {process.Id})" + )); + + var exitCode = await wrapper.WaitForExitTaskAsync().ConfigureAwait(false); + progress.Report(new ProgressReport( + ProgressKind.Status, + $"Game exited (code {exitCode})" + )); + return exitCode; + } +} diff --git a/launcher/Services/ManifestSyncService.cs b/launcher/Services/ManifestSyncService.cs new file mode 100644 index 0000000..dd5c5b0 --- /dev/null +++ b/launcher/Services/ManifestSyncService.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ModpackLauncher.Models; + +namespace ModpackLauncher.Services; + +public sealed class ManifestSyncService +{ + private const string PackVersionFile = "pack-version.json"; + + private static readonly string[] ManagedRoots = + { + "mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs" + }; + + private readonly HttpClient _http; + + public ManifestSyncService(HttpClient? http = null) + { + _http = http ?? new HttpClient(); + _http.Timeout = TimeSpan.FromMinutes(5); + } + + /// Configure HTTP Basic auth for all subsequent requests. Pass null to clear. + public void SetBasicAuth(string? username, string? password) + { + if (string.IsNullOrEmpty(username)) + { + _http.DefaultRequestHeaders.Authorization = null; + return; + } + var raw = $"{username}:{password ?? ""}"; + var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw)); + _http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", b64); + } + + public sealed record SyncResult(Manifest Manifest, int Downloaded, int Removed); + + /// Just fetch the manifest JSON without doing the full file sync. Used for "is an update available?" checks on startup. + public async Task FetchManifestOnlyAsync(string manifestUrl, CancellationToken ct = default) + { + var json = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + if (manifest == null) throw new InvalidOperationException("Manifest is empty or invalid."); + manifest.Files ??= new System.Collections.Generic.List(); + return manifest; + } + + /// + /// Fast check (no hashing): which files listed in the manifest are missing on disk. + /// Used as a pre-launch sanity pass to catch AV quarantines / interrupted installs. + /// + public System.Collections.Generic.List FindMissingFiles(Manifest manifest, string installDir) + { + var missing = new System.Collections.Generic.List(); + foreach (var file in manifest.Files) + { + var dest = Path.Combine(installDir, file.Path); + if (!File.Exists(dest)) missing.Add(file); + } + return missing; + } + + public PackVersionRecord? GetLocalPackVersion(string installDir) + { + var path = Path.Combine(installDir, PackVersionFile); + if (!File.Exists(path)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch + { + return null; + } + } + + public async Task SyncAsync( + string manifestUrl, + string installDir, + IProgress progress, + CancellationToken ct = default) + { + progress.Report(new ProgressReport(ProgressKind.Status, "Fetching manifest...")); + + var manifestJson = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(manifestJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidOperationException("Manifest is empty or invalid."); + + if (string.IsNullOrWhiteSpace(manifest.Minecraft.Version)) + { + throw new InvalidOperationException("Manifest is missing minecraft.version."); + } + + Directory.CreateDirectory(installDir); + + var local = GetLocalPackVersion(installDir); + progress.Report(new ProgressReport( + ProgressKind.Status, + $"Pack: {manifest.Name ?? "modpack"} v{manifest.Version ?? "?"} (local: {local?.Version ?? "none"})" + )); + + var wantedPaths = new HashSet( + manifest.Files.Select(f => NormalizePath(f.Path)), + StringComparer.OrdinalIgnoreCase + ); + + // Remove managed files no longer in manifest + var toRemove = new List(); + foreach (var root in ManagedRoots) + { + var rootDir = Path.Combine(installDir, root); + if (!Directory.Exists(rootDir)) continue; + foreach (var file in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories)) + { + var rel = NormalizePath(Path.GetRelativePath(installDir, file)); + if (!wantedPaths.Contains(rel)) + { + toRemove.Add(file); + } + } + } + + foreach (var file in toRemove) + { + try + { + File.Delete(file); + progress.Report(new ProgressReport( + ProgressKind.Log, + $"Removed: {Path.GetRelativePath(installDir, file)}" + )); + } + catch (Exception ex) + { + progress.Report(new ProgressReport( + ProgressKind.Log, + $"Could not remove {file}: {ex.Message}" + )); + } + } + + // Determine what to download + var toDownload = new List(); + foreach (var file in manifest.Files) + { + ct.ThrowIfCancellationRequested(); + var dest = Path.Combine(installDir, file.Path); + if (!File.Exists(dest)) + { + toDownload.Add(file); + continue; + } + if (!string.IsNullOrEmpty(file.Sha1)) + { + var actual = await ComputeSha1Async(dest, ct).ConfigureAwait(false); + if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase)) + { + toDownload.Add(file); + } + } + } + + progress.Report(new ProgressReport( + ProgressKind.Status, + toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} file(s)..." + )); + + for (int i = 0; i < toDownload.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var file = toDownload[i]; + var pct = toDownload.Count == 0 ? 100 : (i * 100.0 / toDownload.Count); + progress.Report(new ProgressReport( + ProgressKind.Progress, + $"Downloading {file.Path}", + Current: i + 1, + Total: toDownload.Count, + Percent: pct + )); + var dest = Path.Combine(installDir, file.Path); + await DownloadFileAsync(file.Url, dest, file.Sha1, ct).ConfigureAwait(false); + } + + var record = new PackVersionRecord + { + Name = manifest.Name, + Version = manifest.Version, + SyncedAt = DateTime.UtcNow.ToString("o") + }; + await File.WriteAllTextAsync( + Path.Combine(installDir, PackVersionFile), + JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }), + ct + ).ConfigureAwait(false); + + progress.Report(new ProgressReport(ProgressKind.Status, "Sync complete.")); + return new SyncResult(manifest, toDownload.Count, toRemove.Count); + } + + private async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + var tmp = destPath + ".part"; + + using (var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false)) + { + response.EnsureSuccessStatusCode(); + await using var src = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await using var dst = File.Create(tmp); + await src.CopyToAsync(dst, ct).ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(expectedSha1)) + { + var actual = await ComputeSha1Async(tmp, ct).ConfigureAwait(false); + if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(tmp); + throw new InvalidOperationException( + $"Hash mismatch for {Path.GetFileName(destPath)} (expected {expectedSha1}, got {actual})" + ); + } + } + + if (File.Exists(destPath)) File.Delete(destPath); + File.Move(tmp, destPath); + } + + private static async Task ComputeSha1Async(string path, CancellationToken ct) + { + using var sha = SHA1.Create(); + await using var stream = File.OpenRead(path); + var bytes = await sha.ComputeHashAsync(stream, ct).ConfigureAwait(false); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string NormalizePath(string p) => p.Replace('\\', '/').TrimStart('/'); +} diff --git a/launcher/Services/ProgressReport.cs b/launcher/Services/ProgressReport.cs new file mode 100644 index 0000000..b8e8893 --- /dev/null +++ b/launcher/Services/ProgressReport.cs @@ -0,0 +1,17 @@ +namespace ModpackLauncher.Services; + +public enum ProgressKind +{ + Status, + Progress, + Log, + Error +} + +public sealed record ProgressReport( + ProgressKind Kind, + string Message, + int Current = 0, + int Total = 0, + double Percent = -1 +); diff --git a/launcher/Services/ServerListService.cs b/launcher/Services/ServerListService.cs new file mode 100644 index 0000000..ede465c --- /dev/null +++ b/launcher/Services/ServerListService.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Linq; +using fNbt; + +namespace ModpackLauncher.Services; + +/// +/// Pre-populates Minecraft's multiplayer server list (`servers.dat`) so the +/// modpack's server is one click away on first launch. +/// +/// servers.dat is uncompressed NBT (unlike level.dat which is gzipped). Schema: +/// compound { +/// servers : list[compound] { +/// name : string +/// ip : string +/// acceptTextures : byte (optional, 1 = enabled) +/// hidden : byte (optional, 0 = visible) +/// icon : string (optional, base64 PNG) +/// } +/// } +/// +public sealed class ServerListService +{ + /// + /// Add or update an entry. Match-by-IP: if an entry with the same IP exists, + /// update its name; otherwise prepend a new entry so the friend's first + /// glance at multiplayer shows our server at the top. + /// + public void EnsureServer(string gameDir, string name, string ip) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(ip)) return; + Directory.CreateDirectory(gameDir); + var path = Path.Combine(gameDir, "servers.dat"); + + NbtCompound root; + NbtList servers; + + if (File.Exists(path)) + { + try + { + var nbt = new NbtFile(); + nbt.LoadFromFile(path, NbtCompression.None, _ => true); + root = nbt.RootTag; + if (root.TryGet("servers", out NbtList? existingList) && existingList is not null) + { + servers = existingList; + } + else + { + servers = new NbtList("servers", NbtTagType.Compound); + root.Add(servers); + } + } + catch + { + // Existing file unreadable -- start fresh rather than crashing the install. + root = new NbtCompound(""); + servers = new NbtList("servers", NbtTagType.Compound); + root.Add(servers); + } + } + else + { + root = new NbtCompound(""); + servers = new NbtList("servers", NbtTagType.Compound); + root.Add(servers); + } + + // Match-by-IP. If found, update the display name; otherwise prepend. + for (int i = 0; i < servers.Count; i++) + { + if (servers[i] is not NbtCompound entry) continue; + if (entry.TryGet("ip", out NbtString? ipTag) && ipTag?.Value == ip) + { + entry["name"] = new NbtString("name", name); + Save(root, path); + return; + } + } + + var newEntry = new NbtCompound + { + new NbtString("name", name), + new NbtString("ip", ip), + // acceptTextures = 1 lets the server send its resource pack without prompting + // (the player still gets the prompt; this just allows the option). Default + // value matches what vanilla MC writes when you click "Done" in the UI. + new NbtByte("acceptTextures", 1), + }; + servers.Insert(0, newEntry); + Save(root, path); + } + + private static void Save(NbtCompound root, string path) + { + var file = new NbtFile(root); + file.SaveToFile(path, NbtCompression.None); + } +} diff --git a/launcher/Services/SystemInfo.cs b/launcher/Services/SystemInfo.cs new file mode 100644 index 0000000..7fc7e98 --- /dev/null +++ b/launcher/Services/SystemInfo.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace ModpackLauncher.Services; + +public static class SystemInfo +{ + private const long DefaultFallbackKB = 8L * 1024 * 1024; // assume 8 GB if detection fails + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetPhysicallyInstalledSystemMemory(out long memoryInKilobytes); + + /// Total physically installed system RAM in megabytes. + public static int TotalPhysicalMemoryMB + { + get + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (GetPhysicallyInstalledSystemMemory(out var kb)) + return (int)(kb / 1024); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Read MemTotal from /proc/meminfo + foreach (var line in File.ReadAllLines("/proc/meminfo")) + { + if (line.StartsWith("MemTotal:", StringComparison.Ordinal)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && long.TryParse(parts[1], out var memKb)) + return (int)(memKb / 1024); + } + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // GC heap-size hint; not perfect but a reasonable fallback for macOS. + var gcInfo = GC.GetGCMemoryInfo(); + if (gcInfo.TotalAvailableMemoryBytes > 0) + return (int)(gcInfo.TotalAvailableMemoryBytes / (1024 * 1024)); + } + } + catch + { + // Fall through to default + } + return (int)(DefaultFallbackKB / 1024); + } + } + + /// Recommended max user-allocatable RAM (leaves headroom for OS + other apps). + public static int SafeMaxAllocationMB + { + get + { + var total = TotalPhysicalMemoryMB; + // Leave at least 4 GB for OS + browser + Discord + everything else. + var headroom = total >= 32 * 1024 ? 6 * 1024 : 4 * 1024; + return Math.Max(2048, total - headroom); + } + } +} diff --git a/launcher/Services/WebView2Check.cs b/launcher/Services/WebView2Check.cs new file mode 100644 index 0000000..67902e2 --- /dev/null +++ b/launcher/Services/WebView2Check.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.Win32; + +namespace ModpackLauncher.Services; + +/// +/// Detects whether Microsoft Edge WebView2 Runtime is installed. +/// Required by the Xbox Live SDK + WebView2 sign-in flow used by the launcher when +/// no custom Azure client ID is configured. Preinstalled on Windows 10/11 since +/// 2021 (came with Edge), but not guaranteed on older / cleaned Windows installs. +/// +public static class WebView2Check +{ + private const string ClientGuid = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; // Microsoft Edge WebView2 Runtime + + public const string DownloadUrl = "https://developer.microsoft.com/microsoft-edge/webview2/"; + + public static bool IsInstalled() + { + // The runtime registers in one of three places depending on machine vs. per-user install. + return GetVersion(RegistryHive.LocalMachine, $@"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v1 && v1 != "0.0.0.0" + || GetVersion(RegistryHive.LocalMachine, $@"SOFTWARE\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v2 && v2 != "0.0.0.0" + || GetVersion(RegistryHive.CurrentUser, $@"Software\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v3 && v3 != "0.0.0.0"; + } + + private static string? GetVersion(RegistryHive hive, string keyPath) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, RegistryView.Default); + using var key = baseKey.OpenSubKey(keyPath); + return key?.GetValue("pv") as string; + } + catch + { + return null; + } + } +} diff --git a/launcher/Services/WhitelistRequestService.cs b/launcher/Services/WhitelistRequestService.cs new file mode 100644 index 0000000..02666ae --- /dev/null +++ b/launcher/Services/WhitelistRequestService.cs @@ -0,0 +1,67 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace ModpackLauncher.Services; + +/// +/// Friend-side wrapper for the brass-sigil-server's public whitelist endpoints. +/// Exists so the launcher can: +/// - Send a "please add me" request without the friend needing to share their +/// MC username with the admin out-of-band. +/// - Poll status afterwards so the launcher UI reflects pending/approved/denied. +/// +public sealed class WhitelistRequestService +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) }; + + public sealed class StatusResponse + { + [JsonPropertyName("ok")] public bool Ok { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } + } + + public sealed class RequestResponse + { + [JsonPropertyName("ok")] public bool Ok { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } + [JsonPropertyName("error")] public string? Error { get; set; } + } + + /// + /// Returns "pending" / "approved" / "denied" / "unknown" / "" (network error). + /// + public async Task GetStatusAsync(string panelUrl, string username) + { + try + { + var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/status?username={Uri.EscapeDataString(username)}"; + var resp = await _http.GetFromJsonAsync(url); + return resp?.Status ?? ""; + } + catch + { + return ""; + } + } + + public async Task SubmitAsync(string panelUrl, string username, string? message = null) + { + try + { + var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/request"; + var resp = await _http.PostAsJsonAsync(url, new { username, message }); + var body = await resp.Content.ReadFromJsonAsync(); + if (body is null) return new RequestResponse { Ok = false, Error = "Empty response." }; + if (!resp.IsSuccessStatusCode && string.IsNullOrEmpty(body.Error)) + body.Error = $"HTTP {(int)resp.StatusCode}"; + return body; + } + catch (Exception ex) + { + return new RequestResponse { Ok = false, Error = ex.Message }; + } + } +} diff --git a/launcher/app.manifest b/launcher/app.manifest new file mode 100644 index 0000000..7efa5b9 --- /dev/null +++ b/launcher/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/launcher/launcher-config.template.json b/launcher/launcher-config.template.json new file mode 100644 index 0000000..f1bd5fd --- /dev/null +++ b/launcher/launcher-config.template.json @@ -0,0 +1,10 @@ +{ + "_comment": "Copy this file to launcher-config.json and fill in real values before building. The launcher-config.json file is gitignored so local values never get committed. httpUsername/httpPassword are optional and only needed if your manifest/file host uses HTTP Basic auth.", + "packName": "Brass & Sigil", + "manifestUrl": "https://your-server.example/pack/manifest.json", + "installDirName": "BrassAndSigilData", + "memoryMB": 8192, + "msalClientId": "00000000-0000-0000-0000-000000000000", + "httpUsername": null, + "httpPassword": null +} diff --git a/launcher/manifest.example.json b/launcher/manifest.example.json new file mode 100644 index 0000000..595778d --- /dev/null +++ b/launcher/manifest.example.json @@ -0,0 +1,33 @@ +{ + "name": "Brass & Sigil", + "version": "0.1.0", + "minecraft": { + "version": "1.21.1" + }, + "loader": { + "type": "neoforge", + "version": "21.1.95" + }, + "files": [ + { + "path": "mods/create-1.21.1-6.0.10.jar", + "url": "https://sijbers.uk/pack/files/mods/create-1.21.1-6.0.10.jar", + "sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353" + }, + { + "path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar", + "url": "https://sijbers.uk/pack/files/mods/create-aeronautics-bundled-1.21.1-1.2.1.jar", + "sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9" + }, + { + "path": "mods/distant-horizons-3.0.2-b-1.21.1.jar", + "url": "https://sijbers.uk/pack/files/mods/distant-horizons-3.0.2-b-1.21.1.jar", + "sha1": "0000000000000000000000000000000000000000" + }, + { + "path": "config/example.cfg", + "url": "https://sijbers.uk/pack/files/config/example.cfg", + "sha1": "0000000000000000000000000000000000000000" + } + ] +} diff --git a/pack/pack.lock.json b/pack/pack.lock.json new file mode 100644 index 0000000..c8b03bf --- /dev/null +++ b/pack/pack.lock.json @@ -0,0 +1,349 @@ +{ + "$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing", + "name": "Brass and Sigil", + "version": "0.9.2", + "minecraft": "1.21.1", + "loader": { + "type": "neoforge", + "version": "21.1.228" + }, + "lockedAt": "2026-05-04T14:22:58.7131203+01:00", + "mods": [ + { + "source": "modrinth", + "slug": "create", + "versionId": "UjX6dr61", + "version": "6.0.10+mc1.21.1", + "path": "mods/create-1.21.1-6.0.10.jar", + "url": "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar", + "sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353", + "size": 19123767 + }, + { + "source": "modrinth", + "slug": "create-aeronautics", + "versionId": "YhZLrAFC", + "version": "1.2.1+mc1.21.1", + "path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar", + "url": "https://cdn.modrinth.com/data/oWaK0Q19/versions/YhZLrAFC/create-aeronautics-bundled-1.21.1-1.2.1.jar", + "sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9", + "size": 33030286 + }, + { + "source": "modrinth", + "slug": "sable", + "versionId": "3FMsUjO4", + "version": "1.2.2+mc1.21.1", + "path": "mods/sable-neoforge-1.21.1-1.2.2.jar", + "url": "https://cdn.modrinth.com/data/T9PomCSv/versions/3FMsUjO4/sable-neoforge-1.21.1-1.2.2.jar", + "sha1": "c5ecd3fcf60a31d84112c708abe29e341b2d1b73", + "size": 12719293 + }, + { + "source": "modrinth", + "slug": "create-big-cannons", + "versionId": "bsGaXKEd", + "version": "5.11.3", + "path": "mods/createbigcannons-5.11.3+mc.1.21.1.jar", + "url": "https://cdn.modrinth.com/data/GWp4jCJj/versions/bsGaXKEd/createbigcannons-5.11.3%2Bmc.1.21.1.jar", + "sha1": "8b61fa850e260bdeb5d360576123f98c260afa50", + "size": 3715787 + }, + { + "source": "modrinth", + "slug": "create-tfmg", + "versionId": "uDi14nbt", + "version": "1.2.0", + "path": "mods/tfmg-1.2.0.jar", + "url": "https://cdn.modrinth.com/data/USgVjXsk/versions/uDi14nbt/tfmg-1.2.0.jar", + "sha1": "b520f3687f60a69eb265ff5b9a16759b9e124103", + "size": 4924243 + }, + { + "source": "modrinth", + "slug": "distanthorizons", + "versionId": "KkaaQtTD", + "version": "3.0.2-b-1.21.1", + "path": "mods/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar", + "url": "https://cdn.modrinth.com/data/uCdwusMi/versions/KkaaQtTD/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar", + "sha1": "1ff0a8920e52add541471f7b32d0d389997145ba", + "size": 30019727 + }, + { + "source": "modrinth", + "slug": "sodium", + "versionId": "Pb3OXVqC", + "version": "mc1.21.1-0.6.13-neoforge", + "path": "mods/sodium-neoforge-0.6.13+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/Pb3OXVqC/sodium-neoforge-0.6.13%2Bmc1.21.1.jar", + "sha1": "38af70fa4dc4b2aaac636e92fdba3bedd5a025e1", + "size": 1162994 + }, + { + "source": "modrinth", + "slug": "iris", + "versionId": "t3ruzodq", + "version": "1.8.12+1.21.1-neoforge", + "path": "mods/iris-neoforge-1.8.12+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/YL57xq9U/versions/t3ruzodq/iris-neoforge-1.8.12%2Bmc1.21.1.jar", + "sha1": "a3e6355915c7d3b2bc392724795113e51d289378", + "size": 2438548 + }, + { + "source": "modrinth", + "slug": "modernfix", + "versionId": "6U8JVjdw", + "version": "5.27.4+mc1.21.1", + "path": "mods/modernfix-neoforge-5.27.4+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/nmDcB62a/versions/6U8JVjdw/modernfix-neoforge-5.27.4%2Bmc1.21.1.jar", + "sha1": "2f39363f0d6d5a5ccc2a9e0f50ad3385611c3cb7", + "size": 562051 + }, + { + "source": "modrinth", + "slug": "ferrite-core", + "versionId": "x7kQWVju", + "version": "7.0.3-neoforge", + "path": "mods/ferritecore-7.0.3-neoforge.jar", + "url": "https://cdn.modrinth.com/data/uXXizFIs/versions/x7kQWVju/ferritecore-7.0.3-neoforge.jar", + "sha1": "9563692efb708b6b568df27a01ec52f6311928ef", + "size": 121559 + }, + { + "source": "modrinth", + "slug": "architectury-api", + "versionId": "ZxYGwlk0", + "version": "13.0.8+neoforge", + "path": "mods/architectury-13.0.8-neoforge.jar", + "url": "https://cdn.modrinth.com/data/lhGA9TYQ/versions/ZxYGwlk0/architectury-13.0.8-neoforge.jar", + "sha1": "6ca11d3cc136bf69bb8f4d56982481eb85b5100b", + "size": 584004 + }, + { + "source": "modrinth", + "slug": "rhino", + "versionId": "ZdLtebKH", + "version": "2101.2.7-build.81+Rhino-1.21", + "path": "mods/rhino-2101.2.7-build.81.jar", + "url": "https://cdn.modrinth.com/data/sk9knFPE/versions/ZdLtebKH/rhino-2101.2.7-build.81.jar", + "sha1": "480235a9f7749f68ce6fec3b9c3cac3428b92a4a", + "size": 882033 + }, + { + "source": "modrinth", + "slug": "rpl", + "versionId": "hZ6B2Z0x", + "version": "2.1.2", + "path": "mods/ritchiesprojectilelib-2.1.2+mc.1.21.1-neoforge.jar", + "url": "https://cdn.modrinth.com/data/B3pb093D/versions/hZ6B2Z0x/ritchiesprojectilelib-2.1.2%2Bmc.1.21.1-neoforge.jar", + "sha1": "ec2e4996f8bee8714173e603e379fef8a6901765", + "size": 76369 + }, + { + "source": "modrinth", + "slug": "kubejs", + "versionId": "Fe9CjPws", + "version": "2101.7.2-build.363", + "path": "mods/kubejs-neoforge-2101.7.2-build.363.jar", + "url": "https://cdn.modrinth.com/data/umyGl7zF/versions/Fe9CjPws/kubejs-neoforge-2101.7.2-build.363.jar", + "sha1": "d4e88254e8c26687d4c6aeb4dfa9c2ad70f260a2", + "size": 2270442 + }, + { + "source": "modrinth", + "slug": "jei", + "versionId": "YAcQ6elZ", + "version": "19.27.0.340", + "path": "mods/jei-1.21.1-neoforge-19.27.0.340.jar", + "url": "https://cdn.modrinth.com/data/u6dRKJwZ/versions/YAcQ6elZ/jei-1.21.1-neoforge-19.27.0.340.jar", + "sha1": "27d0d85e7e32e926fc3664ab6815df5cdabb7941", + "size": 1529391 + }, + { + "source": "modrinth", + "slug": "jade", + "versionId": "yd8FKCmx", + "version": "15.10.5+neoforge", + "path": "mods/Jade-1.21.1-NeoForge-15.10.5.jar", + "url": "https://cdn.modrinth.com/data/nvQzSEkH/versions/yd8FKCmx/Jade-1.21.1-NeoForge-15.10.5.jar", + "sha1": "d5bf134b3dbde9f5258666823900e21341dc0a50", + "size": 725742 + }, + { + "source": "modrinth", + "slug": "chunky", + "versionId": "LuFhm4eU", + "version": "1.4.23", + "path": "mods/Chunky-NeoForge-1.4.23.jar", + "url": "https://cdn.modrinth.com/data/fALzjamp/versions/LuFhm4eU/Chunky-NeoForge-1.4.23.jar", + "sha1": "ab0c74743a653020fe2dfc4986b43e893947f3e9", + "size": 340572 + }, + { + "source": "curseforge", + "slug": "ftb-library", + "fileId": "7746959", + "version": "2101.1.31", + "path": "mods/ftb-library-neoforge-2101.1.31.jar", + "url": "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar", + "sha1": "686d4e784c28c14f7760cc22b2de6a8573b56b74", + "size": 1411181 + }, + { + "source": "curseforge", + "slug": "ftb-teams", + "fileId": "7369021", + "version": "2101.1.9", + "path": "mods/ftb-teams-neoforge-2101.1.9.jar", + "url": "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar", + "sha1": "328e04bf1a445870aacea8fe7637670f84272a8f", + "size": 291847 + }, + { + "source": "curseforge", + "slug": "ftb-chunks", + "fileId": "7608681", + "version": "2101.1.14", + "path": "mods/ftb-chunks-neoforge-2101.1.14.jar", + "url": "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar", + "sha1": "908b63b11d0e00ae6c9557d3fe6440bdbcf21bb7", + "size": 642340 + }, + { + "source": "modrinth", + "slug": "ars-nouveau", + "versionId": "BmGGrC9A", + "version": "5.11.3+mc1.21.1", + "path": "mods/ars_nouveau-1.21.1-5.11.3.jar", + "url": "https://cdn.modrinth.com/data/TKB6INcv/versions/BmGGrC9A/ars_nouveau-1.21.1-5.11.3.jar", + "sha1": "0af12dd7fda63a4261ceb302c9bb57fc235641c6", + "size": 20689115 + }, + { + "source": "modrinth", + "slug": "terralith", + "versionId": "MuJMtPGQ", + "version": "2.5.8", + "path": "mods/Terralith_1.21.x_v2.5.8.jar", + "url": "https://cdn.modrinth.com/data/8oi3bsk5/versions/MuJMtPGQ/Terralith_1.21.x_v2.5.8.jar", + "sha1": "bee0cfb1a8cd4bf3d96bccea224fb45d74de9085", + "size": 3115385 + }, + { + "source": "modrinth", + "slug": "yungs-better-strongholds", + "versionId": "8U0dIfSM", + "version": "1.21.1-NeoForge-5.1.3", + "path": "mods/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar", + "url": "https://cdn.modrinth.com/data/kidLKymU/versions/8U0dIfSM/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar", + "sha1": "5d06a5850af7c577612d4592706a8e156bbe1cbf", + "size": 461244 + }, + { + "source": "modrinth", + "slug": "lithostitched", + "versionId": "IONexlgI", + "version": "1.7.2-neoforge-21.1", + "path": "mods/lithostitched-1.7.2-neoforge-21.1.jar", + "url": "https://cdn.modrinth.com/data/XaDC71GB/versions/IONexlgI/lithostitched-1.7.2-neoforge-21.1.jar", + "sha1": "ce35206214647131ebdf14212d1986349aeba79a", + "size": 810015 + }, + { + "source": "modrinth", + "slug": "c2me-neoforge", + "versionId": "9iPiN34N", + "version": "0.3.0+alpha.0.91+1.21.1", + "path": "mods/c2me-neoforge-mc1.21.1-0.3.0+alpha.0.91.jar", + "url": "https://cdn.modrinth.com/data/COlSi5iR/versions/9iPiN34N/c2me-neoforge-mc1.21.1-0.3.0%2Balpha.0.91.jar", + "sha1": "c858c8becfb5205eb12aaf0420eb82c307c2e6a7", + "size": 4508649 + }, + { + "source": "modrinth", + "slug": "noisium", + "versionId": "nJBE6tif", + "version": "2.3.0+mc1.21-1.21.1", + "path": "mods/noisium-neoforge-2.3.0+mc1.21-1.21.1.jar", + "url": "https://cdn.modrinth.com/data/KuNKN7d2/versions/nJBE6tif/noisium-neoforge-2.3.0%2Bmc1.21-1.21.1.jar", + "sha1": "1bea6b61378ba80f038256c4345d9ff3b67928c4", + "size": 60296 + }, + { + "source": "modrinth", + "slug": "async-locator-refined", + "versionId": "3BdGHbV2", + "version": "1.21.1-1.5.3", + "path": "mods/async-locator-refined-neoforge-1.21.1-1.5.3.jar", + "url": "https://cdn.modrinth.com/data/LUIHK4LD/versions/3BdGHbV2/async-locator-refined-neoforge-1.21.1-1.5.3.jar", + "sha1": "2993e3efc6d211ad8d4db179851dea6fdfff4e07", + "size": 273320 + }, + { + "source": "modrinth", + "slug": "servercore", + "versionId": "77MAnmOn", + "version": "1.5.10+1.21.1", + "path": "mods/servercore-neoforge-1.5.10+1.21.1.jar", + "url": "https://cdn.modrinth.com/data/4WWQxlQP/versions/77MAnmOn/servercore-neoforge-1.5.10%2B1.21.1.jar", + "sha1": "4524cd40cfa5019d8b5fbcb628b1616031838a0c", + "size": 1429522 + }, + { + "source": "modrinth", + "slug": "lithium", + "versionId": "RXHf27Wv", + "version": "mc1.21.1-0.15.3-neoforge", + "path": "mods/lithium-neoforge-0.15.3+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/RXHf27Wv/lithium-neoforge-0.15.3%2Bmc1.21.1.jar", + "sha1": "9fd5fa9076044180ae7f51672de74669196ec72e", + "size": 774148 + }, + { + "source": "modrinth", + "slug": "geckolib", + "versionId": "gFmrC8Ru", + "version": "4.8.4", + "path": "mods/geckolib-neoforge-1.21.1-4.8.4.jar", + "url": "https://cdn.modrinth.com/data/8BmcQJ2H/versions/gFmrC8Ru/geckolib-neoforge-1.21.1-4.8.4.jar", + "sha1": "eb854c8ec53ef922a5f3877a1aa4c1ce1352e0ce", + "size": 622582 + }, + { + "source": "modrinth", + "slug": "curios", + "versionId": "yohfFbgD", + "version": "9.5.1+1.21.1", + "path": "mods/curios-neoforge-9.5.1+1.21.1.jar", + "url": "https://cdn.modrinth.com/data/vvuO3ImH/versions/yohfFbgD/curios-neoforge-9.5.1%2B1.21.1.jar", + "sha1": "418fcd42e3a7844c9bdc71c9b6401fdb3894e0c4", + "size": 410690 + }, + { + "source": "modrinth", + "slug": "yungs-api", + "versionId": "ZB22DE9q", + "version": "1.21.1-NeoForge-5.1.6", + "path": "mods/YungsApi-1.21.1-NeoForge-5.1.6.jar", + "url": "https://cdn.modrinth.com/data/Ua7DFN59/versions/ZB22DE9q/YungsApi-1.21.1-NeoForge-5.1.6.jar", + "sha1": "e1c394779fb9e038e4f7a1b4558d0432607d263b", + "size": 388678 + }, + { + "source": "modrinth", + "slug": "complementary-reimagined", + "versionId": "836bPNGo", + "version": "r5.7.1", + "path": "shaderpacks/ComplementaryReimagined_r5.7.1.zip", + "url": "https://cdn.modrinth.com/data/HVnmMxH1/versions/836bPNGo/ComplementaryReimagined_r5.7.1.zip", + "sha1": "b560f646a124d5204b1fb7321fec373b9c346fa5", + "size": 522970 + } + ], + "defaultServer": { + "name": "Brass and Sigil", + "ip": "bns.sijbers.uk" + }, + "defaultShader": "ComplementaryReimagined_r5.7.1.zip", + "panelUrl": "https://bns-admin.sijbers.uk" +} \ No newline at end of file diff --git a/pack/tweaks/README.md b/pack/tweaks/README.md new file mode 100644 index 0000000..6bd2425 --- /dev/null +++ b/pack/tweaks/README.md @@ -0,0 +1,91 @@ +# Pack tweaks + +This folder is the source for any data-only NeoForge mods we ship with the +modpack. Each subfolder becomes one jar at build time. + +The build pipeline: + +``` +pack/tweaks// --(Build-Tweaks.ps1)--> pack/overrides/mods/-.jar + | + v + (Build-Pack.ps1 picks it up) + | + v + manifest.json -> launcher install +``` + +Because the output is a real mod jar (with `META-INF/neoforge.mods.toml`), +the data inside auto-loads in **single-player and on the server** -- no +`/datapack enable` needed, no per-world setup. + +--- + +## Adding a new tweak (one big jar, recommended) + +The simplest workflow is to keep extending `brassandsigil-tweaks` with more +worldgen / loot / recipe overrides. Just drop more JSON under +`brassandsigil-tweaks/data/brassandsigil_tweaks/...`. + +Common targets: + +| What | Path | +| --------------------------------- | ----------------------------------------------------------------------- | +| Custom configured feature (ore) | `data/brassandsigil_tweaks/worldgen/configured_feature/.json` | +| Custom placed feature | `data/brassandsigil_tweaks/worldgen/placed_feature/.json` | +| Lithostitched modifier | `data/brassandsigil_tweaks/lithostitched/worldgen_modifier/.json` | +| Override another mod's loot table | `data//loot_table/blocks/.json` | +| Override another mod's recipe | `data//recipe/.json` | +| Override / extend a vanilla tag | `data/minecraft/tags//.json` | + +After editing, bump the `version` in +`brassandsigil-tweaks/META-INF/neoforge.mods.toml` so launcher clients see +it as a new file and re-download. + +## Adding a separate tweak mod + +Make a sibling folder. The structure must be: + +``` +tweaks/ + / + META-INF/ + neoforge.mods.toml # modId + version are required + pack.mcmeta # pack_format: 48 for 1.21.1 + data/ + / + ... +``` + +`Build-Tweaks.ps1` reads `modId` and `version` from the toml and produces +`pack/overrides/mods/-.jar`. Old jars for the same `modId` +are auto-cleaned, so version bumps don't leave stale files behind. + +--- + +## What's in `brassandsigil-tweaks` today + +- **`skylands_end_stone_ore`** -- adds small End Stone veins to Terralith's + Skylands biomes (`#terralith:skylands`). Targets `stone_ore_replaceables` + so it only replaces the stone interior of the floating islands. Powers the + Aeronautics Levitite-blend recipe without forcing players to visit The + End. Tuning lives in + `data/brassandsigil_tweaks/worldgen/placed_feature/skylands_end_stone_ore.json` + (`count` = veins per chunk; `size` in the configured_feature controls vein size). + +--- + +## Building + +From the repo root: + +```powershell +# Builds tweak jars + manifest in one shot: +.\scripts\Build-Pack.ps1 -OutputPath .\scripts\manifest.json + +# Just rebuild tweak jars: +.\scripts\Build-Tweaks.ps1 +``` + +Then deploy: `.\scripts\Deploy-Brass.ps1` mirrors `pack/overrides/` to the +public share's `files/` subdir and pushes the regenerated manifest alongside. diff --git a/pack/tweaks/brassandsigil-tweaks/META-INF/neoforge.mods.toml b/pack/tweaks/brassandsigil-tweaks/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..67f35b0 --- /dev/null +++ b/pack/tweaks/brassandsigil-tweaks/META-INF/neoforge.mods.toml @@ -0,0 +1,42 @@ +modLoader = "lowcodefml" +loaderVersion = "[1,)" +license = "All Rights Reserved" +issueTrackerURL = "https://sijbers.uk/pack/" + +[[mods]] +modId = "brassandsigil_tweaks" +version = "1.0.0" +displayName = "Brass & Sigil Tweaks" +description = ''' +Server-and-client worldgen and balance tweaks for the Brass & Sigil modpack. +Ships custom configured features, placed features, and Lithostitched worldgen +modifiers that retune third-party content for this pack. +''' + +[[dependencies.brassandsigil_tweaks]] + modId = "neoforge" + type = "required" + versionRange = "[21.1,)" + ordering = "NONE" + side = "BOTH" + +[[dependencies.brassandsigil_tweaks]] + modId = "minecraft" + type = "required" + versionRange = "[1.21.1,1.22)" + ordering = "NONE" + side = "BOTH" + +[[dependencies.brassandsigil_tweaks]] + modId = "lithostitched" + type = "required" + versionRange = "[1.7,)" + ordering = "NONE" + side = "BOTH" + +[[dependencies.brassandsigil_tweaks]] + modId = "terralith" + type = "required" + versionRange = "[2.5,)" + ordering = "AFTER" + side = "BOTH" diff --git a/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/lithostitched/worldgen_modifier/skylands_end_stone.json b/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/lithostitched/worldgen_modifier/skylands_end_stone.json new file mode 100644 index 0000000..4031c25 --- /dev/null +++ b/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/lithostitched/worldgen_modifier/skylands_end_stone.json @@ -0,0 +1,6 @@ +{ + "type": "lithostitched:add_features", + "biomes": "#terralith:skylands", + "features": "brassandsigil_tweaks:skylands_end_stone_ore", + "step": "underground_ores" +} diff --git a/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/worldgen/configured_feature/skylands_end_stone_ore.json b/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/worldgen/configured_feature/skylands_end_stone_ore.json new file mode 100644 index 0000000..077bfc6 --- /dev/null +++ b/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/worldgen/configured_feature/skylands_end_stone_ore.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:ore", + "config": { + "discard_chance_on_air_exposure": 0.0, + "size": 6, + "targets": [ + { + "target": { + "predicate_type": "minecraft:tag_match", + "tag": "minecraft:stone_ore_replaceables" + }, + "state": { + "Name": "minecraft:end_stone" + } + } + ] + } +} diff --git a/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/worldgen/placed_feature/skylands_end_stone_ore.json b/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/worldgen/placed_feature/skylands_end_stone_ore.json new file mode 100644 index 0000000..5f15446 --- /dev/null +++ b/pack/tweaks/brassandsigil-tweaks/data/brassandsigil_tweaks/worldgen/placed_feature/skylands_end_stone_ore.json @@ -0,0 +1,16 @@ +{ + "feature": "brassandsigil_tweaks:skylands_end_stone_ore", + "placement": [ + { "type": "minecraft:count", "count": 3 }, + { "type": "minecraft:in_square" }, + { + "type": "minecraft:height_range", + "height": { + "type": "minecraft:uniform", + "min_inclusive": { "absolute": 200 }, + "max_inclusive": { "absolute": 250 } + } + }, + { "type": "minecraft:biome" } + ] +} diff --git a/pack/tweaks/brassandsigil-tweaks/pack.mcmeta b/pack/tweaks/brassandsigil-tweaks/pack.mcmeta new file mode 100644 index 0000000..a56801b --- /dev/null +++ b/pack/tweaks/brassandsigil-tweaks/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 48, + "description": "Brass & Sigil pack tweaks" + } +} diff --git a/scripts/Build-Pack.ps1 b/scripts/Build-Pack.ps1 new file mode 100644 index 0000000..a373626 --- /dev/null +++ b/scripts/Build-Pack.ps1 @@ -0,0 +1,183 @@ +#requires -Version 5 +<# +.SYNOPSIS + Generates manifest.json deterministically from pack.lock.json. + +.DESCRIPTION + The lockfile (pack.lock.json) is the source of truth for every mod's exact + version, URL, SHA-1, and size. Running this script does not change versions. + + To intentionally bump versions, use Update-Pack.ps1 with the -Refresh flag. + To see what new versions are available, use Check-Updates.ps1. + + Workflow: + 1. Edit pack.lock.json (manually or via Update-Pack.ps1 -Refresh) + 2. Run Build-Pack.ps1 -OutputPath ...\manifest.json + 3. Update the server to match + 4. Deploy manifest to your hosted URL + +.PARAMETER OutputPath + Where to write manifest.json. + +.PARAMETER LocalPackSource + Optional folder containing local override files (configs, resourcepacks, etc.). + +.PARAMETER SelfHostBaseUrl + Public URL prefix for self-hosted files (only used when LocalPackSource is set). +#> + +[CmdletBinding()] +param( + [string]$OutputPath = ".\manifest.json", + [string]$LocalPackSource = "", + [string]$SelfHostBaseUrl = "https://sijbers.uk/pack/files", + # Optional -- point at a published launcher .exe to embed launcherVersion + launcherUrl + # in the manifest. The launcher displays a "newer version available" banner when + # its embedded version is lower than this. Skip entirely to leave both fields out. + [string]$LauncherExePath = "", + [string]$LauncherPublicUrl = "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe", + # Skip the auto-invoke of Build-Tweaks.ps1 (only touch this if you're debugging). + [switch]$SkipTweaks +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$lockPath = Join-Path $here '..\pack\pack.lock.json' + +# Build any data-only tweak jars first, then fold the resulting overrides +# folder into the manifest. Set -SkipTweaks to bypass. The "overrides" name +# matches CurseForge/Modrinth modpack conventions: files that override or +# augment the standard mod set, hosted by us. +$tweaksRoot = Join-Path $here '..\pack\tweaks' +$overridesRoot = Join-Path $here '..\pack\overrides' +if (-not $SkipTweaks -and (Test-Path $tweaksRoot)) { + & (Join-Path $here 'Build-Tweaks.ps1') -TweaksRoot $tweaksRoot -OutputRoot (Join-Path $overridesRoot 'mods') +} +if (-not $LocalPackSource -and (Test-Path $overridesRoot)) { + $LocalPackSource = $overridesRoot +} + +if (-not (Test-Path $lockPath)) { + throw "pack.lock.json not found at $lockPath. Run Update-Pack.ps1 -Refresh first to bootstrap it." +} + +$lock = Get-Content $lockPath -Raw | ConvertFrom-Json + +Write-Host "" +Write-Host "Building manifest from pack.lock.json:" +Write-Host (" Pack: {0} v{1}" -f $lock.name, $lock.version) +Write-Host (" MC: {0} ({1} {2})" -f $lock.minecraft, $lock.loader.type, $lock.loader.version) +Write-Host (" Locked: {0}" -f $lock.lockedAt) +Write-Host (" Mods: {0}" -f $lock.mods.Count) +Write-Host "" + +$files = @() +$totalBytes = 0L + +foreach ($mod in $lock.mods) { + $files += [pscustomobject]@{ + path = $mod.path + url = $mod.url + sha1 = $mod.sha1 + size = $mod.size + } + $totalBytes += $mod.size + Write-Host (" [{0}] {1,-26} {2,-22} {3,8:N0} KB" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $mod.version, ($mod.size/1KB)) +} + +# Local overrides (configs, custom files not on Modrinth/CurseForge) +if ($LocalPackSource -and (Test-Path $LocalPackSource)) { + Write-Host "" + Write-Host "Adding local overrides from $LocalPackSource..." + $managedRoots = @('mods', 'config', 'resourcepacks', 'shaderpacks', 'kubejs', 'defaultconfigs') + $base = $SelfHostBaseUrl.TrimEnd('/') + $sourceFull = (Resolve-Path $LocalPackSource).Path.TrimEnd('\','/') + foreach ($root in $managedRoots) { + $rootDir = Join-Path $LocalPackSource $root + if (-not (Test-Path $rootDir)) { continue } + Get-ChildItem -Path $rootDir -Recurse -File | ForEach-Object { + $rel = $_.FullName.Substring($sourceFull.Length).TrimStart('\','/') -replace '\\','/' + $sha1 = (Get-FileHash -Algorithm SHA1 -Path $_.FullName).Hash.ToLowerInvariant() + $files += [pscustomobject]@{ + path = $rel + url = "$base/$rel" + sha1 = $sha1 + size = $_.Length + } + $totalBytes += $_.Length + Write-Host (" [local] {0}" -f $rel) + } + } +} + +$manifest = [ordered]@{ + name = $lock.name + version = $lock.version + minecraft = [ordered]@{ version = $lock.minecraft } + loader = [ordered]@{ type = $lock.loader.type; version = $lock.loader.version } + files = $files +} + +# Optional: copy a defaultServer block through from the lockfile so the launcher +# can pre-populate friends' multiplayer list. Lockfile schema: +# "defaultServer": { "name": "Brass & Sigil", "ip": "bns.sijbers.uk" } +if ($lock.PSObject.Properties.Name -contains "defaultServer" -and $lock.defaultServer) { + $manifest.defaultServer = [ordered]@{ + name = $lock.defaultServer.name + ip = $lock.defaultServer.ip + } + Write-Host (" Default server: {0} ({1})" -f $lock.defaultServer.name, $lock.defaultServer.ip) +} + +# Optional: shader pack to enable by default on fresh installs. Lockfile schema: +# "defaultShader": "ComplementaryReimagined_r5.7.1.zip" +if ($lock.PSObject.Properties.Name -contains "defaultShader" -and $lock.defaultShader) { + $manifest.defaultShader = $lock.defaultShader + Write-Host (" Default shader: {0}" -f $lock.defaultShader) +} + +# Optional: public URL of the brass-sigil-server panel. Used by the launcher +# to send friend-side whitelist requests. Lockfile schema: +# "panelUrl": "https://bns-admin.sijbers.uk" +if ($lock.PSObject.Properties.Name -contains "panelUrl" -and $lock.panelUrl) { + $manifest.panelUrl = $lock.panelUrl + Write-Host (" Panel URL: {0}" -f $lock.panelUrl) +} + +# Optional launcher metadata: when -LauncherExePath is supplied, read the exe's +# FileVersion (set via in ModpackLauncher.csproj) and embed it. The +# launcher compares its assembly version to this value and shows an upgrade +# banner pointing at LauncherPublicUrl when older. +if ($LauncherExePath) { + if (-not (Test-Path $LauncherExePath)) { + throw "LauncherExePath '$LauncherExePath' does not exist." + } + $launcherFile = Get-Item $LauncherExePath + $launcherVersion = $launcherFile.VersionInfo.FileVersion + if ([string]::IsNullOrWhiteSpace($launcherVersion)) { + throw "Launcher exe has no FileVersion -- set in ModpackLauncher.csproj and republish." + } + # FileVersion is the four-component form (e.g. "0.1.0.0" for csproj 0.1.0). + # Embed as-is -- the launcher's Version.Parse handles it directly and avoids ambiguous + # comparisons between trimmed and untrimmed forms. + $manifest.launcherVersion = $launcherVersion + $manifest.launcherUrl = $LauncherPublicUrl + + Write-Host "" + Write-Host ("Launcher metadata embedded:") + Write-Host (" Version: {0}" -f $launcherVersion) + Write-Host (" Url: {0}" -f $LauncherPublicUrl) + Write-Host (" Source: {0}" -f $launcherFile.FullName) +} + +$json = $manifest | ConvertTo-Json -Depth 10 +$outDir = Split-Path -Parent $OutputPath +if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } +[System.IO.File]::WriteAllText($OutputPath, $json, [System.Text.UTF8Encoding]::new($false)) + +Write-Host "" +Write-Host "Manifest written: $OutputPath" +Write-Host (" Files: {0}" -f $files.Count) +Write-Host (" Total: {0:N1} MB (download size)" -f ($totalBytes / 1MB)) diff --git a/scripts/Build-Tweaks.ps1 b/scripts/Build-Tweaks.ps1 new file mode 100644 index 0000000..4e8d819 --- /dev/null +++ b/scripts/Build-Tweaks.ps1 @@ -0,0 +1,115 @@ +#requires -Version 5 +<# +.SYNOPSIS + Builds every tweak source folder under ..\pack\tweaks\ into a data-only + mod jar and drops it into ..\pack\overrides\mods\. + +.DESCRIPTION + Each subfolder of ..\pack\tweaks\ is treated as one self-contained + "data-only" NeoForge mod. Its modId, version, and display name come from + the folder's META-INF\neoforge.mods.toml. The script: + + 1. Reads modId + version from neoforge.mods.toml. + 2. Zips the folder contents (data\, META-INF\, pack.mcmeta) into a jar + named "-.jar". + 3. Removes any older jars for the same modId from the output folder so + stale versions don't get bundled into the manifest. + + Run this before Build-Pack.ps1, or let Build-Pack.ps1 invoke it for you. + +.PARAMETER TweaksRoot + Folder containing tweak source subfolders. Defaults to ..\pack\tweaks\. + +.PARAMETER OutputRoot + Where built jars go. Defaults to ..\pack\overrides\mods\. This path + becomes Build-Pack.ps1's -LocalPackSource (one level up). +#> + +[CmdletBinding()] +param( + [string]$TweaksRoot = "", + [string]$OutputRoot = "" +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $TweaksRoot) { $TweaksRoot = Join-Path $here '..\pack\tweaks' } +if (-not $OutputRoot) { $OutputRoot = Join-Path $here '..\pack\overrides\mods' } + +if (-not (Test-Path $TweaksRoot)) { + Write-Host "No tweaks folder at $TweaksRoot -- nothing to build." + return +} + +if (-not (Test-Path $OutputRoot)) { + New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null +} + +# Read modId + version out of a neoforge.mods.toml. We do this with a tiny regex +# parser instead of a full TOML library because we only need two scalars and we +# control the file format. Lines like: modId = "foo" version = "1.2.3" +function Get-ModMeta { + param([string]$TomlPath) + if (-not (Test-Path $TomlPath)) { + throw "Missing $TomlPath -- every tweak folder needs META-INF\neoforge.mods.toml." + } + $content = Get-Content $TomlPath -Raw + + $modIdMatch = [regex]::Match($content, '(?m)^\s*modId\s*=\s*"([^"]+)"') + $versionMatch = [regex]::Match($content, '(?m)^\s*version\s*=\s*"([^"]+)"') + + if (-not $modIdMatch.Success) { throw "Could not parse modId from $TomlPath" } + if (-not $versionMatch.Success) { throw "Could not parse version from $TomlPath" } + + return [pscustomobject]@{ + ModId = $modIdMatch.Groups[1].Value + Version = $versionMatch.Groups[1].Value + } +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$tweakDirs = Get-ChildItem -Path $TweaksRoot -Directory + +if ($tweakDirs.Count -eq 0) { + Write-Host "No tweak folders found under $TweaksRoot." + return +} + +Write-Host "" +Write-Host "Building tweak jars from $TweaksRoot" +Write-Host "" + +foreach ($dir in $tweakDirs) { + $tomlPath = Join-Path $dir.FullName 'META-INF\neoforge.mods.toml' + $meta = Get-ModMeta -TomlPath $tomlPath + + $jarName = "$($meta.ModId)-$($meta.Version).jar" + $jarPath = Join-Path $OutputRoot $jarName + + # Wipe stale jars for this modId so old versions don't get bundled into + # the manifest alongside the new one. + Get-ChildItem -Path $OutputRoot -Filter "$($meta.ModId)-*.jar" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne $jarName } | + ForEach-Object { + Write-Host (" removing stale: {0}" -f $_.Name) + Remove-Item $_.FullName -Force + } + + if (Test-Path $jarPath) { Remove-Item $jarPath -Force } + + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $dir.FullName, + $jarPath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) + + $size = (Get-Item $jarPath).Length + Write-Host (" built {0,-40} {1,8:N0} bytes" -f $jarName, $size) +} + +Write-Host "" +Write-Host "Tweak jars are in: $OutputRoot" diff --git a/scripts/Check-Updates.ps1 b/scripts/Check-Updates.ps1 new file mode 100644 index 0000000..7f793c7 --- /dev/null +++ b/scripts/Check-Updates.ps1 @@ -0,0 +1,101 @@ +#requires -Version 5 +<# +.SYNOPSIS + Read-only update checker. Diffs the locked Modrinth versions against the + latest available on Modrinth. Prints a report. Does not change anything. + +.DESCRIPTION + Run this periodically to see what mod updates are available without + committing to anything. Decide which to bump, then either edit pack.lock.json + by hand or re-run Update-Pack.ps1 to refresh everything. + + CurseForge mods (FTB family) aren't auto-checked here -- CF requires an API + key for proper version listing. Manually monitor those at: + https://www.curseforge.com/minecraft/mc-mods/ftb-chunks-forge + https://www.curseforge.com/minecraft/mc-mods/ftb-library-forge + https://www.curseforge.com/minecraft/mc-mods/ftb-teams-forge +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$lockPath = Join-Path $here '..\pack\pack.lock.json' + +if (-not (Test-Path $lockPath)) { + throw "pack.lock.json not found. Run Update-Pack.ps1 first to bootstrap it." +} + +$lock = Get-Content $lockPath -Raw | ConvertFrom-Json +$mc = $lock.minecraft +$lt = $lock.loader.type + +Write-Host "" +Write-Host "Checking Modrinth for newer versions..." +Write-Host (" Pack: $($lock.name) v$($lock.version)") +Write-Host (" MC: $mc ($lt $($lock.loader.version))") +Write-Host (" Locked: $($lock.lockedAt)") +Write-Host "" + +Add-Type -AssemblyName System.Web + +function Invoke-Modrinth { + param([string]$Path) + Invoke-RestMethod -Uri "https://api.modrinth.com/v2$Path" -Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' } +} + +$updatesAvailable = 0 +$upToDate = 0 +$skipped = 0 + +foreach ($mod in $lock.mods) { + if ($mod.source -ne "modrinth") { + Write-Host (" [cforge] $($mod.slug.PadRight(22)) $($mod.version.PadRight(34)) (manual check)") + $skipped++ + continue + } + + try { + $encGv = [System.Web.HttpUtility]::UrlEncode('["' + $mc + '"]') + $encL = [System.Web.HttpUtility]::UrlEncode('["' + $lt + '"]') + $versions = Invoke-Modrinth ("/project/$($mod.slug)/version?game_versions=$encGv" + "&" + "loaders=$encL") + } catch { + Write-Warning " [error] $($mod.slug) - $($_.Exception.Message)" + continue + } + if (-not $versions -or $versions.Count -eq 0) { + Write-Warning " [gone] $($mod.slug) - no versions found on Modrinth" + continue + } + + $latest = $versions[0] + $latestVer = $latest.version_number + $latestType = $latest.version_type + + if ($latest.id -eq $mod.versionId) { + Write-Host (" [ok] $($mod.slug.PadRight(22)) $($mod.version.PadRight(34)) (current)") + $upToDate++ + } + else { + $tag = if ($latestType -eq 'beta') { '[update*]' } else { '[update] ' } + Write-Host (" $tag $($mod.slug.PadRight(22)) $($mod.version.PadRight(34)) -> $latestVer") -ForegroundColor Yellow + $updatesAvailable++ + } +} + +Write-Host "" +Write-Host "Summary:" +Write-Host (" Up to date: $upToDate") +$updateColor = if ($updatesAvailable -gt 0) { 'Yellow' } else { 'White' } +Write-Host (" Updates: $updatesAvailable") -ForegroundColor $updateColor +Write-Host (" Manual check: $skipped (CurseForge)") +Write-Host "" +if ($updatesAvailable -gt 0) { + Write-Host "[update*] = beta release. Bump only if you specifically want it." + Write-Host "" + Write-Host "To take all updates: run Update-Pack.ps1 (then Build-Pack.ps1 + deploy server)." + Write-Host "To pick selectively: edit pack.lock.json by hand, then run Build-Pack.ps1." +} diff --git a/scripts/Deploy-Brass.ps1 b/scripts/Deploy-Brass.ps1 new file mode 100644 index 0000000..b3508ea --- /dev/null +++ b/scripts/Deploy-Brass.ps1 @@ -0,0 +1,231 @@ +#requires -Version 5 +<# +.SYNOPSIS + One-shot deploy: build launcher + server, regenerate manifest, mirror the + deploy share, scp the server binary. + +.DESCRIPTION + Reads `deploy.config.ps1` (sibling file, gitignored) for local paths + + SSH details. Stages run in order; -Stage limits which stages run. + + The script does NOT auto-restart the production daemon. After a server + binary deploy it prompts you to do that yourself. + +.PARAMETER Stage + All | Launcher | Server | Pack. Defaults to All. + + Launcher = build launcher + regenerate manifest + push to deploy share + Server = build server + scp binary (atomic swap) + Pack = regenerate manifest + mirror pack/overrides/* to share + All = everything, in order + +.PARAMETER SkipBuild + Skip dotnet publish steps. Use when you've already built and just want + to push artifacts. + +.PARAMETER DryRun + Print each action without executing. No files copied, no SSH, no build. +#> + +[CmdletBinding()] +param( + [ValidateSet('All','Launcher','Server','Pack')] + [string]$Stage = 'All', + [switch]$SkipBuild, + [switch]$DryRun, + # Skip the version-bump check. Use only for cosmetic/internal-only changes + # where you're SURE clients don't need to re-sync. The default is to refuse + # if the local pack/launcher version matches what's already deployed. + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# ─── Resolve repo + load config ──────────────────────────────────────────── +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $here '..') +$cfgPath = Join-Path $here 'deploy.config.ps1' +if (-not (Test-Path $cfgPath)) { + throw "Missing $cfgPath. Copy deploy.config.template.ps1 -> deploy.config.ps1 and fill in real values." +} +. $cfgPath + +# Sanity-check required vars actually got set (template ships with CHANGE_ME). +foreach ($v in 'DeployShare','ServerSshHost','ServerSshKey','ServerBinaryRemote') { + $val = Get-Variable -Name $v -ValueOnly -ErrorAction Stop + if ($val -match 'CHANGE_ME' -or [string]::IsNullOrWhiteSpace($val)) { + throw "deploy.config.ps1 has placeholder/empty `$$v. Fill it in." + } +} + +$shouldRunLauncher = $Stage -in @('All','Launcher') +$shouldRunServer = $Stage -in @('All','Server') +$shouldRunPack = $Stage -in @('All','Launcher','Pack') # manifest needs launcher exe meta + +# ─── Pre-flight: version-bump check ──────────────────────────────────────── +# The launcher caches the pack by version: a client that already synced +# pack v0.9.2 will short-circuit at "already on 0.9.2" if you re-deploy +# under the same version, even if the file list / SHAs changed. Same idea +# applies to launcherVersion (drives the in-launcher upgrade banner). +# We fetch the currently-deployed manifest and refuse to deploy if the +# matching version field hasn't been bumped. Use -Force to override (e.g. +# for cosmetic-only changes where re-sync isn't needed). +if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force) { + if (-not $ManifestPublicUrl -or $ManifestPublicUrl -match 'CHANGE_ME') { + throw "deploy.config.ps1 is missing `$ManifestPublicUrl. Set it to the deployed manifest URL (e.g. https://example.com/pack/manifest.json)." + } + $deployed = $null + try { + $deployed = Invoke-RestMethod -Uri $ManifestPublicUrl -TimeoutSec 8 + } catch { + Write-Host "Pre-flight: couldn't fetch deployed manifest at $ManifestPublicUrl -- version check skipped (probably first deploy)." -ForegroundColor DarkGray + } + if ($deployed) { + $errs = @() + # Pack version check applies only when the user is explicitly deploying + # pack content (Stage = All or Pack). A launcher-only deploy intentionally + # leaves pack content alone, so the pack version SHOULD stay constant. + $strictPackCheck = $Stage -in @('All','Pack') + if ($strictPackCheck) { + $lock = Get-Content (Join-Path $repoRoot 'pack\pack.lock.json') -Raw | ConvertFrom-Json + if ($deployed.version -eq $lock.version) { + $errs += "Pack version is unchanged ($($lock.version)). Clients cached at that version will SKIP the sync -- they won't pick up your pack changes. Bump 'version' in pack/pack.lock.json before deploying." + } + } + if ($shouldRunLauncher) { + $csprojPath = Join-Path $repoRoot 'launcher\ModpackLauncher.csproj' + [xml]$csproj = Get-Content $csprojPath + $localLauncherVersion = ($csproj.Project.PropertyGroup.Version | Where-Object { $_ }) | Select-Object -First 1 + # Manifest stores 4-part FileVersion (e.g. "0.4.4.0"); csproj is 3-part ("0.4.4"). Compare normalised. + $deployedNorm = ($deployed.launcherVersion -replace '\.0+$','') + $localNorm = ($localLauncherVersion -replace '\.0+$','') + if ($deployedNorm -eq $localNorm -and $localNorm) { + $errs += "Launcher version is unchanged ($localLauncherVersion). Existing 0.4.x installs won't see an upgrade prompt -- bump in launcher/ModpackLauncher.csproj before deploying." + } + } + if ($errs.Count -gt 0) { + Write-Host "" + Write-Host "VERSION-BUMP CHECK FAILED:" -ForegroundColor Red + foreach ($e in $errs) { Write-Host " - $e" -ForegroundColor Red } + Write-Host "" + Write-Host "If you're re-deploying without any user-visible changes, pass -Force to skip this check." -ForegroundColor DarkGray + exit 1 + } + } +} + +# ─── Pre-flight: was the daemon already running? ─────────────────────────── +# We don't auto-stop it (kicks active players to fix a problem that isn't +# actually broken -- the atomic swap is safe with the daemon running). But +# knowing the state up front lets us tailor the final "next steps" message +# so a deploy success doesn't silently leave you on the old code. +$daemonWasRunning = $false +if ($shouldRunServer) { + Write-Host "Pre-flight: checking daemon state on $ServerSshHost..." -ForegroundColor DarkGray + # Single-quoted PS string so PowerShell doesn't try to interpret the + # bash-side metacharacters. The remote shell sees the literal pgrep + # command; the trailing $ anchors so we don't match run.sh wrappers. + $remoteCmd = 'pgrep -f /brass-sigil-server$ 2>/dev/null' + $pgrepOut = & ssh -i $ServerSshKey -o ConnectTimeout=5 -o BatchMode=yes $ServerSshHost $remoteCmd 2>$null + $daemonWasRunning = -not [string]::IsNullOrWhiteSpace($pgrepOut) + if ($daemonWasRunning) { + Write-Host " Daemon is RUNNING. Atomic swap is safe -- but you'll need to" -ForegroundColor Yellow + Write-Host " stop+start it after deploy for the new code to take effect." -ForegroundColor Yellow + } else { + Write-Host " Daemon is stopped. New binary will run as soon as you start it." -ForegroundColor DarkGray + } +} + +# ─── Helpers ─────────────────────────────────────────────────────────────── +$stepNum = 0 +function Step($desc, [scriptblock]$body) { + $script:stepNum++ + $start = Get-Date + Write-Host "" + Write-Host ("[{0}] {1}" -f $script:stepNum, $desc) -ForegroundColor Cyan + if ($DryRun) { + Write-Host " (dry-run, skipping)" -ForegroundColor DarkGray + return + } + & $body + $elapsed = (Get-Date) - $start + Write-Host (" done in {0:N1}s" -f $elapsed.TotalSeconds) -ForegroundColor DarkGray +} + +# ─── Stage 1: build launcher ─────────────────────────────────────────────── +$launcherExe = Join-Path $repoRoot (Join-Path $LauncherPublishDir $LauncherExeName) +if ($shouldRunLauncher -and -not $SkipBuild) { + Step "Build launcher (dotnet publish launcher\)" { + Push-Location (Join-Path $repoRoot 'launcher') + try { dotnet publish -c Release -nologo | Out-Host } + finally { Pop-Location } + if (-not (Test-Path $launcherExe)) { throw "Launcher publish didn't produce $launcherExe" } + } +} + +# ─── Stage 2: build server ───────────────────────────────────────────────── +$serverExe = Join-Path $repoRoot (Join-Path $ServerPublishDir $ServerExeName) +if ($shouldRunServer -and -not $SkipBuild) { + Step "Build server (dotnet publish server\ -r linux-x64)" { + Push-Location (Join-Path $repoRoot 'server') + try { dotnet publish -c Release -r linux-x64 --self-contained true -nologo | Out-Host } + finally { Pop-Location } + if (-not (Test-Path $serverExe)) { throw "Server publish didn't produce $serverExe" } + } +} + +# ─── Stage 3: regenerate manifest ────────────────────────────────────────── +$manifestPath = Join-Path $here 'manifest.json' +if ($shouldRunPack) { + Step "Regenerate manifest (Build-Pack.ps1)" { + $args = @{ OutputPath = $manifestPath } + if ($shouldRunLauncher -and (Test-Path $launcherExe)) { $args.LauncherExePath = $launcherExe } + & (Join-Path $here 'Build-Pack.ps1') @args | Out-Host + } +} + +# ─── Stage 4: mirror pack overrides to share ────────────────────────────── +$overridesLocal = Join-Path $repoRoot 'pack\overrides' +$shareFiles = Join-Path $DeployShare 'files' +if ($shouldRunPack -and (Test-Path $overridesLocal)) { + Step "Mirror pack/overrides/ -> $shareFiles" { + # /MIR makes destination match source (deletes orphan files in $shareFiles). + # /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares. + robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host + # Robocopy returns 0-7 for success-with-info; 8+ is real failure. + if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" } + } +} + +# ─── Stage 5: deploy launcher exe + manifest to share ────────────────────── +if ($shouldRunLauncher) { + Step "Copy launcher.exe + manifest to $DeployShare" { + Copy-Item $launcherExe (Join-Path $DeployShare $LauncherDeployedAs) -Force + Copy-Item $manifestPath (Join-Path $DeployShare 'manifest.json') -Force + } +} + +# ─── Stage 6: scp + atomic swap server binary ────────────────────────────── +if ($shouldRunServer) { + Step "scp server binary -> $ServerSshHost`:$ServerBinaryRemote (atomic swap)" { + $remoteNew = "$ServerBinaryRemote.new" + & scp -i $ServerSshKey -o ConnectTimeout=15 $serverExe "$ServerSshHost`:$remoteNew" + if ($LASTEXITCODE -ne 0) { throw "scp failed with exit $LASTEXITCODE" } + $cmd = "chmod +x '$remoteNew' && mv '$remoteNew' '$ServerBinaryRemote' && md5sum '$ServerBinaryRemote'" + & ssh -i $ServerSshKey -o ConnectTimeout=10 $ServerSshHost $cmd + if ($LASTEXITCODE -ne 0) { throw "ssh swap failed with exit $LASTEXITCODE" } + } + Write-Host "" + if ($daemonWasRunning) { + Write-Host "Server binary swapped on disk, but the daemon was running before this" -ForegroundColor Yellow + Write-Host "deploy and is still on the OLD code in memory (Linux preserves the running" -ForegroundColor Yellow + Write-Host "inode through rename). Stop + start the daemon to pick up the new build." -ForegroundColor Yellow + } else { + Write-Host "Server binary swapped on disk. Daemon was stopped -- start it whenever" -ForegroundColor Green + Write-Host "you're ready and it'll run the new build." -ForegroundColor Green + } +} + +Write-Host "" +Write-Host "Deploy finished." -ForegroundColor Green diff --git a/scripts/Update-Pack.ps1 b/scripts/Update-Pack.ps1 new file mode 100644 index 0000000..7b78b2b --- /dev/null +++ b/scripts/Update-Pack.ps1 @@ -0,0 +1,207 @@ +#requires -Version 5 +<# +.SYNOPSIS + Refreshes pack.lock.json by querying Modrinth + CurseForge for the latest + compatible version of each mod listed below. + +.DESCRIPTION + THIS SCRIPT MUTATES pack.lock.json. Run only when you intentionally want to + bump versions (and remember to update the server to match). + + For non-mutating "what's available?" reports, run Check-Updates.ps1. + +.PARAMETER PackName +.PARAMETER PackVersion +.PARAMETER MinecraftVersion +.PARAMETER LoaderType +.PARAMETER LoaderVersion + Override pack metadata. Defaults read from existing lockfile if present. +#> + +[CmdletBinding()] +param( + [string]$PackName = "", + [string]$PackVersion = "", + [string]$MinecraftVersion = "1.21.1", + [string]$LoaderType = "neoforge", + [string]$LoaderVersion = "" +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$lockPath = Join-Path $here '..\pack\pack.lock.json' + +# Read existing lock for defaults +$existing = if (Test-Path $lockPath) { Get-Content $lockPath -Raw | ConvertFrom-Json } else { $null } +if (-not $PackName -and $existing) { $PackName = $existing.name } +if (-not $PackVersion -and $existing) { $PackVersion = $existing.version } +if (-not $LoaderVersion -and $existing) { $LoaderVersion = $existing.loader.version } +if (-not $PackName) { $PackName = "Brass and Sigil" } +if (-not $PackVersion) { $PackVersion = "0.6.0" } +if (-not $LoaderVersion) { $LoaderVersion = "21.1.228" } + +# --------------------------------------------------------------------------- +# Mod definitions: edit here when adding/removing mods. allowBeta=true allows +# fallback to beta if no release exists. +# --------------------------------------------------------------------------- +$modrinthMods = @( + @{ slug = "create"; allowBeta = $false } + @{ slug = "create-aeronautics"; allowBeta = $false } + @{ slug = "sable"; allowBeta = $false } + @{ slug = "create-big-cannons"; allowBeta = $false } + @{ slug = "create-tfmg"; allowBeta = $false } + @{ slug = "distanthorizons"; allowBeta = $true } + @{ slug = "sodium"; allowBeta = $false } + @{ slug = "iris"; allowBeta = $false } + @{ slug = "modernfix"; allowBeta = $false } + @{ slug = "ferrite-core"; allowBeta = $false } + @{ slug = "architectury-api"; allowBeta = $false } + @{ slug = "rhino"; allowBeta = $true } + @{ slug = "rpl"; allowBeta = $false } + @{ slug = "kubejs"; allowBeta = $false } + @{ slug = "jei"; allowBeta = $true } + @{ slug = "jade"; allowBeta = $false } + @{ slug = "chunky"; allowBeta = $false } +) + +# CurseForge mods aren't auto-resolved (no public API without a key). +# Update fileId/version/filename here when bumping. +$curseforgeMods = @( + @{ + slug = "ftb-library" + version = "2101.1.31" + fileId = "7746959" + path = "mods/ftb-library-neoforge-2101.1.31.jar" + url = "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar" + } + @{ + slug = "ftb-teams" + version = "2101.1.9" + fileId = "7369021" + path = "mods/ftb-teams-neoforge-2101.1.9.jar" + url = "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar" + } + @{ + slug = "ftb-chunks" + version = "2101.1.14" + fileId = "7608681" + path = "mods/ftb-chunks-neoforge-2101.1.14.jar" + url = "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar" + } +) + +Add-Type -AssemblyName System.Web + +function Invoke-Modrinth { + param([string]$Path) + Invoke-RestMethod -Uri "https://api.modrinth.com/v2$Path" -Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' } +} + +function Get-LatestModrinthVersion { + param([string]$Slug, [bool]$AllowBeta) + $encGv = [System.Web.HttpUtility]::UrlEncode('["' + $MinecraftVersion + '"]') + $encL = [System.Web.HttpUtility]::UrlEncode('["' + $LoaderType + '"]') + $versions = Invoke-Modrinth ("/project/$Slug/version?game_versions=$encGv" + "&" + "loaders=$encL") + if (-not $versions -or $versions.Count -eq 0) { return $null } + if ($AllowBeta) { + # Take the newest version regardless of stability (Modrinth orders newest first). + return $versions[0] + } + # Stable-only: latest release version, or null if there's no release at all. + return $versions | Where-Object { $_.version_type -eq 'release' } | Select-Object -First 1 +} + +$lockedMods = @() +$missing = @() + +Write-Host "" +Write-Host "Querying Modrinth for $($modrinthMods.Count) mods (MC $MinecraftVersion / $LoaderType)..." +Write-Host "" + +foreach ($mod in $modrinthMods) { + try { + $version = Get-LatestModrinthVersion -Slug $mod.slug -AllowBeta $mod.allowBeta + } catch { + Write-Warning " [error] $($mod.slug) - $($_.Exception.Message)" + $missing += $mod.slug + continue + } + if (-not $version) { + Write-Warning " [missing] $($mod.slug) - no compatible version" + $missing += $mod.slug + continue + } + $primary = $version.files | Where-Object { $_.primary -eq $true } | Select-Object -First 1 + if (-not $primary) { $primary = $version.files[0] } + + $lockedMods += [ordered]@{ + source = "modrinth" + slug = $mod.slug + versionId = $version.id + version = $version.version_number + path = "mods/$($primary.filename)" + url = $primary.url + sha1 = $primary.hashes.sha1 + size = $primary.size + } + + $tag = if ($version.version_type -eq 'beta') { '[beta] ' } else { '[release]' } + Write-Host (" $tag $($mod.slug.PadRight(22)) $($version.version_number.PadRight(28))") +} + +Write-Host "" +Write-Host "Hashing $($curseforgeMods.Count) CurseForge mods (manual entries)..." +$tmpDir = Join-Path $env:TEMP "brassandsigil-cf-cache" +if (-not (Test-Path $tmpDir)) { New-Item -ItemType Directory -Path $tmpDir | Out-Null } + +foreach ($cf in $curseforgeMods) { + $fname = Split-Path -Leaf $cf.path + $tmpFile = Join-Path $tmpDir $fname + try { + if (-not (Test-Path $tmpFile)) { + Invoke-WebRequest -Uri $cf.url -OutFile $tmpFile -UseBasicParsing -Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' } + } + $sha1 = (Get-FileHash -Algorithm SHA1 -Path $tmpFile).Hash.ToLowerInvariant() + $size = (Get-Item $tmpFile).Length + + $lockedMods += [ordered]@{ + source = "curseforge" + slug = $cf.slug + fileId = $cf.fileId + version = $cf.version + path = $cf.path + url = $cf.url + sha1 = $sha1 + size = $size + } + Write-Host (" [cforge] $($cf.slug.PadRight(22)) $($cf.version)") + } + catch { + Write-Warning " [error] $fname - $($_.Exception.Message)" + $missing += $fname + } +} + +$lock = [ordered]@{ + '$schema' = "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing" + name = $PackName + version = $PackVersion + minecraft = $MinecraftVersion + loader = [ordered]@{ type = $LoaderType; version = $LoaderVersion } + lockedAt = (Get-Date -Format 'o') + mods = $lockedMods +} + +$json = $lock | ConvertTo-Json -Depth 10 +[System.IO.File]::WriteAllText($lockPath, $json, [System.Text.UTF8Encoding]::new($false)) + +Write-Host "" +Write-Host "Lockfile written: $lockPath" +Write-Host (" Total mods: {0}" -f $lockedMods.Count) +if ($missing.Count -gt 0) { + Write-Warning "Missing: $($missing -join ', ')" +} +Write-Host "" +Write-Host "Run Build-Pack.ps1 next to generate the manifest." diff --git a/scripts/deploy.config.template.ps1 b/scripts/deploy.config.template.ps1 new file mode 100644 index 0000000..205922c --- /dev/null +++ b/scripts/deploy.config.template.ps1 @@ -0,0 +1,37 @@ +# Local deploy configuration. Copy this file to `deploy.config.ps1` and fill in +# real values. `deploy.config.ps1` is gitignored so your local paths and the +# server hostname never end up in version control. +# +# Deploy-Brass.ps1 dot-sources this file. Every variable below is required. + +# ─── Public file hosting ─────────────────────────────────────────────────── +# Local path that maps (via SMB or similar) to the public docroot that hosts +# `manifest.json` and the launcher .exe. Files copied here become reachable at +# the public URLs embedded in the manifest (`launcherUrl`, file URLs, etc.). +# Example: 'Z:\www\html\example.com\public\pack' +$DeployShare = 'CHANGE_ME' + +# Public URL of the deployed manifest. Used for the version-bump pre-flight: +# Deploy-Brass.ps1 fetches this before deploying so it can refuse if you +# changed pack content but forgot to bump pack/pack.lock.json's version +# (clients with a cached pack at that version would skip the sync). +$ManifestPublicUrl = 'https://CHANGE_ME/pack/manifest.json' + +# ─── Server (brass-sigil-server daemon host) ─────────────────────────────── +# user@host for the Linux box running the daemon. +$ServerSshHost = 'user@CHANGE_ME' + +# Path to the local SSH private key authorised on the server. +$ServerSshKey = "$env:USERPROFILE\.ssh\id_ed25519" + +# Absolute path on the Linux box where the brass-sigil-server binary lives. +# `Deploy-Brass.ps1` uploads to ".new" then `mv` over for atomic swap. +$ServerBinaryRemote = '/home/user/brass-sigil-server/brass-sigil-server' + +# ─── Build outputs (don't normally need to change) ───────────────────────── +$LauncherPublishDir = 'launcher\bin\Release\net8.0-windows\win-x64\publish' +$LauncherExeName = 'ModpackLauncher.exe' # what dotnet publish produces +$LauncherDeployedAs = 'BrassAndSigil-Launcher.exe' # filename on the public host + +$ServerPublishDir = 'server\bin\Release\net8.0\linux-x64\publish' +$ServerExeName = 'brass-sigil-server' diff --git a/server/Assets/icon.ico b/server/Assets/icon.ico new file mode 100644 index 0000000..ce26aa6 Binary files /dev/null and b/server/Assets/icon.ico differ diff --git a/server/Assets/server-icon.png b/server/Assets/server-icon.png new file mode 100644 index 0000000..3726ba9 Binary files /dev/null and b/server/Assets/server-icon.png differ diff --git a/server/BrassAndSigilServer.csproj b/server/BrassAndSigilServer.csproj new file mode 100644 index 0000000..ccefcad --- /dev/null +++ b/server/BrassAndSigilServer.csproj @@ -0,0 +1,40 @@ + + + Exe + net8.0 + enable + enable + BrassAndSigil.Server + brass-sigil-server + Assets\icon.ico + + + linux-x64 + linux-x64;win-x64 + true + true + true + true + embedded + + + true + false + true + + + + + + + + + + + + + + BrassAndSigil.Server.Assets.server-icon.png + + + diff --git a/server/Commands/BaseCommandSettings.cs b/server/Commands/BaseCommandSettings.cs new file mode 100644 index 0000000..14c0bfa --- /dev/null +++ b/server/Commands/BaseCommandSettings.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace BrassAndSigil.Server.Commands; + +public class BaseCommandSettings : CommandSettings +{ + [CommandOption("-c|--config ")] + [Description("Path to server-config.json (defaults to ./server-config.json)")] + public string ConfigPath { get; set; } = "server-config.json"; +} diff --git a/server/Commands/CheckCommand.cs b/server/Commands/CheckCommand.cs new file mode 100644 index 0000000..43382c7 --- /dev/null +++ b/server/Commands/CheckCommand.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using BrassAndSigil.Server.Models; +using BrassAndSigil.Server.Services; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BrassAndSigil.Server.Commands; + +public sealed class CheckCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BaseCommandSettings settings) + { + var config = ServerConfig.Load(settings.ConfigPath); + AnsiConsole.MarkupLine("[bold]Checking server install...[/]"); + var ok = true; + + // 1. Java available? + var javaVersion = await TryRunForOutputAsync(config.JavaPath, "-version"); + if (javaVersion is not null) + AnsiConsole.MarkupLine($" [green]✓[/] Java reachable: {javaVersion.Split('\n')[0].Trim().EscapeMarkup()}"); + else + { AnsiConsole.MarkupLine($" [red]✗[/] Java not found at '{config.JavaPath}'"); ok = false; } + + // 2. Server dir + var serverDir = Path.GetFullPath(config.ServerDir); + if (Directory.Exists(serverDir)) + AnsiConsole.MarkupLine($" [green]✓[/] Server dir exists: {serverDir}"); + else + { AnsiConsole.MarkupLine($" [yellow]?[/] Server dir missing -- run [yellow]install[/] first"); ok = false; } + + // 3. EULA + var eulaPath = Path.Combine(serverDir, "eula.txt"); + if (File.Exists(eulaPath) && File.ReadAllText(eulaPath).Contains("eula=true")) + AnsiConsole.MarkupLine(" [green]✓[/] EULA accepted"); + else + { AnsiConsole.MarkupLine(" [yellow]?[/] EULA not accepted (re-run [yellow]install --accept-eula[/])"); ok = false; } + + // 4. NeoForge run script + var runScript = Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh"); + if (File.Exists(runScript)) + AnsiConsole.MarkupLine($" [green]✓[/] Loader start script: {Path.GetFileName(runScript)}"); + else + AnsiConsole.MarkupLine($" [yellow]?[/] No {Path.GetFileName(runScript)} -- install the NeoForge server first"); + + // 5. Manifest reachable + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var resp = await http.GetAsync(config.ManifestUrl); + if (resp.IsSuccessStatusCode) + AnsiConsole.MarkupLine($" [green]✓[/] Manifest reachable: {config.ManifestUrl}"); + else + { AnsiConsole.MarkupLine($" [red]✗[/] Manifest HTTP {(int)resp.StatusCode}: {config.ManifestUrl}"); ok = false; } + } + catch (Exception ex) + { AnsiConsole.MarkupLine($" [red]✗[/] Manifest fetch error: {ex.Message.EscapeMarkup()}"); ok = false; } + + // 6. Pack version on disk + var packVer = Path.Combine(serverDir, "pack-version.json"); + if (File.Exists(packVer)) + AnsiConsole.MarkupLine($" [green]✓[/] Pack synced: {File.ReadAllText(packVer).Replace("\n", " ").Replace("\r", "").Trim().EscapeMarkup()}"); + else + AnsiConsole.MarkupLine(" [yellow]?[/] Pack not synced yet (run [yellow]sync[/])"); + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine(ok ? "[green]All required checks passed.[/]" : "[yellow]Some checks failed; see above.[/]"); + return ok ? 0 : 1; + } + + private static async Task TryRunForOutputAsync(string fileName, string args) + { + try + { + var p = Process.Start(new ProcessStartInfo + { + FileName = fileName, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }); + if (p is null) return null; + var output = await p.StandardError.ReadToEndAsync() + await p.StandardOutput.ReadToEndAsync(); + await p.WaitForExitAsync(); + return output; + } + catch { return null; } + } +} diff --git a/server/Commands/InstallCommand.cs b/server/Commands/InstallCommand.cs new file mode 100644 index 0000000..339b9e0 --- /dev/null +++ b/server/Commands/InstallCommand.cs @@ -0,0 +1,141 @@ +using System.ComponentModel; +using BrassAndSigil.Server.Models; +using BrassAndSigil.Server.Services; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BrassAndSigil.Server.Commands; + +public sealed class InstallCommand : AsyncCommand +{ + public sealed class Settings : BaseCommandSettings + { + [CommandOption("--manifest ")] + [Description("Manifest URL to bootstrap from")] + public string? ManifestUrl { get; set; } + + [CommandOption("--server-dir ")] + [Description("Where to install the server (defaults to ./server)")] + public string? ServerDir { get; set; } + + [CommandOption("--memory ")] + [Description("RAM allocation in MB (defaults to 8192)")] + public int? MemoryMB { get; set; } + + [CommandOption("--accept-eula")] + [Description("Accept the Minecraft EULA. Required for the server to actually run.")] + public bool AcceptEula { get; set; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + var config = ServerConfig.Load(settings.ConfigPath); + if (settings.ManifestUrl != null) config.ManifestUrl = settings.ManifestUrl; + if (settings.ServerDir != null) config.ServerDir = settings.ServerDir; + if (settings.MemoryMB != null) config.MemoryMB = settings.MemoryMB.Value; + if (settings.AcceptEula) config.AcceptEula = true; + + AnsiConsole.MarkupLine("[bold yellow]Brass & Sigil Server install[/]"); + AnsiConsole.MarkupLine($" Config: {settings.ConfigPath}"); + AnsiConsole.MarkupLine($" ServerDir: {Path.GetFullPath(config.ServerDir)}"); + AnsiConsole.MarkupLine($" Manifest: {config.ManifestUrl}"); + AnsiConsole.MarkupLine($" Memory: {config.MemoryMB} MB"); + AnsiConsole.MarkupLine(""); + + if (!config.AcceptEula) + { + AnsiConsole.MarkupLine("[red]EULA not accepted.[/] Re-run with --accept-eula to confirm you accept the"); + AnsiConsole.MarkupLine("Minecraft End User License Agreement: [blue]https://aka.ms/MinecraftEULA[/]"); + return 1; + } + + Directory.CreateDirectory(config.ServerDir); + + // Generate eula.txt + await File.WriteAllTextAsync( + Path.Combine(config.ServerDir, "eula.txt"), + $"# Generated by brass-sigil-server install\n" + + $"# By setting this to true you agree to the Minecraft EULA: https://aka.ms/MinecraftEULA\n" + + $"eula=true\n"); + + // Generate a default server.properties if none exists yet + var propsPath = Path.Combine(config.ServerDir, "server.properties"); + if (!File.Exists(propsPath)) + { + await File.WriteAllTextAsync(propsPath, DefaultServerProperties(config)); + AnsiConsole.MarkupLine($"[grey]Wrote default server.properties[/]"); + } + + // Generate a random RCON password if missing + if (string.IsNullOrEmpty(config.RconPassword)) + { + config.RconPassword = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant(); + UpdateProp(propsPath, "enable-rcon", "true"); + UpdateProp(propsPath, "rcon.port", config.RconPort.ToString()); + UpdateProp(propsPath, "rcon.password", config.RconPassword); + } + + // Save config + config.Save(settings.ConfigPath); + AnsiConsole.MarkupLine($"[grey]Saved config to {settings.ConfigPath}[/]"); + + // Sync mods + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[bold]Syncing mods from manifest...[/]"); + var sync = new ManifestSync(); + var progress = new Progress(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]")); + try + { + var result = await sync.SyncAsync(config.ManifestUrl, config.ServerDir, progress); + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine($"[green]✓ Sync complete:[/] pack v{result.PackVersion}, " + + $"{result.Downloaded} downloaded, {result.Removed} removed, " + + $"{result.Skipped} client-only mods skipped"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]✗ Sync failed:[/] {ex.Message.EscapeMarkup()}"); + return 1; + } + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[bold green]Install complete.[/]"); + AnsiConsole.MarkupLine("Next steps:"); + AnsiConsole.MarkupLine($" 1. Download the matching NeoForge server installer for the manifest's loader version"); + AnsiConsole.MarkupLine($" (server side, into {Path.GetFullPath(config.ServerDir)}) -- the modpack manifest"); + AnsiConsole.MarkupLine($" doesn't bundle it because each loader has its own installer."); + AnsiConsole.MarkupLine($" [blue]https://maven.neoforged.net/releases/net/neoforged/neoforge/[/]"); + AnsiConsole.MarkupLine($" 2. Run the NeoForge installer with --installServer in the server dir"); + AnsiConsole.MarkupLine($" 3. Then start: [yellow]brass-sigil-server run[/]"); + return 0; + } + + private static string DefaultServerProperties(ServerConfig config) => $@"# Brass & Sigil server defaults -- edit as needed +motd=Brass & Sigil +gamemode=survival +difficulty=normal +hardcore=false +pvp=true +online-mode=true +white-list=true +enforce-whitelist=true +max-players=20 +view-distance=12 +simulation-distance=10 +spawn-protection=0 +enable-rcon=true +rcon.port={config.RconPort} +rcon.password={config.RconPassword} +broadcast-rcon-to-ops=false +"; + + private static void UpdateProp(string path, string key, string value) + { + var lines = File.ReadAllLines(path).ToList(); + var prefix = $"{key}="; + var idx = lines.FindIndex(l => l.StartsWith(prefix)); + if (idx >= 0) lines[idx] = prefix + value; + else lines.Add(prefix + value); + File.WriteAllLines(path, lines); + } +} diff --git a/server/Commands/RunCommand.cs b/server/Commands/RunCommand.cs new file mode 100644 index 0000000..8732593 --- /dev/null +++ b/server/Commands/RunCommand.cs @@ -0,0 +1,1225 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; +using BrassAndSigil.Server.Models; +using BrassAndSigil.Server.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Embedded; +using Microsoft.Extensions.Hosting; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BrassAndSigil.Server.Commands; + +public sealed class RunCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BaseCommandSettings settings) + { + var configPath = settings.ConfigPath; + var config = ServerConfig.Load(configPath); + + AnsiConsole.MarkupLine("[bold yellow]Brass & Sigil Server[/]"); + AnsiConsole.MarkupLine($"[grey]config: {configPath}[/]"); + AnsiConsole.MarkupLine(""); + + // ────────── First-run admin setup ────────── + // Fail-secure: if WebPassword is null (key missing or never set), force a + // password to be configured before doing anything else. Empty string ("") + // is the explicit "no auth" opt-out and is rejected on non-localhost binds. + // Done here, BEFORE the heavy install + MC start so the prompt isn't + // drowned out by streaming log output. + if (config.WebPassword is null) + { + if (Console.IsInputRedirected) + { + var generated = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)) + .Replace("+", "-").Replace("/", "_").Replace("=", ""); + config.WebPassword = generated; + config.Save(configPath); + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[bold yellow]═══════════ ADMIN PASSWORD GENERATED ═══════════[/]"); + AnsiConsole.MarkupLine($"[bold]Password:[/] [yellow]{generated}[/]"); + AnsiConsole.MarkupLine($"[grey]Saved to {configPath}. Use the [/][bold]Change password[/][grey] button in the panel to set your own.[/]"); + AnsiConsole.MarkupLine("[bold yellow]════════════════════════════════════════════════[/]"); + AnsiConsole.MarkupLine(""); + } + else + { + AnsiConsole.MarkupLine("[bold]First-run admin setup[/]"); + AnsiConsole.MarkupLine("[grey]The web panel needs an admin password before continuing.[/]"); + AnsiConsole.MarkupLine("[grey](Set [/][yellow]webPassword[/][grey] to empty string in config to disable -- local dev only.)[/]"); + string pw1, pw2; + while (true) + { + pw1 = AnsiConsole.Prompt(new TextPrompt("Admin password (min 8 chars):").Secret()); + if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; } + pw2 = AnsiConsole.Prompt(new TextPrompt("Confirm:").Secret()); + if (pw1 != pw2) { AnsiConsole.MarkupLine("[red]Doesn't match.[/]"); continue; } + break; + } + config.WebPassword = pw1; + config.Save(configPath); + AnsiConsole.MarkupLine("[green]✓[/] Admin password saved."); + AnsiConsole.MarkupLine(""); + } + } + else if (!IsLocalhostBind(config) && config.WebPassword.Length == 0) + { + AnsiConsole.MarkupLine("[red]Refusing to bind on a non-localhost address with webPassword disabled (\"\").[/]"); + AnsiConsole.MarkupLine("[red]Either set a real password or change [yellow]webHost[/] to [yellow]localhost[/].[/]"); + return 1; + } + + // ────────── Auto-setup phase: bring the server install up to spec ────────── + var ok = await EnsureInstalledAsync(config, configPath); + if (!ok) + { + AnsiConsole.MarkupLine("[red]Auto-setup failed; cannot run server.[/]"); + return 1; + } + + // ────────── Process + daemon phase ────────── + var serverProc = new ServerProcess(config); + // RCON connects lazily on first use AND reconnects after failure -- MC takes + // ~30 s to open the RCON port, so eager-connect-once would cache a dead client. + var rcon = new RconManager("127.0.0.1", config.RconPort, config.RconPassword); + Action srvLog = msg => AnsiConsole.MarkupLine($"[grey]{msg.EscapeMarkup()}[/]"); + var whitelistRequests = new WhitelistRequestService(config); + var broadcaster = new Broadcaster(serverProc); + var updater = new UpdaterService(config, configPath, serverProc, broadcaster, srvLog); + var backup = new BackupService(config, serverProc, broadcaster, srvLog); + var bluemap = new BlueMapService(config, srvLog); + var world = new WorldService(config, serverProc, backup, broadcaster, rcon, srvLog, bluemap); + var scheduler = new BackupScheduler(config, backup, srvLog); + scheduler.Start(); + + try + { + serverProc.Start(); + AnsiConsole.MarkupLine($"[green]✓[/] Started Minecraft (PID {serverProc.Pid})"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]✗ Failed to start Minecraft:[/] {ex.Message.EscapeMarkup()}"); + return 1; + } + + serverProc.OnLogLine += line => + { + var ts = line.At.ToLocalTime().ToString("HH:mm:ss"); + var color = line.IsError ? "red" : "grey"; + AnsiConsole.MarkupLine($"[{color}][[{ts}]] {line.Text.EscapeMarkup()}[/]"); + }; + + // Build Kestrel host + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(opts => + { + if (IsLocalhostBind(config)) + opts.ListenLocalhost(config.WebPort); + else + opts.ListenAnyIP(config.WebPort); + }); + builder.Logging.ClearProviders(); + + // Per-IP rate limit on the login endpoint: 10 attempts / 60 s sliding window. + // Without this, a weak password is bruteforce-trivial. Keyed on remote IP so + // a single brute-forcer can't exhaust everyone's quota. + builder.Services.AddRateLimiter(rl => + { + rl.RejectionStatusCode = 429; + rl.AddPolicy("login", ctx => + System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( + ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + })); + // Public friend-side whitelist request: 5/hour per IP. Keeps spam manageable + // without making it annoying for friends who legitimately want to retry. + rl.AddPolicy("whitelist-request", ctx => + System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( + ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromHours(1), + QueueLimit = 0, + })); + // Status check is the launcher's polling endpoint -- needs a looser limit + // so an open launcher polling status doesn't get throttled. 30/hour gives + // ~one check every 2 minutes, far more than the launcher actually needs. + rl.AddPolicy("whitelist-status", ctx => + System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( + ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromHours(1), + QueueLimit = 0, + })); + }); + + // Trust X-Forwarded-For from loopback (a local reverse proxy like Caddy or nginx). + // Without this, the rate limiter would partition by Caddy's 127.0.0.1 connection + // and a single brute-forcer could exhaust everyone's quota. + builder.Services.Configure(o => + { + o.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor + | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto; + o.KnownProxies.Add(System.Net.IPAddress.Loopback); + o.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback); + }); + + var app = builder.Build(); + app.UseForwardedHeaders(); + app.UseRateLimiter(); + + // Static UI: prefer sidecar wwwroot/ for hot edits, fall back to embedded. + var sidecar = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + IFileProvider uiProvider = Directory.Exists(sidecar) + ? new PhysicalFileProvider(sidecar) + : new ManifestEmbeddedFileProvider(typeof(RunCommand).Assembly, "wwwroot"); + app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = uiProvider }); + app.UseStaticFiles(new StaticFileOptions { FileProvider = uiProvider }); + + // BlueMap output at /map/ -- register middleware unconditionally. We + // pre-create the directory so PhysicalFileProvider's constructor is happy + // even before the first render. + Directory.CreateDirectory(bluemap.WebDir); + var bluemapFp = new PhysicalFileProvider(Path.GetFullPath(bluemap.WebDir)); + + // (First-run admin setup happens early in ExecuteAsync, before the heavy + // install + MC start, so the interactive prompt isn't drowned by log output.) + + // Auth: enforced whenever a password is set, regardless of bind. Accepts either + // X-Brass-Sigil-Auth header (curl / scripts) or the brass-sigil-auth cookie + // (browsers -- works for SSE too since EventSource can't set custom headers). + // /api/auth/login is allowed through without auth (chicken-and-egg) but is + // separately rate-limited to throttle brute-force attempts. + // + // MUST be registered BEFORE the /map static-files handlers below -- otherwise + // those handlers short-circuit the pipeline and serve map tiles to anyone. + // The UI shell at / is intentionally above this (login page must be reachable + // unauthenticated); all sensitive operations go through /api/* which is gated. + if (!string.IsNullOrEmpty(config.WebPassword)) + { + app.Use(async (ctx, next) => + { + var path = ctx.Request.Path; + // Only gate /api/* and /map/* -- everything else is the public UI shell. + var gated = path.StartsWithSegments("/api") || path.StartsWithSegments("/map"); + if (!gated) { await next(); return; } + if (path.StartsWithSegments("/api/auth/login")) { await next(); return; } + // Friend-side whitelist request endpoints are public on purpose so the + // launcher can hit them without holding the admin password. Rate-limited + // separately to throttle abuse. + if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; } + if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; } + var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword); + var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString(); + if (string.IsNullOrEmpty(auth)) + auth = ctx.Request.Cookies["brass-sigil-auth"] ?? ""; + var authBytes = System.Text.Encoding.UTF8.GetBytes(auth); + var ok = authBytes.Length == pwBytes.Length + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes); + if (!ok) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Unauthorized"); + return; + } + await next(); + }); + } + + // /map/.../live/players.json is generated dynamically per-request from RCON + // -- pull-based, so RCON only fires while a browser tab is actually polling. + // No server-side timer, no idle-state to manage. Same model as /api/players. + // Must be registered BEFORE the /map static-files handler so it short-circuits + // even if a stale players.json exists on disk from an earlier daemon version. + const string PlayersJsonPath = "/map/maps/overworld/live/players.json"; + app.Use(async (ctx, next) => + { + if (ctx.Request.Path != PlayersJsonPath) { await next(); return; } + ctx.Response.ContentType = "application/json"; + try + { + var players = await BlueMapPlayers.SnapshotAsync(rcon, config.ServerDir, ctx.RequestAborted); + await ctx.Response.WriteAsync(JsonSerializer.Serialize(new { players }), ctx.RequestAborted); + } + catch (OperationCanceledException) { /* client gone */ } + catch + { + // Don't fail the request -- empty list is fine if RCON's having a moment. + await ctx.Response.WriteAsync("{\"players\":[]}"); + } + }); + + // /map/assets/playerheads/.png -- BlueMap's web UI requests these for + // player markers. The CLI distribution doesn't write them (only the in-server + // mod does), so on a CLI-only setup every marker falls back to assets/steve.png. + // We lazily fetch the head from crafatar.com on first request and cache to + // disk; subsequent requests hit static-files directly (zero outbound traffic). + // To force a skin refresh, delete the matching .png from + // /web/assets/playerheads/. + const string PlayerHeadPrefix = "/map/assets/playerheads/"; + var headHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(8) }; + headHttp.DefaultRequestHeaders.UserAgent.ParseAdd("brass-sigil-server/1.0"); + app.Use(async (ctx, next) => + { + var p = ctx.Request.Path.Value; + if (p is null + || !p.StartsWith(PlayerHeadPrefix, StringComparison.Ordinal) + || !p.EndsWith(".png", StringComparison.Ordinal)) + { await next(); return; } + var uuidStr = p.Substring(PlayerHeadPrefix.Length, p.Length - PlayerHeadPrefix.Length - 4); + if (!Guid.TryParse(uuidStr, out _)) { await next(); return; } + var cachePath = Path.Combine(bluemap.WebDir, "assets", "playerheads", uuidStr + ".png"); + if (!File.Exists(cachePath)) + { + try + { + var bytes = await headHttp.GetByteArrayAsync( + $"https://crafatar.com/avatars/{uuidStr}?size=32&overlay", + ctx.RequestAborted); + Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); + await File.WriteAllBytesAsync(cachePath, bytes, ctx.RequestAborted); + } + catch (OperationCanceledException) { return; } + catch + { + // Fall through to static-files which 404s; BlueMap shows steve.png. + // Better UX than a 5xx -- the rest of the map keeps rendering. + } + } + await next(); + }); + + // BlueMap stores `.json` and `.prbm` files pre-compressed on disk as + // `.json.gz` / `.prbm.gz`, but its web client requests them WITHOUT the + // `.gz` suffix -- it expects the server to transparently serve the gzipped + // variant with Content-Encoding: gzip. This middleware does that rewrite + // BEFORE UseStaticFiles, so the existing OnPrepareResponse below handles + // the headers as it would for a direct .gz request. + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/map", out var rest)) + { + var sub = rest.Value?.TrimStart('/'); + if (!string.IsNullOrEmpty(sub)) + { + var literal = bluemapFp.GetFileInfo(sub); + if (!literal.Exists) + { + var gz = bluemapFp.GetFileInfo(sub + ".gz"); + if (gz.Exists) + { + ctx.Request.Path = "/map/" + sub + ".gz"; + } + } + } + } + await next(); + }); + + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = bluemapFp, + RequestPath = "/map", + }); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = bluemapFp, + RequestPath = "/map", + ServeUnknownFileTypes = true, + DefaultContentType = "application/octet-stream", + OnPrepareResponse = ctx => + { + // Anything ending in .gz needs Content-Encoding: gzip and a content + // type derived from the extension BEFORE .gz (so .json.gz is JSON, + // .prbm.gz is binary). + var name = ctx.File.Name; + if (!name.EndsWith(".gz", StringComparison.Ordinal)) return; + ctx.Context.Response.Headers["Content-Encoding"] = "gzip"; + if (name.EndsWith(".json.gz", StringComparison.Ordinal)) ctx.Context.Response.Headers["Content-Type"] = "application/json"; + else if (name.EndsWith(".prbm.gz", StringComparison.Ordinal)) ctx.Context.Response.Headers["Content-Type"] = "application/octet-stream"; + }, + }); + + // ──────────────── API endpoints ──────────────── + + // ── auth endpoints ── + // /api/auth/login sets an HttpOnly cookie so JS can never read the value; + // this defeats cookie exfiltration via XSS. Rate-limited per IP. + app.MapPost("/api/auth/login", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var supplied = payload?.Password ?? ""; + var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword ?? ""); + var supBytes = System.Text.Encoding.UTF8.GetBytes(supplied); + var ok = pwBytes.Length > 0 + && supBytes.Length == pwBytes.Length + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(supBytes, pwBytes); + if (!ok) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Wrong password"); + return; + } + ctx.Response.Cookies.Append("brass-sigil-auth", config.WebPassword!, new CookieOptions + { + HttpOnly = true, + Secure = ctx.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/", + }); + await ctx.Response.WriteAsJsonAsync(new { ok = true }); + }).AllowAnonymous().RequireRateLimiting("login"); + + app.MapPost("/api/auth/logout", (HttpContext ctx) => + { + ctx.Response.Cookies.Delete("brass-sigil-auth"); + return Results.Json(new { ok = true }); + }); + + app.MapPost("/api/auth/change-password", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var current = payload?.Current ?? ""; + var next = payload?.Next ?? ""; + var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword ?? ""); + var curBytes = System.Text.Encoding.UTF8.GetBytes(current); + var ok = pwBytes.Length > 0 + && curBytes.Length == pwBytes.Length + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(curBytes, pwBytes); + if (!ok) return Results.Json(new { ok = false, error = "Current password is wrong" }, statusCode: 401); + if (next.Length < 8) return Results.Json(new { ok = false, error = "New password must be at least 8 characters" }, statusCode: 400); + config.WebPassword = next; + config.Save(configPath); + // Re-issue the cookie with the new password so the current session stays logged in. + ctx.Response.Cookies.Append("brass-sigil-auth", next, new CookieOptions + { + HttpOnly = true, + Secure = ctx.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/", + }); + return Results.Json(new { ok = true }); + }); + + app.MapGet("/api/status", () => Results.Json(new + { + running = serverProc.IsRunning, + pid = serverProc.Pid, + startedAt = serverProc.StartedAt, + uptime = serverProc.StartedAt is { } s ? (DateTime.UtcNow - s).TotalSeconds : (double?)null, + packVersion = ReadPackVersion(config.ServerDir), + memoryBytes = serverProc.MemoryBytes, + memoryMaxMB = config.MemoryMB, + cpu = serverProc.CpuMetrics is { } m + ? new { current = m.Current, max = m.Max, avg = m.Avg } + : null, + worldSizeBytes = world.GetWorldSizeBytes(), + })); + + app.MapGet("/api/logs", (int? since) => + { + var logs = serverProc.RecentLogs(); + if (since.HasValue) logs = logs.Skip(since.Value).ToArray(); + return Results.Json(new + { + total = serverProc.RecentLogs().Count, + lines = logs.Select(l => new { t = l.At, e = l.IsError, m = l.Text }) + }); + }); + + // Server-Sent Events: instant push of new log lines + a replay of the recent buffer + // on connect. Browsers (EventSource) reconnect automatically if the stream drops. + app.MapGet("/api/logs/stream", async (HttpContext ctx, CancellationToken ct) => + { + ctx.Response.Headers.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx response buffering + await ctx.Response.Body.FlushAsync(ct); + + var channel = System.Threading.Channels.Channel.CreateUnbounded( + new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + void OnLine(ServerProcess.LogLine line) => channel.Writer.TryWrite(line); + serverProc.OnLogLine += OnLine; + + try + { + // Replay the ring buffer first so reconnecting clients get the recent context. + foreach (var l in serverProc.RecentLogs()) + { + await WriteSseLogAsync(ctx.Response, l, ct); + } + + // Then live-stream new lines. Heartbeat every 25 s so the connection isn't + // killed by intermediaries (nginx, browser idle timeouts). + using var heartbeatTimer = new PeriodicTimer(TimeSpan.FromSeconds(25)); + var heartbeatTask = Task.Run(async () => + { + try { while (await heartbeatTimer.WaitForNextTickAsync(ct)) await ctx.Response.WriteAsync(": ping\n\n", ct); } + catch { } + }, ct); + + while (!ct.IsCancellationRequested) + { + var line = await channel.Reader.ReadAsync(ct); + await WriteSseLogAsync(ctx.Response, line, ct); + } + } + catch (OperationCanceledException) { /* client disconnected */ } + finally + { + serverProc.OnLogLine -= OnLine; + channel.Writer.TryComplete(); + } + }); + + app.MapPost("/api/command", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var body = await sr.ReadToEndAsync(); + var payload = JsonSerializer.Deserialize(body, JsonOpts.CaseInsensitive); + var cmd = payload?.Command?.Trim(); + if (string.IsNullOrEmpty(cmd)) return Results.BadRequest("empty command"); + await serverProc.SendInputAsync(cmd); + return Results.Json(new { ok = true }); + }); + + app.MapGet("/api/whitelist", () => + { + var path = Path.Combine(Path.GetFullPath(config.ServerDir), "whitelist.json"); + if (!File.Exists(path)) return Results.Json(new { players = Array.Empty() }); + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var names = doc.RootElement.EnumerateArray() + .Select(e => e.TryGetProperty("name", out var n) ? n.GetString() : null) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .ToArray(); + return Results.Json(new { players = names }); + } + catch (Exception ex) + { + return Results.Json(new { players = Array.Empty(), error = ex.Message }); + } + }); + + // ── Public whitelist request flow (friend-side, no auth) ── + // Friends post their MC username; admin sees the queue in the panel and + // approves/denies. The /whitelist add command itself still goes through the + // normal admin-gated endpoint below. + app.MapPost("/api/whitelist/request", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Username?.Trim(); + if (string.IsNullOrEmpty(name) || name.Length > 16 + || !System.Text.RegularExpressions.Regex.IsMatch(name, @"^[A-Za-z0-9_]{3,16}$")) + { + return Results.BadRequest(new { ok = false, error = "Username must be 3-16 letters/digits/underscore." }); + } + var ip = ctx.Connection.RemoteIpAddress?.ToString(); + var req = whitelistRequests.Submit(name, payload?.Message, ip); + return Results.Json(new { ok = true, status = req.Status }); + }).AllowAnonymous().RequireRateLimiting("whitelist-request"); + + // Same per-IP throttle as the request endpoint -- prevents trivial enumeration + // while still letting the launcher's polling work fine. + app.MapGet("/api/whitelist/status", (string username) => + { + var name = (username ?? "").Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." }); + var status = whitelistRequests.StatusFor(name); + return Results.Json(new { ok = true, status }); + }).AllowAnonymous().RequireRateLimiting("whitelist-status"); + + // ── Admin-side: list/approve/deny (auth required via global middleware) ── + app.MapGet("/api/whitelist/requests", () => + Results.Json(new { requests = whitelistRequests.ListPending() })); + + app.MapPost("/api/whitelist/requests/approve", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." }); + // Mark approved + actually add to MC's whitelist via stdin command. + whitelistRequests.MarkApproved(name); + await serverProc.SendInputAsync($"whitelist add {name}"); + // Remove from the pending file once it's actually in the whitelist. + whitelistRequests.Remove(name); + return Results.Json(new { ok = true }); + }); + + app.MapPost("/api/whitelist/requests/deny", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." }); + whitelistRequests.MarkDenied(name); + return Results.Json(new { ok = true }); + }); + + app.MapPost("/api/whitelist/add", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest("empty name"); + await serverProc.SendInputAsync($"whitelist add {name}"); + return Results.Json(new { ok = true }); + }); + + app.MapPost("/api/whitelist/remove", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest("empty name"); + await serverProc.SendInputAsync($"whitelist remove {name}"); + return Results.Json(new { ok = true }); + }); + + app.MapGet("/api/players", async () => + { + try + { + var resp = await rcon.SendCommandAsync("list"); + var (count, names) = ParsePlayerList(resp); + return Results.Json(new { online = count, players = names }); + } + catch (Exception ex) + { + return Results.Json(new { online = -1, players = Array.Empty(), error = ex.Message }); + } + }); + + app.MapPost("/api/server/stop", async () => + { + await serverProc.StopAsync(); + return Results.Json(new { ok = true }); + }); + + app.MapPost("/api/server/start", () => + { + var started = serverProc.Start(); + return Results.Json(new { ok = started }); + }); + + // ── Server settings (server.properties bridge) ── + app.MapGet("/api/server/settings", () => + { + var svc = new ServerPropertiesService(config); + return Results.Json(new + { + values = svc.ReadEditable(), + editableKeys = ServerPropertiesService.EditableKeys.ToArray(), + running = serverProc.IsRunning, + }); + }); + + app.MapPost("/api/server/settings", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize>( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + if (payload is null || payload.Count == 0) + return Results.BadRequest(new { ok = false, error = "Empty payload." }); + var svc = new ServerPropertiesService(config); + svc.Update(payload); + return Results.Json(new { ok = true, restartRequired = serverProc.IsRunning }); + }); + + // ── BlueMap (manual render) ── + app.MapGet("/api/map/status", () => Results.Json(new + { + inProgress = bluemap.State.InProgress, + phase = bluemap.State.Phase, + error = bluemap.State.Error, + startedAt = bluemap.State.StartedAt, + finishedAt = bluemap.State.FinishedAt, + exitCode = bluemap.State.ExitCode, + lastLogLine = bluemap.State.LastLogLine, + hasOutput = bluemap.HasRendered, + })); + + app.MapPost("/api/map/render", () => + { + var started = bluemap.StartRender(); + return Results.Json(new { ok = started, error = started ? null : "Render already in progress." }); + }); + + app.MapPost("/api/map/cancel", () => + { + var cancelled = bluemap.CancelRender(); + return Results.Json(new { ok = cancelled, error = cancelled ? null : "No render in progress." }); + }); + + // Convenience: stop+start as one call so the panel can do it from a button. + app.MapPost("/api/server/restart", async () => + { + if (serverProc.IsRunning) await serverProc.StopAsync(); + // Brief delay so OS has time to release file handles before relaunch. + await Task.Delay(1500); + var ok = serverProc.Start(); + return Results.Json(new { ok }); + }); + + // ── Updater ── + app.MapGet("/api/update/status", async () => + { + // Quick check (refreshes State.CurrentVersion / AvailableVersion as side-effect). + var check = await updater.CheckAsync(); + return Results.Json(new + { + inProgress = updater.State.InProgress, + phase = updater.State.Phase, + countdownTotal = updater.State.CountdownTotal, + countdownRemaining = updater.State.CountdownRemaining, + current = check.Current, + available = check.Available, + needsUpdate = check.NeedsUpdate, + error = updater.State.Error ?? check.Error, + }); + }); + + app.MapPost("/api/update/start", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var delay = Math.Clamp(payload?.DelaySeconds ?? 300, 0, 3600); + + // Run the update in the background -- don't block the HTTP response on the + // 5-minute countdown. The panel polls /api/update/status for progress. + _ = Task.Run(() => updater.StartAsync(delay)); + + return Results.Accepted("/api/update/status", new { ok = true, delaySeconds = delay }); + }); + + app.MapPost("/api/update/cancel", () => + { + var cancelled = updater.TryCancel(); + return Results.Json(new { ok = cancelled }); + }); + + // On-demand seed fetch -- not part of /api/status polling because the + // seed only changes on wipe, no need to re-query RCON every poll. + app.MapGet("/api/world/seed", async (CancellationToken ct) => + { + var seed = await world.GetCurrentSeedAsync(ct); + return Results.Json(new { seed }); + }); + + app.MapPost("/api/world/wipe", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + if (!string.Equals(payload?.Confirm, "WIPE", StringComparison.Ordinal)) + return Results.BadRequest(new { ok = false, error = "Type WIPE to confirm." }); + var doBackup = payload!.Backup ?? true; + var seedMode = (payload.SeedMode ?? "random").ToLowerInvariant() switch + { + "keep" => WorldService.SeedMode.Keep, + "custom" => WorldService.SeedMode.Custom, + _ => WorldService.SeedMode.Random, + }; + var opts = new WorldService.WipeOptions(doBackup, seedMode, payload.CustomSeed); + var result = await world.WipeWorldAsync(opts); + return Results.Json(new { ok = result.Ok, backupName = result.BackupName, seedUsed = result.SeedUsed, error = result.Error }); + }); + + // ── Backups ── + app.MapGet("/api/backup/list", () => + { + var list = backup.List(); + return Results.Json(new + { + dir = backup.BackupDir, + backups = list, + schedule = config.BackupSchedule, + description = scheduler.Describe(), + keep = config.BackupKeep, + nextRun = scheduler.NextRun(), + }); + }); + + app.MapPost("/api/backup/schedule", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + // Empty string clears the schedule (disables). Otherwise the BackupScheduler + // accepts: "HH:mm", comma-separated "HH:mm,HH:mm,...", or "every Nh"/"every Nm". + var newSchedule = payload?.Schedule; + if (!string.IsNullOrWhiteSpace(newSchedule)) + { + // Probe by parsing -- the scheduler's Describe will return "Disabled" for invalid. + var trial = newSchedule.Trim().ToLowerInvariant(); + bool ok; + if (System.Text.RegularExpressions.Regex.IsMatch(trial, + @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$")) + { + ok = true; + } + else + { + ok = trial.Split(',', StringSplitOptions.RemoveEmptyEntries) + .All(tok => TimeOnly.TryParse(tok.Trim(), out _)); + } + if (!ok) + return Results.BadRequest(new { ok = false, error = "Schedule must be HH:mm, HH:mm,HH:mm, 'every Nh', or 'every Nm' (or empty to disable)." }); + } + if (payload?.Keep is { } k && k < 1) + return Results.BadRequest(new { ok = false, error = "keep must be >= 1." }); + + if (newSchedule != null) config.BackupSchedule = string.IsNullOrWhiteSpace(newSchedule) ? null : newSchedule; + if (payload?.Keep is { } kk) config.BackupKeep = kk; + config.Save(configPath); + scheduler.Reload(); + return Results.Json(new { ok = true, schedule = config.BackupSchedule, keep = config.BackupKeep, nextRun = scheduler.NextRun() }); + }); + + app.MapPost("/api/backup/create", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var result = await backup.CreateAsync(payload?.Reason); + return Results.Json(new { ok = result.Ok, name = result.Name, sizeBytes = result.SizeBytes, error = result.Error }); + }); + + app.MapPost("/api/backup/restore", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing backup name." }); + var (ok, error) = await backup.RestoreAsync(name); + return Results.Json(new { ok, error }); + }); + + app.MapPost("/api/backup/delete", async (HttpContext ctx) => + { + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize( + await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var name = payload?.Name?.Trim(); + if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing backup name." }); + var (ok, error) = backup.Delete(name); + return Results.Json(new { ok, error }); + }); + + AnsiConsole.MarkupLine($"[green]✓[/] Web UI: [blue]http://{config.WebHost}:{config.WebPort}/[/]"); + + // Intercept Ctrl+C / SIGTERM so the Java subprocess is stopped cleanly + // before we exit. The Windows Job Object is a last-resort safety net for + // the cases this can't catch (Task Manager kill, BSOD). + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; // we'll exit ourselves after stopping MC + AnsiConsole.MarkupLine("[yellow]Shutting down...[/]"); + // 60 s headroom matches the systemd unit's TimeoutStopSec -- populated + // worlds with terrain mods + DH LOD writes + C2ME chunk flushes can take + // 30-50 s to finish saving cleanly. Tight timeout = risk of force-kill + // mid-flush. If MC's done sooner, this returns sooner. + try { bluemap.Dispose(); } catch { } // kill any in-flight render before MC stop + try { serverProc.StopAsync(TimeSpan.FromSeconds(60)).GetAwaiter().GetResult(); } catch { } + try { app.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); } catch { } + }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try { bluemap.Dispose(); } catch { } + try { serverProc.StopAsync(TimeSpan.FromSeconds(10)).GetAwaiter().GetResult(); } catch { } + }; + + // Auto-open browser for Windows interactive launches (double-click experience). + if (OperatingSystem.IsWindows() && !Console.IsInputRedirected) + { + _ = Task.Run(async () => + { + await Task.Delay(2500); + try { Process.Start(new ProcessStartInfo { FileName = $"http://{config.WebHost}:{config.WebPort}/", UseShellExecute = true }); } + catch { /* user can browse manually */ } + }); + } + + await app.RunAsync(); + await serverProc.StopAsync(); + scheduler.Dispose(); + bluemap.Dispose(); + rcon.Dispose(); + return 0; + } + + /// + /// Brings the install up to spec before launching: validates Java, accepts EULA, + /// syncs mods from the manifest, and runs the NeoForge server installer if needed. + /// Idempotent and quick when nothing needs doing. + /// + private static async Task EnsureInstalledAsync(ServerConfig config, string configPath) + { + var serverDir = Path.GetFullPath(config.ServerDir); + Directory.CreateDirectory(serverDir); + + // 0. Java version pre-flight -- MC 1.21.1 / NeoForge 21.1.x require Java 21+. + // We try in order: configured javaPath -> bundled at server/java/ -> system PATH. + // If none yield Java 21+, auto-download Adoptium Temurin JRE 21 to server/java/. + var javaInstaller = new JavaInstaller(); + var resolvedJava = await ResolveOrInstallJavaAsync(config, configPath, javaInstaller, serverDir); + if (resolvedJava is null) return false; + config.JavaPath = resolvedJava.Value.Path; + AnsiConsole.MarkupLine($"[green]✓[/] Java {resolvedJava.Value.Major} ({resolvedJava.Value.Vendor}) -- {config.JavaPath}"); + + // 1. EULA + var eulaPath = Path.Combine(serverDir, "eula.txt"); + var eulaAccepted = File.Exists(eulaPath) && File.ReadAllText(eulaPath).Contains("eula=true"); + if (!eulaAccepted && !config.AcceptEula) + { + AnsiConsole.MarkupLine("[bold]The Minecraft EULA has not been accepted yet.[/]"); + AnsiConsole.MarkupLine("Read it: [blue]https://aka.ms/MinecraftEULA[/]"); + if (Console.IsInputRedirected) + { + AnsiConsole.MarkupLine("[red]Re-run with --accept-eula or set acceptEula:true in server-config.json.[/]"); + return false; + } + if (!AnsiConsole.Confirm("Do you accept the Minecraft EULA?", false)) + { + AnsiConsole.MarkupLine("[red]EULA not accepted, exiting.[/]"); + return false; + } + config.AcceptEula = true; + config.Save(configPath); + } + if (!File.Exists(eulaPath)) + { + await File.WriteAllTextAsync(eulaPath, + "# Generated by brass-sigil-server\n" + + "# By setting eula=true you accept https://aka.ms/MinecraftEULA\n" + + "eula=true\n"); + } + + // 2. server.properties (only if missing) + var propsPath = Path.Combine(serverDir, "server.properties"); + if (!File.Exists(propsPath)) + { + if (string.IsNullOrEmpty(config.RconPassword)) + { + config.RconPassword = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant(); + config.Save(configPath); + } + await File.WriteAllTextAsync(propsPath, + $"motd=Brass & Sigil\n" + + $"gamemode=survival\n" + + $"difficulty=normal\n" + + $"online-mode=true\n" + + $"white-list=true\n" + + $"enforce-whitelist=true\n" + + $"max-players=20\n" + + $"view-distance=12\n" + + $"simulation-distance=10\n" + + $"enable-rcon=true\n" + + $"rcon.port={config.RconPort}\n" + + $"rcon.password={config.RconPassword}\n" + + $"broadcast-rcon-to-ops=false\n"); + AnsiConsole.MarkupLine("[grey]Wrote default server.properties[/]"); + } + + // server-icon.png -- extract from embedded resource if missing. Survives wipes + // (lives at server root, not inside world/) and persists across pack updates. + var iconPath = Path.Combine(serverDir, "server-icon.png"); + if (!File.Exists(iconPath)) + { + try + { + using var stream = typeof(RunCommand).Assembly + .GetManifestResourceStream("BrassAndSigil.Server.Assets.server-icon.png"); + if (stream is not null) + { + await using var fs = File.Create(iconPath); + await stream.CopyToAsync(fs); + AnsiConsole.MarkupLine("[grey]Placed default server-icon.png[/]"); + } + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[grey]Couldn't write server-icon.png: {ex.Message.EscapeMarkup()}[/]"); + } + } + + // 3. Fetch manifest + sync mods + AnsiConsole.MarkupLine("[bold]Checking modpack...[/]"); + var sync = new ManifestSync(); + Manifest manifest; + try + { + manifest = await sync.FetchManifestAsync(config.ManifestUrl); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Could not fetch manifest:[/] {ex.Message.EscapeMarkup()}"); + return false; + } + + var localPackVer = ReadLocalPackVersion(serverDir); + var needsSync = !string.Equals(localPackVer, manifest.Version, StringComparison.Ordinal); + if (needsSync) + { + AnsiConsole.MarkupLine(localPackVer is null + ? $"[yellow]No pack installed yet. Syncing v{manifest.Version}...[/]" + : $"[yellow]Pack v{localPackVer} -> v{manifest.Version}, syncing...[/]"); + var progress = new Progress(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]")); + try + { + var result = await sync.SyncAsync(config.ManifestUrl, serverDir, progress); + AnsiConsole.MarkupLine($"[green]✓[/] Pack synced: {result.Downloaded} downloaded, {result.Removed} removed, {result.Skipped} client-only skipped"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Sync failed:[/] {ex.Message.EscapeMarkup()}"); + return false; + } + } + else + { + AnsiConsole.MarkupLine($"[green]✓[/] Pack already at v{localPackVer}"); + } + + // 4. Loader install (NeoForge only for now) + var loader = manifest.Loader; + if (loader is not null && loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase)) + { + var nfInstaller = new NeoForgeInstaller(); + if (!nfInstaller.IsAlreadyInstalled(serverDir)) + { + AnsiConsole.MarkupLine($"[yellow]Installing NeoForge {loader.Version}...[/]"); + var progress = new Progress(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]")); + var success = await nfInstaller.InstallAsync(loader.Version, serverDir, config.JavaPath, progress, default); + if (!success) + { + AnsiConsole.MarkupLine("[red]NeoForge install failed. Make sure 'java' is on PATH (or set javaPath in config).[/]"); + return false; + } + AnsiConsole.MarkupLine("[green]✓[/] NeoForge installed"); + } + else + { + AnsiConsole.MarkupLine("[green]✓[/] NeoForge already installed"); + } + } + + return true; + } + + private static async Task<(string Path, int Major, string Vendor)?> ResolveOrInstallJavaAsync( + ServerConfig config, string configPath, JavaInstaller installer, string serverDir) + { + // 1. Try the configured javaPath as-is. + var info = await GetJavaInfoAsync(config.JavaPath); + if (info is { Major: >= 21 }) return (config.JavaPath, info.Value.Major, info.Value.Vendor); + + // 2. Try a previously bundled Java at server/java/. + var bundled = installer.FindBundledJava(serverDir); + if (bundled is not null) + { + var bundledInfo = await GetJavaInfoAsync(bundled); + if (bundledInfo is { Major: >= 21 }) + { + config.JavaPath = bundled; + config.Save(configPath); + return (bundled, bundledInfo.Value.Major, bundledInfo.Value.Vendor); + } + } + + // 3. Last resort: download Adoptium Temurin JRE 21. + AnsiConsole.MarkupLine(info is null + ? $"[yellow]No Java found at '[bold]{config.JavaPath}[/]'. Auto-installing Java 21...[/]" + : $"[yellow]Found Java {info.Value.Major} ({info.Value.Vendor}) but need 21+. Auto-installing...[/]"); + + var progress = new Progress(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]")); + var installed = await installer.InstallJre21Async(serverDir, progress, default); + if (installed is null) + { + AnsiConsole.MarkupLine("[red]Java auto-install failed.[/] Install Java 21 manually from " + + "[blue]https://adoptium.net/temurin/releases/?version=21[/] and set " + + $"[yellow]javaPath[/] in {configPath}."); + return null; + } + + var newInfo = await GetJavaInfoAsync(installed); + if (newInfo is null || newInfo.Value.Major < 21) + { + AnsiConsole.MarkupLine("[red]Auto-installed Java didn't pass the version check.[/]"); + return null; + } + + config.JavaPath = installed; + config.Save(configPath); + return (installed, newInfo.Value.Major, newInfo.Value.Vendor); + } + + private static async Task<(int Major, string Vendor)?> GetJavaInfoAsync(string javaPath) + { + try + { + var psi = new ProcessStartInfo + { + FileName = javaPath, + Arguments = "-version", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + var proc = Process.Start(psi); + if (proc is null) return null; + var stderr = await proc.StandardError.ReadToEndAsync(); + var stdout = await proc.StandardOutput.ReadToEndAsync(); + await proc.WaitForExitAsync(); + var output = stderr + "\n" + stdout; + + // First line is typically: openjdk version "21.0.7" 2025-... LTS + // or: java version "1.8.0_261" + var verMatch = System.Text.RegularExpressions.Regex.Match(output, @"version\s+""([0-9._]+)"); + if (!verMatch.Success) return null; + var verStr = verMatch.Groups[1].Value; + int major; + if (verStr.StartsWith("1.")) + { + // Old format e.g. 1.8.0 → Java 8 + var parts = verStr.Split('.'); + major = parts.Length > 1 && int.TryParse(parts[1], out var m) ? m : 0; + } + else + { + var parts = verStr.Split('.'); + major = int.TryParse(parts[0], out var m) ? m : 0; + } + + string vendor = "OpenJDK"; + if (output.Contains("Microsoft", StringComparison.OrdinalIgnoreCase)) vendor = "Microsoft"; + else if (output.Contains("Temurin", StringComparison.OrdinalIgnoreCase)) vendor = "Temurin"; + else if (output.Contains("Zulu", StringComparison.OrdinalIgnoreCase)) vendor = "Azul Zulu"; + else if (output.Contains("Oracle", StringComparison.OrdinalIgnoreCase)) vendor = "Oracle"; + else if (output.Contains("GraalVM", StringComparison.OrdinalIgnoreCase)) vendor = "GraalVM"; + + return (major, vendor); + } + catch + { + return null; + } + } + + private static string? TrySuggestJava21OnWindows() + { + if (!OperatingSystem.IsWindows()) return null; + var roots = new[] + { + @"C:\Program Files\Java", + @"C:\Program Files\Eclipse Adoptium", + @"C:\Program Files\Microsoft", + @"C:\Program Files\Zulu", + @"C:\Program Files\BellSoft", + }; + foreach (var root in roots) + { + if (!Directory.Exists(root)) continue; + try + { + foreach (var dir in Directory.EnumerateDirectories(root)) + { + var name = Path.GetFileName(dir); + if (name.Contains("21", StringComparison.Ordinal)) + { + var javaExe = Path.Combine(dir, "bin", "java.exe"); + if (File.Exists(javaExe)) return javaExe; + } + } + } + catch { } + } + return null; + } + + 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 bool IsLocalhostBind(ServerConfig cfg) => + cfg.WebHost is "localhost" or "127.0.0.1" or "::1"; + + private static (int Count, string[] Names) ParsePlayerList(string rconResp) + { + var colon = rconResp.IndexOf(':'); + if (colon < 0) return (0, Array.Empty()); + var head = rconResp.Substring(0, colon); + var tail = rconResp.Substring(colon + 1).Trim(); + int count = 0; + foreach (var p in head.Split(' ')) + if (int.TryParse(p, out var n)) { count = n; break; } + var names = string.IsNullOrWhiteSpace(tail) + ? Array.Empty() + : tail.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return (count, names); + } + + private static object? ReadPackVersion(string serverDir) + { + var path = Path.Combine(serverDir, "pack-version.json"); + if (!File.Exists(path)) return null; + try { return JsonSerializer.Deserialize(File.ReadAllText(path)); } + catch { return null; } + } + + private sealed class CommandPayload { public string? Command { get; set; } } + private sealed class NamePayload { public string? Name { get; set; } } + private sealed class PasswordPayload { public string? Password { get; set; } } + private sealed class ChangePasswordPayload { public string? Current { get; set; } public string? Next { get; set; } } + private sealed class UpdateStartPayload { public int? DelaySeconds { get; set; } } + private sealed class WipePayload { + public string? Confirm { get; set; } + public bool? Backup { get; set; } + public string? SeedMode { get; set; } // "keep" | "random" | "custom" + public string? CustomSeed { get; set; } // only used when SeedMode == "custom" + } + private sealed class BackupCreatePayload { public string? Reason { get; set; } } + private sealed class BackupNamePayload { public string? Name { get; set; } } + private sealed class BackupSchedulePayload { public string? Schedule { get; set; } public int? Keep { get; set; } } + private sealed class WhitelistRequestPayload { public string? Username { get; set; } public string? Message { get; set; } } + + private static async Task WriteSseLogAsync(HttpResponse resp, ServerProcess.LogLine line, CancellationToken ct) + { + var json = JsonSerializer.Serialize(new { t = line.At, e = line.IsError, m = line.Text }); + await resp.WriteAsync($"event: log\ndata: {json}\n\n", ct); + await resp.Body.FlushAsync(ct); + } +} diff --git a/server/Commands/SetPasswordCommand.cs b/server/Commands/SetPasswordCommand.cs new file mode 100644 index 0000000..36377a3 --- /dev/null +++ b/server/Commands/SetPasswordCommand.cs @@ -0,0 +1,44 @@ +using BrassAndSigil.Server.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BrassAndSigil.Server.Commands; + +/// +/// Set or rotate the web panel admin password from the CLI. +/// Useful when first-time-setting up before exposing the panel publicly, +/// or rotating after a suspected leak without going through the panel UI. +/// +public sealed class SetPasswordCommand : Command +{ + public override int Execute(CommandContext context, BaseCommandSettings settings) + { + var config = ServerConfig.Load(settings.ConfigPath); + + if (Console.IsInputRedirected) + { + AnsiConsole.MarkupLine("[red]set-password requires an interactive terminal.[/]"); + return 1; + } + + AnsiConsole.MarkupLine("[bold]Set admin password[/]"); + if (!string.IsNullOrEmpty(config.WebPassword)) + AnsiConsole.MarkupLine("[grey]An existing password is already set; this will overwrite it.[/]"); + + string pw1, pw2; + while (true) + { + pw1 = AnsiConsole.Prompt(new TextPrompt("New password (min 8 chars):").Secret()); + if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; } + pw2 = AnsiConsole.Prompt(new TextPrompt("Confirm:").Secret()); + if (pw1 != pw2) { AnsiConsole.MarkupLine("[red]Doesn't match.[/]"); continue; } + break; + } + + config.WebPassword = pw1; + config.Save(settings.ConfigPath); + AnsiConsole.MarkupLine($"[green]✓[/] Saved to {settings.ConfigPath}."); + AnsiConsole.MarkupLine("[grey]Restart the server for the new password to take effect.[/]"); + return 0; + } +} diff --git a/server/Commands/SyncCommand.cs b/server/Commands/SyncCommand.cs new file mode 100644 index 0000000..3c00b24 --- /dev/null +++ b/server/Commands/SyncCommand.cs @@ -0,0 +1,32 @@ +using BrassAndSigil.Server.Models; +using BrassAndSigil.Server.Services; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BrassAndSigil.Server.Commands; + +public sealed class SyncCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BaseCommandSettings settings) + { + var config = ServerConfig.Load(settings.ConfigPath); + AnsiConsole.MarkupLine($"[bold]Syncing[/] from [blue]{config.ManifestUrl}[/]"); + AnsiConsole.MarkupLine($"[grey]Target: {Path.GetFullPath(config.ServerDir)}[/]"); + AnsiConsole.MarkupLine(""); + + var sync = new ManifestSync(); + var progress = new Progress(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]")); + try + { + var result = await sync.SyncAsync(config.ManifestUrl, config.ServerDir, progress); + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine($"[green]✓[/] pack v{result.PackVersion} | downloaded={result.Downloaded} removed={result.Removed} client-only-skipped={result.Skipped}"); + return 0; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]✗[/] {ex.Message.EscapeMarkup()}"); + return 1; + } + } +} diff --git a/server/Models/Manifest.cs b/server/Models/Manifest.cs new file mode 100644 index 0000000..63d184f --- /dev/null +++ b/server/Models/Manifest.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace BrassAndSigil.Server.Models; + +public sealed class Manifest +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("minecraft")] + public MinecraftSpec Minecraft { get; set; } = new(); + + [JsonPropertyName("loader")] + public LoaderSpec? Loader { get; set; } + + [JsonPropertyName("files")] + public List Files { get; set; } = new(); +} + +public sealed class MinecraftSpec +{ + [JsonPropertyName("version")] public string Version { get; set; } = ""; +} + +public sealed class LoaderSpec +{ + [JsonPropertyName("type")] public string Type { get; set; } = "vanilla"; + [JsonPropertyName("version")] public string Version { get; set; } = ""; +} + +public sealed class ManifestFile +{ + [JsonPropertyName("path")] public string Path { get; set; } = ""; + [JsonPropertyName("url")] public string Url { get; set; } = ""; + [JsonPropertyName("sha1")] public string? Sha1 { get; set; } + [JsonPropertyName("size")] public long? Size { get; set; } +} + +public sealed class PackLock +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("version")] public string Version { get; set; } = ""; + [JsonPropertyName("minecraft")] public string Minecraft { get; set; } = ""; + [JsonPropertyName("loader")] public LoaderSpec Loader { get; set; } = new(); + [JsonPropertyName("mods")] public List Mods { get; set; } = new(); +} + +public sealed class LockedMod +{ + [JsonPropertyName("source")] public string Source { get; set; } = ""; + [JsonPropertyName("slug")] public string? Slug { get; set; } + [JsonPropertyName("versionId")] public string? VersionId { get; set; } + [JsonPropertyName("fileId")] public string? FileId { get; set; } + [JsonPropertyName("version")] public string Version { get; set; } = ""; + [JsonPropertyName("path")] public string Path { get; set; } = ""; + [JsonPropertyName("url")] public string Url { get; set; } = ""; + [JsonPropertyName("sha1")] public string Sha1 { get; set; } = ""; + [JsonPropertyName("size")] public long Size { get; set; } +} diff --git a/server/Models/ServerConfig.cs b/server/Models/ServerConfig.cs new file mode 100644 index 0000000..e0c39c7 --- /dev/null +++ b/server/Models/ServerConfig.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BrassAndSigil.Server.Services; + +namespace BrassAndSigil.Server.Models; + +public sealed class ServerConfig +{ + [JsonPropertyName("manifestUrl")] + public string ManifestUrl { get; set; } = "https://sijbers.uk/pack/manifest.json"; + + [JsonPropertyName("serverDir")] + public string ServerDir { get; set; } = "./server"; + + [JsonPropertyName("javaPath")] + public string JavaPath { get; set; } = "java"; + + [JsonPropertyName("memoryMB")] + public int MemoryMB { get; set; } = 8192; + + [JsonPropertyName("webPort")] + public int WebPort { get; set; } = 8080; + + /// "localhost" by default -- bind to 0.0.0.0 only behind a reverse proxy. + [JsonPropertyName("webHost")] + public string WebHost { get; set; } = "localhost"; + + /// Shared password for web UI. Required if WebHost is not localhost. + [JsonPropertyName("webPassword")] + public string? WebPassword { get; set; } + + /// Where world backups land. Empty -> <serverDir>/../backups. Set to a + /// large/slower drive on real deployments -- backups grow over time. + [JsonPropertyName("backupDir")] + public string? BackupDir { get; set; } + + /// Auto-rotation: keep this many most recent backups, delete older. + [JsonPropertyName("backupKeep")] + public int BackupKeep { get; set; } = 10; + + /// Daily auto-backup time as "HH:mm" (24-hour, server-local). Null/empty disables. + [JsonPropertyName("backupSchedule")] + public string? BackupSchedule { get; set; } + + /// + /// Where BlueMap CLI's working dir lives (cli.jar, configs, render output). + /// Empty -> alongside <serverDir>/.. (default ~/brass-sigil-server/bluemap). + /// Set to a big-disk path on real deployments -- rendered output for a 5000-block + /// world is several GB. + /// + [JsonPropertyName("bluemapDir")] + public string? BlueMapDir { get; set; } + + [JsonPropertyName("rconPort")] + public int RconPort { get; set; } = 25575; + + [JsonPropertyName("rconPassword")] + public string RconPassword { get; set; } = ""; + + [JsonPropertyName("acceptEula")] + public bool AcceptEula { get; set; } = false; + + public static ServerConfig Load(string path) + { + if (!File.Exists(path)) return new ServerConfig(); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOpts.CaseInsensitive) ?? new ServerConfig(); + } + + public void Save(string path) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(this, JsonOpts.Pretty); + File.WriteAllText(path, json); + } +} diff --git a/server/Program.cs b/server/Program.cs new file mode 100644 index 0000000..420b58f --- /dev/null +++ b/server/Program.cs @@ -0,0 +1,52 @@ +using BrassAndSigil.Server.Commands; +using Spectre.Console; +using Spectre.Console.Cli; + +// Detect interactive double-click on Windows so we can hold the console open at exit +// (otherwise the window vanishes before the user can read errors). +var interactive = !Console.IsInputRedirected && OperatingSystem.IsWindows(); + +var app = new CommandApp(); +app.Configure(config => +{ + config.SetApplicationName("brass-sigil-server"); + config.SetApplicationVersion("0.1.0"); + + config.AddCommand("install") + .WithDescription("Force a fresh setup: download mods + run the NeoForge installer."); + + config.AddCommand("sync") + .WithDescription("Update mods to match the current manifest. Server should be stopped first."); + + config.AddCommand("run") + .WithDescription("Run the server daemon (auto-installs anything missing, then serves the web UI).") + .WithAlias("start"); + + config.AddCommand("check") + .WithDescription("Verify install: dependencies, EULA, manifest reachability."); + + config.AddCommand("set-password") + .WithDescription("Set or rotate the web panel admin password."); +}); + +int result; +try +{ + result = await app.RunAsync(args); +} +catch (Exception ex) +{ + AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything); + result = 1; +} + +// Hold the console open at exit only when an error occurred during interactive use. +// Successful daemon termination (Ctrl+C, /api/server/stop) closes cleanly. +if (interactive && result != 0) +{ + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[grey]Press any key to close...[/]"); + Console.ReadKey(intercept: true); +} + +return result; diff --git a/server/Services/BackupScheduler.cs b/server/Services/BackupScheduler.cs new file mode 100644 index 0000000..45d240b --- /dev/null +++ b/server/Services/BackupScheduler.cs @@ -0,0 +1,202 @@ +using System.Text.RegularExpressions; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Auto-backup driven by config.BackupSchedule. Accepted formats: +/// - "HH:mm" single daily slot (e.g. "04:00") +/// - "HH:mm,HH:mm,..." multiple daily slots +/// - "every Nh" every N hours (>= 15 minutes) +/// - "every Nm" every N minutes +/// Wakes once a minute and fires backups when the clock matches the spec. +/// Doesn't catch up if the server was off when a slot passed -- daily/interval +/// backups don't need replay logic. +/// +public sealed class BackupScheduler : IDisposable +{ + private readonly ServerConfig _config; + private readonly BackupService _backup; + private readonly Action _log; + private CancellationTokenSource? _cts; + private Task? _loop; + + // Tracking for "fired" state. For interval: just the last fire time. For + // daily-times: which times have fired today, reset at day rollover. + private DateTimeOffset? _lastIntervalFire; + private DateOnly _lastFireDay = DateOnly.MinValue; + private readonly HashSet _firedToday = new(); + + public BackupScheduler(ServerConfig config, BackupService backup, Action log) + { + _config = config; + _backup = backup; + _log = log; + } + + public void Start() + { + if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return; + if (Parse(_config.BackupSchedule) == default) + { + _log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled."); + return; + } + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + _loop = Task.Run(() => RunAsync(_cts.Token)); + _log($"[backup-scheduler] Schedule active: {Describe()}"); + } + + /// Stop the current loop and re-Start with the latest config values. + public void Reload() + { + try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { } + _cts?.Dispose(); + _cts = null; + _loop = null; + Start(); + } + + /// Compute the next future scheduled fire time. Null if no schedule. + public DateTimeOffset? NextRun() + { + var (interval, times) = Parse(_config.BackupSchedule); + if (interval.HasValue) + { + var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1); + var next = baseTime + interval.Value; + // If we've never fired and we're past the implied first slot, "next" might be + // in the past -- clamp to "imminent" by using now + small buffer. + if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1); + return next.ToLocalTime(); + } + if (times is not null) + { + var now = DateTime.Now; + var nowTime = TimeOnly.FromDateTime(now); + // Use Cast().FirstOrDefault() so "no pending" is null rather than 00:00. + var pendingToday = times + .Where(t => t > nowTime && !_firedToday.Contains(t)) + .OrderBy(t => t) + .Cast() + .FirstOrDefault(); + if (pendingToday.HasValue) + return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan())); + // None left today -- first slot tomorrow. + var firstTomorrow = times.Min(); + return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan())); + } + return null; + } + + private async Task RunAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(TimeSpan.FromMinutes(1), ct); } + catch (OperationCanceledException) { break; } + + var (interval, times) = Parse(_config.BackupSchedule); + if (interval is null && times is null) continue; + + var nowUtc = DateTimeOffset.UtcNow; + var nowLocal = DateTime.Now; + var today = DateOnly.FromDateTime(nowLocal); + var nowTime = TimeOnly.FromDateTime(nowLocal); + + bool shouldFire = false; + if (interval.HasValue) + { + shouldFire = !_lastIntervalFire.HasValue + || (nowUtc - _lastIntervalFire.Value) >= interval.Value; + } + else if (times is not null) + { + if (today != _lastFireDay) + { + _firedToday.Clear(); + _lastFireDay = today; + } + foreach (var t in times) + { + if (t <= nowTime && !_firedToday.Contains(t)) + { + shouldFire = true; + _firedToday.Add(t); + break; + } + } + } + + if (!shouldFire) continue; + + _log("[backup-scheduler] Triggering scheduled backup."); + try + { + var result = await _backup.CreateAsync("scheduled", ct: ct); + if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB)."); + else _log($"[backup-scheduler] Failed: {result.Error}"); + if (interval.HasValue) _lastIntervalFire = nowUtc; + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); } + } + } + + /// Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty. + private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return (null, null); + var s = input.Trim().ToLowerInvariant(); + + // every Nh / every Nm + var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$"); + if (m.Success) + { + var n = int.Parse(m.Groups[1].Value); + var unit = m.Groups[2].Value; + var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n); + // Sanity floor -- anything below 15 min creates more save-lag than backups are worth. + if (span < TimeSpan.FromMinutes(15)) return (null, null); + if (span > TimeSpan.FromDays(7)) return (null, null); + return (span, null); + } + + // Comma-separated HH:mm + var list = new List(); + foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null); + list.Add(t); + } + return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray()); + } + + public string Describe() + { + var (interval, times) = Parse(_config.BackupSchedule); + if (interval.HasValue) + { + var totalMin = (int)interval.Value.TotalMinutes; + if (totalMin >= 60 && totalMin % 60 == 0) + { + var h = totalMin / 60; + return h == 1 ? "Every hour" : $"Every {h} hours"; + } + return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes"; + } + if (times is not null) + { + if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}"; + return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm"))); + } + return "Disabled"; + } + + public void Dispose() + { + try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { } + _cts?.Dispose(); + } +} diff --git a/server/Services/BackupService.cs b/server/Services/BackupService.cs new file mode 100644 index 0000000..4085b23 --- /dev/null +++ b/server/Services/BackupService.cs @@ -0,0 +1,257 @@ +using System.IO.Compression; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// World backups: ZIP the level-name dir into a separate (typically slower-but-bigger) +/// backup directory. Online backups via /save-all flush + /save-off while the server is +/// running mean players don't see downtime -- just a brief save lag. +/// +public sealed class BackupService +{ + private readonly ServerConfig _config; + private readonly ServerProcess _proc; + private readonly Broadcaster _broadcast; + private readonly Action _log; + private readonly SemaphoreSlim _gate = new(1, 1); + + public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action log) + { + _config = config; + _proc = proc; + _broadcast = broadcast; + _log = log; + } + + public sealed record BackupInfo(string Name, long SizeBytes, DateTimeOffset CreatedAt); + public sealed record CreateResult(bool Ok, string? Name, long SizeBytes, string? Error); + + public string BackupDir => ResolveBackupDir(); + + public List List() + { + var dir = BackupDir; + if (!Directory.Exists(dir)) return new(); + return Directory.EnumerateFiles(dir, "*.zip") + .Select(p => + { + var fi = new FileInfo(p); + return new BackupInfo(fi.Name, fi.Length, new DateTimeOffset(fi.CreationTimeUtc, TimeSpan.Zero)); + }) + .OrderByDescending(b => b.CreatedAt) + .ToList(); + } + + /// + /// Create a ZIP backup of the world dir. Online (no shutdown) when the server is + /// running. + /// + /// = false (default): just save-off + brief drain + + /// ZIP + save-on. Near-zero player-visible lag. Backup captures state up to + /// MC's last autosave (within ~5 min) -- fine for hourly snapshots. + /// + /// + /// = true: also runs save-all flush first, which + /// synchronously serialises every loaded chunk before the ZIP. Captures state up + /// to NOW. Causes a tick spike of seconds-to-tens-of-seconds depending on world + /// size. Used only for irreversible operations (pre-wipe) where freshness matters. + /// + /// + public async Task CreateAsync(string? reason = null, bool flush = false, CancellationToken ct = default) + { + if (!await _gate.WaitAsync(0, ct)) + return new CreateResult(false, null, 0, "Another backup is already in progress."); + + var dir = ResolveBackupDir(); + try + { + Directory.CreateDirectory(dir); + } + catch (Exception ex) + { + _gate.Release(); + return new CreateResult(false, null, 0, $"Couldn't create backup dir '{dir}': {ex.Message}"); + } + + var levelName = ReadLevelName(_config.ServerDir) ?? "world"; + var worldDir = Path.Combine(_config.ServerDir, levelName); + if (!Directory.Exists(worldDir)) + { + _gate.Release(); + return new CreateResult(false, null, 0, $"World directory not found at '{worldDir}'."); + } + + var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var slug = string.IsNullOrWhiteSpace(reason) ? "" : "-" + Slugify(reason); + var name = $"{levelName}-{stamp}{slug}.zip"; + var path = Path.Combine(dir, name); + + var serverRunning = _proc.IsRunning; + try + { + if (serverRunning) + { + if (flush) + { + // Loud path: fresh state, but pays the tick spike. Tell players. + try { await _broadcast.SayAsync("Saving world for backup (brief lag possible)...", ct); } catch { } + _log("[backup] save-all flush"); + await _proc.SendInputAsync("save-all flush", ct); + await Task.Delay(2500, ct); + } + _log("[backup] save-off"); + await _proc.SendInputAsync("save-off", ct); + // Brief drain so any save tasks already enqueued can finish before we + // start reading from disk for the ZIP. + await Task.Delay(500, ct); + } + + _log($"[backup] Archiving {worldDir} -> {name}"); + // Run on a worker thread so the request thread doesn't block on disk I/O. + await Task.Run(() => + ZipFile.CreateFromDirectory(worldDir, path, CompressionLevel.Fastest, includeBaseDirectory: false), ct); + + var size = new FileInfo(path).Length; + _log($"[backup] Created {name} ({size / (1024 * 1024)} MB)."); + // No completion broadcast for silent path -- backup was invisible to players, + // no need to tell them it finished. Loud path is wipe-only and the wipe + // sequence has its own messaging. + + RotateOldest(dir, _config.BackupKeep); + + return new CreateResult(true, name, size, null); + } + catch (Exception ex) + { + _log($"[backup] Failed: {ex.Message}"); + try { File.Delete(path); } catch { } // partial archive + return new CreateResult(false, null, 0, ex.Message); + } + finally + { + try + { + if (serverRunning && _proc.IsRunning) + { + _log("[backup] save-on"); + await _proc.SendInputAsync("save-on"); + } + } + catch { } + _gate.Release(); + } + } + + /// + /// Stop the server, move the current world out of the way as a "pre-restore" safety + /// copy, extract the chosen archive, restart. + /// + public async Task<(bool Ok, string? Error)> RestoreAsync(string backupName, CancellationToken ct = default) + { + if (!await _gate.WaitAsync(0, ct)) + return (false, "Another backup operation is already in progress."); + + try + { + var dir = ResolveBackupDir(); + var path = Path.Combine(dir, Path.GetFileName(backupName)); + if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found."); + + var levelName = ReadLevelName(_config.ServerDir) ?? "world"; + var worldDir = Path.Combine(_config.ServerDir, levelName); + + if (_proc.IsRunning) + { + _log("[restore] Stopping server before restore..."); + await _proc.StopAsync(TimeSpan.FromSeconds(30), ct); + } + + // Always preserve the current world as a pre-restore snapshot in case the + // chosen archive is corrupt or wrong. + if (Directory.Exists(worldDir)) + { + var preRestore = $"{worldDir}-prerestore-{DateTime.UtcNow:yyyyMMddHHmmss}"; + _log($"[restore] Moving current world to {Path.GetFileName(preRestore)}"); + Directory.Move(worldDir, preRestore); + } + + _log($"[restore] Extracting {backupName}"); + Directory.CreateDirectory(worldDir); + await Task.Run(() => ZipFile.ExtractToDirectory(path, worldDir, overwriteFiles: true), ct); + + _log("[restore] Starting server."); + _proc.Start(); + return (true, null); + } + catch (Exception ex) + { + _log($"[restore] Failed: {ex.Message}"); + try { if (!_proc.IsRunning) _proc.Start(); } catch { } + return (false, ex.Message); + } + finally + { + _gate.Release(); + } + } + + public (bool Ok, string? Error) Delete(string backupName) + { + try + { + var path = Path.Combine(ResolveBackupDir(), Path.GetFileName(backupName)); + if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found."); + File.Delete(path); + return (true, null); + } + catch (Exception ex) { return (false, ex.Message); } + } + + private string ResolveBackupDir() + { + if (!string.IsNullOrWhiteSpace(_config.BackupDir)) + return Path.GetFullPath(_config.BackupDir); + // Default: sibling of serverDir so it survives server-dir wipes. + var serverFull = Path.GetFullPath(_config.ServerDir); + var parent = Path.GetDirectoryName(serverFull) ?? serverFull; + return Path.Combine(parent, "backups"); + } + + private static void RotateOldest(string dir, int keep) + { + if (keep <= 0) return; + try + { + var zips = Directory.EnumerateFiles(dir, "*.zip") + .Select(p => new FileInfo(p)) + .OrderByDescending(fi => fi.CreationTimeUtc) + .ToList(); + foreach (var old in zips.Skip(keep)) + { + try { old.Delete(); } + catch { /* best-effort */ } + } + } + catch { } + } + + private static string? ReadLevelName(string serverDir) + { + var path = Path.Combine(serverDir, "server.properties"); + if (!File.Exists(path)) return null; + foreach (var line in File.ReadAllLines(path)) + { + if (line.StartsWith("level-name=", StringComparison.Ordinal)) + return line.Substring("level-name=".Length).Trim(); + } + return null; + } + + private static string Slugify(string s) + { + var chars = s.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray(); + var slug = new string(chars).ToLowerInvariant(); + return slug.Length > 32 ? slug.Substring(0, 32) : slug; + } +} diff --git a/server/Services/BlueMapPlayers.cs b/server/Services/BlueMapPlayers.cs new file mode 100644 index 0000000..3dce88f --- /dev/null +++ b/server/Services/BlueMapPlayers.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace BrassAndSigil.Server.Services; + +/// +/// On-demand snapshot of online players + positions, formatted for BlueMap's +/// live player overlay. Pull-based: the BlueMap web UI polls a JSON file at +/// /map/maps/overworld/live/players.json roughly every 2 s, and the +/// daemon intercepts that path and calls per +/// request. Closed tab = no requests = no RCON calls -- same model as +/// /api/players, no server-side timer to manage. +/// +public static class BlueMapPlayers +{ + public static async Task> SnapshotAsync(RconManager rcon, string serverDir, CancellationToken ct) + { + var listResp = await rcon.SendCommandAsync("list", ct); + // Format: "There are N of a max of M players online: name1, name2, ..." + var colon = listResp.IndexOf(':'); + var names = colon < 0 + ? Array.Empty() + : listResp.Substring(colon + 1) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var result = new List(); + foreach (var name in names) + { + ct.ThrowIfCancellationRequested(); + try + { + var posResp = await rcon.SendCommandAsync($"data get entity {name} Pos", ct); + var pos = ParseDoubleTriple(posResp); + if (pos is null) continue; + var rotResp = await rcon.SendCommandAsync($"data get entity {name} Rotation", ct); + var rot = ParseFloatPair(rotResp); + + result.Add(new + { + uuid = ResolveUuid(serverDir, name), + name, + foreign = false, + position = new { x = pos.Value.x, y = pos.Value.y, z = pos.Value.z }, + rotation = new { pitch = rot?.pitch ?? 0, yaw = rot?.yaw ?? 0, roll = 0.0 }, + }); + } + catch + { + // One bad player shouldn't drop the rest of the list. + } + } + return result; + } + + // ── UUID resolution ────────────────────────────────────────────────────── + // BlueMap uses the UUID for two things: marker identity across polls (so a + // changing UUID makes the marker flash on/off as it thinks the player keeps + // leaving and rejoining) and skin lookup against Mojang's profile API. We + // need the *real* Mojang UUID to satisfy both -- MC writes name→uuid pairs + // into /usercache.json after each successful auth. We cache that + // file's contents in memory and reload on mtime change, since usercache + // updates rarely (player join, periodic refresh). + private sealed class UserCacheEntry + { + public string? Name { get; set; } + public string? Uuid { get; set; } + } + + private static readonly object _cacheLock = new(); + private static Dictionary? _cache; // case-insensitive name → uuid + private static DateTime _cacheLoadedAt = DateTime.MinValue; + private static string? _cachePath; + + private static string ResolveUuid(string serverDir, string name) + { + var path = Path.Combine(Path.GetFullPath(serverDir), "usercache.json"); + lock (_cacheLock) + { + if (File.Exists(path)) + { + var mtime = File.GetLastWriteTimeUtc(path); + if (_cache is null || _cachePath != path || mtime > _cacheLoadedAt) + { + try + { + var json = File.ReadAllText(path); + var entries = JsonSerializer.Deserialize(json, JsonOpts.CaseInsensitive); + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (entries is not null) + foreach (var e in entries) + if (!string.IsNullOrEmpty(e.Name) && !string.IsNullOrEmpty(e.Uuid)) + dict[e.Name] = e.Uuid; + _cache = dict; + _cachePath = path; + _cacheLoadedAt = mtime; + } + catch + { + // Corrupt usercache shouldn't take down the marker -- fall through. + } + } + } + if (_cache is not null && _cache.TryGetValue(name, out var uuid)) return uuid; + } + + // Fallback when usercache hasn't been written yet (very early after + // first auth) or has been wiped: deterministic UUID derived from the + // name. Stable across polls so the marker doesn't flash; skin lookup + // will 404 against Mojang and BlueMap will show a default head. + return DeriveStableUuid(name).ToString(); + } + + private static Guid DeriveStableUuid(string name) + { + var bytes = SHA1.HashData(Encoding.UTF8.GetBytes("brass-sigil:" + name.ToLowerInvariant())); + var guidBytes = new byte[16]; + Array.Copy(bytes, guidBytes, 16); + return new Guid(guidBytes); + } + + // Parse a NBT-style double triple like "[123.4d, 64.0d, -56.7d]" out of an + // RCON `data get` response. + private static (double x, double y, double z)? ParseDoubleTriple(string resp) + { + var m = Regex.Match(resp, @"\[([\-\d\.]+)d?,\s*([\-\d\.]+)d?,\s*([\-\d\.]+)d?\]"); + if (!m.Success) return null; + if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) return null; + if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return null; + if (!double.TryParse(m.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) return null; + return (x, y, z); + } + + // Rotation comes as `[yaw, pitch]` in floats. + private static (double pitch, double yaw)? ParseFloatPair(string resp) + { + var m = Regex.Match(resp, @"\[([\-\d\.]+)f?,\s*([\-\d\.]+)f?\]"); + if (!m.Success) return null; + if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var yaw)) return null; + if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pitch)) return null; + return (pitch, yaw); + } +} diff --git a/server/Services/BlueMapService.cs b/server/Services/BlueMapService.cs new file mode 100644 index 0000000..8f519d7 --- /dev/null +++ b/server/Services/BlueMapService.cs @@ -0,0 +1,321 @@ +using System.Diagnostics; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Wraps BlueMap CLI as an out-of-process renderer. +/// +/// The CLI jar is downloaded from GitHub releases on first render (mirrors the +/// JavaInstaller pattern -- keeps the brass-sigil-server binary lean and lets +/// BlueMap update independently). BlueMap 5.20+ requires Java 25, so we also +/// auto-install Adoptium Temurin JRE 25 alongside the JRE 21 we use for MC. +/// +/// Renders are kicked off manually from the panel and produce static HTML/JS/PNG +/// output served at /map/. Zero impact on the running MC server (separate JVM, +/// separate memory pool -- only competes for disk I/O during render). +/// +public sealed class BlueMapService : IDisposable +{ + private const string BlueMapVersion = "5.20"; + private const string BlueMapJarUrl = + "https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v" + + BlueMapVersion + "/bluemap-" + BlueMapVersion + "-cli.jar"; + private const int BlueMapJavaVersion = 25; + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) }; + + private readonly ServerConfig _config; + private readonly Action _log; + private readonly SemaphoreSlim _gate = new(1, 1); + + private Process? _renderProc; + public sealed class RenderState + { + public bool InProgress { get; set; } + public string Phase { get; set; } = "idle"; + // "idle" | "extracting" | "configuring" | "rendering" | "complete" | "failed" + public string? Error { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? FinishedAt { get; set; } + public int? ExitCode { get; set; } + public string? LastLogLine { get; set; } + } + public RenderState State { get; private set; } = new(); + + public string RootDir + { + get + { + // Configured override (e.g. /mnt/md0p1/brass-sigil/bluemap) wins. Default + // is sibling of serverDir so it doesn't bloat the world dir or the + // server install -- typically ~/brass-sigil-server/bluemap. + if (!string.IsNullOrWhiteSpace(_config.BlueMapDir)) + return Path.GetFullPath(_config.BlueMapDir); + var serverFull = Path.GetFullPath(_config.ServerDir); + var parent = Path.GetDirectoryName(serverFull) ?? serverFull; + return Path.Combine(parent, "bluemap"); + } + } + public string CliJarPath => Path.Combine(RootDir, "cli.jar"); + public string WebDir => Path.Combine(RootDir, "web"); + public string ConfigDir => Path.Combine(RootDir, "config"); + + public BlueMapService(ServerConfig config, Action log) + { + _config = config; + _log = log; + } + + public bool HasRendered => Directory.Exists(WebDir) && + File.Exists(Path.Combine(WebDir, "index.html")); + + /// Kick off a render in the background. Returns false if one is already running. + public bool StartRender() + { + if (!_gate.Wait(0)) return false; + State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow }; + _ = Task.Run(RenderAsync); + return true; + } + + /// + /// Cancel an in-progress render by killing the BlueMap process. State on disk + /// is preserved, so the next render resumes from where this one stopped. + /// + public bool CancelRender() + { + if (!State.InProgress) return false; + try + { + // Kill the whole process tree (the BlueMap CLI may spawn worker JVMs + // for parallel rendering). entireProcessTree=true on Linux uses + // process group; on Windows it walks the tree via Job Objects. + _renderProc?.Kill(entireProcessTree: true); + State.Phase = "cancelled"; + State.Error = "Cancelled by user."; + _log("[bluemap] Render cancelled by user."); + return true; + } + catch (Exception ex) + { + _log($"[bluemap] Cancel failed: {ex.Message}"); + return false; + } + } + + /// + /// Delete rendered map output + render state. Used after a world wipe -- the + /// old tiles reference terrain that no longer exists. Preserves cli.jar and + /// configs so a follow-up Render still works (and skips re-download + + /// re-config). Returns true if anything was deleted. + /// + public bool ClearRenderOutput() + { + var dataDir = Path.Combine(RootDir, "data"); + var mapsDir = Path.Combine(RootDir, "web", "maps"); + var anyDeleted = false; + try + { + if (Directory.Exists(dataDir)) { Directory.Delete(dataDir, true); anyDeleted = true; _log("[bluemap] Cleared render state."); } + if (Directory.Exists(mapsDir)) { Directory.Delete(mapsDir, true); anyDeleted = true; _log("[bluemap] Cleared rendered tiles."); } + } + catch (Exception ex) { _log($"[bluemap] Couldn't clear output: {ex.Message}"); } + return anyDeleted; + } + + /// + /// Called when brass-sigil-server itself shuts down -- kill any in-flight + /// BlueMap process so it doesn't orphan to PID 1 and keep eating CPU after + /// the daemon's gone. Render state on disk is preserved; next start can + /// resume the render exactly where this one was killed. + /// + public void Dispose() + { + try + { + if (_renderProc is { HasExited: false }) + { + _renderProc.Kill(entireProcessTree: true); + _renderProc.WaitForExit(2000); + } + } + catch { } + _renderProc?.Dispose(); + _gate.Dispose(); + } + + private async Task RenderAsync() + { + try + { + Directory.CreateDirectory(RootDir); + + State.Phase = "downloading"; + await DownloadJarIfMissingAsync(); + + // BlueMap 5.20 needs Java 25; our MC server runs Java 21. Maintain a + // separate JRE 25 install for BlueMap only. + var bluemapJava = await EnsureJava25Async(); + if (bluemapJava is null) throw new InvalidOperationException("Couldn't install JRE 25 for BlueMap."); + + // First run: BlueMap with no config writes default configs and exits. + // We need to (a) run it once to seed configs, (b) patch the world path, + // (c) re-run with -r to actually render. + State.Phase = "configuring"; + EnsureConfig(bluemapJava); + + State.Phase = "rendering"; + // -r = render. --mods = pull textures from mod jars so Create + // blocks etc. show real colours instead of magenta/grey fallback. + var modsDir = Path.Combine(Path.GetFullPath(_config.ServerDir), "mods"); + var args = new List { "-jar", CliJarPath, "-r" }; + if (Directory.Exists(modsDir)) + { + args.Add("--mods"); + args.Add(modsDir); + } + + var psi = new ProcessStartInfo + { + FileName = bluemapJava, + WorkingDirectory = RootDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + _renderProc = new Process { StartInfo = psi, EnableRaisingEvents = true }; + _renderProc.OutputDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } }; + _renderProc.ErrorDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } }; + + _renderProc.Start(); + _renderProc.BeginOutputReadLine(); + _renderProc.BeginErrorReadLine(); + await _renderProc.WaitForExitAsync(); + + State.ExitCode = _renderProc.ExitCode; + State.FinishedAt = DateTimeOffset.UtcNow; + if (_renderProc.ExitCode == 0) State.Phase = "complete"; + else { State.Phase = "failed"; State.Error = $"BlueMap exited with code {_renderProc.ExitCode}"; } + } + catch (Exception ex) + { + State.Phase = "failed"; + State.Error = ex.Message; + State.FinishedAt = DateTimeOffset.UtcNow; + _log($"[bluemap] Failed: {ex.Message}"); + } + finally + { + State.InProgress = false; + _renderProc?.Dispose(); + _renderProc = null; + _gate.Release(); + } + } + + private async Task DownloadJarIfMissingAsync() + { + if (File.Exists(CliJarPath)) return; + _log($"[bluemap] Downloading BlueMap CLI v{BlueMapVersion} to {CliJarPath}"); + Directory.CreateDirectory(Path.GetDirectoryName(CliJarPath)!); + using var resp = await _http.GetAsync(BlueMapJarUrl, HttpCompletionOption.ResponseHeadersRead); + resp.EnsureSuccessStatusCode(); + await using var src = await resp.Content.ReadAsStreamAsync(); + await using var dst = File.Create(CliJarPath); + await src.CopyToAsync(dst); + _log($"[bluemap] Downloaded {new FileInfo(CliJarPath).Length / 1024} KB."); + } + + /// Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs). + private async Task EnsureJava25Async() + { + var installer = new JavaInstaller(); + var serverFull = Path.GetFullPath(_config.ServerDir); + // Look for an existing JRE 25 install first (idempotent across renders). + var existing = installer.FindBundledJava(serverFull, BlueMapJavaVersion); + if (existing is not null) return existing; + var installDir = installer.GetJavaInstallDir(serverFull, BlueMapJavaVersion); + var progress = new Progress(msg => _log("[bluemap] " + msg)); + return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default); + } + + /// + /// Run BlueMap with no flags so it writes default configs (bluemap.conf, + /// maps/overworld.conf, etc.), then patch the overworld map's world path + /// to point at our serverDir. Idempotent -- only writes configs that don't + /// exist; existing user edits survive. + /// + private void EnsureConfig(string javaPath) + { + Directory.CreateDirectory(ConfigDir); + var bluemapConf = Path.Combine(ConfigDir, "core.conf"); + var seeded = File.Exists(bluemapConf); + if (!seeded) + { + _log("[bluemap] First run -- generating default configs."); + var psi = new ProcessStartInfo + { + FileName = javaPath, + WorkingDirectory = RootDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-jar"); + psi.ArgumentList.Add(CliJarPath); + using var p = Process.Start(psi)!; + p.WaitForExit(60_000); + } + + // Patch the overworld map config to reference our world dir. + var serverDirAbs = Path.GetFullPath(_config.ServerDir); + var levelName = ReadLevelName(_config.ServerDir) ?? "world"; + var worldDir = Path.Combine(serverDirAbs, levelName).Replace('\\', '/'); + + var mapsDir = Path.Combine(ConfigDir, "maps"); + Directory.CreateDirectory(mapsDir); + var owConf = Path.Combine(mapsDir, "overworld.conf"); + if (!File.Exists(owConf) || !File.ReadAllText(owConf).Contains(worldDir)) + { + File.WriteAllText(owConf, $@"# Generated by brass-sigil-server. Edit at your own risk. +world: ""{worldDir}"" +dimension: ""minecraft:overworld"" +name: ""Brass and Sigil -- Overworld"" +sorting: 0 +sky-color: ""#7dabff"" +ambient-light: 0 +"); + _log("[bluemap] Wrote map config: maps/overworld.conf"); + } + + // Tell core.conf to accept that we read its license/disclaimer (otherwise CLI exits with a notice). + if (File.Exists(bluemapConf)) + { + var text = File.ReadAllText(bluemapConf); + if (!text.Contains("accept-download: true")) + { + text = text.Replace("accept-download: false", "accept-download: true"); + if (!text.Contains("accept-download:")) + text += "\naccept-download: true\n"; + File.WriteAllText(bluemapConf, text); + _log("[bluemap] Set accept-download: true in core.conf"); + } + } + } + + private static string? ReadLevelName(string serverDir) + { + var path = Path.Combine(serverDir, "server.properties"); + if (!File.Exists(path)) return null; + foreach (var line in File.ReadAllLines(path)) + { + if (line.StartsWith("level-name=", StringComparison.Ordinal)) + return line.Substring("level-name=".Length).Trim(); + } + return null; + } +} diff --git a/server/Services/Broadcaster.cs b/server/Services/Broadcaster.cs new file mode 100644 index 0000000..08fad5f --- /dev/null +++ b/server/Services/Broadcaster.cs @@ -0,0 +1,109 @@ +namespace BrassAndSigil.Server.Services; + +/// +/// Pushes player-visible messages and overlays into Minecraft via stdin +/// commands (/say, /title, /bossbar). The boss-bar countdown is the primary +/// primitive the updater uses for restart announcements. +/// +public sealed class Broadcaster +{ + private readonly ServerProcess _proc; + private const string BossBarId = "brass:announce"; + + public Broadcaster(ServerProcess proc) => _proc = proc; + + public Task SayAsync(string message, CancellationToken ct = default) + => _proc.SendInputAsync($"say {SingleLine(message)}", ct); + + public Task ActionBarAsync(string message, CancellationToken ct = default) + => _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(message)}\"}}", ct); + + /// + /// Re-sends the action bar text once per second so it stays sticky for the + /// full duration. Action bar fades after ~2-3 s of inactivity, so the + /// re-send is mandatory. Doesn't conflict with boss-bar UI for actual + /// boss fights -- preferred over BossBarCountdownAsync for restart warnings. + /// + public async Task ActionBarCountdownAsync( + string title, int durationSeconds, CancellationToken ct = default) + { + if (durationSeconds <= 0) return; + // Silence /title's "Showing new title for X" chat broadcast for the loop -- + // otherwise it spams chat once per second per online player. Restored in + // the finally block. World save typically isn't quick enough to persist + // the off state if we crash mid-flight, but worst case admins can flip + // it back manually with /gamerule sendCommandFeedback true. + await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct); + try + { + for (int sec = durationSeconds; sec > 0; sec--) + { + ct.ThrowIfCancellationRequested(); + var mins = sec / 60; + var secs = sec % 60; + var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s"; + await _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(label)}\"}}", ct); + try { await Task.Delay(1000, ct); } + catch (OperationCanceledException) { throw; } + } + } + finally + { + // Clear the action bar AND restore feedback. Both best-effort: if MC + // is stopping these'll fail and that's fine. + try { await _proc.SendInputAsync("title @a actionbar {\"text\":\"\"}"); } catch { } + try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { } + } + } + + public Task TitleAsync(string message, CancellationToken ct = default) + => _proc.SendInputAsync($"title @a title {{\"text\":\"{EscapeJson(message)}\"}}", ct); + + /// + /// Show a draining boss bar at the top of every player's screen for + /// . Updates the bar's name with a + /// "title -- Mm Ss" countdown each second. Returns when the bar is removed. + /// Honours cancellation: bar is removed cleanly even on cancel. + /// + public async Task BossBarCountdownAsync( + string title, int durationSeconds, string color = "yellow", CancellationToken ct = default) + { + if (durationSeconds <= 0) return; + + // Silence /bossbar feedback for the same reason as ActionBarCountdownAsync. + await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct); + await _proc.SendInputAsync($"bossbar add {BossBarId} {{\"text\":\"{EscapeJson(title)}\"}}", ct); + await _proc.SendInputAsync($"bossbar set {BossBarId} color {color}", ct); + await _proc.SendInputAsync($"bossbar set {BossBarId} max {durationSeconds}", ct); + await _proc.SendInputAsync($"bossbar set {BossBarId} value {durationSeconds}", ct); + await _proc.SendInputAsync($"bossbar set {BossBarId} players @a", ct); + + try + { + for (int sec = durationSeconds; sec > 0; sec--) + { + ct.ThrowIfCancellationRequested(); + var mins = sec / 60; + var secs = sec % 60; + var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s"; + await _proc.SendInputAsync($"bossbar set {BossBarId} name {{\"text\":\"{EscapeJson(label)}\"}}", ct); + await _proc.SendInputAsync($"bossbar set {BossBarId} value {sec}", ct); + try { await Task.Delay(1000, ct); } + catch (OperationCanceledException) { throw; } + } + } + finally + { + // Always remove the bar -- even on cancel, so a stuck bar isn't left + // on every player's screen. Use CancellationToken.None for the cleanup. + try { await _proc.SendInputAsync($"bossbar remove {BossBarId}"); } catch { } + try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { } + } + } + + private static string EscapeJson(string s) => + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", " "); + + private static string SingleLine(string s) => + s.Replace("\r", " ").Replace("\n", " ").Trim(); +} diff --git a/server/Services/JavaInstaller.cs b/server/Services/JavaInstaller.cs new file mode 100644 index 0000000..34ed712 --- /dev/null +++ b/server/Services/JavaInstaller.cs @@ -0,0 +1,156 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace BrassAndSigil.Server.Services; + +/// +/// Downloads + extracts Adoptium Temurin JRE 21 to server/java/. Used as a fallback +/// when system Java is missing or too old. Adoptium's API gives us a stable +/// platform-keyed download URL without needing API keys or auth. +/// +public sealed class JavaInstaller +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(15) }; + + public string GetJavaInstallDir(string serverDir) => Path.Combine(serverDir, "java"); + public string GetJavaInstallDir(string serverDir, int majorVersion) => + Path.Combine(serverDir, "java" + majorVersion); + + /// If a previous install put a java executable under serverDir/java/, return its path. + public string? FindBundledJava(string serverDir) + { + var javaDir = GetJavaInstallDir(serverDir); + if (!Directory.Exists(javaDir)) return null; + var exe = OperatingSystem.IsWindows() ? "java.exe" : "java"; + return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault(); + } + + /// Find a Java install for a specific major version (e.g. javaXX/jdk-XX*/bin/java). + public string? FindBundledJava(string serverDir, int majorVersion) + { + var javaDir = GetJavaInstallDir(serverDir, majorVersion); + if (!Directory.Exists(javaDir)) return null; + var exe = OperatingSystem.IsWindows() ? "java.exe" : "java"; + return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault(); + } + + public Task InstallJre21Async(string serverDir, IProgress? progress, CancellationToken ct) + => InstallJreAsync(21, serverDir, GetJavaInstallDir(serverDir), progress, ct); + + /// + /// Download + extract Adoptium Temurin JRE for a specific major version into + /// . Used by BlueMap to get JRE 25 alongside the + /// JRE 21 we use for Minecraft. + /// + public async Task InstallJreAsync(int majorVersion, string serverDir, string installDir, + IProgress? progress, CancellationToken ct) + { + var javaDir = installDir; + Directory.CreateDirectory(javaDir); + + var (url, archiveName, isZip) = PickAdoptiumDownload(majorVersion); + if (url is null) + { + progress?.Report($"[err] No supported Adoptium binary for {RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}."); + return null; + } + + var archivePath = Path.Combine(javaDir, archiveName!); + progress?.Report($"Downloading Adoptium Temurin JRE 21 ({(isZip ? "zip" : "tar.gz")})..."); + + try + { + using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)) + { + resp.EnsureSuccessStatusCode(); + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(archivePath); + await src.CopyToAsync(dst, ct); + } + } + catch (Exception ex) + { + progress?.Report($" [err] Download failed: {ex.Message}"); + return null; + } + + progress?.Report($" Downloaded {new FileInfo(archivePath).Length:N0} bytes"); + progress?.Report("Extracting..."); + + try + { + if (isZip) + { + ZipFile.ExtractToDirectory(archivePath, javaDir, overwriteFiles: true); + } + else + { + await using var fs = File.OpenRead(archivePath); + await using var gzip = new GZipStream(fs, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gzip, javaDir, overwriteFiles: true, ct); + } + } + catch (Exception ex) + { + progress?.Report($" [err] Extract failed: {ex.Message}"); + return null; + } + + try { File.Delete(archivePath); } catch { /* best-effort */ } + + var javaExe = FindBundledJava(serverDir); + if (javaExe is null) + { + progress?.Report(" [err] Extracted, but couldn't locate bin/java in the result."); + return null; + } + + // On Linux/macOS, make sure java is executable. TarFile preserves mode bits in + // most setups, but be defensive. + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(javaExe, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + catch { } + } + + progress?.Report($"Java {majorVersion} ready: {javaExe}"); + return javaExe; + } + + private static (string? Url, string? ArchiveName, bool IsZip) PickAdoptiumDownload(int majorVersion) + { + // Adoptium API picks the latest GA release matching our os/arch. + // Docs: https://api.adoptium.net/q/swagger-ui/ + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "aarch64", + _ => null + }; + if (arch is null) return (null, null, false); + + if (OperatingSystem.IsWindows()) + { + return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/windows/{arch}/jre/hotspot/normal/eclipse", + $"jre{majorVersion}.zip", true); + } + if (OperatingSystem.IsLinux()) + { + return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/linux/{arch}/jre/hotspot/normal/eclipse", + $"jre{majorVersion}.tar.gz", false); + } + if (OperatingSystem.IsMacOS()) + { + return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/mac/{arch}/jre/hotspot/normal/eclipse", + $"jre{majorVersion}.tar.gz", false); + } + return (null, null, false); + } +} diff --git a/server/Services/JsonOpts.cs b/server/Services/JsonOpts.cs new file mode 100644 index 0000000..8de793d --- /dev/null +++ b/server/Services/JsonOpts.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace BrassAndSigil.Server.Services; + +/// +/// Project-wide JSON serializer options. Always case-insensitive on read so that +/// hand-edited config files / API responses don't silently fail to bind a property +/// because of a casing mismatch (e.g. "Command" vs "command", "JavaPath" vs "javaPath"). +/// Use this everywhere we call JsonSerializer.Deserialize. +/// +public static class JsonOpts +{ + public static readonly JsonSerializerOptions CaseInsensitive = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + public static readonly JsonSerializerOptions Pretty = new() + { + WriteIndented = true, + }; +} diff --git a/server/Services/ManifestSync.cs b/server/Services/ManifestSync.cs new file mode 100644 index 0000000..d6a7d49 --- /dev/null +++ b/server/Services/ManifestSync.cs @@ -0,0 +1,199 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Server-side mod sync. Downloads only mods that the server needs: +/// queries Modrinth's project metadata for each mod's `server_side` field +/// and skips anything marked "unsupported" (Iris, Sodium, JEI, etc). +/// CurseForge mods can't be auto-classified without an API key, so they +/// are downloaded as-is and the server admin can manually delete unwanted ones. +/// +public sealed class ManifestSync +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) }; + private const string PackVersionFile = "pack-version.json"; + private const string ServerManifestCache = "server-pack.cache.json"; + + public sealed record SyncResult(int Downloaded, int Removed, int Skipped, string PackVersion); + + public async Task FetchManifestAsync(string url, CancellationToken ct = default) + { + var json = await _http.GetStringAsync(url, ct); + var manifest = JsonSerializer.Deserialize(json, JsonOpts.CaseInsensitive) + ?? throw new InvalidOperationException("Manifest is empty."); + manifest.Files ??= new(); + return manifest; + } + + public async Task SyncAsync( + string manifestUrl, string serverDir, IProgress? progress = null, CancellationToken ct = default) + { + progress?.Report("Fetching manifest..."); + var manifest = await FetchManifestAsync(manifestUrl, ct); + + progress?.Report($"Pack: {manifest.Name} v{manifest.Version}"); + Directory.CreateDirectory(serverDir); + + // Resolve which mods are server-side. + var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct); + + // Build the filtered list of files to keep on the server. + var keepFiles = manifest.Files + .Where(f => !ShouldSkipFile(f.Path, skipSlugs)) + .ToList(); + var skippedCount = manifest.Files.Count - keepFiles.Count; + + // Prune managed files that aren't in the keep set. + var wantedPaths = new HashSet( + keepFiles.Select(f => f.Path.Replace('\\', '/')), + StringComparer.OrdinalIgnoreCase); + var toRemove = ListManagedFiles(serverDir).Where(p => !wantedPaths.Contains(p)).ToList(); + foreach (var rel in toRemove) + { + var full = Path.Combine(serverDir, rel); + try { File.Delete(full); progress?.Report($" Removed: {rel}"); } + catch (Exception ex) { progress?.Report($" Could not remove {rel}: {ex.Message}"); } + } + + // Download missing or hash-mismatched files. + var toDownload = new List(); + foreach (var file in keepFiles) + { + ct.ThrowIfCancellationRequested(); + var dest = Path.Combine(serverDir, file.Path); + if (!File.Exists(dest)) { toDownload.Add(file); continue; } + if (!string.IsNullOrEmpty(file.Sha1)) + { + var actual = await ComputeSha1Async(dest, ct); + if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase)) + toDownload.Add(file); + } + } + + progress?.Report(toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} files..."); + for (int i = 0; i < toDownload.Count; i++) + { + var file = toDownload[i]; + progress?.Report($" [{i + 1}/{toDownload.Count}] {file.Path}"); + await DownloadFileAsync(file.Url, Path.Combine(serverDir, file.Path), file.Sha1, ct); + } + + // Write pack-version.json marker. + var record = new + { + name = manifest.Name, + version = manifest.Version, + syncedAt = DateTime.UtcNow.ToString("o"), + includedFiles = keepFiles.Count, + skippedFiles = skippedCount + }; + await File.WriteAllTextAsync( + Path.Combine(serverDir, PackVersionFile), + JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }), + ct); + + return new SyncResult(toDownload.Count, toRemove.Count, skippedCount, manifest.Version ?? "?"); + } + + private static bool ShouldSkipFile(string filePath, HashSet skipSlugs) + { + if (skipSlugs.Count == 0) return false; + var name = Path.GetFileNameWithoutExtension(filePath).ToLowerInvariant(); + // Match if any skip slug appears at the start of the filename (slug-version.jar) + return skipSlugs.Any(slug => name.StartsWith(slug + "-", StringComparison.OrdinalIgnoreCase) + || name.Equals(slug, StringComparison.OrdinalIgnoreCase)); + } + + /// Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set. + private async Task> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct) + { + var skip = new HashSet(StringComparer.OrdinalIgnoreCase); + var modrinthIds = new HashSet(); + + foreach (var file in manifest.Files) + { + if (!file.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) continue; + // Modrinth URL pattern: https://cdn.modrinth.com/data/{projectId}/versions/{versionId}/... + var url = file.Url; + const string prefix = "cdn.modrinth.com/data/"; + var idx = url.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (idx < 0) continue; + var rest = url.Substring(idx + prefix.Length); + var slash = rest.IndexOf('/'); + if (slash < 0) continue; + modrinthIds.Add(rest.Substring(0, slash)); + } + + foreach (var pid in modrinthIds) + { + ct.ThrowIfCancellationRequested(); + try + { + var info = await _http.GetFromJsonAsync( + $"https://api.modrinth.com/v2/project/{pid}", ct); + var slug = info.TryGetProperty("slug", out var s) ? s.GetString() : null; + var serverSide = info.TryGetProperty("server_side", out var ss) ? ss.GetString() : null; + if (!string.IsNullOrEmpty(slug) && string.Equals(serverSide, "unsupported", StringComparison.OrdinalIgnoreCase)) + { + skip.Add(slug); + } + } + catch + { + // Best-effort: if we can't classify, keep the mod (safer to ship extra than missing) + } + } + + return skip; + } + + private static List ListManagedFiles(string serverDir) + { + var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" }; + var result = new List(); + foreach (var root in roots) + { + var rootDir = Path.Combine(serverDir, root); + if (!Directory.Exists(rootDir)) continue; + foreach (var f in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories)) + result.Add(Path.GetRelativePath(serverDir, f).Replace('\\', '/')); + } + return result; + } + + private static async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + var tmp = destPath + ".part"; + using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)) + { + resp.EnsureSuccessStatusCode(); + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(tmp); + await src.CopyToAsync(dst, ct); + } + if (!string.IsNullOrEmpty(expectedSha1)) + { + var actual = await ComputeSha1Async(tmp, ct); + if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(tmp); + throw new InvalidOperationException($"Hash mismatch for {Path.GetFileName(destPath)}"); + } + } + if (File.Exists(destPath)) File.Delete(destPath); + File.Move(tmp, destPath); + } + + private static async Task ComputeSha1Async(string path, CancellationToken ct) + { + using var sha = SHA1.Create(); + await using var stream = File.OpenRead(path); + var bytes = await sha.ComputeHashAsync(stream, ct); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/server/Services/NeoForgeInstaller.cs b/server/Services/NeoForgeInstaller.cs new file mode 100644 index 0000000..ac6bdcd --- /dev/null +++ b/server/Services/NeoForgeInstaller.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; + +namespace BrassAndSigil.Server.Services; + +/// +/// Downloads NeoForge's official server installer JAR and runs it with --installServer +/// to produce run.sh/run.bat + the server library tree. Handles Java invocation and +/// streams installer output via a progress callback. +/// +public sealed class NeoForgeInstaller +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) }; + + public bool IsAlreadyInstalled(string serverDir) + { + return File.Exists(Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh")); + } + + public async Task InstallAsync(string version, string serverDir, string javaPath, + IProgress? progress, CancellationToken ct) + { + Directory.CreateDirectory(serverDir); + + // 1. Download installer + var installerName = $"neoforge-{version}-installer.jar"; + var installerPath = Path.Combine(serverDir, installerName); + var url = $"https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/{installerName}"; + + if (!File.Exists(installerPath)) + { + progress?.Report($"Downloading NeoForge {version} installer..."); + var bytes = await _http.GetByteArrayAsync(url, ct); + await File.WriteAllBytesAsync(installerPath, bytes, ct); + progress?.Report($" Saved {bytes.Length:N0} bytes to {installerName}"); + } + else + { + progress?.Report($"NeoForge installer already present, skipping download."); + } + + // 2. Run installer + progress?.Report("Running NeoForge installer (java -jar ... --installServer)..."); + var psi = new ProcessStartInfo + { + FileName = javaPath, + WorkingDirectory = serverDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-jar"); + psi.ArgumentList.Add(installerName); + psi.ArgumentList.Add("--installServer"); + + Process? proc; + try + { + proc = Process.Start(psi); + } + catch (Exception ex) + { + progress?.Report($" [error] Could not start java: {ex.Message}"); + return false; + } + if (proc is null) + { + progress?.Report(" [error] Failed to start java."); + return false; + } + + var stdoutTask = StreamLines(proc.StandardOutput, line => progress?.Report($" {line}"), ct); + var stderrTask = StreamLines(proc.StandardError, line => progress?.Report($" [err] {line}"), ct); + + await proc.WaitForExitAsync(ct); + await Task.WhenAll(stdoutTask, stderrTask); + + if (proc.ExitCode != 0) + { + progress?.Report($" [error] NeoForge installer exited with code {proc.ExitCode}"); + return false; + } + + // 3. Verify run script exists + if (!IsAlreadyInstalled(serverDir)) + { + progress?.Report(" [error] NeoForge installer ran but run.sh/run.bat is missing."); + return false; + } + + progress?.Report($"NeoForge {version} installed."); + + // 4. Clean up the installer JAR (large, no longer needed) + try { File.Delete(installerPath); } catch { /* best-effort */ } + + return true; + } + + private static async Task StreamLines(StreamReader reader, Action onLine, CancellationToken ct) + { + try + { + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) break; + onLine(line); + } + } + catch { } + } +} diff --git a/server/Services/RconClient.cs b/server/Services/RconClient.cs new file mode 100644 index 0000000..9518cb0 --- /dev/null +++ b/server/Services/RconClient.cs @@ -0,0 +1,93 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; + +namespace BrassAndSigil.Server.Services; + +/// +/// Minimal Minecraft RCON client (Source RCON protocol). +/// Used for sending console commands and reading "list" output for player counts. +/// +public sealed class RconClient : IDisposable +{ + private const int SERVERDATA_AUTH = 3; + private const int SERVERDATA_EXECCOMMAND = 2; + private const int SERVERDATA_RESPONSE_VALUE = 0; + + private TcpClient? _tcp; + private NetworkStream? _stream; + private int _nextRequestId = 1; + + public bool Connected => _tcp?.Connected ?? false; + + public async Task ConnectAsync(string host, int port, string password, CancellationToken ct = default) + { + _tcp = new TcpClient(); + await _tcp.ConnectAsync(host, port, ct); + _stream = _tcp.GetStream(); + + var authId = NextId(); + await SendPacketAsync(authId, SERVERDATA_AUTH, password, ct); + + // Read auth response. Server sends an empty response value first, then the auth result. + var (id1, _, _) = await ReadPacketAsync(ct); + if (id1 == -1) throw new InvalidOperationException("RCON auth failed (bad password)"); + // Auth ok if id matches what we sent + } + + public async Task SendCommandAsync(string command, CancellationToken ct = default) + { + if (_stream == null) throw new InvalidOperationException("Not connected"); + var id = NextId(); + await SendPacketAsync(id, SERVERDATA_EXECCOMMAND, command, ct); + var (_, _, body) = await ReadPacketAsync(ct); + return body; + } + + private int NextId() => Interlocked.Increment(ref _nextRequestId); + + private async Task SendPacketAsync(int id, int type, string body, CancellationToken ct) + { + var bodyBytes = Encoding.UTF8.GetBytes(body); + var packetSize = 4 + 4 + bodyBytes.Length + 2; // id + type + body + 2 null bytes + var buffer = new byte[4 + packetSize]; + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), packetSize); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4, 4), id); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(8, 4), type); + bodyBytes.CopyTo(buffer.AsSpan(12)); + // last two bytes already 0 by default + await _stream!.WriteAsync(buffer, ct); + } + + private async Task<(int Id, int Type, string Body)> ReadPacketAsync(CancellationToken ct) + { + var sizeBuf = new byte[4]; + await ReadExactAsync(sizeBuf, ct); + var size = BinaryPrimitives.ReadInt32LittleEndian(sizeBuf); + if (size < 10 || size > 1024 * 1024) throw new InvalidOperationException($"Bad RCON packet size {size}"); + + var pkt = new byte[size]; + await ReadExactAsync(pkt, ct); + var id = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(0, 4)); + var type = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(4, 4)); + var body = Encoding.UTF8.GetString(pkt, 8, size - 10); // strip 2 trailing nulls + return (id, type, body); + } + + private async Task ReadExactAsync(byte[] buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + var n = await _stream!.ReadAsync(buffer.AsMemory(read), ct); + if (n == 0) throw new EndOfStreamException(); + read += n; + } + } + + public void Dispose() + { + _stream?.Dispose(); + _tcp?.Dispose(); + } +} diff --git a/server/Services/RconManager.cs b/server/Services/RconManager.cs new file mode 100644 index 0000000..2cc9a1d --- /dev/null +++ b/server/Services/RconManager.cs @@ -0,0 +1,68 @@ +namespace BrassAndSigil.Server.Services; + +/// +/// Thin reconnecting wrapper around . The original +/// single-connection-with-no-retry pattern caches a dead client whenever the +/// initial connect happens before MC has opened the RCON port (which is normal -- +/// boot takes ~30 s). This manager lazily connects on first use, retries on +/// failure, and drops the client when a send throws so the next call reconnects. +/// +public sealed class RconManager : IDisposable +{ + private readonly string _host; + private readonly int _port; + private readonly string _password; + private readonly SemaphoreSlim _lock = new(1, 1); + private RconClient? _client; + + public RconManager(string host, int port, string password) + { + _host = host; + _port = port; + _password = password; + } + + public async Task SendCommandAsync(string command, CancellationToken ct = default) + { + var client = await EnsureConnectedAsync(ct); + try + { + return await client.SendCommandAsync(command, ct); + } + catch + { + await DropAsync(); + throw; + } + } + + private async Task EnsureConnectedAsync(CancellationToken ct) + { + await _lock.WaitAsync(ct); + try + { + if (_client is { Connected: true }) return _client; + _client?.Dispose(); + _client = new RconClient(); + await _client.ConnectAsync(_host, _port, _password, ct); + return _client; + } + finally + { + _lock.Release(); + } + } + + private async Task DropAsync() + { + await _lock.WaitAsync(); + try { _client?.Dispose(); _client = null; } + finally { _lock.Release(); } + } + + public void Dispose() + { + _client?.Dispose(); + _lock.Dispose(); + } +} diff --git a/server/Services/ServerProcess.cs b/server/Services/ServerProcess.cs new file mode 100644 index 0000000..ba8a11a --- /dev/null +++ b/server/Services/ServerProcess.cs @@ -0,0 +1,405 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Manages the Minecraft Java subprocess: spawns it with the right JVM args, +/// captures stdout/stderr into a ring buffer (so the web UI can show recent +/// logs), broadcasts new lines via an event, handles graceful shutdown. +/// +public sealed class ServerProcess : IDisposable +{ + private readonly ServerConfig _config; + private Process? _process; + private readonly ConcurrentQueue _logRing = new(); + private const int LogRingSize = 2000; + + // Process-tree containment: on Windows, the Job Object kills our subprocess + // automatically when our own process dies -- regardless of how we died. + // Created lazily on first Start() on Windows; destroyed in Dispose. + private static WindowsJobObject? _jobObject; + + public event Action? OnLogLine; + public event Action? Exited; + + public bool IsRunning => _process is { HasExited: false }; + public DateTime? StartedAt { get; private set; } + public int? Pid => _process?.Id; + + // Memory + CPU sampling. CpuMetrics is a moving average across calls -- the + // first call after start returns null because we need two samples for a delta. + // We track the Java *descendant* of our shell, not the shell itself, because + // run.sh / run.bat is ~2 MB and useless for stats. + private TimeSpan _lastCpuTime; + private DateTime _lastCpuSampleAt; + private int _lastTrackedPid; + private Process? _trackedJava; + private readonly object _statsLock = new(); + private readonly Queue _cpuSamples = new(); + private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling + + /// + /// Returns the Java JVM descendant if found, otherwise falls back to the + /// directly-spawned shell process. Cached and re-resolved when the cached + /// pid exits (e.g., MC restart). + /// + private Process? TrackedProcess + { + get + { + if (_process is null || _process.HasExited) return null; + if (_trackedJava is { HasExited: false }) return _trackedJava; + // Try to find a 'java' descendant of our shell. If not found yet (still + // booting), fall back to the shell -- first stats will read tiny, then + // reset on next call once Java is up. + var found = FindJavaDescendant(_process.Id); + _trackedJava = found; + return found ?? _process; + } + } + + public long? MemoryBytes + { + get + { + var p = TrackedProcess; + if (p is null || p.HasExited) return null; + try { p.Refresh(); return p.WorkingSet64; } + catch { return null; } + } + } + + /// + /// System-wide CPU percentage (0-100) plus rolling-window peak and average. + /// Each call samples once and contributes to a 20-entry history (~60 s at 3 s + /// polling). First call after start returns null (need two samples for a delta). + /// + public (double Current, double Max, double Avg)? CpuMetrics + { + get + { + var p = TrackedProcess; + if (p is null || p.HasExited) return null; + lock (_statsLock) + { + try + { + p.Refresh(); + var now = DateTime.UtcNow; + var cpuNow = p.TotalProcessorTime; + if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default) + { + _lastTrackedPid = p.Id; + _lastCpuTime = cpuNow; + _lastCpuSampleAt = now; + _cpuSamples.Clear(); + return null; + } + var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds; + var elapsedCpu = (cpuNow - _lastCpuTime).TotalMilliseconds; + _lastCpuTime = cpuNow; + _lastCpuSampleAt = now; + if (elapsedReal <= 0) return (0, 0, 0); + + // System-wide: divide by core count so the value is bounded 0-100 + // and intuitive ("42% of total CPU capacity"). The user found the + // top-style 0-N*100 range confusing for a fleet view. + var perCore = elapsedCpu / elapsedReal * 100.0; + var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount); + + _cpuSamples.Enqueue(systemWide); + while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue(); + + return (systemWide, _cpuSamples.Max(), _cpuSamples.Average()); + } + catch { return null; } + } + } + } + + /// + /// BFS the process tree starting from looking for + /// a process named like "java". Linux: read /proc/PID/task/PID/children. + /// Windows: enumerate parents via Process objects (no extra deps). + /// + private static Process? FindJavaDescendant(int rootPid) + { + try + { + if (OperatingSystem.IsLinux()) + { + return FindJavaDescendantLinux(rootPid); + } + if (OperatingSystem.IsWindows()) + { + return FindJavaDescendantWindows(rootPid); + } + } + catch { } + return null; + } + + private static Process? FindJavaDescendantLinux(int rootPid) + { + var visited = new HashSet(); + var queue = new Queue(); + queue.Enqueue(rootPid); + while (queue.Count > 0) + { + var pid = queue.Dequeue(); + if (!visited.Add(pid)) continue; + var childrenPath = $"/proc/{pid}/task/{pid}/children"; + if (!File.Exists(childrenPath)) continue; + string raw; + try { raw = File.ReadAllText(childrenPath); } catch { continue; } + foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (!int.TryParse(token, out var cpid)) continue; + Process? p = null; + try { p = Process.GetProcessById(cpid); } catch { continue; } + if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase)) + return p; + queue.Enqueue(cpid); + } + } + return null; + } + + private static Process? FindJavaDescendantWindows(int rootPid) + { + // Build a parent->children map by reading every running process's parent + // PID once, then BFS. Slower than Linux's /proc but works without WMI. + var allProcs = Process.GetProcesses(); + var byParent = new Dictionary>(); + foreach (var p in allProcs) + { + int parent; + try { parent = GetParentPidWindows(p); } catch { continue; } + if (parent == 0) continue; + if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List(); + list.Add(p); + } + var visited = new HashSet(); + var queue = new Queue(); + queue.Enqueue(rootPid); + while (queue.Count > 0) + { + var pid = queue.Dequeue(); + if (!visited.Add(pid)) continue; + if (!byParent.TryGetValue(pid, out var children)) continue; + foreach (var c in children) + { + if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase)) + return c; + queue.Enqueue(c.Id); + } + } + return null; + } + + [System.Runtime.InteropServices.DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, int processInformationClass, + ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength); + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + public IntPtr Reserved2_0; + public IntPtr Reserved2_1; + public IntPtr UniqueProcessId; + public IntPtr InheritedFromUniqueProcessId; // <-- parent PID + } + + private static int GetParentPidWindows(Process p) + { + var info = new ProcessBasicInformation(); + int rc = NtQueryInformationProcess(p.Handle, 0, ref info, + System.Runtime.InteropServices.Marshal.SizeOf(), out _); + return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32(); + } + + public ServerProcess(ServerConfig config) => _config = config; + + public bool Start() + { + if (IsRunning) return false; + + // Reset cached Java descendant + CPU sampling baseline so the next call + // re-resolves once the new run.sh -> java tree is up. + _trackedJava = null; + _lastTrackedPid = 0; + _lastCpuSampleAt = default; + + // The mod loader's start script lives next to the server jar. NeoForge produces + // run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on + // the server side, so we build the equivalent JVM command ourselves. + var startScript = ResolveStartCommand(out var argList); + var startInfo = new ProcessStartInfo + { + FileName = startScript, + WorkingDirectory = Path.GetFullPath(_config.ServerDir), + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var a in argList) startInfo.ArgumentList.Add(a); + + // CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to + // whatever's on PATH. If we configured a specific Java (or auto-downloaded one), + // prepend its bin dir + set JAVA_HOME so the script picks up the right JVM + // instead of an older system Java. + ApplyJavaEnvironment(startInfo); + + _process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + _process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false); + _process.ErrorDataReceived += (_, e) => OnLine(e.Data, isError: true); + _process.Exited += (_, _) => + { + var code = _process?.ExitCode ?? -1; + OnLine($"=== Server exited (code {code}) ===", isError: false); + Exited?.Invoke(code); + }; + + _process.Start(); + + // Bind the Java subprocess to a Job Object on Windows so it gets killed + // automatically if we exit ungracefully (X-button, Task Manager, etc.). + if (OperatingSystem.IsWindows()) + { + try + { + _jobObject ??= new WindowsJobObject(); + _jobObject.AssignProcess(_process); + } + catch (Exception ex) + { + // Non-fatal -- we still try to clean up via Process.Kill in Dispose. + OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true); + } + } + + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + StartedAt = DateTime.UtcNow; + return true; + } + + private string ResolveStartCommand(out List args) + { + args = new List(); + var dir = Path.GetFullPath(_config.ServerDir); + + // Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args. + var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh"; + var runScript = Path.Combine(dir, runShell); + if (File.Exists(runScript)) + { + // Make sure the user_jvm_args.txt has the memory we want + EnsureUserJvmArgs(dir); + // Suppress the Swing server-GUI window -- we use the web panel instead. + // NeoForge's run.bat / run.sh forwards extra args to Minecraft. + args.Add("nogui"); + return runScript; + } + + // Fallback: invoke java directly on the server jar. This won't work for NeoForge + // (it requires the loader's run script), but it works for vanilla and Forge legacy. + args.Add($"-Xms{_config.MemoryMB}M"); + args.Add($"-Xmx{_config.MemoryMB}M"); + args.Add("-jar"); + args.Add("server.jar"); + args.Add("nogui"); + return _config.JavaPath; + } + + private void ApplyJavaEnvironment(ProcessStartInfo psi) + { + // Only meaningful when we have an absolute path to a specific java binary + // (i.e., not just "java" on PATH). + if (string.IsNullOrEmpty(_config.JavaPath)) return; + if (!Path.IsPathRooted(_config.JavaPath)) return; + if (!File.Exists(_config.JavaPath)) return; + + var javaBinDir = Path.GetDirectoryName(_config.JavaPath); + if (string.IsNullOrEmpty(javaBinDir)) return; + var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/ + + var sep = OperatingSystem.IsWindows() ? ";" : ":"; + var existingPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}"; + if (!string.IsNullOrEmpty(javaHome)) + { + psi.Environment["JAVA_HOME"] = javaHome; + } + } + + private void EnsureUserJvmArgs(string dir) + { + // NeoForge's run.bat / run.sh reads this file for JVM-level args. + // Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons + // and significantly better than G1 for heavily-modded MC. Requires Java 21+. + var path = Path.Combine(dir, "user_jvm_args.txt"); + var content = + $"-Xms{_config.MemoryMB}M\n" + + $"-Xmx{_config.MemoryMB}M\n" + + "-XX:+UseZGC\n" + + "-XX:+ZGenerational\n"; + try { File.WriteAllText(path, content); } catch { /* best-effort */ } + } + + private void OnLine(string? data, bool isError) + { + if (string.IsNullOrEmpty(data)) return; + var line = new LogLine(DateTimeOffset.UtcNow, isError, data); + _logRing.Enqueue(line); + while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _); + OnLogLine?.Invoke(line); + } + + public IReadOnlyList RecentLogs() => _logRing.ToArray(); + + public async Task SendInputAsync(string command, CancellationToken ct = default) + { + if (_process is null || _process.HasExited) return; + await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct); + await _process.StandardInput.FlushAsync(ct); + } + + public async Task StopAsync(TimeSpan? graceful = null, CancellationToken ct = default) + { + if (!IsRunning) return false; + graceful ??= TimeSpan.FromSeconds(30); + try + { + await SendInputAsync("stop", ct); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(graceful.Value); + try { await _process!.WaitForExitAsync(cts.Token); return true; } + catch (OperationCanceledException) + { + _process?.Kill(entireProcessTree: true); + return false; + } + } + catch + { + try { _process?.Kill(entireProcessTree: true); } catch { } + return false; + } + } + + public void Dispose() + { + try { _process?.Kill(entireProcessTree: true); } catch { } + _process?.Dispose(); + } + + public sealed record LogLine(DateTimeOffset At, bool IsError, string Text); +} diff --git a/server/Services/ServerPropertiesService.cs b/server/Services/ServerPropertiesService.cs new file mode 100644 index 0000000..3ebf791 --- /dev/null +++ b/server/Services/ServerPropertiesService.cs @@ -0,0 +1,149 @@ +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Reads and writes Minecraft's server.properties. Editable keys are +/// gated by an allowlist so a compromised panel can't flip security-critical +/// fields like online-mode arbitrarily -- only common gameplay knobs. +/// Preserves comments and key order on write; appends new keys at the end. +/// +public sealed class ServerPropertiesService +{ + private readonly ServerConfig _config; + + public ServerPropertiesService(ServerConfig config) => _config = config; + + /// + /// Keys that may be modified via /api/server/settings. Anything outside this + /// set is silently dropped from the update payload -- admin must SSH for those. + /// + public static readonly IReadOnlySet EditableKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "motd", + "difficulty", + "gamemode", + "view-distance", + "simulation-distance", + "max-players", + "pvp", + "hardcore", + "white-list", + "enforce-whitelist", + "allow-flight", + "enable-command-block", + "spawn-protection", + }; + + public string PropertiesPath => Path.Combine(Path.GetFullPath(_config.ServerDir), "server.properties"); + + public Dictionary ReadAll() + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!File.Exists(PropertiesPath)) return dict; + foreach (var raw in File.ReadAllLines(PropertiesPath)) + { + var line = raw.TrimStart(); + if (line.Length == 0 || line[0] == '#' || line[0] == '!') continue; + var idx = line.IndexOf('='); + if (idx < 0) continue; + dict[line.Substring(0, idx).Trim()] = line.Substring(idx + 1); + } + return dict; + } + + /// Returns just the editable subset, with values left as raw strings. + public Dictionary ReadEditable() + { + var all = ReadAll(); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var key in EditableKeys) + { + if (all.TryGetValue(key, out var v)) result[key] = v; + } + return result; + } + + /// + /// Read the current level-seed value, or null if absent / empty. + /// + public string? GetLevelSeed() + { + if (!File.Exists(PropertiesPath)) return null; + foreach (var raw in File.ReadAllLines(PropertiesPath)) + { + var line = raw.TrimStart(); + if (!line.StartsWith("level-seed=", StringComparison.Ordinal)) continue; + var v = line.Substring("level-seed=".Length).Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } + return null; + } + + /// + /// Direct write of level-seed. Bypasses + /// because the seed is set as part of the wipe flow (with confirmation), + /// not by general settings UI -- exposing it through the regular Update() + /// path would let it be flipped from any settings save. Empty string + /// clears the field, which makes Minecraft pick a random seed on next + /// world generation. + /// + public void SetLevelSeed(string seed) + { + var lines = File.Exists(PropertiesPath) + ? File.ReadAllLines(PropertiesPath).ToList() + : new List(); + var done = false; + for (int i = 0; i < lines.Count; i++) + { + var trimmed = lines[i].TrimStart(); + if (trimmed.StartsWith("level-seed=", StringComparison.Ordinal)) + { + lines[i] = $"level-seed={seed}"; + done = true; + break; + } + } + if (!done) lines.Add($"level-seed={seed}"); + File.WriteAllLines(PropertiesPath, lines); + } + + /// + /// Apply updates to the file. Keys not in are + /// silently dropped. Lines that already exist are updated in-place to + /// preserve order and comments; new keys are appended at the end. + /// + public void Update(IDictionary updates) + { + // Filter to allowed keys only. + var filtered = updates + .Where(kv => EditableKeys.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); + if (filtered.Count == 0) return; + + var lines = File.Exists(PropertiesPath) + ? File.ReadAllLines(PropertiesPath).ToList() + : new List(); + + var applied = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < lines.Count; i++) + { + var raw = lines[i]; + var trimmed = raw.TrimStart(); + if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == '!') continue; + var idx = trimmed.IndexOf('='); + if (idx < 0) continue; + var key = trimmed.Substring(0, idx).Trim(); + if (filtered.TryGetValue(key, out var newValue)) + { + lines[i] = $"{key}={newValue}"; + applied.Add(key); + } + } + foreach (var (key, value) in filtered) + { + if (!applied.Contains(key)) lines.Add($"{key}={value}"); + } + File.WriteAllLines(PropertiesPath, lines); + } +} diff --git a/server/Services/UpdaterService.cs b/server/Services/UpdaterService.cs new file mode 100644 index 0000000..7982626 --- /dev/null +++ b/server/Services/UpdaterService.cs @@ -0,0 +1,260 @@ +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")}"; + } +} diff --git a/server/Services/WhitelistRequestService.cs b/server/Services/WhitelistRequestService.cs new file mode 100644 index 0000000..4cf1172 --- /dev/null +++ b/server/Services/WhitelistRequestService.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Tracks "I want to play" requests from friends, pending admin approval. +/// State is a flat JSON file in the server dir so it survives daemon restarts +/// without needing a database. Single-flight gate prevents concurrent-write +/// corruption when admin and friend act at the same time. +/// +/// State machine: (none) -> pending -> approved | denied +/// "approved" means the admin clicked Approve; the actual /whitelist add +/// command goes through the existing whitelist endpoint, which removes the +/// request from the pending list. +/// +public sealed class WhitelistRequestService +{ + private readonly ServerConfig _config; + private readonly object _lock = new(); + + public WhitelistRequestService(ServerConfig config) => _config = config; + + public sealed class Request + { + public string Username { get; set; } = ""; + public string? Message { get; set; } + public string Status { get; set; } = "pending"; // pending | approved | denied + public DateTimeOffset RequestedAt { get; set; } + public DateTimeOffset? ResolvedAt { get; set; } + public string? RemoteIp { get; set; } // for admin diagnosis if needed + } + + private string FilePath => + Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist-requests.json"); + + private List Load() + { + try + { + if (!File.Exists(FilePath)) return new(); + var text = File.ReadAllText(FilePath); + if (string.IsNullOrWhiteSpace(text)) return new(); + return JsonSerializer.Deserialize>(text, JsonOpts.CaseInsensitive) ?? new(); + } + catch { return new(); } + } + + private void Save(List list) + { + var text = JsonSerializer.Serialize(list, JsonOpts.Pretty); + File.WriteAllText(FilePath, text); + } + + public IReadOnlyList List() { lock (_lock) return Load(); } + + public IReadOnlyList ListPending() + { + lock (_lock) + return Load().Where(r => r.Status == "pending").ToList(); + } + + /// Submit a new request. Idempotent on (username, status=pending) -- won't dupe. + public Request Submit(string username, string? message, string? remoteIp) + { + lock (_lock) + { + var list = Load(); + // Drop any prior request for this username (case-insensitive) so the + // most recent one wins regardless of previous state. Keeps the file + // from growing if a friend re-requests after a denial. + list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + var req = new Request + { + Username = username, + Message = string.IsNullOrWhiteSpace(message) ? null : message, + Status = "pending", + RequestedAt = DateTimeOffset.UtcNow, + RemoteIp = remoteIp, + }; + list.Add(req); + Save(list); + return req; + } + } + + /// + /// Effective status for the launcher. If the username is in the actual + /// whitelist.json (regardless of whether they ever filed a request), returns + /// "approved" -- that's what the friend's launcher cares about. Otherwise + /// falls back to whatever request record we have, or "unknown". + /// + public string StatusFor(string username) + { + if (IsActuallyWhitelisted(username)) return "approved"; + lock (_lock) + { + var match = Load().FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + return match?.Status ?? "unknown"; + } + } + + private bool IsActuallyWhitelisted(string username) + { + var path = Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist.json"); + if (!File.Exists(path)) return false; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.EnumerateArray().Any(e => + e.TryGetProperty("name", out var n) && + string.Equals(n.GetString(), username, StringComparison.OrdinalIgnoreCase)); + } + catch { return false; } + } + + public bool MarkApproved(string username) => SetStatus(username, "approved"); + public bool MarkDenied(string username) => SetStatus(username, "denied"); + + /// Remove the request entirely (used after the actual /whitelist add fires). + public bool Remove(string username) + { + lock (_lock) + { + var list = Load(); + var removed = list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + if (removed > 0) Save(list); + return removed > 0; + } + } + + private bool SetStatus(string username, string status) + { + lock (_lock) + { + var list = Load(); + var match = list.FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + if (match is null) return false; + match.Status = status; + match.ResolvedAt = DateTimeOffset.UtcNow; + Save(list); + return true; + } + } +} diff --git a/server/Services/WindowsJobObject.cs b/server/Services/WindowsJobObject.cs new file mode 100644 index 0000000..2e03bb2 --- /dev/null +++ b/server/Services/WindowsJobObject.cs @@ -0,0 +1,124 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace BrassAndSigil.Server.Services; + +/// +/// Wraps a Windows Job Object configured with KILL_ON_JOB_CLOSE so that any process +/// assigned to it dies the moment our process does -- regardless of *how* we died +/// (X-button on the console, Task Manager End Task, parent BSOD, etc.). Without +/// this, a Java subprocess can outlive us and keep the server files locked. +/// +/// On Linux, use systemd's cgroup management instead; the equivalent guarantee +/// comes for free when the tool runs as a systemd unit. +/// +public sealed class WindowsJobObject : IDisposable +{ + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr hObject); + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private IntPtr _handle; + private bool _disposed; + + public WindowsJobObject() + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("WindowsJobObject only works on Windows."); + + _handle = CreateJobObject(IntPtr.Zero, null); + if (_handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed"); + + var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION + { + LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + } + }; + var size = Marshal.SizeOf(info); + var ptr = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(info, ptr, fDeleteOld: false); + if (!SetInformationJobObject(_handle, JobObjectExtendedLimitInformation, ptr, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed"); + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + public void AssignProcess(Process process) + { + if (_disposed) throw new ObjectDisposedException(nameof(WindowsJobObject)); + if (!AssignProcessToJobObject(_handle, process.Handle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed"); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_handle != IntPtr.Zero) + { + CloseHandle(_handle); // Closing the last handle triggers KILL_ON_JOB_CLOSE + _handle = IntPtr.Zero; + } + GC.SuppressFinalize(this); + } + + ~WindowsJobObject() => Dispose(); +} diff --git a/server/Services/WorldService.cs b/server/Services/WorldService.cs new file mode 100644 index 0000000..81f78d1 --- /dev/null +++ b/server/Services/WorldService.cs @@ -0,0 +1,229 @@ +using System.Text.RegularExpressions; +using BrassAndSigil.Server.Models; + +namespace BrassAndSigil.Server.Services; + +/// +/// Destructive world operations -- wipe and replace. Always single-flight, always +/// stops the server first, and offers a rename-as-backup default so an accidental +/// click doesn't lose data permanently. +/// +public sealed class WorldService +{ + private readonly ServerConfig _config; + private readonly ServerProcess _proc; + private readonly BackupService _backup; + private readonly Broadcaster _broadcast; + private readonly RconManager _rcon; + private readonly BlueMapService? _bluemap; + private readonly Action _log; + private readonly SemaphoreSlim _lock = new(1, 1); + + public WorldService(ServerConfig config, ServerProcess proc, BackupService backup, + Broadcaster broadcast, RconManager rcon, Action log, + BlueMapService? bluemap = null) + { + _config = config; + _proc = proc; + _backup = backup; + _broadcast = broadcast; + _rcon = rcon; + _bluemap = bluemap; + _log = log; + } + + public sealed record WipeResult(bool Ok, string? BackupName, string? SeedUsed, string? Error); + + /// + /// What seed strategy a wipe should use: + /// + /// Keep -- capture the live seed via RCON before wipe and reuse it. + /// Random -- clear level-seed so MC picks a fresh random one. + /// Custom -- set level-seed to . + /// + /// + public enum SeedMode { Keep, Random, Custom } + + public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed); + + /// + /// Best-effort lookup of the current world seed. Prefers RCON's seed + /// command (always returns the actual generated seed even when + /// server.properties has level-seed empty); falls back to the configured + /// level-seed value if RCON is unavailable (server stopped, no MC, etc.). + /// Returns null when neither is available. + /// + public async Task GetCurrentSeedAsync(CancellationToken ct = default) + { + if (_proc.IsRunning) + { + try + { + var resp = await _rcon.SendCommandAsync("seed", ct); + // Format: "Seed: []" + var m = Regex.Match(resp, @"Seed:\s*\[(-?\d+)\]"); + if (m.Success) return m.Groups[1].Value; + } + catch { /* fall through to properties */ } + } + return new ServerPropertiesService(_config).GetLevelSeed(); + } + + // Caching world size: scanning a large world dir is O(file count). 30 s cache + // dampens that to roughly once per status poll cycle on busy panels. + private long _cachedSize; + private DateTime _cachedAt = DateTime.MinValue; + + public long GetWorldSizeBytes() + { + if ((DateTime.UtcNow - _cachedAt) < TimeSpan.FromSeconds(30)) return _cachedSize; + var levelName = ReadLevelName(_config.ServerDir) ?? "world"; + var worldDir = Path.Combine(Path.GetFullPath(_config.ServerDir), levelName); + if (!Directory.Exists(worldDir)) { _cachedSize = 0; _cachedAt = DateTime.UtcNow; return 0; } + long total = 0; + try + { + foreach (var f in Directory.EnumerateFiles(worldDir, "*", SearchOption.AllDirectories)) + { + try { total += new FileInfo(f).Length; } catch { } + } + } + catch { } + _cachedSize = total; + _cachedAt = DateTime.UtcNow; + return total; + } + + /// + /// Stop the server, optionally archive the world via BackupService, delete the + /// world folder(s), apply the chosen seed strategy, then restart. Backups go + /// to the configured backup dir (typically a slower-but-bigger drive) rather + /// than next to the world dir. + /// + public async Task WipeWorldAsync(WipeOptions options, int warningSeconds = 30, CancellationToken ct = default) + { + if (!await _lock.WaitAsync(0, ct)) + return new WipeResult(false, null, null, "Another wipe is already in progress."); + + try + { + var serverDir = Path.GetFullPath(_config.ServerDir); + var levelName = ReadLevelName(serverDir) ?? "world"; + var primaryWorld = Path.Combine(serverDir, levelName); + var props = new ServerPropertiesService(_config); + + // ── Decide what seed the new world will use, BEFORE we stop the server ── + // Keep mode needs RCON, which only works while MC is alive. + string seedToWrite; // value for level-seed= line; "" means random + string? capturedSeed = null; + switch (options.Mode) + { + case SeedMode.Keep: + capturedSeed = await GetCurrentSeedAsync(ct); + if (string.IsNullOrEmpty(capturedSeed)) + { + return new WipeResult(false, null, null, + "Couldn't read current seed (RCON unreachable + level-seed empty). Stop the server, set level-seed manually, or pick Random/Custom."); + } + seedToWrite = capturedSeed; + _log($"[wipe] Keep-seed mode: captured current seed {capturedSeed} for reuse."); + break; + case SeedMode.Custom: + var custom = options.CustomSeed?.Trim() ?? ""; + if (string.IsNullOrEmpty(custom)) + return new WipeResult(false, null, null, "Custom seed mode selected but no seed provided."); + seedToWrite = custom; + _log($"[wipe] Custom-seed mode: new world will use seed {custom}."); + break; + default: // Random + seedToWrite = ""; + _log("[wipe] Random-seed mode: clearing level-seed so MC generates a fresh seed."); + break; + } + + // Loud, urgent player warning before any irreversible action -- wipe is + // destructive even with a backup (admins still need to restore manually). + if (warningSeconds > 0 && _proc.IsRunning) + { + _log($"[wipe] Announcing {warningSeconds}s wipe warning to players..."); + try + { + await _broadcast.SayAsync( + $"WORLD WIPE in {warningSeconds} seconds. Disconnect now if you want to keep your current world!", ct); + await _broadcast.ActionBarCountdownAsync("WORLD WIPING", warningSeconds, ct); + await _broadcast.SayAsync("Wiping world now."); + } + catch (OperationCanceledException) { throw; } + catch { /* don't abort wipe over a broadcast error */ } + } + + string? backupName = null; + if (options.Backup && Directory.Exists(primaryWorld)) + { + _log("[wipe] Creating backup before wipe..."); + // flush:true here -- we're about to delete the world. Capture every block + // and every move up to right now, even at the cost of a tick spike. + var br = await _backup.CreateAsync("pre-wipe", flush: true, ct: ct); + if (!br.Ok) + return new WipeResult(false, null, null, $"Backup failed: {br.Error}. Wipe aborted to preserve data."); + backupName = br.Name; + } + + if (_proc.IsRunning) + { + _log("[wipe] Stopping server..."); + await _proc.StopAsync(TimeSpan.FromSeconds(30), ct); + } + + if (Directory.Exists(primaryWorld)) + { + _log($"[wipe] Deleting {primaryWorld}"); + Directory.Delete(primaryWorld, recursive: true); + } + // Legacy sibling dirs (rare on modern NeoForge but cheap to handle) + foreach (var altSuffix in new[] { "_nether", "_the_end" }) + { + var altDir = Path.Combine(serverDir, levelName + altSuffix); + if (!Directory.Exists(altDir)) continue; + _log($"[wipe] Deleting {altDir}"); + Directory.Delete(altDir, recursive: true); + } + + // Apply the seed AFTER deletion but BEFORE restart -- MC reads + // server.properties at startup to determine the new world's seed. + props.SetLevelSeed(seedToWrite); + + // Map output for the now-deleted world is stale -- clear it so the next + // render starts fresh against the new terrain. + _bluemap?.ClearRenderOutput(); + + _log("[wipe] World wiped. Restarting server -- Minecraft will generate a fresh world."); + _proc.Start(); + // For Random mode we don't know the exact new seed yet (MC picks at startup); + // return the level-seed value we wrote, which is "" for random. + return new WipeResult(true, backupName, string.IsNullOrEmpty(seedToWrite) ? null : seedToWrite, null); + } + catch (Exception ex) + { + _log($"[wipe] Failed: {ex.Message}"); + try { if (!_proc.IsRunning) _proc.Start(); } catch { } + return new WipeResult(false, null, null, ex.Message); + } + finally + { + _lock.Release(); + } + } + + private static string? ReadLevelName(string serverDir) + { + var path = Path.Combine(serverDir, "server.properties"); + if (!File.Exists(path)) return null; + foreach (var line in File.ReadAllLines(path)) + { + if (line.StartsWith("level-name=", StringComparison.Ordinal)) + return line.Substring("level-name=".Length).Trim(); + } + return null; + } +} diff --git a/server/deploy/Caddyfile b/server/deploy/Caddyfile new file mode 100644 index 0000000..aefbead --- /dev/null +++ b/server/deploy/Caddyfile @@ -0,0 +1,50 @@ +# Caddyfile for the brass-sigil-server web panel. +# +# Caddy auto-fetches and renews a Let's Encrypt cert for your domain, +# so HTTPS just works once DNS is pointed at the server and ports 80 + 443 +# are open. +# +# Prereqs: +# 1. A domain name (e.g. panel.example.com) with an A/AAAA record pointing +# at this server's public IP. Let's Encrypt does NOT issue certs for +# raw IPs -- you need a hostname. +# 2. Inbound 80 (for the HTTP-01 ACME challenge) and 443 (for the panel) +# open in your firewall and in any cloud security group. +# 3. Caddy installed: +# sudo apt install caddy # Debian / Ubuntu +# brew install caddy # macOS +# winget install CaddyServer.Caddy # Windows +# 4. brass-sigil-server running on localhost:8080 with webHost: localhost +# and webPassword set (use `brass-sigil-server set-password` if you +# haven't already). +# +# Install: +# Linux package: replace /etc/caddy/Caddyfile with this file, then +# sudo systemctl reload caddy +# Manual: caddy run --config Caddyfile + +panel.example.com { + encode gzip + + reverse_proxy localhost:8080 { + # SSE log stream uses chunked streaming responses -- Caddy must not + # buffer them, otherwise console updates arrive in batches every minute + # instead of in real time. + flush_interval -1 + + # Pass the real client IP through. brass-sigil-server's ForwardedHeaders + # middleware honours this so the per-IP login rate limit partitions + # correctly (10 attempts / minute / IP). + header_up X-Forwarded-For {remote_host} + header_up X-Real-IP {remote_host} + } + + # Sensible hardening defaults. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } +} diff --git a/server/deploy/bns-admin.sijbers.uk.conf b/server/deploy/bns-admin.sijbers.uk.conf new file mode 100644 index 0000000..9685fe3 --- /dev/null +++ b/server/deploy/bns-admin.sijbers.uk.conf @@ -0,0 +1,27 @@ +# /etc/apache2/sites-available/bns-admin.sijbers.uk.conf +# +# Reverse-proxy vhost for the brass-sigil-server admin panel. +# certbot will manage the SSL config (certificate paths, redirect from :80, etc.) +# when run as: sudo certbot --apache -d bns-admin.sijbers.uk + + + ServerName bns-admin.sijbers.uk + + ProxyPreserveHost On + ProxyRequests Off + + # SSE log stream needs streaming responses, not buffered ones. + SetEnv no-gzip 1 + SetEnv proxy-sendcl 1 + + # `flushpackets=on` is the SSE-critical bit on Apache -- pushes each chunk + # straight through instead of batching for ~60 s. + ProxyPass / http://127.0.0.1:8080/ flushpackets=on keepalive=On + ProxyPassReverse / http://127.0.0.1:8080/ + + # So brass-sigil-server's rate limiter sees the real client IP, not 127.0.0.1. + RequestHeader set X-Forwarded-Proto "https" + + ErrorLog ${APACHE_LOG_DIR}/bns-admin.sijbers.uk-error.log + CustomLog ${APACHE_LOG_DIR}/bns-admin.sijbers.uk-access.log combined + diff --git a/server/deploy/brass-and-sigil.html b/server/deploy/brass-and-sigil.html new file mode 100644 index 0000000..f173ba6 --- /dev/null +++ b/server/deploy/brass-and-sigil.html @@ -0,0 +1,284 @@ + + + + + + + + Brass & Sigil Launcher -- Matt Sijbers + + + + + + + + + + +
+ Back to portfolio + +
+

Brass & Sigil Launcher

+

A private custom Minecraft Java Edition launcher for a small friend group.

+
+ C# / .NET 8 + Avalonia + CmlLib.Core + Single-file Windows + Private project +
+ +

+ + You'll need to be whitelisted on the server to actually join — message Matt with your Minecraft username. +

+
+ +
+

About the project

+

+ Brass & Sigil is a private Minecraft modpack centred on the Create mod, aeronautics, tech and magic mods, + with Distant Horizons for far-render exploration. This launcher is the desktop client built specifically + to distribute the pack to a small friend group (under 50 players, no public release). +

+

+ The launcher is a native Windows application written in C# on .NET 8, using the Avalonia UI framework. + It ships as a single self-contained executable so friends can just download and run it — no installer, + no .NET runtime to install, no separate config files. +

+
+ +
+

What it does

+
    +
  • Fetches a JSON manifest from a self-hosted server and syncs the modpack files (mods, configs, resourcepacks) to the player's local install directory, using SHA-1 hashing so only changed files are downloaded.
  • +
  • Authenticates the player with their own personal Microsoft account via the standard MSAL OAuth flow, so only legitimate Minecraft Java Edition owners can sign in.
  • +
  • Installs the right Minecraft version and Forge loader, then launches the game with the configured memory and the player's session.
  • +
  • Auto-updates the modpack on every launch when the manifest changes — players never have to manually install or update mods.
  • +
+
+ +
+

Technical details

+
+
+

Language

+

C# (.NET 8)

+
+
+

UI Framework

+

Avalonia 12

+
+
+

Minecraft auth

+

CmlLib.Core.Auth.Microsoft

+
+
+

Game launching

+

CmlLib.Core 4.x + Forge installer

+
+
+

Distribution

+

Single-file self-contained .exe

+
+
+

Audience

+

Private friend group (< 50)

+
+
+
+ +
+

Privacy & data

+

+ The launcher does not collect, store, or transmit any user data beyond what the standard Microsoft and + Minecraft authentication flows require. Auth tokens are cached locally on the player's machine via the + MSAL token cache — no telemetry, no analytics, no third-party services beyond Microsoft and Mojang. +

+

+ The modpack manifest and mod files are served from a self-hosted Linux server that I personally operate. +

+
+ +
+

Status

+

+ Active development. The launcher is functional end-to-end (manifest sync, Microsoft auth, Forge install, + game launch) and is currently being prepared for distribution to a small group of friends. +

+

+ Source code is publicly available, MIT-licensed, on my self-hosted Bitbucket: + sijbers.uk:8443/.../brass-and-sigil-launcher. +

+
+ +
+

+ NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT. +

+
+ +
+ Brass & Sigil Launcher — a private project by Matt Sijbers. +
+
+ + + diff --git a/server/deploy/brass-sigil-server.service b/server/deploy/brass-sigil-server.service new file mode 100644 index 0000000..5106d2b --- /dev/null +++ b/server/deploy/brass-sigil-server.service @@ -0,0 +1,26 @@ +[Unit] +Description=Brass & Sigil Minecraft server (with web admin panel) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=matt +Group=matt +WorkingDirectory=/home/matt/brass-sigil-server +ExecStart=/home/matt/brass-sigil-server/brass-sigil-server run +Restart=on-failure +RestartSec=10s + +# Give the JVM enough room to start up gracefully on Stop (sends "stop" to MC, +# waits for clean shutdown, then escalates to SIGTERM/SIGKILL). +TimeoutStopSec=60s +KillMode=mixed + +# Tighten attack surface -- typical for a service running as a regular user. +PrivateTmp=true +ProtectSystem=full +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/server/deploy/cleanup-sudo.sh b/server/deploy/cleanup-sudo.sh new file mode 100644 index 0000000..30c4813 --- /dev/null +++ b/server/deploy/cleanup-sudo.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Sudo cleanup script -- review before running. +# Removes dormant game servers (ARK, Valheim, Terraria), trims journald logs, +# caps snap revision retention. Does NOT touch Bitbucket, TeamViewer, or the +# matt-wakeford.co.uk / sijbers.uk / diceheart.com websites. +# +# Run with: sudo bash /tmp/cleanup-sudo.sh +# +# Set -e: abort on first error so we don't cascade-damage state. +set -euo pipefail + +echo "=== Pre-cleanup disk ===" +df -h / | grep -v Filesystem + +# ──────────────────────────────────────────────── +# 1. ARK server -- install, user, broken systemd unit +# ──────────────────────────────────────────────── +echo +echo "=== ARK ===" +if systemctl list-unit-files ark.service &>/dev/null; then + echo " Disabling ark.service" + systemctl disable --now ark.service 2>/dev/null || true +fi +if [[ -f /lib/systemd/system/ark.service ]]; then + echo " Removing /lib/systemd/system/ark.service" + rm -f /lib/systemd/system/ark.service +fi +if [[ -f /etc/systemd/system/ark.service ]]; then + echo " Removing /etc/systemd/system/ark.service" + rm -f /etc/systemd/system/ark.service +fi +if id ark &>/dev/null; then + # Make sure no processes are running as ark before userdel. + pkill -u ark 2>/dev/null || true + sleep 1 + echo " Removing 'ark' user + home" + userdel -r ark 2>/dev/null || userdel ark +fi + +# ──────────────────────────────────────────────── +# 2. Valheim (vhserver) -- LGSM stack +# ──────────────────────────────────────────────── +echo +echo "=== Valheim (vhserver) ===" +if id vhserver &>/dev/null; then + pkill -u vhserver 2>/dev/null || true + sleep 1 + echo " Removing 'vhserver' user + home (incl. orphaned LGSM backups)" + userdel -r vhserver 2>/dev/null || userdel vhserver +fi + +# ──────────────────────────────────────────────── +# 3. Terraria -- empty home, never used +# ──────────────────────────────────────────────── +echo +echo "=== Terraria ===" +if id Terraria &>/dev/null; then + pkill -u Terraria 2>/dev/null || true + echo " Removing 'Terraria' user + home" + userdel -r Terraria 2>/dev/null || userdel Terraria +fi + +# ──────────────────────────────────────────────── +# 4. systemd reload to forget the gone units +# ──────────────────────────────────────────────── +echo +echo "=== systemctl daemon-reload ===" +systemctl daemon-reload +systemctl reset-failed 2>/dev/null || true + +# ──────────────────────────────────────────────── +# 5. journald -- cap to 500 MB +# ──────────────────────────────────────────────── +echo +echo "=== journald ===" +echo " Trimming /var/log/journal to 500 MB" +journalctl --vacuum-size=500M + +# ──────────────────────────────────────────────── +# 6. snap -- only keep current + 1 prior revision +# ──────────────────────────────────────────────── +echo +echo "=== snap retention ===" +echo " Setting refresh.retain=2 (snap will auto-clean older revs over time)" +snap set system refresh.retain=2 + +# Force-clean orphaned _old.snap files older than 30 days. Snap will redo +# this organically but we can prod it now to reclaim immediately. +echo " Forcing snap to drop disabled revisions" +for snap in $(snap list --all | awk '/disabled/{print $1, $3}'); do + if [[ "$snap" == "disabled" ]]; then continue; fi +done +# Easier path: ask snap directly. +LANG=C snap list --all | awk '/disabled/{print $1, $3}' | \ + while read -r name rev; do + echo " snap remove --revision=$rev $name" + snap remove --revision="$rev" "$name" || true + done + +# ──────────────────────────────────────────────── +echo +echo "=== Post-cleanup disk ===" +df -h / | grep -v Filesystem +echo +echo "Done. Bitbucket, TeamViewer, and websites untouched." diff --git a/server/deploy/manifest.json b/server/deploy/manifest.json new file mode 100644 index 0000000..e3287af --- /dev/null +++ b/server/deploy/manifest.json @@ -0,0 +1,135 @@ +{ + "name": "Brass and Sigil", + "version": "0.6.1", + "minecraft": { + "version": "1.21.1" + }, + "loader": { + "type": "neoforge", + "version": "21.1.228" + }, + "files": [ + { + "path": "mods/create-1.21.1-6.0.10.jar", + "url": "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar", + "sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353", + "size": 19123767 + }, + { + "path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar", + "url": "https://cdn.modrinth.com/data/oWaK0Q19/versions/YhZLrAFC/create-aeronautics-bundled-1.21.1-1.2.1.jar", + "sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9", + "size": 33030286 + }, + { + "path": "mods/sable-neoforge-1.21.1-1.2.2.jar", + "url": "https://cdn.modrinth.com/data/T9PomCSv/versions/3FMsUjO4/sable-neoforge-1.21.1-1.2.2.jar", + "sha1": "c5ecd3fcf60a31d84112c708abe29e341b2d1b73", + "size": 12719293 + }, + { + "path": "mods/createbigcannons-5.11.3+mc.1.21.1.jar", + "url": "https://cdn.modrinth.com/data/GWp4jCJj/versions/bsGaXKEd/createbigcannons-5.11.3%2Bmc.1.21.1.jar", + "sha1": "8b61fa850e260bdeb5d360576123f98c260afa50", + "size": 3715787 + }, + { + "path": "mods/tfmg-1.2.0.jar", + "url": "https://cdn.modrinth.com/data/USgVjXsk/versions/uDi14nbt/tfmg-1.2.0.jar", + "sha1": "b520f3687f60a69eb265ff5b9a16759b9e124103", + "size": 4924243 + }, + { + "path": "mods/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar", + "url": "https://cdn.modrinth.com/data/uCdwusMi/versions/KkaaQtTD/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar", + "sha1": "1ff0a8920e52add541471f7b32d0d389997145ba", + "size": 30019727 + }, + { + "path": "mods/sodium-neoforge-0.6.13+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/Pb3OXVqC/sodium-neoforge-0.6.13%2Bmc1.21.1.jar", + "sha1": "38af70fa4dc4b2aaac636e92fdba3bedd5a025e1", + "size": 1162994 + }, + { + "path": "mods/iris-neoforge-1.8.12+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/YL57xq9U/versions/t3ruzodq/iris-neoforge-1.8.12%2Bmc1.21.1.jar", + "sha1": "a3e6355915c7d3b2bc392724795113e51d289378", + "size": 2438548 + }, + { + "path": "mods/modernfix-neoforge-5.27.4+mc1.21.1.jar", + "url": "https://cdn.modrinth.com/data/nmDcB62a/versions/6U8JVjdw/modernfix-neoforge-5.27.4%2Bmc1.21.1.jar", + "sha1": "2f39363f0d6d5a5ccc2a9e0f50ad3385611c3cb7", + "size": 562051 + }, + { + "path": "mods/ferritecore-7.0.3-neoforge.jar", + "url": "https://cdn.modrinth.com/data/uXXizFIs/versions/x7kQWVju/ferritecore-7.0.3-neoforge.jar", + "sha1": "9563692efb708b6b568df27a01ec52f6311928ef", + "size": 121559 + }, + { + "path": "mods/architectury-13.0.8-neoforge.jar", + "url": "https://cdn.modrinth.com/data/lhGA9TYQ/versions/ZxYGwlk0/architectury-13.0.8-neoforge.jar", + "sha1": "6ca11d3cc136bf69bb8f4d56982481eb85b5100b", + "size": 584004 + }, + { + "path": "mods/rhino-2101.2.7-build.81.jar", + "url": "https://cdn.modrinth.com/data/sk9knFPE/versions/ZdLtebKH/rhino-2101.2.7-build.81.jar", + "sha1": "480235a9f7749f68ce6fec3b9c3cac3428b92a4a", + "size": 882033 + }, + { + "path": "mods/ritchiesprojectilelib-2.1.2+mc.1.21.1-neoforge.jar", + "url": "https://cdn.modrinth.com/data/B3pb093D/versions/hZ6B2Z0x/ritchiesprojectilelib-2.1.2%2Bmc.1.21.1-neoforge.jar", + "sha1": "ec2e4996f8bee8714173e603e379fef8a6901765", + "size": 76369 + }, + { + "path": "mods/kubejs-neoforge-2101.7.2-build.363.jar", + "url": "https://cdn.modrinth.com/data/umyGl7zF/versions/Fe9CjPws/kubejs-neoforge-2101.7.2-build.363.jar", + "sha1": "d4e88254e8c26687d4c6aeb4dfa9c2ad70f260a2", + "size": 2270442 + }, + { + "path": "mods/jei-1.21.1-neoforge-19.27.0.340.jar", + "url": "https://cdn.modrinth.com/data/u6dRKJwZ/versions/YAcQ6elZ/jei-1.21.1-neoforge-19.27.0.340.jar", + "sha1": "27d0d85e7e32e926fc3664ab6815df5cdabb7941", + "size": 1529391 + }, + { + "path": "mods/Jade-1.21.1-NeoForge-15.10.5.jar", + "url": "https://cdn.modrinth.com/data/nvQzSEkH/versions/yd8FKCmx/Jade-1.21.1-NeoForge-15.10.5.jar", + "sha1": "d5bf134b3dbde9f5258666823900e21341dc0a50", + "size": 725742 + }, + { + "path": "mods/Chunky-NeoForge-1.4.23.jar", + "url": "https://cdn.modrinth.com/data/fALzjamp/versions/LuFhm4eU/Chunky-NeoForge-1.4.23.jar", + "sha1": "ab0c74743a653020fe2dfc4986b43e893947f3e9", + "size": 340572 + }, + { + "path": "mods/ftb-library-neoforge-2101.1.31.jar", + "url": "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar", + "sha1": "686d4e784c28c14f7760cc22b2de6a8573b56b74", + "size": 1411181 + }, + { + "path": "mods/ftb-teams-neoforge-2101.1.9.jar", + "url": "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar", + "sha1": "328e04bf1a445870aacea8fe7637670f84272a8f", + "size": 291847 + }, + { + "path": "mods/ftb-chunks-neoforge-2101.1.14.jar", + "url": "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar", + "sha1": "908b63b11d0e00ae6c9557d3fe6440bdbcf21bb7", + "size": 642340 + } + ], + "launcherVersion": "0.1.0", + "launcherUrl": "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe" +} \ No newline at end of file diff --git a/server/deploy/server-config.example.json b/server/deploy/server-config.example.json new file mode 100644 index 0000000..ee3a104 --- /dev/null +++ b/server/deploy/server-config.example.json @@ -0,0 +1,12 @@ +{ + "manifestUrl": "https://sijbers.uk/pack/manifest.json", + "serverDir": "./server", + "javaPath": "java", + "memoryMB": 8192, + "webPort": 8080, + "webHost": "localhost", + "webPassword": null, + "rconPort": 25575, + "rconPassword": "", + "acceptEula": false +} diff --git a/server/wwwroot/app.js b/server/wwwroot/app.js new file mode 100644 index 0000000..a3d5dcf --- /dev/null +++ b/server/wwwroot/app.js @@ -0,0 +1,42 @@ +"use strict"; + +import { tickStatus, tickPlayers, tickWhitelist, refreshWhitelistSoon } from "./modules/panels.js"; +import { setupConsole } from "./modules/console.js"; +import { setupAutocomplete } from "./modules/autocomplete.js"; +import { setupWhitelistActions } from "./modules/whitelist.js"; +import { setupServerControls } from "./modules/serverControls.js"; +import { setupPregen } from "./modules/pregen.js"; +import { setupAuth } from "./modules/auth.js"; +import { setupUpdate } from "./modules/update.js"; +import { setupDanger } from "./modules/danger.js"; +import { setupBackup } from "./modules/backup.js"; +import { setupModalTriggers } from "./modules/modal.js"; +import { setupSettings } from "./modules/settings.js"; +import { setupMap } from "./modules/map.js"; + +setupModalTriggers(); +setupAuth(); +setupConsole(); +setupAutocomplete(); +setupWhitelistActions(refreshWhitelistSoon); +setupServerControls(); +setupPregen(); +setupUpdate(); +setupBackup(); +setupDanger(); +setupSettings(); +setupMap(); + +// First paint +tickStatus(); +tickPlayers(); +tickWhitelist(); + +// Polling cadence: +// status 3 s -- pid/uptime/pack version (cheap, doesn't change much) +// players 10 s -- RCON `list` call; players join/leave infrequently +// whitelist 30 s -- file read; mostly relies on refresh-on-action via add/remove +// (Logs are NOT polled -- they stream live via Server-Sent Events from /api/logs/stream.) +setInterval(tickStatus, 3000); +setInterval(tickPlayers, 10000); +setInterval(tickWhitelist, 30000); diff --git a/server/wwwroot/favicon.png b/server/wwwroot/favicon.png new file mode 100644 index 0000000..009e577 Binary files /dev/null and b/server/wwwroot/favicon.png differ diff --git a/server/wwwroot/index.html b/server/wwwroot/index.html new file mode 100644 index 0000000..9d91dcc --- /dev/null +++ b/server/wwwroot/index.html @@ -0,0 +1,379 @@ + + + + + + Brass & Sigil -- Server Panel + + + + +
+
+ +

BRASS & SIGIL -- SERVER

+
+
Connecting…
+
+ +
+ + +
+
+

Console

+
Connecting to server log…
+
+
+
+ +
+
+ +
+
+ Tab autocomplete · / history · Esc dismiss +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/server/wwwroot/modules/api.js b/server/wwwroot/modules/api.js new file mode 100644 index 0000000..a018437 --- /dev/null +++ b/server/wwwroot/modules/api.js @@ -0,0 +1,28 @@ +// Tiny JSON API helper used by every module. +"use strict"; + +export async function api(path, opts) { + const res = await fetch(path, opts); + if (res.status === 401) { + // Auth cookie missing or wrong. Surface to the auth module which + // shows the login overlay; the caller still gets an error so any + // calling code stops cleanly. + document.dispatchEvent(new CustomEvent("authrequired")); + throw new Error("Unauthorized"); + } + if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`); + return await res.json(); +} + +export async function apiJson(path, body, method = "POST") { + return api(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); +} + +export function escapeHtml(s) { + return s.replace(/[&<>"']/g, c => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); +} diff --git a/server/wwwroot/modules/auth.js b/server/wwwroot/modules/auth.js new file mode 100644 index 0000000..07edb5f --- /dev/null +++ b/server/wwwroot/modules/auth.js @@ -0,0 +1,115 @@ +// Login overlay + Account panel (logout / change password). +// +// Cookie is set server-side as HttpOnly so JS never sees it -- that defeats +// XSS-based exfiltration. We only briefly hold the password during input. +"use strict"; + +// We deliberately don't import apiJson here -- change-password returns its +// own error messages and we want to surface them verbatim to the user. + +let overlayShown = false; +function showOverlay() { + if (overlayShown) return; + overlayShown = true; + const overlay = document.getElementById("loginOverlay"); + if (overlay) { + overlay.hidden = false; + document.getElementById("loginPassword")?.focus(); + } +} + +export function setupAuth() { + document.addEventListener("authrequired", showOverlay); + setupLoginForm(); + setupAccountPanel(); +} + +function setupLoginForm() { + const overlay = document.getElementById("loginOverlay"); + const input = document.getElementById("loginPassword"); + const button = document.getElementById("loginSubmit"); + const errorEl = document.getElementById("loginError"); + if (!overlay || !input || !button || !errorEl) return; + + async function tryLogin() { + const pw = input.value; + if (!pw) return; + errorEl.textContent = ""; + button.disabled = true; + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: pw }), + }); + if (res.status === 401) { errorEl.textContent = "Wrong password."; input.select(); return; } + if (res.status === 429) { errorEl.textContent = "Too many attempts. Wait a minute."; return; } + if (!res.ok) { errorEl.textContent = `Error ${res.status}`; return; } + // Server set the cookie. Reload so SSE / pollers pick it up. + location.reload(); + } catch (e) { + errorEl.textContent = e.message; + } finally { + button.disabled = false; + input.value = ""; + } + } + + button.addEventListener("click", tryLogin); + input.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); }); +} + +function setupAccountPanel() { + const logoutBtn = document.getElementById("acctLogout"); + const changeBtn = document.getElementById("acctChangePw"); + const form = document.getElementById("acctChangeForm"); + const cur = document.getElementById("acctCurrent"); + const nxt = document.getElementById("acctNew"); + const cnf = document.getElementById("acctConfirm"); + const submit = document.getElementById("acctSubmit"); + const cancel = document.getElementById("acctCancel"); + const msg = document.getElementById("acctMsg"); + if (!logoutBtn || !changeBtn) return; + + logoutBtn.addEventListener("click", async () => { + if (!confirm("Log out of the panel?")) return; + try { await fetch("/api/auth/logout", { method: "POST" }); } + finally { location.reload(); } + }); + + changeBtn.addEventListener("click", () => { + form.hidden = !form.hidden; + msg.textContent = ""; + if (!form.hidden) cur.focus(); + }); + cancel.addEventListener("click", () => { + form.hidden = true; + cur.value = nxt.value = cnf.value = ""; + msg.textContent = ""; + }); + + submit.addEventListener("click", async () => { + msg.className = "acct-msg"; + msg.textContent = ""; + if (nxt.value.length < 8) { msg.textContent = "New password must be at least 8 characters."; return; } + if (nxt.value !== cnf.value) { msg.textContent = "New password and confirmation don't match."; return; } + submit.disabled = true; + try { + const res = await fetch("/api/auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current: cur.value, next: nxt.value }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { msg.textContent = body.error || `Error ${res.status}`; return; } + msg.className = "acct-msg ok"; + msg.textContent = "Password changed."; + cur.value = nxt.value = cnf.value = ""; + setTimeout(() => { form.hidden = true; msg.textContent = ""; }, 1500); + } catch (e) { + msg.textContent = e.message; + } finally { + submit.disabled = false; + } + }); +} diff --git a/server/wwwroot/modules/autocomplete.js b/server/wwwroot/modules/autocomplete.js new file mode 100644 index 0000000..02facab --- /dev/null +++ b/server/wwwroot/modules/autocomplete.js @@ -0,0 +1,263 @@ +// Tab-completion + suggestion dropdown for the console command input. +// - Ghost text shows the top match inline (Tab to accept) +// - A dropdown list shows up to N matches with their argument signature +// (MC-style: [optional] for choices) +// - Click a list item to insert it; arrow keys + Enter also navigate the list +// when it's open +"use strict"; + +import { state } from "./state.js"; +import { escapeHtml } from "./api.js"; + +const COMMANDS = [ + "help", "list", "say", "tell", "msg", "me", "w", + "op", "deop", + "whitelist", "ban", "ban-ip", "pardon", "pardon-ip", "banlist", + "kick", + "tp", "teleport", + "give", "clear", "kill", + "gamemode", "gamerule", "difficulty", + "weather", "time", "seed", "spawnpoint", "setworldspawn", + "save-all", "save-on", "save-off", "stop", "reload", + "xp", "experience", "effect", "enchant", + "summon", "data", "execute", "fill", "setblock", "locate", "tag", + "ftbchunks", "ftbteams", + "chunky", + "kubejs", "kjs", +]; + +const SUBCOMMANDS = { + whitelist: ["add", "remove", "list", "reload", "on", "off"], + gamemode: ["survival", "creative", "adventure", "spectator"], + weather: ["clear", "rain", "thunder"], + difficulty: ["peaceful", "easy", "normal", "hard"], + time: ["set", "add", "query"], + chunky: ["start", "cancel", "pause", "continue", "world", "shape", "center", "radius", "force_load", "force_unload", "trim", "help"], + ftbchunks: ["claim", "unclaim", "load", "unload", "admin"], +}; + +const TAKES_PLAYER_AT = { + "op": 1, "deop": 1, "tp": 1, "teleport": 1, "kick": 1, + "ban": 1, "pardon": 1, "kill": 1, + "tell": 1, "msg": 1, "w": 1, + "give": 1, "clear": 1, "effect": 1, "enchant": 1, "xp": 1, "experience": 1, + "whitelist add": 2, "whitelist remove": 2, + "gamemode survival": 2, "gamemode creative": 2, "gamemode adventure": 2, "gamemode spectator": 2, +}; + +// MC-style argument signatures for each command. Shown as a hint after the name +// in the suggestion list. [optional] for enum choices. +const SIGNATURES = { + help: "[command]", + list: "", + say: "", + tell: " ", + msg: " ", + me: "", + w: " ", + op: "", + deop: "", + whitelist: "", + "whitelist add": "", + "whitelist remove": "", + "whitelist list": "", + "whitelist on": "", + "whitelist off": "", + "whitelist reload": "", + ban: " [reason…]", + "ban-ip": " [reason…]", + pardon: "", + "pardon-ip": "", + banlist: "[ips|players]", + kick: " [reason…]", + tp: " [destination]", + teleport: " [destination]", + give: " [count]", + clear: "[player] [item]", + kill: "[target]", + gamemode: " [player]", + "gamemode survival": "[player]", + "gamemode creative": "[player]", + "gamemode adventure": "[player]", + "gamemode spectator": "[player]", + gamerule: " [value]", + difficulty: "", + weather: " [duration]", + time: " ", + seed: "", + spawnpoint: "[player] [pos]", + setworldspawn: "[pos]", + "save-all": "[flush]", + "save-on": "", + "save-off": "", + stop: "", + reload: "", + xp: " [player]", + experience: " ", + effect: " ", + enchant: " [level]", + summon: " [pos]", + fill: " ", + setblock: " ", + locate: " ", + tag: " [tag]", + chunky: "", + "chunky start": "[world] [shape] [center_x] [center_z] [radius]", + "chunky cancel": "", + "chunky pause": "", + "chunky continue": "", + "chunky world": "", + "chunky shape": "", + "chunky center": " ", + "chunky radius": "", + "chunky trim": "[world] [radius] [trim_radius]", + ftbchunks: "", + ftbteams: "", + kubejs: "", + kjs: "", +}; + +const MAX_SUGGESTIONS = 8; + +let activeIndex = 0; +let currentSuggestions = []; + +export function setupAutocomplete() { + const cmdInput = document.getElementById("cmdInput"); + const cmdGhost = document.getElementById("cmdGhost"); + const cmdSuggest = document.getElementById("cmdSuggest"); + + function refresh() { + const v = cmdInput.value; + currentSuggestions = computeAllSuggestions(v).slice(0, MAX_SUGGESTIONS); + activeIndex = 0; + + // Inline ghost = top suggestion (only if it extends what they typed) + const top = currentSuggestions[0]; + if (top && top.text.startsWith(v) && top.text !== v) { + const suffix = top.text.substring(v.length); + cmdGhost.innerHTML = `${escapeHtml(v)}${escapeHtml(suffix)}`; + cmdInput.dataset.suggestion = top.text; + } else { + cmdGhost.innerHTML = ""; + cmdInput.dataset.suggestion = ""; + } + + renderList(cmdSuggest, currentSuggestions); + } + + cmdInput.addEventListener("input", refresh); + cmdInput.addEventListener("focus", refresh); + cmdInput.addEventListener("blur", () => { + // Delay so a click on a list item registers before we hide + setTimeout(() => cmdSuggest.classList.remove("show"), 150); + }); + + cmdInput.addEventListener("keydown", e => { + if (e.key === "Tab") { + const sug = currentSuggestions[activeIndex]; + if (sug) { + e.preventDefault(); + cmdInput.value = sug.text + " "; + refresh(); + } + } else if (e.key === "Escape") { + cmdGhost.innerHTML = ""; + cmdInput.dataset.suggestion = ""; + cmdSuggest.classList.remove("show"); + } else if (e.key === "ArrowDown" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) { + e.preventDefault(); + activeIndex = (activeIndex + 1) % currentSuggestions.length; + highlightActive(cmdSuggest); + } else if (e.key === "ArrowUp" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) { + e.preventDefault(); + activeIndex = (activeIndex - 1 + currentSuggestions.length) % currentSuggestions.length; + highlightActive(cmdSuggest); + } + // Note: Enter is handled by console.js (sends the command) + }); + + cmdSuggest.addEventListener("mousedown", e => { + // mousedown (not click) so we beat the input blur handler + const item = e.target.closest(".suggest-item"); + if (!item) return; + e.preventDefault(); + const idx = parseInt(item.dataset.idx, 10); + const sug = currentSuggestions[idx]; + if (sug) { + cmdInput.value = sug.text + " "; + cmdInput.focus(); + refresh(); + } + }); +} + +function highlightActive(listEl) { + [...listEl.querySelectorAll(".suggest-item")].forEach((el, i) => { + el.classList.toggle("active", i === activeIndex); + if (i === activeIndex) el.scrollIntoView({ block: "nearest" }); + }); +} + +function renderList(listEl, suggestions) { + if (suggestions.length === 0) { + listEl.classList.remove("show"); + listEl.innerHTML = ""; + return; + } + listEl.innerHTML = suggestions.map((s, i) => { + const args = s.args ? `${escapeHtml(s.args)}` : ""; + return `
` + + `${escapeHtml(s.text)}${args}
`; + }).join(""); + listEl.classList.add("show"); +} + +// Returns an array of {text, args} suggestions ordered by relevance. +// args is the MC-style hint shown next to the name. +function computeAllSuggestions(input) { + if (!input) return []; + const stripped = input.startsWith("/") ? input.substring(1) : input; + const tokens = stripped.split(" "); + const partial = tokens[tokens.length - 1].toLowerCase(); + const completed = tokens.slice(0, -1); + const prefix = input.startsWith("/") ? "/" : ""; + + // First token: command name + if (completed.length === 0) { + const matches = COMMANDS.filter(c => c.startsWith(partial)).sort(); + return matches.map(name => ({ + text: prefix + name, + args: SIGNATURES[name] ?? "", + })); + } + + // Subcommands + const headLower = completed[0].toLowerCase(); + if (completed.length === 1 && SUBCOMMANDS[headLower]) { + const subs = SUBCOMMANDS[headLower].filter(s => s.startsWith(partial)); + return subs.map(sub => ({ + text: prefix + [...completed, sub].join(" "), + args: SIGNATURES[`${headLower} ${sub}`] ?? "", + })); + } + + // Player-name positions + const cmdKey = completed.join(" ").toLowerCase(); + const playerSlot = TAKES_PLAYER_AT[cmdKey]; + if (playerSlot !== undefined && tokens.length === playerSlot + 1) { + const matches = state.knownPlayers.filter(p => p.toLowerCase().startsWith(partial)); + return matches.map(p => ({ + text: prefix + [...completed, p].join(" "), + args: "", + })); + } + + // No structured suggestion at this position -- but still show the current + // command's signature as a contextual hint + const sig = SIGNATURES[cmdKey]; + if (sig) { + return [{ text: input, args: sig }]; + } + return []; +} diff --git a/server/wwwroot/modules/backup.js b/server/wwwroot/modules/backup.js new file mode 100644 index 0000000..beda00f --- /dev/null +++ b/server/wwwroot/modules/backup.js @@ -0,0 +1,183 @@ +// World backup management -- list, create, restore, delete. +// +// Backups are server-online (no downtime) -- the daemon issues `save-all flush` +// + `save-off`, archives the world, then `save-on`. Restore *does* stop the +// server (it has to), and snapshots the current world to a `-prerestore-*` dir +// before extracting so a wrong restore is recoverable. +"use strict"; + +import { api } from "./api.js"; + +const els = {}; +let lastSchedule = null; +let lastKeep = null; + +function fmtSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function fmtDate(iso) { + try { return new Date(iso).toLocaleString(); } catch { return iso; } +} + +function fmtRelativeFuture(iso) { + if (!iso) return "--"; + const target = new Date(iso).getTime(); + const ms = target - Date.now(); + if (ms <= 0) return "imminent"; + const sec = Math.round(ms / 1000); + if (sec < 60) return `in ${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `in ${min}m`; + const hr = Math.floor(min / 60); + const rem = min % 60; + if (hr < 24) return rem ? `in ${hr}h ${rem}m` : `in ${hr}h`; + const days = Math.floor(hr / 24); + return `in ${days}d ${hr % 24}h`; +} + +async function refresh() { + let data; + try { data = await api("/api/backup/list"); } + catch { return; } + + els.dir.textContent = data.dir || "--"; + // Server returns a human description ("Daily at 04:00", "Every 6 hours", "Disabled"). + els.schedule.textContent = data.description || (data.schedule ? `Daily at ${data.schedule}` : "Disabled"); + els.next.textContent = data.nextRun ? fmtRelativeFuture(data.nextRun) : "--"; + els.keep.textContent = data.keep != null ? `${data.keep} latest` : "--"; + lastSchedule = data.schedule || ""; + lastKeep = data.keep ?? 14; + + // Right-sidebar badge: count of backups + const badge = document.getElementById("bkpBadge"); + if (badge) badge.textContent = data.backups?.length ? `${data.backups.length}` : "0"; + + if (!data.backups || data.backups.length === 0) { + els.list.innerHTML = '
  • No backups yet
  • '; + return; + } + + els.list.innerHTML = data.backups.map(b => ` +
  • +
    +
    ${escape(b.name)}
    +
    ${fmtSize(b.sizeBytes)} · ${fmtDate(b.createdAt)}
    +
    +
    + + +
    +
  • + `).join(""); +} + +function escape(s) { + return String(s).replace(/[&<>"']/g, c => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); +} + +function showMsg(text, ok = false) { + els.msg.className = ok ? "acct-msg ok" : "acct-msg"; + els.msg.textContent = text; +} + +async function postJson(path, body) { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return { ok: res.ok, status: res.status, body: await res.json().catch(() => ({})) }; +} + +export function setupBackup() { + els.dir = document.getElementById("backupDir"); + els.list = document.getElementById("bkpList"); + els.create = document.getElementById("bkpCreate"); + els.msg = document.getElementById("bkpMsg"); + els.schedule = document.getElementById("backupSchedule"); + els.next = document.getElementById("backupNext"); + els.keep = document.getElementById("backupKeep"); + els.editBtn = document.getElementById("bkpEditSchedule"); + els.form = document.getElementById("bkpScheduleForm"); + els.input = document.getElementById("bkpScheduleInput"); + els.keepInput = document.getElementById("bkpKeepInput"); + els.saveBtn = document.getElementById("bkpScheduleSave"); + els.cancelBtn = document.getElementById("bkpScheduleCancel"); + if (!els.create) return; + + els.editBtn?.addEventListener("click", () => { + els.form.hidden = !els.form.hidden; + if (!els.form.hidden) { + els.input.value = lastSchedule || ""; + els.keepInput.value = lastKeep ?? 14; + els.input.focus(); + } + }); + + els.cancelBtn?.addEventListener("click", () => { + els.form.hidden = true; + showMsg(""); + }); + + els.saveBtn?.addEventListener("click", async () => { + const sched = els.input.value.trim(); + const keep = parseInt(els.keepInput.value, 10); + const r = await postJson("/api/backup/schedule", { + schedule: sched, + keep: Number.isFinite(keep) ? keep : undefined, + }); + if (!r.ok || r.body.ok === false) { + showMsg(r.body.error || `Error ${r.status}`); + } else { + showMsg(sched ? `Schedule saved: daily at ${sched}` : "Schedule disabled.", true); + els.form.hidden = true; + refresh(); + } + }); + + els.create.addEventListener("click", async () => { + const reason = prompt("Optional reason / label for this backup (e.g. 'pre-update'). Leave blank for none:"); + if (reason === null) return; // user cancelled + showMsg("Creating backup -- this may take a minute on a large world..."); + els.create.disabled = true; + const r = await postJson("/api/backup/create", { reason: reason.trim() || null }); + els.create.disabled = false; + if (!r.ok || r.body.ok === false) { + showMsg(r.body.error || `Error ${r.status}`); + } else { + showMsg(`Backup created: ${r.body.name} (${fmtSize(r.body.sizeBytes)})`, true); + refresh(); + } + }); + + els.list.addEventListener("click", async e => { + const restore = e.target.closest(".bkp-restore"); + const del = e.target.closest(".bkp-delete"); + if (restore) { + const name = restore.dataset.name; + if (!confirm(`Restore from ${name}?\n\nServer will stop, current world is moved to a "-prerestore" folder for safety, then the backup is extracted and server starts again.`)) + return; + showMsg("Restoring -- this stops the server..."); + const r = await postJson("/api/backup/restore", { name }); + if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`); + else showMsg("Restore complete. Server is starting.", true); + } + if (del) { + const name = del.dataset.name; + if (!confirm(`Delete backup ${name}? This cannot be undone.`)) return; + const r = await postJson("/api/backup/delete", { name }); + if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`); + else { showMsg("Deleted.", true); refresh(); } + } + }); + + refresh(); + // Backups don't change often; light poll to pick up new ones if scheduled + // backups are added later, or just to refresh after an external mv/rm. + setInterval(refresh, 30000); +} diff --git a/server/wwwroot/modules/console.js b/server/wwwroot/modules/console.js new file mode 100644 index 0000000..9d01474 --- /dev/null +++ b/server/wwwroot/modules/console.js @@ -0,0 +1,76 @@ +// Live log streaming via Server-Sent Events + command-input wiring. +// EventSource gives us instant push (no 1-second polling lag) and reconnects +// automatically if the connection drops. +"use strict"; + +import { api, apiJson, escapeHtml } from "./api.js"; +import { state } from "./state.js"; + +const consoleEl = () => document.getElementById("console"); + +export function setupConsole() { + consoleEl().textContent = "Connecting to server log…"; + + const es = new EventSource("/api/logs/stream"); + let firstEvent = true; + es.addEventListener("log", e => { + if (firstEvent) { consoleEl().textContent = ""; firstEvent = false; } + try { + const d = JSON.parse(e.data); + const ts = new Date(d.t).toLocaleTimeString(); + const div = document.createElement("div"); + if (d.e) div.className = "err"; + div.textContent = `[${ts}] ${d.m}`; + consoleEl().appendChild(div); + consoleEl().scrollTop = consoleEl().scrollHeight; + // Trim very old lines so the DOM doesn't grow unbounded + while (consoleEl().childNodes.length > 5000) { + consoleEl().removeChild(consoleEl().firstChild); + } + // Re-broadcast so other modules (e.g. pregen) can react to log lines + // without opening a second SSE connection. + document.dispatchEvent(new CustomEvent("serverlog", { detail: d })); + } catch {} + }); + es.onerror = () => { + // EventSource will retry automatically. + }; + + // Command input + const cmdInput = document.getElementById("cmdInput"); + document.getElementById("cmdSend").addEventListener("click", sendCommand); + cmdInput.addEventListener("keydown", onCmdKeyDown); +} + +async function sendCommand() { + const cmdInput = document.getElementById("cmdInput"); + const v = cmdInput.value.trim(); + if (!v) return; + try { + await apiJson("/api/command", { command: v }); + state.cmdHistory.push(v); + state.cmdHistoryIdx = state.cmdHistory.length; + cmdInput.value = ""; + cmdInput.dispatchEvent(new Event("input")); // refresh ghost text + } catch (e) { alert(e.message); } +} + +function onCmdKeyDown(e) { + const cmdInput = document.getElementById("cmdInput"); + if (e.key === "Enter") { + sendCommand(); + } else if (e.key === "ArrowUp") { + if (state.cmdHistory.length === 0) return; + e.preventDefault(); + state.cmdHistoryIdx = Math.max(0, state.cmdHistoryIdx - 1); + cmdInput.value = state.cmdHistory[state.cmdHistoryIdx] || ""; + cmdInput.dispatchEvent(new Event("input")); + } else if (e.key === "ArrowDown") { + if (state.cmdHistory.length === 0) return; + e.preventDefault(); + state.cmdHistoryIdx = Math.min(state.cmdHistory.length, state.cmdHistoryIdx + 1); + cmdInput.value = state.cmdHistory[state.cmdHistoryIdx] || ""; + cmdInput.dispatchEvent(new Event("input")); + } + // Note: Tab is handled by the autocomplete module's keydown listener. +} diff --git a/server/wwwroot/modules/danger.js b/server/wwwroot/modules/danger.js new file mode 100644 index 0000000..2b88a80 --- /dev/null +++ b/server/wwwroot/modules/danger.js @@ -0,0 +1,111 @@ +// Danger zone -- destructive operations. +// Currently: world wipe. Always type-to-confirm to prevent accidental clicks. +"use strict"; + +export function setupDanger() { + const btn = document.getElementById("wipeBtn"); + const cb = document.getElementById("wipeBackup"); + const msg = document.getElementById("wipeMsg"); + const seedDisplay = document.getElementById("wipeCurrentSeed"); + const customInput = document.getElementById("wipeCustomSeed"); + if (!btn) return; + + // Enable the custom-seed text field only when its radio is selected. + document.querySelectorAll('input[name="wipeSeedMode"]').forEach(radio => { + radio.addEventListener("change", () => { + const mode = document.querySelector('input[name="wipeSeedMode"]:checked')?.value; + customInput.disabled = (mode !== "custom"); + if (mode === "custom") customInput.focus(); + }); + }); + + // Fetch current seed each time the wipe modal becomes visible. Watching + // the wipe section's ancestor modal works without coupling to the modal + // module's open/close API. + const refreshSeed = async () => { + seedDisplay.textContent = "loading..."; + try { + const res = await fetch("/api/world/seed"); + const body = await res.json(); + seedDisplay.textContent = body.seed + ? body.seed + : "(unknown -- server stopped or seed not set)"; + } catch (e) { + seedDisplay.textContent = "(failed to read)"; + } + }; + // Refresh on first load + whenever the modal becomes visible. Modal markup + // uses a wrapping div with "[hidden]" attr, so we observe attribute changes. + refreshSeed(); + const modal = btn.closest(".modal"); + if (modal) { + new MutationObserver(muts => { + for (const m of muts) { + if (m.attributeName === "hidden" && !modal.hasAttribute("hidden")) { + refreshSeed(); + } + } + }).observe(modal, { attributes: true }); + } + + btn.addEventListener("click", async () => { + msg.className = "acct-msg"; + msg.textContent = ""; + + const mode = document.querySelector('input[name="wipeSeedMode"]:checked')?.value || "random"; + const customSeed = (customInput.value || "").trim(); + if (mode === "custom" && !customSeed) { + msg.textContent = "Custom seed selected but the field is empty."; + return; + } + + // Build a confirmation prompt that reflects the chosen seed strategy + // so the user sees exactly what's about to happen. + let seedNote = ""; + if (mode === "keep") seedNote = `Same seed (${seedDisplay.textContent}) will be reused.\n`; + else if (mode === "custom") seedNote = `Seed will be set to: ${customSeed}\n`; + else seedNote = "A new random seed will be generated.\n"; + + const typed = prompt( + "Type WIPE (uppercase, exactly) to confirm world wipe.\n" + + "Server will stop, world will be replaced, server will restart.\n\n" + + seedNote + ); + if (typed !== "WIPE") { + if (typed != null) msg.textContent = "Confirmation didn't match -- nothing wiped."; + return; + } + + btn.disabled = true; + msg.textContent = "Wiping..."; + try { + const res = await fetch("/api/world/wipe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirm: typed, + backup: cb.checked, + seedMode: mode, + customSeed: mode === "custom" ? customSeed : null, + }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.ok === false) { + msg.textContent = body.error || `Error ${res.status}`; + return; + } + msg.className = "acct-msg ok"; + const parts = ["World wiped."]; + if (body.seedUsed) parts.push(`Seed: ${body.seedUsed}.`); + if (body.backupName) parts.push(`Backup: ${body.backupName}.`); + parts.push("Server restarting..."); + msg.textContent = parts.join(" "); + // Refresh the seed display so user sees the new value once MC is back. + setTimeout(refreshSeed, 5000); + } catch (e) { + msg.textContent = e.message; + } finally { + btn.disabled = false; + } + }); +} diff --git a/server/wwwroot/modules/map.js b/server/wwwroot/modules/map.js new file mode 100644 index 0000000..971303a --- /dev/null +++ b/server/wwwroot/modules/map.js @@ -0,0 +1,114 @@ +// World map (BlueMap) controls. +// +// Render runs out-of-process via BlueMap CLI. Status polled every 3 s while +// the modal is open OR while a render is in progress. The "Open map" button +// only opens the new tab if there's actually rendered output -- otherwise we +// pop a friendly message saying "render first". +"use strict"; + +import { api } from "./api.js"; + +const els = {}; +let pollTimer = null; +let modalOpen = false; +let lastHasOutput = false; + +function setPolling(intervalMs) { + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(tick, intervalMs); +} + +async function tick() { + let s; + try { s = await api("/api/map/status"); } + catch { return; } + + lastHasOutput = !!s.hasOutput; + document.getElementById("mapBadge").hidden = !s.inProgress; + if (s.inProgress) document.getElementById("mapBadge").textContent = "rendering"; + + if (!modalOpen) return; // don't bother updating modal DOM if hidden + + els.phase.textContent = phaseLabel(s.phase); + els.lastLog.textContent = s.lastLogLine ?? "--"; + els.render.disabled = s.inProgress; + els.render.textContent = s.inProgress ? "Rendering…" : "Render now"; + els.cancel.hidden = !s.inProgress; + + if (s.phase === "complete" || s.phase === "failed" || s.phase === "cancelled") { + if (s.phase === "failed" && s.error) showMsg("Failed: " + s.error); + else if (s.phase === "cancelled") showMsg("Cancelled. Next render resumes from this point."); + else if (s.phase === "complete") showMsg("Render complete.", true); + } +} + +function phaseLabel(phase) { + switch (phase) { + case "downloading": return "Downloading CLI"; + case "extracting": return "Extracting CLI"; + case "configuring": return "Configuring"; + case "rendering": return "Rendering"; + case "complete": return "Complete"; + case "failed": return "Failed"; + case "cancelled": return "Cancelled"; + default: return "Idle"; + } +} + +function showMsg(text, ok = false) { + els.msg.className = ok ? "acct-msg ok" : "acct-msg"; + els.msg.textContent = text; +} + +export function setupMap() { + els.phase = document.getElementById("mapPhase"); + els.lastLog = document.getElementById("mapLastLog"); + els.render = document.getElementById("mapRender"); + els.cancel = document.getElementById("mapCancel"); + els.open = document.getElementById("mapOpen"); + els.msg = document.getElementById("mapMsg"); + if (!els.render) return; + + els.cancel.addEventListener("click", async () => { + if (!confirm("Cancel the render? It's resumable -- next time you click Render, BlueMap continues from where it stopped.")) return; + try { + const res = await fetch("/api/map/cancel", { method: "POST" }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.ok === false) showMsg(body.error || `Error ${res.status}`); + else { showMsg("Cancelling…"); tick(); } + } catch (e) { showMsg(e.message); } + }); + + // Track modal open/close so we can poll faster when the user is watching. + const modal = document.getElementById("modalMap"); + new MutationObserver(() => { + modalOpen = !modal.hidden; + if (modalOpen) tick(); + }).observe(modal, { attributes: true, attributeFilter: ["hidden"] }); + + els.render.addEventListener("click", async () => { + showMsg("Starting render…"); + els.render.disabled = true; + try { + const res = await fetch("/api/map/render", { method: "POST" }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.ok === false) { + showMsg(body.error || `Error ${res.status}`); + els.render.disabled = false; + return; + } + tick(); + } catch (e) { showMsg(e.message); els.render.disabled = false; } + }); + + els.open.addEventListener("click", () => { + if (!lastHasOutput) { + showMsg("No map output yet -- click Render now first."); + return; + } + window.open("/map/", "_blank", "noopener"); + }); + + tick(); + setPolling(3000); // light poll keeps the badge fresh + catches background renders +} diff --git a/server/wwwroot/modules/modal.js b/server/wwwroot/modules/modal.js new file mode 100644 index 0000000..245f2aa --- /dev/null +++ b/server/wwwroot/modules/modal.js @@ -0,0 +1,57 @@ +// Tiny modal helper. Registers a single document-level Esc + backdrop-click +// handler so individual modals don't have to. Public API: openModal(id) / +// closeModal(id) / closeAllModals(). +"use strict"; + +let bound = false; + +function bindGlobal() { + if (bound) return; + bound = true; + document.addEventListener("keydown", e => { + if (e.key === "Escape") closeAllModals(); + }); + document.addEventListener("click", e => { + // Backdrop click closes the topmost open modal. + const backdrop = e.target.closest(".modal-backdrop"); + if (backdrop) closeModal(backdrop.parentElement.id); + const closeBtn = e.target.closest(".modal-close"); + if (closeBtn) closeModal(closeBtn.closest(".modal").id); + }); +} + +export function openModal(id) { + bindGlobal(); + const m = document.getElementById(id); + if (!m) return; + m.hidden = false; + document.body.classList.add("modal-open"); + // Focus first input/button for keyboard users. + setTimeout(() => { + const focusable = m.querySelector("input, button:not(.modal-close), select, textarea"); + focusable?.focus(); + }, 50); +} + +export function closeModal(id) { + const m = document.getElementById(id); + if (!m) return; + m.hidden = true; + if (!document.querySelector(".modal:not([hidden])")) { + document.body.classList.remove("modal-open"); + } +} + +export function closeAllModals() { + document.querySelectorAll(".modal:not([hidden])").forEach(m => m.hidden = true); + document.body.classList.remove("modal-open"); +} + +/// Wires `data-open-modal="someId"` on any element to opening the modal. +export function setupModalTriggers() { + bindGlobal(); + document.addEventListener("click", e => { + const trigger = e.target.closest("[data-open-modal]"); + if (trigger) openModal(trigger.getAttribute("data-open-modal")); + }); +} diff --git a/server/wwwroot/modules/panels.js b/server/wwwroot/modules/panels.js new file mode 100644 index 0000000..a3af67e --- /dev/null +++ b/server/wwwroot/modules/panels.js @@ -0,0 +1,117 @@ +// Status / players / whitelist sidebar panels. Polled (not streamed) because the +// data they show changes infrequently. Logs use SSE -- see console.js. +"use strict"; + +import { api, escapeHtml } from "./api.js"; +import { state, rebuildKnownPlayers } from "./state.js"; + +export async function tickStatus() { + const pill = document.getElementById("statusPill"); + const text = document.getElementById("statusText"); + const memEl = document.getElementById("memUsage"); + const memBar = document.getElementById("memBar"); + const cpuCur = document.getElementById("cpuCurrent"); + const cpuBar = document.getElementById("cpuBar"); + const cpuMax = document.getElementById("cpuMax"); + const cpuAvg = document.getElementById("cpuAvg"); + + function renderResources(s) { + if (s.memoryBytes != null) { + const usedGB = s.memoryBytes / (1024 ** 3); + const maxGB = s.memoryMaxMB ? s.memoryMaxMB / 1024 : null; + memEl.textContent = maxGB + ? `${usedGB.toFixed(2)} / ${maxGB.toFixed(1)} GB` + : `${usedGB.toFixed(2)} GB`; + memBar.style.width = maxGB ? `${Math.min(100, (usedGB / maxGB) * 100)}%` : "0%"; + } else { + memEl.textContent = "--"; + memBar.style.width = "0%"; + } + if (s.cpu) { + cpuCur.textContent = `${s.cpu.current.toFixed(1)} %`; + cpuBar.style.width = `${Math.min(100, s.cpu.current)}%`; + cpuMax.textContent = `${s.cpu.max.toFixed(1)}%`; + cpuAvg.textContent = `${s.cpu.avg.toFixed(1)}%`; + } else { + cpuCur.textContent = "--"; + cpuBar.style.width = "0%"; + cpuMax.textContent = "--"; + cpuAvg.textContent = "--"; + } + } + + try { + const s = await api("/api/status"); + if (s.running) { + pill.className = "status-pill online"; + text.textContent = "Online"; + document.getElementById("pid").textContent = s.pid ?? "--"; + const secs = Math.floor(s.uptime ?? 0); + const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60); + document.getElementById("uptime").textContent = `${h}h ${m}m`; + renderResources(s); + } else { + pill.className = "status-pill offline"; + text.textContent = "Offline"; + document.getElementById("pid").textContent = "--"; + document.getElementById("uptime").textContent = "--"; + renderResources({ memoryBytes: null, cpu: null, memoryMaxMB: null }); + } + const pv = s.packVersion; + document.getElementById("packVersion").textContent = pv?.name ? `${pv.name} v${pv.version}` : "--"; + + // World size -- even when server is offline we can still report disk usage. + const worldEl = document.getElementById("worldSize"); + if (worldEl) { + const b = s.worldSizeBytes; + if (b == null || b === 0) worldEl.textContent = "--"; + else if (b < 1024 * 1024) worldEl.textContent = `${(b / 1024).toFixed(0)} KB`; + else if (b < 1024 * 1024 * 1024) worldEl.textContent = `${(b / (1024 * 1024)).toFixed(1)} MB`; + else worldEl.textContent = `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + } catch { + pill.className = "status-pill offline"; + text.textContent = "Disconnected"; + } +} + +export async function tickPlayers() { + try { + const p = await api("/api/players"); + state.onlinePlayers = (p.players || []).slice(); + document.getElementById("playerCount").textContent = p.online >= 0 ? p.online : "?"; + const list = document.getElementById("players"); + if (state.onlinePlayers.length === 0) { + list.innerHTML = '
  • No-one online
  • '; + } else { + list.innerHTML = state.onlinePlayers.map(n => `
  • ${escapeHtml(n)}
  • `).join(""); + } + } catch {} + rebuildKnownPlayers(); +} + +export async function tickWhitelist() { + try { + const w = await api("/api/whitelist"); + state.whitelistedPlayers = (w.players || []).slice(); + const list = document.getElementById("whitelist"); + if (state.whitelistedPlayers.length === 0) { + list.innerHTML = '
  • No players whitelisted yet
  • '; + } else { + list.innerHTML = state.whitelistedPlayers.map(n => + `
  • ${escapeHtml(n)}
  • ` + ).join(""); + } + } catch {} + rebuildKnownPlayers(); +} + +// MC takes ~1-2 s to look up a UUID via Mojang and write whitelist.json. +// Refresh shortly after a user-triggered add/remove instead of waiting for the +// 30-second polling tick. +let pendingRefresh; +export function refreshWhitelistSoon() { + clearTimeout(pendingRefresh); + pendingRefresh = setTimeout(tickWhitelist, 1500); + setTimeout(tickWhitelist, 4000); // belt-and-braces if Mojang is slow +} diff --git a/server/wwwroot/modules/pregen.js b/server/wwwroot/modules/pregen.js new file mode 100644 index 0000000..89cd8a3 --- /dev/null +++ b/server/wwwroot/modules/pregen.js @@ -0,0 +1,222 @@ +// World pre-generation controls + live status display. +// +// We use the canonical config-then-start sequence rather than the all-in-one +// `chunky start ` form because the all-in-one +// form's argument order varies between Chunky versions, and Brigadier silently +// prints the usage hint instead of erroring when it doesn't match. +// +// Status is parsed from Chunky's own log lines (re-broadcast by console.js as +// the `serverlog` custom event) -- no separate polling endpoint is needed. +// +// Chunky is intentionally only invoked from this panel -- it can punch holes +// in chunks if it crashes mid-run, so we don't want it ticking on its own. +"use strict"; + +import { apiJson } from "./api.js"; + +async function send(cmd) { + await apiJson("/api/command", { command: cmd }); +} + +async function startPregen(radius) { + await send("chunky world minecraft:overworld"); + await send("chunky shape square"); + await send("chunky center 0 0"); + await send(`chunky radius ${radius}`); + await send("chunky start"); +} + +// ─────────── status display ─────────── + +const els = {}; + +function setState(label, cssClass) { + if (!els.state) return; + els.state.textContent = label; + els.state.className = "val " + cssClass; + applyButtonStates(cssClass); +} + +/// Enable/disable the Start/Pause/Resume/Cancel buttons based on the current +/// pregen state. Called whenever setState changes the displayed status. +function applyButtonStates(cssClass) { + if (!els.btnStart) return; + // Map the state CSS class to a logical state name. Default = idle. + let s = "idle"; + if (cssClass === "pg-state-running") s = "running"; + else if (cssClass === "pg-state-paused") s = "paused"; + else if (cssClass === "pg-state-cancelling") s = "cancelling"; + + els.btnStart.disabled = s !== "idle"; + els.btnPause.disabled = s !== "running"; + els.btnContinue.disabled = s !== "paused"; + els.btnCancel.disabled = !(s === "running" || s === "paused"); +} + +function resetMetrics() { + if (!els.progressFill) return; + els.progressFill.style.width = "0%"; + els.progressText.textContent = "--"; + els.chunks.textContent = "--"; + els.rate.textContent = "--"; + els.eta.textContent = "--"; +} + +// Parse a Chunky log line. Returns an object describing what changed, or null +// if this line isn't a Chunky message we recognise (or is for a different world). +// +// Chunky supports one task per world running concurrently, so we narrow our +// display to the overworld -- that's the only world the Start button targets, +// and it keeps the UI sane if someone kicks off other worlds via raw command. +// +// Real Chunky lines look roughly like: +// "[Chunky] Task running for minecraft:overworld at 0,0. Progress: 12.50% (1234/9876 chunks), 45.20 cps, ETA: 0h 1m 30s" +// "[Chunky] Task started for minecraft:overworld." +// "[Chunky] Task stopped for minecraft:overworld." +// "[Chunky] Task paused for minecraft:overworld." +// "[Chunky] No task running." +const TARGET_WORLD = "minecraft:overworld"; + +function parseChunky(text) { + if (!/Chunky|chunky/.test(text)) return null; + + // If a world is named, only react when it's the one we're tracking. + // Lines without a world (e.g. "No task running.") fall through. + const worldMatch = text.match(/(minecraft:[a-z_]+|the_nether|the_end|overworld)/i); + if (worldMatch) { + const w = worldMatch[1].toLowerCase(); + const normalised = w.startsWith("minecraft:") ? w : `minecraft:${w}`; + if (normalised !== TARGET_WORLD) return null; + } + + // State transitions + if (/Task started/i.test(text)) return { state: "running" }; + if (/Task paused/i.test(text)) return { state: "paused" }; + if (/Task (stopped|cancelled|canceled|completed|finished)/i.test(text)) + return { state: "idle", clear: true }; + if (/No task running/i.test(text)) return { state: "idle", clear: true }; + + // Progress line: try to extract whatever pieces are present + const out = {}; + let matched = false; + + const pct = text.match(/(\d+(?:\.\d+)?)\s*%/); + if (pct) { out.percent = parseFloat(pct[1]); matched = true; } + + const chunks = text.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*chunks?/i); + if (chunks) { + out.done = chunks[1].replace(/,/g, ""); + out.total = chunks[2].replace(/,/g, ""); + matched = true; + } + + const cps = text.match(/(\d+(?:\.\d+)?)\s*cps/i); + if (cps) { out.cps = parseFloat(cps[1]); matched = true; } + + const eta = text.match(/ETA[:\s]+([^,)\n]+?)(?=[,)\n]|$)/i); + if (eta) { out.eta = eta[1].trim(); matched = true; } + + if (matched) { out.state = "running"; return out; } + return null; +} + +function applyParsed(p) { + if (!els.state) return; + if (p.state === "running") setState("Running", "pg-state-running"); + if (p.state === "paused") setState("Paused", "pg-state-paused"); + if (p.state === "idle") setState("Idle", "pg-state-idle"); + if (p.clear) resetMetrics(); + + if (p.percent != null) { + els.progressFill.style.width = `${Math.min(100, p.percent)}%`; + els.progressText.textContent = `${p.percent.toFixed(2)}%`; + } + if (p.done && p.total) { + const fmt = n => Number(n).toLocaleString(); + els.chunks.textContent = `${fmt(p.done)} / ${fmt(p.total)}`; + } + if (p.cps != null) els.rate.textContent = `${p.cps.toFixed(1)} chunks/s`; + if (p.eta) els.eta.textContent = p.eta; +} + +function setupCollapsible() { + // Persist collapsed state per card across reloads via localStorage. + document.querySelectorAll(".card.collapsible").forEach(card => { + const id = card.id || ""; + const storageKey = id ? `bs-collapsed:${id}` : null; + const startCollapsed = storageKey && localStorage.getItem(storageKey) === "1"; + if (!startCollapsed) card.classList.add("expanded"); + const toggle = card.querySelector(".collapsible-toggle"); + if (!toggle) return; + toggle.addEventListener("click", () => { + card.classList.toggle("expanded"); + if (storageKey) { + localStorage.setItem(storageKey, + card.classList.contains("expanded") ? "0" : "1"); + } + }); + }); +} + +export function setupPregen() { + setupCollapsible(); + els.state = document.getElementById("pgState"); + els.progressFill = document.getElementById("pgProgressFill"); + els.progressText = document.getElementById("pgProgressText"); + els.chunks = document.getElementById("pgChunks"); + els.rate = document.getElementById("pgRate"); + els.eta = document.getElementById("pgEta"); + els.btnStart = document.getElementById("pgStart"); + els.btnPause = document.getElementById("pgPause"); + els.btnContinue = document.getElementById("pgContinue"); + els.btnCancel = document.getElementById("pgCancel"); + + // Idle by default -- disable everything except Start. + applyButtonStates("pg-state-idle"); + + const radiusInput = document.getElementById("pgRadius"); + + document.getElementById("pgStart").addEventListener("click", async () => { + const r = parseInt(radiusInput.value, 10); + if (!Number.isFinite(r) || r < 100) { + alert("Enter a radius of at least 100 blocks."); + return; + } + if (r > 20000 && !confirm(`Radius ${r} is large and may take hours. Continue?`)) { + return; + } + try { + setState("Starting…", "pg-state-running"); + resetMetrics(); + await startPregen(r); + } catch (e) { + setState("Idle", "pg-state-idle"); + alert(e.message); + } + }); + + document.getElementById("pgPause").addEventListener("click", async () => { + try { await send("chunky pause"); } catch (e) { alert(e.message); } + }); + document.getElementById("pgContinue").addEventListener("click", async () => { + try { + await send("chunky continue"); + setState("Running", "pg-state-running"); + } catch (e) { alert(e.message); } + }); + document.getElementById("pgCancel").addEventListener("click", async () => { + if (!confirm("Cancel the current pre-generation run?")) return; + try { + setState("Cancelling…", "pg-state-cancelling"); + await send("chunky cancel"); + } catch (e) { alert(e.message); } + }); + + // Subscribe to the shared SSE re-broadcast from console.js + document.addEventListener("serverlog", e => { + const msg = e.detail?.m; + if (typeof msg !== "string") return; + const parsed = parseChunky(msg); + if (parsed) applyParsed(parsed); + }); +} diff --git a/server/wwwroot/modules/serverControls.js b/server/wwwroot/modules/serverControls.js new file mode 100644 index 0000000..8fab107 --- /dev/null +++ b/server/wwwroot/modules/serverControls.js @@ -0,0 +1,16 @@ +// Start / stop buttons. +"use strict"; + +import { api } from "./api.js"; + +export function setupServerControls() { + document.getElementById("btnStart").addEventListener("click", async () => { + try { await api("/api/server/start", { method: "POST" }); } + catch (e) { alert(e.message); } + }); + document.getElementById("btnStop").addEventListener("click", async () => { + if (!confirm("Stop the server?")) return; + try { await api("/api/server/stop", { method: "POST" }); } + catch (e) { alert(e.message); } + }); +} diff --git a/server/wwwroot/modules/settings.js b/server/wwwroot/modules/settings.js new file mode 100644 index 0000000..e3d6b5f --- /dev/null +++ b/server/wwwroot/modules/settings.js @@ -0,0 +1,135 @@ +// Server settings: read/write a curated subset of server.properties. +// Changes require an MC restart -- Save writes only, Save & restart bounces MC. +"use strict"; + +import { api } from "./api.js"; + +const els = {}; + +// Map of input element ID -> server.properties key. Keeps the form ↔ file +// translation in one place; new fields can be added by adding a row here + +// matching elements in index.html. +const FIELDS = [ + { id: "ssfMotd", key: "motd", type: "string" }, + { id: "ssfGamemode", key: "gamemode", type: "string" }, + { id: "ssfDifficulty", key: "difficulty", type: "string" }, + { id: "ssfViewDistance", key: "view-distance", type: "int" }, + { id: "ssfSimulationDistance", key: "simulation-distance", type: "int" }, + { id: "ssfMaxPlayers", key: "max-players", type: "int" }, + { id: "ssfSpawnProtection", key: "spawn-protection", type: "int" }, + { id: "ssfPvp", key: "pvp", type: "bool" }, + { id: "ssfHardcore", key: "hardcore", type: "bool" }, + { id: "ssfAllowFlight", key: "allow-flight", type: "bool" }, + { id: "ssfWhiteList", key: "white-list", type: "bool" }, + { id: "ssfEnforceWhitelist", key: "enforce-whitelist", type: "bool" }, + { id: "ssfEnableCommandBlock", key: "enable-command-block", type: "bool" }, +]; + +function readForm() { + const out = {}; + for (const f of FIELDS) { + const el = document.getElementById(f.id); + if (!el) continue; + if (f.type === "bool") out[f.key] = el.checked ? "true" : "false"; + else if (f.type === "int") { + const v = parseInt(el.value, 10); + if (Number.isFinite(v)) out[f.key] = String(v); + } else { + out[f.key] = el.value; + } + } + return out; +} + +function writeForm(values) { + for (const f of FIELDS) { + const el = document.getElementById(f.id); + if (!el) continue; + const v = values[f.key]; + if (v === undefined) continue; + if (f.type === "bool") el.checked = (v === "true"); + else el.value = v; + } +} + +function renderSummary(values) { + document.getElementById("ssMotd").textContent = values["motd"] ?? "--"; + document.getElementById("ssDifficulty").textContent = values["difficulty"] ?? "--"; + document.getElementById("ssDistances").textContent = + `${values["view-distance"] ?? "--"} / ${values["simulation-distance"] ?? "--"}`; + document.getElementById("ssMaxPlayers").textContent = values["max-players"] ?? "--"; + const wl = values["white-list"] === "true"; + const enf = values["enforce-whitelist"] === "true"; + document.getElementById("ssWhitelist").textContent = + wl ? (enf ? "enforced" : "enabled") : "off"; +} + +async function refresh() { + try { + const data = await api("/api/server/settings"); + renderSummary(data.values || {}); + writeForm(data.values || {}); + } catch { /* ignore -- panel just shows last-known */ } +} + +async function postSettings() { + const payload = readForm(); + const res = await fetch("/api/server/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return { ok: res.ok, body: await res.json().catch(() => ({})) }; +} + +function showMsg(text, ok = false) { + els.msg.className = ok ? "acct-msg ok" : "acct-msg"; + els.msg.textContent = text; +} + +export function setupSettings() { + els.msg = document.getElementById("ssMsg"); + els.save = document.getElementById("ssSave"); + els.restart = document.getElementById("ssRestart"); + if (!els.save) return; + + els.save.addEventListener("click", async () => { + showMsg("Saving..."); + els.save.disabled = true; + try { + const r = await postSettings(); + if (!r.ok || r.body.ok === false) { + showMsg(r.body.error || `Error ${r.body.status ?? ""}`); + return; + } + showMsg(r.body.restartRequired + ? "Saved. Restart for changes to take effect." + : "Saved.", true); + refresh(); + } catch (e) { showMsg(e.message); } + finally { els.save.disabled = false; } + }); + + els.restart.addEventListener("click", async () => { + if (!confirm("Save changes and restart the server now? Players will be disconnected briefly.")) return; + showMsg("Saving + restarting..."); + els.save.disabled = true; els.restart.disabled = true; + try { + const r = await postSettings(); + if (!r.ok || r.body.ok === false) { + showMsg(r.body.error || `Save failed: ${r.body.status ?? ""}`); + return; + } + const rr = await fetch("/api/server/restart", { method: "POST" }); + const rb = await rr.json().catch(() => ({})); + if (!rr.ok || rb.ok === false) showMsg("Saved, but restart failed: " + (rb.error || rr.status)); + else showMsg("Saved + restarting. New settings live in ~30s.", true); + refresh(); + } catch (e) { showMsg(e.message); } + finally { els.save.disabled = false; els.restart.disabled = false; } + }); + + refresh(); + // Light poll: pick up out-of-band edits to server.properties. + setInterval(refresh, 30000); +} diff --git a/server/wwwroot/modules/state.js b/server/wwwroot/modules/state.js new file mode 100644 index 0000000..881f2ab --- /dev/null +++ b/server/wwwroot/modules/state.js @@ -0,0 +1,18 @@ +// Shared in-memory state -- the union of online + whitelisted players is what +// tab-completion matches against, so we keep it centralised here. +"use strict"; + +export const state = { + onlinePlayers: [], + whitelistedPlayers: [], + knownPlayers: [], // sorted union, for autocomplete + cmdHistory: [], + cmdHistoryIdx: -1, +}; + +export function rebuildKnownPlayers() { + const set = new Set(); + state.onlinePlayers.forEach(n => set.add(n)); + state.whitelistedPlayers.forEach(n => set.add(n)); + state.knownPlayers = [...set].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); +} diff --git a/server/wwwroot/modules/update.js b/server/wwwroot/modules/update.js new file mode 100644 index 0000000..52a6256 --- /dev/null +++ b/server/wwwroot/modules/update.js @@ -0,0 +1,138 @@ +// Modpack update controls. +// +// The card hides itself when there's no update available and reveals when the +// manifest reports a newer pack version. Polls /api/update/status (every 5 s +// when idle, every 1 s when an update is in-flight) to keep state fresh. +"use strict"; + +import { api, apiJson } from "./api.js"; + +const els = {}; +let pollTimer = null; +let pollInterval = 5000; + +function setPolling(intervalMs) { + if (intervalMs === pollInterval && pollTimer) return; + if (pollTimer) clearInterval(pollTimer); + pollInterval = intervalMs; + pollTimer = setInterval(tick, intervalMs); +} + +async function tick() { + let s; + try { s = await api("/api/update/status"); } + catch { return; } + + els.current.textContent = s.current ?? "--"; + els.available.textContent = s.available ?? "--"; + + const card = els.card; + if (s.needsUpdate || s.inProgress) { + card.hidden = false; + card.classList.toggle("has-update", s.needsUpdate && !s.inProgress); + } else { + card.hidden = true; + } + + if (s.inProgress) { + els.progress.hidden = false; + els.start.disabled = true; + els.delay.disabled = true; + els.phase.textContent = phaseLabel(s.phase); + + const showCancel = s.phase === "countdown"; + els.cancel.hidden = !showCancel; + + if (s.phase === "countdown" && s.countdownTotal > 0) { + const elapsed = s.countdownTotal - s.countdownRemaining; + const pct = (elapsed / s.countdownTotal) * 100; + els.fill.style.width = `${pct}%`; + els.status.textContent = `Restarting in ${formatSeconds(s.countdownRemaining)}`; + } else { + // Indeterminate during sync / loader install / start phases -- + // just show 100% and a phase-specific status string. + els.fill.style.width = "100%"; + els.status.textContent = phaseStatus(s.phase); + } + setPolling(1000); + } else { + els.progress.hidden = true; + els.start.disabled = !s.needsUpdate; + els.delay.disabled = false; + if (s.phase === "failed" && s.error) { + els.progress.hidden = false; + els.phase.textContent = "FAILED"; + els.status.textContent = s.error; + els.fill.style.width = "0%"; + } + setPolling(5000); + } +} + +function phaseLabel(phase) { + switch (phase) { + case "countdown": return "COUNTDOWN"; + case "stopping": return "STOPPING"; + case "syncing": return "SYNCING MODS"; + case "installing_loader": return "INSTALLING LOADER"; + case "starting": return "STARTING"; + case "complete": return "COMPLETE"; + case "failed": return "FAILED"; + case "cancelled": return "CANCELLED"; + default: return "WORKING"; + } +} + +function phaseStatus(phase) { + switch (phase) { + case "stopping": return "Stopping Minecraft cleanly..."; + case "syncing": return "Syncing mods from manifest..."; + case "installing_loader": return "Re-running NeoForge installer..."; + case "starting": return "Starting Minecraft..."; + case "complete": return "Update complete."; + default: return ""; + } +} + +function formatSeconds(s) { + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60), r = s % 60; + return `${m}m ${String(r).padStart(2, "0")}s`; +} + +export function setupUpdate() { + els.card = document.getElementById("updateCard"); + els.current = document.getElementById("updCurrent"); + els.available = document.getElementById("updAvailable"); + els.delay = document.getElementById("updDelay"); + els.start = document.getElementById("updStart"); + els.progress = document.getElementById("updProgress"); + els.phase = document.getElementById("updPhaseLabel"); + els.fill = document.getElementById("updProgressFill"); + els.status = document.getElementById("updStatusText"); + els.cancel = document.getElementById("updCancel"); + + els.start.addEventListener("click", async () => { + const delay = parseInt(els.delay.value, 10); + if (!Number.isFinite(delay) || delay < 0) { + alert("Enter a non-negative warning duration."); + return; + } + if (!confirm(`Update modpack? Players get a ${delay}s warning, then the server restarts.`)) return; + try { + await apiJson("/api/update/start", { delaySeconds: delay }); + await tick(); + } catch (e) { alert(e.message); } + }); + + els.cancel.addEventListener("click", async () => { + if (!confirm("Cancel the countdown? Update will be aborted; server stays running.")) return; + try { + await apiJson("/api/update/cancel", {}); + await tick(); + } catch (e) { alert(e.message); } + }); + + tick(); + setPolling(5000); +} diff --git a/server/wwwroot/modules/whitelist.js b/server/wwwroot/modules/whitelist.js new file mode 100644 index 0000000..42a15cf --- /dev/null +++ b/server/wwwroot/modules/whitelist.js @@ -0,0 +1,86 @@ +// Whitelist add / remove via the API; refreshes the panel display shortly after +// each action (server takes ~1-2 s to look up UUID via Mojang and write whitelist.json). +"use strict"; + +import { api, apiJson, escapeHtml } from "./api.js"; + +export function setupWhitelistActions(refreshSoon) { + const wlInput = document.getElementById("wlInput"); + document.getElementById("wlAdd").addEventListener("click", () => addWhitelisted(refreshSoon)); + wlInput.addEventListener("keydown", e => { if (e.key === "Enter") addWhitelisted(refreshSoon); }); + + // Delegated removal -- list items are re-rendered each tick, no static binding. + document.getElementById("whitelist").addEventListener("click", async e => { + const btn = e.target.closest(".wl-remove"); + if (!btn) return; + const name = btn.dataset.name; + if (!name) return; + if (!confirm(`Remove ${name} from whitelist?`)) return; + try { + await apiJson("/api/whitelist/remove", { name }); + refreshSoon(); + } catch (err) { alert(err.message); } + }); + + // Pending whitelist requests from friends. Approve adds to whitelist + clears + // the request; Deny just marks denied so the friend's launcher knows. + const reqsList = document.getElementById("wlRequests"); + const reqsBlock = document.getElementById("wlRequestsBlock"); + const reqsBadge = document.getElementById("wlReqBadge"); + + reqsList?.addEventListener("click", async e => { + const btn = e.target.closest("button[data-req-action]"); + if (!btn) return; + const name = btn.dataset.name; + const action = btn.dataset.reqAction; // "approve" | "deny" + if (!name || !action) return; + if (action === "deny" && !confirm(`Deny ${name}'s request?`)) return; + try { + await apiJson(`/api/whitelist/requests/${action}`, { name }); + await refreshRequests(); + // Approving fires /whitelist add via stdin -- let the server-side write + // ~1-2 s of grace before re-reading whitelist.json. + if (action === "approve") refreshSoon(); + } catch (err) { alert(err.message); } + }); + + async function refreshRequests() { + if (!reqsList || !reqsBlock || !reqsBadge) return; + let data; + try { data = await api("/api/whitelist/requests"); } + catch { return; } + const reqs = data.requests || []; + if (reqs.length === 0) { + reqsBlock.hidden = true; + reqsBadge.hidden = true; + return; + } + reqsBlock.hidden = false; + reqsBadge.hidden = false; + reqsBadge.textContent = String(reqs.length); + reqsList.innerHTML = reqs.map(r => ` +
  • +
    ${escapeHtml(r.username)}
    + ${r.message ? `
    "${escapeHtml(r.message)}"
    ` : ""} +
    + + +
    +
  • + `).join(""); + } + + refreshRequests(); + setInterval(refreshRequests, 15000); +} + +async function addWhitelisted(refreshSoon) { + const inp = document.getElementById("wlInput"); + const name = inp.value.trim(); + if (!name) return; + try { + await apiJson("/api/whitelist/add", { name }); + inp.value = ""; + refreshSoon(); + } catch (e) { alert(e.message); } +} diff --git a/server/wwwroot/styles.css b/server/wwwroot/styles.css new file mode 100644 index 0000000..b9ba053 --- /dev/null +++ b/server/wwwroot/styles.css @@ -0,0 +1,521 @@ +:root { + --bg-deep: #070b16; + --bg: #0b1220; + --card: #13192a; + --card-edge: #2a3552; + --text: #e8dfc8; + --text-muted: #7a8497; + --brass: #d4a24c; + --brass-hi: #e8b95c; + --brass-lo: #5c4519; + --magic: #5dd4e8; + --danger: #b94228; + --ok: #4ade80; +} + +html, body { + margin: 0; padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; + min-height: 100vh; +} + +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 22px; + border-bottom: 1px solid var(--brass-lo); + background: linear-gradient(180deg, #0f1626 0%, var(--bg-deep) 100%); +} +.topbar h1 { + font-size: 16px; margin: 0; + color: var(--brass-hi); + font-weight: 600; letter-spacing: 0.04em; +} + +.status-pill { + display: inline-flex; align-items: center; gap: 8px; + padding: 6px 12px; border-radius: 99px; + background: var(--bg-deep); border: 1px solid var(--card-edge); + font-size: 13px; +} +.status-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); } +.status-pill.online .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); } +.status-pill.offline .dot { background: var(--danger); } + +.layout { + max-width: 1400px; margin: 22px auto; padding: 0 22px; + display: grid; grid-template-columns: 280px 1fr 280px; gap: 18px; +} +/* Below the 3-column breakpoint: drop the right sidebar to a new full-width row + under the main + left sidebar so cards still get reasonable horizontal space. */ +@media (max-width: 1100px) { + .layout { grid-template-columns: 280px 1fr; } + .aside-right { grid-column: 1 / -1; } +} +@media (max-width: 800px) { + .layout { grid-template-columns: 1fr; } + .aside-right { grid-column: 1 / -1; } +} + +.card { + background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%); + border: 1px solid var(--brass-lo); + border-radius: 8px; + padding: 18px; +} +.card + .card { margin-top: 14px; } +.card h2 { + margin: 0 0 14px; + font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--brass-hi); font-weight: 600; +} + +.stat-row { display: flex; justify-content: space-between; margin: 6px 0; font-size: 13px; } +.stat-row .key { color: var(--text-muted); } +.stat-row .val { color: var(--text); font-family: "SF Mono", Consolas, monospace; } + +.name-list { list-style: none; padding: 0; margin: 0; } +.name-list li { + padding: 6px 8px; background: var(--bg-deep); border-radius: 4px; + margin-bottom: 4px; font-size: 13px; + display: flex; align-items: center; justify-content: space-between; +} +.name-list li button { + background: transparent; border: 1px solid var(--card-edge); + color: var(--text-muted); padding: 2px 8px; font-size: 11px; + border-radius: 3px; cursor: pointer; +} +.name-list li button:hover { color: var(--danger); border-color: var(--danger); } +.empty-state { color: var(--text-muted); font-size: 13px; padding: 8px 0; font-style: italic; } + +.console-pane { + background: #050810; + border: 1px solid var(--card-edge); + border-radius: 4px; + padding: 12px; + font-family: "SF Mono", Consolas, "Cascadia Mono", monospace; + font-size: 12px; line-height: 1.4; + color: #b7c0d6; + height: 480px; overflow-y: auto; + white-space: pre-wrap; word-break: break-word; +} +.console-pane .err { color: #ff8a72; } + +.input-row { display: flex; gap: 8px; margin-top: 10px; } +.input-wrap { + flex: 1; position: relative; + background: var(--bg-deep); + border: 1px solid var(--card-edge); + border-radius: 4px; +} +.input-wrap:focus-within { border-color: var(--brass); } + +.ghost { + position: absolute; inset: 0; + padding: 8px 12px; + font-family: "SF Mono", Consolas, monospace; font-size: 13px; + line-height: normal; + color: var(--text-muted); + pointer-events: none; + white-space: pre; + overflow: hidden; + opacity: 0.55; +} +.ghost .typed { color: transparent; } + +.input-wrap input { + width: 100%; box-sizing: border-box; + background: transparent; + border: none; + color: var(--text); + padding: 8px 12px; + font-family: "SF Mono", Consolas, monospace; font-size: 13px; + position: relative; +} +.input-wrap input:focus { outline: none; } +/* Hide the browser's built-in number-input spinner -- looks out of place against the dark theme */ +.input-wrap input[type="number"]::-webkit-inner-spin-button, +.input-wrap input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; margin: 0; +} +.input-wrap input[type="number"] { -moz-appearance: textfield; } + +/* Suggestion dropdown -- shown below the command input with multiple matches */ +.suggest-list { + position: absolute; + top: calc(100% + 4px); + left: 0; right: 0; + background: var(--bg-deep); + border: 1px solid var(--brass-lo); + border-radius: 4px; + max-height: 220px; + overflow-y: auto; + z-index: 10; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); + display: none; +} +.suggest-list.show { display: block; } +.suggest-item { + padding: 7px 12px; + cursor: pointer; + font-family: "SF Mono", Consolas, monospace; + font-size: 13px; + color: var(--text); + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} +.suggest-item + .suggest-item { + border-top: 1px solid #1a2436; +} +.suggest-item:hover, .suggest-item.active { + background: var(--card); + color: var(--brass-hi); +} +.suggest-item .args { + color: var(--text-muted); + font-size: 11px; + font-style: italic; +} +.suggest-item .args em { font-style: normal; color: var(--brass); } +.suggest-empty { padding: 8px 12px; color: var(--text-muted); font-size: 12px; font-style: italic; } + +.hint { + font-size: 11px; color: var(--text-muted); + padding: 6px 0 0 4px; min-height: 16px; +} +.hint kbd { + background: var(--bg-deep); border: 1px solid var(--card-edge); + padding: 1px 4px; border-radius: 2px; font-size: 10px; +} + +button { + background: linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 50%, var(--brass-lo) 100%); + color: #1a140f; + border: 1px solid var(--brass-lo); + padding: 8px 16px; border-radius: 4px; + font-weight: 600; cursor: pointer; font-size: 13px; +} +button:hover { filter: brightness(1.1); } +button.danger { + background: linear-gradient(180deg, #d65a3e 0%, var(--danger) 50%, #6a2814 100%); + color: #fff; + border-color: #6a2814; +} +button.ghost-btn { + background: var(--bg-deep); + color: var(--text); + border-color: var(--card-edge); +} + +.actions { display: flex; gap: 8px; flex-wrap: wrap; } +.footer { color: var(--text-muted); font-size: 11px; text-align: center; padding: 22px; } + +/* Modal dialogs (Pregen / Backups / Wipe / etc.) */ +.modal { + position: fixed; inset: 0; z-index: 50; + display: flex; align-items: center; justify-content: center; + padding: 20px; +} +.modal[hidden] { display: none; } +.modal-backdrop { + position: absolute; inset: 0; + background: rgba(7, 11, 22, 0.85); + backdrop-filter: blur(2px); +} +.modal-dialog { + position: relative; + background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%); + border: 1px solid var(--brass-lo); + border-radius: 8px; + padding: 24px; + width: 100%; max-width: 520px; + max-height: 85vh; overflow-y: auto; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6); +} +.modal-dialog.danger { border-color: #6a2814; } +.modal-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid var(--card-edge); +} +.modal-header h2 { + margin: 0; + font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--brass-hi); font-weight: 600; +} +.modal-dialog.danger .modal-header h2 { color: #d65a3e; } +.modal-close { + background: transparent; border: none; + color: var(--text-muted); font-size: 22px; line-height: 1; + cursor: pointer; padding: 0 4px; +} +.modal-close:hover { color: var(--text); } + +/* Trigger button list (the "World" card, etc.) */ +.trigger-list { + display: flex; flex-direction: column; gap: 6px; +} +.trigger-list button { + width: 100%; text-align: left; + display: flex; align-items: center; justify-content: space-between; + gap: 8px; +} +.trigger-list .badge { + background: var(--bg-deep); border: 1px solid var(--card-edge); + color: var(--text-muted); + padding: 1px 6px; border-radius: 99px; + font-size: 10px; font-weight: 500; +} +.trigger-list .badge.ok { color: var(--ok); border-color: var(--ok); } +.trigger-list .badge.warn { color: var(--brass-hi); border-color: var(--brass-lo); } + +/* Topbar server icon */ +.topbar-icon { + height: 28px; width: 28px; + margin-right: 10px; + image-rendering: -webkit-optimize-contrast; +} +.topbar-left { + display: flex; align-items: center; +} + +/* Login overlay */ +.login-overlay { + position: fixed; inset: 0; z-index: 100; + background: rgba(7, 11, 22, 0.92); + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(2px); +} +.login-overlay[hidden] { display: none; } +.login-box { + background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%); + border: 1px solid var(--brass-lo); + border-radius: 8px; + padding: 28px 32px; + width: 320px; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6); +} +.login-box h2 { + margin: 0 0 4px; + font-size: 16px; letter-spacing: 0.04em; + color: var(--brass-hi); font-weight: 600; +} +.login-box p { + margin: 0 0 16px; color: var(--text-muted); font-size: 13px; +} +.login-box .input-wrap { margin-bottom: 12px; } +.login-box button { width: 100%; } +.login-error { color: var(--danger); font-size: 12px; min-height: 14px; padding-top: 8px; } + +/* Account card */ +.acct-form { font-size: 12px; } +.acct-msg { font-size: 12px; min-height: 14px; margin-top: 8px; color: var(--danger); } +.acct-msg.ok { color: var(--ok); } + +/* Modpack update card */ +#updateCard .update-note { + font-size: 12px; color: var(--text-muted); + margin: 10px 0 0; line-height: 1.4; +} +#updateCard .update-progress { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--card-edge); } +#updateCard .update-phase { + font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--brass-hi); font-weight: 600; margin-bottom: 6px; +} +#updateCard .update-status { font-size: 12px; color: var(--text); margin-top: 6px; } +#updateCard .update-progress button { margin-top: 10px; } +/* Pulsing border highlight when an update is available */ +#updateCard.has-update { + border-color: var(--brass); + box-shadow: 0 0 0 1px var(--brass-lo), 0 0 18px rgba(212, 162, 76, 0.18); +} + +/* Resources card -- Memory + CPU with progress bars */ +.res-block { margin-bottom: 14px; } +.res-block:last-child { margin-bottom: 0; } +.res-label { + display: flex; justify-content: space-between; align-items: baseline; + font-size: 12px; color: var(--text-muted); + margin-bottom: 6px; +} +.res-label .res-val { color: var(--text); font-family: "SF Mono", Consolas, monospace; } +.res-bar { + height: 6px; + background: var(--bg-deep); + border: 1px solid var(--card-edge); + border-radius: 3px; + overflow: hidden; +} +.res-bar > div { + height: 100%; width: 0%; + background: linear-gradient(90deg, var(--brass-lo), var(--brass-hi)); + transition: width 0.4s ease; +} +.res-sub { + display: flex; justify-content: space-between; + margin-top: 6px; + font-size: 11px; color: var(--text-muted); +} +.res-sub strong { + color: var(--text); font-family: "SF Mono", Consolas, monospace; + font-weight: 500; margin-left: 4px; +} + +/* Collapsible cards (h2 click toggles) */ +.card.collapsible .collapsible-toggle { + cursor: pointer; + user-select: none; + display: flex; align-items: center; gap: 8px; +} +.card.collapsible .caret { + display: inline-block; + transition: transform 0.2s ease; + color: var(--brass); + font-size: 12px; +} +.card.collapsible:not(.expanded) .caret { transform: rotate(-90deg); } +.card.collapsible:not(.expanded) .card-body { display: none; } +.card.collapsible:not(.expanded) h2 { margin: 0; } + +/* Server settings modal */ +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 14px; +} +.settings-grid label { + display: flex; flex-direction: column; gap: 4px; + font-size: 11px; color: var(--text-muted); +} +.settings-grid input, .settings-grid select { + background: var(--bg-deep); + border: 1px solid var(--card-edge); + border-radius: 4px; + color: var(--text); + padding: 6px 8px; + font-family: "SF Mono", Consolas, monospace; + font-size: 12px; +} +.settings-grid input:focus, .settings-grid select:focus { + outline: none; border-color: var(--brass); +} +.settings-grid label:nth-child(1) { grid-column: 1 / -1; } /* MOTD spans both cols */ +.settings-checks { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 14px; + padding-top: 10px; + border-top: 1px solid var(--card-edge); +} +.settings-checks label { font-size: 12px; } + +/* Whitelist requests */ +.wl-req-label { + font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--brass-hi); font-weight: 600; + margin: 6px 0 6px 2px; +} +#wlRequests li { + flex-direction: column; align-items: stretch; gap: 6px; +} +.wl-req-meta { font-size: 11px; color: var(--text); } +.wl-req-msg { font-size: 11px; color: var(--text-muted); font-style: italic; } +.wl-req-actions { display: flex; gap: 4px; } +.wl-req-actions button { padding: 3px 8px; font-size: 10px; } +.card h2 .badge { vertical-align: middle; margin-left: 6px; } + +/* Backups card */ +.backup-item { + display: flex; justify-content: space-between; align-items: center; + gap: 8px; padding: 8px 10px; + flex-wrap: wrap; +} +.backup-meta { flex: 1; min-width: 0; } +.backup-name { + font-family: "SF Mono", Consolas, monospace; font-size: 11px; + color: var(--text); word-break: break-all; +} +.backup-sub { font-size: 10px; color: var(--text-muted); margin-top: 2px; } +.backup-actions { display: flex; gap: 4px; flex-shrink: 0; } +.backup-actions button { font-size: 10px; padding: 3px 8px; } + +/* Danger zone card */ +.danger-card { border-color: #6a2814; } +.danger-card .collapsible-toggle .caret { color: var(--danger); } +.danger-card h2 { color: #d65a3e; } +.danger-note { + font-size: 12px; color: var(--text-muted); + margin: 0 0 12px; line-height: 1.45; +} +.danger-note code { + background: var(--bg-deep); border: 1px solid var(--card-edge); + padding: 1px 5px; border-radius: 3px; font-size: 11px; +} +.danger-row { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--text); + cursor: pointer; +} +.danger-row input[type="checkbox"] { margin: 0; } +.danger-section { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--card-edge); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); +} +.danger-section-title { + font-size: 12px; font-weight: 600; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 8px; + letter-spacing: 0.4px; +} +.danger-section .danger-row { margin: 4px 0; } +.danger-section code { + font-family: var(--mono, monospace); + font-size: 12px; + color: var(--accent, #d4a24c); +} +.danger-section input[type="text"] { + background: var(--input-bg, #0b1220); + color: var(--text); + border: 1px solid var(--card-edge); + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + font-family: var(--mono, monospace); +} +.danger-section input[type="text"]:disabled { opacity: 0.4; } + +/* Pre-generation status panel */ +.pg-status { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--card-edge); + font-size: 12px; +} +.pg-status .stat-row { font-size: 12px; margin: 4px 0; } +.pg-progress-bar { + height: 6px; + background: var(--bg-deep); + border: 1px solid var(--card-edge); + border-radius: 3px; + margin: 8px 0 10px; + overflow: hidden; +} +.pg-progress-bar > div { + height: 100%; + background: linear-gradient(90deg, var(--brass-lo), var(--brass-hi)); + width: 0%; + transition: width 0.4s ease; +} +.pg-state-running { color: var(--ok); } +.pg-state-paused { color: var(--brass-hi); } +.pg-state-idle { color: var(--text-muted); } +.pg-state-cancelling { color: var(--danger); }