Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+62
View File
@@ -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
+21
View File
@@ -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.
+68
View File
@@ -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 <remote> 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/<name>/` 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`.
+9
View File
@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ModpackLauncher.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+23
View File
@@ -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();
}
}
+98
View File
@@ -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)"
+43
View File
@@ -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})"
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+395
View File
@@ -0,0 +1,395 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="ModpackLauncher.MainWindow"
Title="Modpack Launcher"
Width="900" Height="640"
MinWidth="720" MinHeight="540"
Background="Transparent"
TransparencyLevelHint="Transparent"
WindowDecorations="None"
Icon="/Assets/icon.png"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<!-- Palette: dark navy base + brass trim + cyan magic accent -->
<!-- Brass gradient (lit from top, polished) -->
<LinearGradientBrush x:Key="BrassTrim" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#F5D88A" />
<GradientStop Offset="0.35" Color="#E8B95C" />
<GradientStop Offset="0.65" Color="#A37A2E" />
<GradientStop Offset="1" Color="#5C4519" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="BrassFill" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#F5D88A" />
<GradientStop Offset="0.5" Color="#D4A24C" />
<GradientStop Offset="1" Color="#8C6829" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="BrassFillHover" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FBE3A0" />
<GradientStop Offset="0.5" Color="#E8B95C" />
<GradientStop Offset="1" Color="#A37A2E" />
</LinearGradientBrush>
<!-- Rivet (radial gradient, lit from upper-left) -->
<RadialGradientBrush x:Key="RivetFill" Center="35%,35%" GradientOrigin="35%,35%" RadiusX="0.55" RadiusY="0.55">
<GradientStop Offset="0" Color="#F8DC95" />
<GradientStop Offset="0.5" Color="#C99843" />
<GradientStop Offset="1" Color="#3F2E10" />
</RadialGradientBrush>
<!-- Card fill (deep navy gradient) -->
<LinearGradientBrush x:Key="CardFill" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#13192A" />
<GradientStop Offset="1" Color="#0A0F1A" />
</LinearGradientBrush>
<!-- Title bar metallic -->
<LinearGradientBrush x:Key="TitleBarFill" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#0F1626" />
<GradientStop Offset="1" Color="#070B16" />
</LinearGradientBrush>
<!-- Magic cyan glow (radial) -->
<RadialGradientBrush x:Key="MagicGlow" Center="50%,50%" GradientOrigin="50%,50%" RadiusX="0.8" RadiusY="0.8">
<GradientStop Offset="0" Color="#605DD4E8" />
<GradientStop Offset="0.5" Color="#205DD4E8" />
<GradientStop Offset="1" Color="#005DD4E8" />
</RadialGradientBrush>
<!-- Tileable noise overlay -->
<ImageBrush x:Key="NoiseBrush" Source="/Assets/noise.png"
TileMode="Tile" Stretch="None"
DestinationRect="0,0,128,128" />
</Window.Resources>
<Window.Styles>
<!-- Reusable "brass panel" with metal trim + corner rivets -->
<Style Selector="ContentControl.brass-panel">
<Setter Property="Padding" Value="20" />
<Setter Property="Template">
<ControlTemplate>
<Grid>
<!-- Outer brass trim -->
<Border BorderBrush="{StaticResource BrassTrim}" BorderThickness="2" CornerRadius="6"
Background="{StaticResource CardFill}">
<ContentPresenter Content="{TemplateBinding Content}"
Margin="{TemplateBinding Padding}" />
</Border>
<!-- Corner rivets -->
<Ellipse Width="6" Height="6" HorizontalAlignment="Left" VerticalAlignment="Top"
Margin="6" Fill="{StaticResource RivetFill}" />
<Ellipse Width="6" Height="6" HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="6" Fill="{StaticResource RivetFill}" />
<Ellipse Width="6" Height="6" HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="6" Fill="{StaticResource RivetFill}" />
<Ellipse Width="6" Height="6" HorizontalAlignment="Right" VerticalAlignment="Bottom"
Margin="6" Fill="{StaticResource RivetFill}" />
</Grid>
</ControlTemplate>
</Setter>
</Style>
<!-- Primary button: brass gradient + cyan magic hover glow -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource BrassFill}" />
<Setter Property="Foreground" Value="#1A140F" />
<Setter Property="Padding" Value="26 12" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="BorderBrush" Value="#5C4519" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.primary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource BrassFillHover}" />
</Style>
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="#3A2D1E" />
<Setter Property="Foreground" Value="#6B5F4A" />
</Style>
<!-- Secondary button -->
<Style Selector="Button.secondary">
<Setter Property="Background" Value="#1B233A" />
<Setter Property="Foreground" Value="#E8DFC8" />
<Setter Property="Padding" Value="14 8" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="BorderBrush" Value="#A37A2E" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.secondary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#26314D" />
<Setter Property="BorderBrush" Value="#E8B95C" />
</Style>
<Style Selector="TextBlock.h1">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="#E8DFC8" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="#7A8497" />
<Setter Property="FontSize" Value="13" />
</Style>
<!-- Brass progress bar -->
<Style Selector="ProgressBar.brass">
<Setter Property="Foreground" Value="{StaticResource BrassFill}" />
<Setter Property="Background" Value="#0A0F1A" />
<Setter Property="BorderBrush" Value="{StaticResource BrassTrim}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- Custom title bar caption buttons -->
<Style Selector="Button.caption">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="36" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#1B233A" />
</Style>
<Style Selector="Button.caption Path">
<Setter Property="Stroke" Value="#A37A2E" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption:pointerover Path">
<Setter Property="Stroke" Value="#F5D88A" />
</Style>
<Style Selector="Button.caption-close">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="36" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption-close:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#B94228" />
</Style>
<Style Selector="Button.caption-close Path">
<Setter Property="Stroke" Value="#A37A2E" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.caption-close:pointerover Path">
<Setter Property="Stroke" Value="#F5D88A" />
</Style>
</Window.Styles>
<!-- Outermost: main brass-bordered window + slide-out info panel (separate column, OUTSIDE the window) -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Name="RootBorder" CornerRadius="10"
Background="{StaticResource BrassTrim}"
ClipToBounds="True">
<Border CornerRadius="8" Background="#0B1220" Margin="2" ClipToBounds="True">
<Panel Background="{StaticResource NoiseBrush}">
<Grid RowDefinitions="36,1,*">
<!-- Title bar -->
<Grid Grid.Row="0" Name="TitleBar" Background="{StaticResource TitleBarFill}"
PointerPressed="OnTitleBarPressed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Magic glow halo behind icon + title text -->
<Grid Grid.Column="0" Margin="10 0 0 0" VerticalAlignment="Center" IsHitTestVisible="False">
<Ellipse Width="36" Height="36" HorizontalAlignment="Left"
Margin="-8 0 0 0" Fill="{StaticResource MagicGlow}" />
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Image Source="/Assets/icon.png" Width="22" Height="22"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<TextBlock Name="TitleText" Text="Brass &amp; Sigil Launcher"
Foreground="#F5D88A" FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Stretch">
<Button Classes="caption" Click="OnRefreshClick" ToolTip.Tip="Check for updates">
<Path Width="11" Height="11"
Data="M 9,1.5 A 4,4 0 1 0 10.5,5 M 9,1.5 V 4 H 6.5" />
</Button>
<Button Classes="caption" Click="OnInfoClick" ToolTip.Tip="Pack info">
<Path Width="11" Height="11"
Data="M 5.5,0.5 A 5,5 0 1 1 5.499,0.5 Z M 5.5,4 V 9 M 5.5,2 V 2.5" />
</Button>
<Button Classes="caption" Click="OnMinimizeClick" ToolTip.Tip="Minimize">
<Path Width="10" Height="10" Data="M 0,5 H 10" />
</Button>
<Button Classes="caption-close" Click="OnCloseClick" ToolTip.Tip="Close">
<Path Width="10" Height="10" Data="M 0,0 L 10,10 M 10,0 L 0,10" />
</Button>
</StackPanel>
</Grid>
<!-- Brass divider hairline (own row so hover backgrounds can't paint over it) -->
<Border Grid.Row="1" Background="{StaticResource BrassTrim}" IsHitTestVisible="False" />
<!-- Main content -->
<Grid Grid.Row="2" RowDefinitions="Auto,Auto,*,Auto" Margin="14">
<!-- Update-available banner (hidden until manifest reports a newer launcherVersion) -->
<Border Name="UpdateBanner" Grid.Row="0" IsVisible="False"
Background="#13192A" BorderBrush="#D4A24C" BorderThickness="1"
CornerRadius="6" Padding="12 10" Margin="0 0 0 12">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Text="ⓘ" FontSize="16"
Foreground="#E8B95C" VerticalAlignment="Center" Margin="0 0 10 0" />
<TextBlock Grid.Column="1" Name="UpdateBannerText" VerticalAlignment="Center"
Foreground="#E8DFC8" FontSize="13" TextWrapping="Wrap"
Text="A newer launcher is available." />
<Button Grid.Column="2" Name="UpdateBannerDownloadButton"
Classes="secondary" Content="Download"
Click="OnUpdateBannerDownloadClick" />
</Grid>
</Border>
<!-- Header card -->
<ContentControl Grid.Row="1" Classes="brass-panel" Margin="0 0 0 14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="4">
<TextBlock Name="PackNameText" Classes="h1" Text="Modpack" />
<TextBlock Name="PackVersionText" Classes="muted" Text="No pack synced yet" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
<TextBlock Name="UserText" Classes="muted" Text="Not signed in" VerticalAlignment="Center" />
<TextBlock Name="WhitelistStatusText" Classes="muted" Text="" IsVisible="False"
VerticalAlignment="Center" FontStyle="Italic" />
<Button Name="RequestAccessButton" Classes="secondary" Content="Request access"
Click="OnRequestAccessClick" IsVisible="False"
ToolTip.Tip="Send the server admin a request to whitelist your account." />
<Button Name="LoginButton" Classes="secondary" Content="Sign in" Click="OnLoginClick" />
<Button Name="LogoutButton" Classes="secondary" Content="Sign out"
Click="OnLogoutClick" IsVisible="False" />
</StackPanel>
</Grid>
</ContentControl>
<!-- Action + log -->
<Grid Grid.Row="2" RowDefinitions="Auto,*">
<ContentControl Grid.Row="0" Classes="brass-panel" Margin="0 0 0 14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
<TextBlock Name="StatusText" Classes="h1" Text="Ready" />
<TextBlock Name="StatusSubtext" Classes="muted"
Text="Click Play to sync the pack and launch Minecraft." />
</StackPanel>
<!-- Play button with subtle cyan glow halo behind -->
<Grid Grid.Column="1" Width="180" Height="48">
<Ellipse HorizontalAlignment="Center" VerticalAlignment="Center"
Width="200" Height="80" Fill="{StaticResource MagicGlow}"
Opacity="0.45" IsHitTestVisible="False" />
<Button Name="PlayButton" Classes="primary" Content="Play"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
Click="OnPlayClick" />
</Grid>
</Grid>
<ProgressBar Name="ProgressBar" Classes="brass" Height="8"
Minimum="0" Maximum="100" Value="0" CornerRadius="4" />
</StackPanel>
</ContentControl>
<ContentControl Grid.Row="1" Classes="brass-panel">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0 0 0 8">
<TextBlock Grid.Column="0" Classes="h1" Text="Log" FontSize="14" />
<Button Grid.Column="1" Classes="secondary" Content="Open install folder"
Click="OnOpenFolderClick" />
</Grid>
<Border Grid.Row="1" Background="#070B16" CornerRadius="4" Padding="10"
BorderBrush="#1B233A" BorderThickness="1">
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto">
<TextBlock Name="LogText" FontFamily="Cascadia Mono, Consolas, monospace"
FontSize="12" Foreground="#B7C0D6" TextWrapping="Wrap" />
</ScrollViewer>
</Border>
</Grid>
</ContentControl>
</Grid>
<!-- Footer / settings -->
<ContentControl Grid.Row="3" Classes="brass-panel" Margin="0 14 0 0">
<StackPanel Spacing="10">
<!-- RAM allocation -->
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<TextBlock Grid.Column="0" Text="RAM" VerticalAlignment="Center" Classes="muted" />
<NumericUpDown Grid.Column="1" Name="RamBox" Margin="10 0 10 0" Width="140"
Minimum="2048" Maximum="65536" Increment="1024" FormatString="0" />
<TextBlock Grid.Column="2" Text="MB" VerticalAlignment="Center" Classes="muted" Margin="0 0 10 0" />
<TextBlock Grid.Column="3" Name="RamWarningText" VerticalAlignment="Center"
FontSize="11" TextWrapping="Wrap" />
</Grid>
<!-- Install location -->
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="Install location" VerticalAlignment="Center" Classes="muted" />
<TextBlock Grid.Column="1" Name="InstallDirText" VerticalAlignment="Center" Margin="10 0 10 0"
TextTrimming="CharacterEllipsis" Foreground="#B7C0D6" FontSize="12" />
<Button Grid.Column="2" Classes="secondary" Content="Change..." Click="OnChangeInstallDirClick" />
</Grid>
<TextBlock Foreground="#5C6478" FontSize="10" TextWrapping="Wrap" HorizontalAlignment="Center"
Text="NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." />
</StackPanel>
</ContentControl>
</Grid>
</Grid>
</Panel>
</Border>
</Border>
<!-- Slide-out container: starts at Width=0 so the Auto column contributes nothing.
InfoPanel inside has fixed Width=320; HorizontalAlignment=Right + ClipToBounds=True
on the container makes the panel "slide in" from the right as the container grows.
Window.Width is animated in lock-step with InfoPanelContainer.Width so the main
content (RootBorder, in the Star column) keeps its current width throughout.
Vertically: outer Grid is single-row (full window height) so this Border
stretches naturally; the inner ScrollViewer inherits it via its Auto,*
Grid row. No code-behind height management needed. -->
<Border Grid.Column="1" Name="InfoPanelContainer" Width="0"
ClipToBounds="True">
<Border Name="InfoPanel" HorizontalAlignment="Right" Width="320" Margin="14 0 0 0"
CornerRadius="10" Background="{StaticResource BrassTrim}"
ClipToBounds="True">
<Border CornerRadius="8" Background="#0F1622" Margin="2" ClipToBounds="True">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="14 12 8 8">
<TextBlock Grid.Column="0" Classes="h1" Text="Pack info" FontSize="14"
VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="caption" Click="OnInfoClick" ToolTip.Tip="Close"
Width="32" Height="28">
<Path Width="9" Height="9" Data="M 0,0 L 9,9 M 9,0 L 0,9" />
</Button>
</Grid>
<ScrollViewer Grid.Row="1" Name="InfoScrollViewer"
Padding="14 0 14 40"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Name="InfoPanelContent" Spacing="14" Margin="0 0 0 8" />
</ScrollViewer>
</Grid>
</Border>
</Border>
</Border>
</Grid>
</Window>
+980
View File
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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<ManifestFile> 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<ManifestFile> 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<string>(StringComparer.OrdinalIgnoreCase)
{ "neoforge", "forge", "fabric", "bundled" };
var acronyms = new System.Collections.Generic.HashSet<string>(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<ProgressReport>(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<ProgressReport>(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;
}
}
+106
View File
@@ -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; } = "";
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("installDirName")]
public string InstallDirName { get; set; } = "BrassAndSigilData";
[JsonPropertyName("memoryMB")]
public int MemoryMB { get; set; } = 8192;
[JsonPropertyName("msalClientId")]
public string MsalClientId { get; set; } = "";
/// <summary>Optional HTTP Basic auth username for the manifest URL and mod file URLs.</summary>
[JsonPropertyName("httpUsername")]
public string? HttpUsername { get; set; }
/// <summary>Optional HTTP Basic auth password (paired with HttpUsername).</summary>
[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<LauncherConfig>(json, opts) ?? new LauncherConfig();
}
/// <summary>
/// Resolve the absolute install directory. The launcher behaves as a
/// portable app: by default it installs alongside the exe in
/// <c>&lt;exe-folder&gt;/&lt;InstallDirName&gt;/</c>. The user can override
/// via the "Change..." picker, which stores the chosen *parent* folder
/// in <c>InstallDirOverride</c>; we then append <see cref="InstallDirName"/>
/// 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".
/// </summary>
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);
}
}
+54
View File
@@ -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; }
/// <summary>
/// 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.
/// </summary>
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<LauncherSettings>(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
}
}
}
+110
View File
@@ -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<ManifestFile> Files { get; set; } = new();
/// <summary>
/// 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 <see cref="LauncherUrl"/>.
/// </summary>
[JsonPropertyName("launcherVersion")]
public string? LauncherVersion { get; set; }
/// <summary>Public download URL for the latest launcher (shown in the banner).</summary>
[JsonPropertyName("launcherUrl")]
public string? LauncherUrl { get; set; }
/// <summary>
/// Optional. If present, the launcher writes this entry into the player's
/// <c>servers.dat</c> on first install so the modpack's server appears in
/// the multiplayer list automatically -- no copy-paste needed.
/// </summary>
[JsonPropertyName("defaultServer")]
public DefaultServer? DefaultServer { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("defaultShader")]
public string? DefaultShader { get; set; }
/// <summary>
/// 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.
/// </summary>
[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
{
/// <summary>"forge" | "fabric" | "neoforge" | "vanilla" (or null)</summary>
[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; }
}
+65
View File
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- net8.0-windows is required for the XboxAuthNet WebView2 OAuth flow:
the netstandard2.0 build of XboxAuthNet has no WebUI implementation. -->
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<RootNamespace>ModpackLauncher</RootNamespace>
<AssemblyName>ModpackLauncher</AssemblyName>
<Version>0.4.5</Version>
<ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) -->
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>embedded</DebugType>
<CopyDebugSymbolFilesFromPackages>false</CopyDebugSymbolFilesFromPackages>
<CopyDocumentationFilesFromPackages>false</CopyDocumentationFilesFromPackages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.2" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CmlLib.Core" Version="4.0.6" />
<PackageReference Include="CmlLib.Core.Auth.Microsoft" Version="3.3.1" />
<PackageReference Include="CmlLib.Core.Installer.Forge" Version="1.1.1" />
<PackageReference Include="CmlLib.Core.Installer.NeoForge" Version="4.0.0" />
<PackageReference Include="XboxAuthNet.Game.Msal" Version="0.1.3" />
<PackageReference Include="fNbt" Version="0.7.1" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="launcher-config.json" Condition="Exists('launcher-config.json')">
<LogicalName>launcher-config.json</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="launcher-config.template.json">
<LogicalName>launcher-config.template.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\icon.png" />
<AvaloniaResource Include="Assets\noise.png" />
</ItemGroup>
<Target Name="StripNativePdbs" AfterTargets="Publish">
<ItemGroup>
<_StripPdb Include="$(PublishDir)*.pdb" Exclude="$(PublishDir)$(AssemblyName).pdb" />
</ItemGroup>
<Delete Files="@(_StripPdb)" />
</Target>
</Project>
+24
View File
@@ -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<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
+112
View File
@@ -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%\<installDirName>\` -- 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)
+95
View File
@@ -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;
/// <summary>
/// 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).
/// </summary>
public sealed class AuthService
{
private readonly string _clientId;
public AuthService(string clientId)
{
_clientId = clientId;
}
/// <summary>True when the user has provided their own Azure App Registration ID.</summary>
public bool HasCustomClientId => !string.IsNullOrWhiteSpace(_clientId)
&& _clientId != "00000000-0000-0000-0000-000000000000";
/// <summary>Auth is always available now (BuildDefault provides a fallback).</summary>
public bool IsConfigured => true;
public async Task<MSession> AuthenticateAsync()
{
var loginHandler = await BuildLoginHandlerAsync();
try
{
return await loginHandler.AuthenticateSilently();
}
catch
{
return await loginHandler.AuthenticateInteractively();
}
}
public async Task<MSession> SignInInteractivelyAsync()
{
var loginHandler = await BuildLoginHandlerAsync();
return await loginHandler.AuthenticateInteractively();
}
public async Task<MSession?> 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<JELoginHandler> 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();
}
}
+45
View File
@@ -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 { }
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace ModpackLauncher.Services;
/// <summary>
/// Pre-seeds Iris's <c>config/iris.properties</c> 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
/// <c>shaderPack=...</c> entry, we leave it alone -- only fresh installs (or
/// installs where Iris has never been opened) get the default.
/// </summary>
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<string>();
// 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<string, string>(System.StringComparer.OrdinalIgnoreCase)
{
{ "shaderPack", shaderPackFilename },
{ "enableShaders", "true" },
};
var seen = new HashSet<string>(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);
}
}
+227
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
private async Task VerifyVersionLibrariesAsync(string versionId,
IProgress<ProgressReport> 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<string> InstallVersionAsync(
Manifest manifest,
IProgress<ProgressReport> progress,
CancellationToken ct)
{
EventHandler<InstallerProgressChangedEventArgs> 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<ByteProgress> 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<int> LaunchAsync(
string versionId,
MSession session,
int minMemoryMB,
int maxMemoryMB,
IProgress<ProgressReport> 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;
}
}
+252
View File
@@ -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);
}
/// <summary>Configure HTTP Basic auth for all subsequent requests. Pass null to clear.</summary>
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);
/// <summary>Just fetch the manifest JSON without doing the full file sync. Used for "is an update available?" checks on startup.</summary>
public async Task<Manifest> FetchManifestOnlyAsync(string manifestUrl, CancellationToken ct = default)
{
var json = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<Manifest>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (manifest == null) throw new InvalidOperationException("Manifest is empty or invalid.");
manifest.Files ??= new System.Collections.Generic.List<ManifestFile>();
return manifest;
}
/// <summary>
/// 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.
/// </summary>
public System.Collections.Generic.List<ManifestFile> FindMissingFiles(Manifest manifest, string installDir)
{
var missing = new System.Collections.Generic.List<ManifestFile>();
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<PackVersionRecord>(File.ReadAllText(path));
}
catch
{
return null;
}
}
public async Task<SyncResult> SyncAsync(
string manifestUrl,
string installDir,
IProgress<ProgressReport> 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<Manifest>(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<string>(
manifest.Files.Select(f => NormalizePath(f.Path)),
StringComparer.OrdinalIgnoreCase
);
// Remove managed files no longer in manifest
var toRemove = new List<string>();
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<ManifestFile>();
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<string> 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('/');
}
+17
View File
@@ -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
);
+101
View File
@@ -0,0 +1,101 @@
using System;
using System.IO;
using System.Linq;
using fNbt;
namespace ModpackLauncher.Services;
/// <summary>
/// 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)
/// }
/// }
/// </summary>
public sealed class ServerListService
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
+67
View File
@@ -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);
/// <summary>Total physically installed system RAM in megabytes.</summary>
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);
}
}
/// <summary>Recommended max user-allocatable RAM (leaves headroom for OS + other apps).</summary>
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);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using Microsoft.Win32;
namespace ModpackLauncher.Services;
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>
/// Returns "pending" / "approved" / "denied" / "unknown" / "" (network error).
/// </summary>
public async Task<string> GetStatusAsync(string panelUrl, string username)
{
try
{
var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/status?username={Uri.EscapeDataString(username)}";
var resp = await _http.GetFromJsonAsync<StatusResponse>(url);
return resp?.Status ?? "";
}
catch
{
return "";
}
}
public async Task<RequestResponse> 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<RequestResponse>();
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 };
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="ModpackLauncher.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+10
View File
@@ -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
}
+33
View File
@@ -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"
}
]
}
+349
View File
@@ -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"
}
+91
View File
@@ -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/<name>/ --(Build-Tweaks.ps1)--> pack/overrides/mods/<name>-<ver>.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/<name>.json` |
| Custom placed feature | `data/brassandsigil_tweaks/worldgen/placed_feature/<name>.json` |
| Lithostitched modifier | `data/brassandsigil_tweaks/lithostitched/worldgen_modifier/<name>.json` |
| Override another mod's loot table | `data/<their_modid>/loot_table/blocks/<name>.json` |
| Override another mod's recipe | `data/<their_modid>/recipe/<name>.json` |
| Override / extend a vanilla tag | `data/minecraft/tags/<registry>/<name>.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/
<my-tweak>/
META-INF/
neoforge.mods.toml # modId + version are required
pack.mcmeta # pack_format: 48 for 1.21.1
data/
<my_namespace>/
...
```
`Build-Tweaks.ps1` reads `modId` and `version` from the toml and produces
`pack/overrides/mods/<modId>-<version>.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.
@@ -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"
@@ -0,0 +1,6 @@
{
"type": "lithostitched:add_features",
"biomes": "#terralith:skylands",
"features": "brassandsigil_tweaks:skylands_end_stone_ore",
"step": "underground_ores"
}
@@ -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"
}
}
]
}
}
@@ -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" }
]
}
@@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 48,
"description": "Brass & Sigil pack tweaks"
}
}
+183
View File
@@ -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 <Version> 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 <Version> in ModpackLauncher.csproj and republish."
}
# FileVersion is the four-component form (e.g. "0.1.0.0" for csproj <Version>0.1.0</Version>).
# 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))
+115
View File
@@ -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 "<modId>-<version>.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"
+101
View File
@@ -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."
}
+231
View File
@@ -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 <Version> 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 <Version> 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
+207
View File
@@ -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."
+37
View File
@@ -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 "<this>.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'
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BrassAndSigil.Server</RootNamespace>
<AssemblyName>brass-sigil-server</AssemblyName>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults -->
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">linux-x64</RuntimeIdentifier>
<RuntimeIdentifiers>linux-x64;win-x64</RuntimeIdentifiers>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>embedded</DebugType>
<!-- Embed wwwroot/* into the assembly so the published exe is truly single-file. -->
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
<StaticWebAssetsEnabled>false</StaticWebAssetsEnabled>
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.7" />
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<!-- Replace the implicit Web SDK Content rule for wwwroot with EmbeddedResource. -->
<Content Remove="wwwroot\**" />
<EmbeddedResource Include="wwwroot\**" />
<!-- Server-list icon dropped into <serverDir>/server-icon.png on first install. -->
<EmbeddedResource Include="Assets\server-icon.png">
<LogicalName>BrassAndSigil.Server.Assets.server-icon.png</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
+11
View File
@@ -0,0 +1,11 @@
using System.ComponentModel;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
public class BaseCommandSettings : CommandSettings
{
[CommandOption("-c|--config <PATH>")]
[Description("Path to server-config.json (defaults to ./server-config.json)")]
public string ConfigPath { get; set; } = "server-config.json";
}
+90
View File
@@ -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<BaseCommandSettings>
{
public override async Task<int> 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<string?> 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; }
}
}
+141
View File
@@ -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<InstallCommand.Settings>
{
public sealed class Settings : BaseCommandSettings
{
[CommandOption("--manifest <URL>")]
[Description("Manifest URL to bootstrap from")]
public string? ManifestUrl { get; set; }
[CommandOption("--server-dir <PATH>")]
[Description("Where to install the server (defaults to ./server)")]
public string? ServerDir { get; set; }
[CommandOption("--memory <MB>")]
[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<int> 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<string>(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);
}
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
using BrassAndSigil.Server.Models;
using Spectre.Console;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
/// <summary>
/// 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.
/// </summary>
public sealed class SetPasswordCommand : Command<BaseCommandSettings>
{
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<string>("New password (min 8 chars):").Secret());
if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; }
pw2 = AnsiConsole.Prompt(new TextPrompt<string>("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;
}
}
+32
View File
@@ -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<BaseCommandSettings>
{
public override async Task<int> 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<string>(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;
}
}
}
+62
View File
@@ -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<ManifestFile> 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<LockedMod> 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; }
}
+77
View File
@@ -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;
/// <summary>"localhost" by default -- bind to 0.0.0.0 only behind a reverse proxy.</summary>
[JsonPropertyName("webHost")]
public string WebHost { get; set; } = "localhost";
/// <summary>Shared password for web UI. Required if WebHost is not localhost.</summary>
[JsonPropertyName("webPassword")]
public string? WebPassword { get; set; }
/// <summary>Where world backups land. Empty -> &lt;serverDir&gt;/../backups. Set to a
/// large/slower drive on real deployments -- backups grow over time.</summary>
[JsonPropertyName("backupDir")]
public string? BackupDir { get; set; }
/// <summary>Auto-rotation: keep this many most recent backups, delete older.</summary>
[JsonPropertyName("backupKeep")]
public int BackupKeep { get; set; } = 10;
/// <summary>Daily auto-backup time as "HH:mm" (24-hour, server-local). Null/empty disables.</summary>
[JsonPropertyName("backupSchedule")]
public string? BackupSchedule { get; set; }
/// <summary>
/// Where BlueMap CLI's working dir lives (cli.jar, configs, render output).
/// Empty -> alongside &lt;serverDir&gt;/.. (default ~/brass-sigil-server/bluemap).
/// Set to a big-disk path on real deployments -- rendered output for a 5000-block
/// world is several GB.
/// </summary>
[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<ServerConfig>(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);
}
}
+52
View File
@@ -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<RunCommand>();
app.Configure(config =>
{
config.SetApplicationName("brass-sigil-server");
config.SetApplicationVersion("0.1.0");
config.AddCommand<InstallCommand>("install")
.WithDescription("Force a fresh setup: download mods + run the NeoForge installer.");
config.AddCommand<SyncCommand>("sync")
.WithDescription("Update mods to match the current manifest. Server should be stopped first.");
config.AddCommand<RunCommand>("run")
.WithDescription("Run the server daemon (auto-installs anything missing, then serves the web UI).")
.WithAlias("start");
config.AddCommand<CheckCommand>("check")
.WithDescription("Verify install: dependencies, EULA, manifest reachability.");
config.AddCommand<SetPasswordCommand>("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;
+202
View File
@@ -0,0 +1,202 @@
using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class BackupScheduler : IDisposable
{
private readonly ServerConfig _config;
private readonly BackupService _backup;
private readonly Action<string> _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<TimeOnly> _firedToday = new();
public BackupScheduler(ServerConfig config, BackupService backup, Action<string> 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()}");
}
/// <summary>Stop the current loop and re-Start with the latest config values.</summary>
public void Reload()
{
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts?.Dispose();
_cts = null;
_loop = null;
Start();
}
/// <summary>Compute the next future scheduled fire time. Null if no schedule.</summary>
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<TimeOnly?>().FirstOrDefault() so "no pending" is null rather than 00:00.
var pendingToday = times
.Where(t => t > nowTime && !_firedToday.Contains(t))
.OrderBy(t => t)
.Cast<TimeOnly?>()
.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}"); }
}
}
/// <summary>Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty.</summary>
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<TimeOnly>();
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();
}
}
+257
View File
@@ -0,0 +1,257 @@
using System.IO.Compression;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class BackupService
{
private readonly ServerConfig _config;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action<string> 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<BackupInfo> 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();
}
/// <summary>
/// Create a ZIP backup of the world dir. Online (no shutdown) when the server is
/// running.
/// <para>
/// <paramref name="flush"/> = false (default): just <c>save-off</c> + brief drain +
/// ZIP + <c>save-on</c>. Near-zero player-visible lag. Backup captures state up to
/// MC's last autosave (within ~5 min) -- fine for hourly snapshots.
/// </para>
/// <para>
/// <paramref name="flush"/> = true: also runs <c>save-all flush</c> 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.
/// </para>
/// </summary>
public async Task<CreateResult> 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();
}
}
/// <summary>
/// Stop the server, move the current world out of the way as a "pre-restore" safety
/// copy, extract the chosen archive, restart.
/// </summary>
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;
}
}
+145
View File
@@ -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;
/// <summary>
/// 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
/// <c>/map/maps/overworld/live/players.json</c> roughly every 2 s, and the
/// daemon intercepts that path and calls <see cref="SnapshotAsync"/> per
/// request. Closed tab = no requests = no RCON calls -- same model as
/// <c>/api/players</c>, no server-side timer to manage.
/// </summary>
public static class BlueMapPlayers
{
public static async Task<List<object>> 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<string>()
: listResp.Substring(colon + 1)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<object>();
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 <serverDir>/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<string, string>? _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<UserCacheEntry[]>(json, JsonOpts.CaseInsensitive);
var dict = new Dictionary<string, string>(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);
}
}
+321
View File
@@ -0,0 +1,321 @@
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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).
/// </summary>
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<string> _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<string> log)
{
_config = config;
_log = log;
}
public bool HasRendered => Directory.Exists(WebDir) &&
File.Exists(Path.Combine(WebDir, "index.html"));
/// <summary>Kick off a render in the background. Returns false if one is already running.</summary>
public bool StartRender()
{
if (!_gate.Wait(0)) return false;
State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow };
_ = Task.Run(RenderAsync);
return true;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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 <modsDir> = 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<string> { "-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.");
}
/// <summary>Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs).</summary>
private async Task<string?> 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<string>(msg => _log("[bluemap] " + msg));
return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
+109
View File
@@ -0,0 +1,109 @@
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// Show a draining boss bar at the top of every player's screen for
/// <paramref name="durationSeconds"/>. 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.
/// </summary>
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();
}
+156
View File
@@ -0,0 +1,156 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Runtime.InteropServices;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>If a previous install put a java executable under serverDir/java/, return its path.</summary>
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();
}
/// <summary>Find a Java install for a specific major version (e.g. javaXX/jdk-XX*/bin/java).</summary>
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<string?> InstallJre21Async(string serverDir, IProgress<string>? progress, CancellationToken ct)
=> InstallJreAsync(21, serverDir, GetJavaInstallDir(serverDir), progress, ct);
/// <summary>
/// Download + extract Adoptium Temurin JRE for a specific major version into
/// <paramref name="installDir"/>. Used by BlueMap to get JRE 25 alongside the
/// JRE 21 we use for Minecraft.
/// </summary>
public async Task<string?> InstallJreAsync(int majorVersion, string serverDir, string installDir,
IProgress<string>? 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);
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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,
};
}
+199
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Manifest> FetchManifestAsync(string url, CancellationToken ct = default)
{
var json = await _http.GetStringAsync(url, ct);
var manifest = JsonSerializer.Deserialize<Manifest>(json, JsonOpts.CaseInsensitive)
?? throw new InvalidOperationException("Manifest is empty.");
manifest.Files ??= new();
return manifest;
}
public async Task<SyncResult> SyncAsync(
string manifestUrl, string serverDir, IProgress<string>? 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<string>(
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<ManifestFile>();
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<string> 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));
}
/// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
{
var skip = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var modrinthIds = new HashSet<string>();
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<JsonElement>(
$"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<string> ListManagedFiles(string serverDir)
{
var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" };
var result = new List<string>();
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<string> 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();
}
}
+112
View File
@@ -0,0 +1,112 @@
using System.Diagnostics;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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<bool> InstallAsync(string version, string serverDir, string javaPath,
IProgress<string>? 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<string> onLine, CancellationToken ct)
{
try
{
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync(ct);
if (line is null) break;
onLine(line);
}
}
catch { }
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Minimal Minecraft RCON client (Source RCON protocol).
/// Used for sending console commands and reading "list" output for player counts.
/// </summary>
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<string> 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();
}
}
+68
View File
@@ -0,0 +1,68 @@
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Thin reconnecting wrapper around <see cref="RconClient"/>. 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.
/// </summary>
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<string> 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<RconClient> 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();
}
}
+405
View File
@@ -0,0 +1,405 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class ServerProcess : IDisposable
{
private readonly ServerConfig _config;
private Process? _process;
private readonly ConcurrentQueue<LogLine> _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<LogLine>? OnLogLine;
public event Action<int>? 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<double> _cpuSamples = new();
private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling
/// <summary>
/// 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).
/// </summary>
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; }
}
}
/// <summary>
/// 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).
/// </summary>
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; }
}
}
}
/// <summary>
/// BFS the process tree starting from <paramref name="rootPid"/> looking for
/// a process named like "java". Linux: read /proc/PID/task/PID/children.
/// Windows: enumerate parents via Process objects (no extra deps).
/// </summary>
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<int>();
var queue = new Queue<int>();
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<int, List<Process>>();
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<Process>();
list.Add(p);
}
var visited = new HashSet<int>();
var queue = new Queue<int>();
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<ProcessBasicInformation>(), 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<string> args)
{
args = new List<string>();
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<LogLine> 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<bool> 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);
}
+149
View File
@@ -0,0 +1,149 @@
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Reads and writes Minecraft's <c>server.properties</c>. Editable keys are
/// gated by an allowlist so a compromised panel can't flip security-critical
/// fields like <c>online-mode</c> arbitrarily -- only common gameplay knobs.
/// Preserves comments and key order on write; appends new keys at the end.
/// </summary>
public sealed class ServerPropertiesService
{
private readonly ServerConfig _config;
public ServerPropertiesService(ServerConfig config) => _config = config;
/// <summary>
/// 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.
/// </summary>
public static readonly IReadOnlySet<string> EditableKeys = new HashSet<string>(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<string, string> ReadAll()
{
var dict = new Dictionary<string, string>(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;
}
/// <summary>Returns just the editable subset, with values left as raw strings.</summary>
public Dictionary<string, string> ReadEditable()
{
var all = ReadAll();
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in EditableKeys)
{
if (all.TryGetValue(key, out var v)) result[key] = v;
}
return result;
}
/// <summary>
/// Read the current <c>level-seed</c> value, or null if absent / empty.
/// </summary>
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;
}
/// <summary>
/// Direct write of <c>level-seed</c>. Bypasses <see cref="EditableKeys"/>
/// 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.
/// </summary>
public void SetLevelSeed(string seed)
{
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
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);
}
/// <summary>
/// Apply updates to the file. Keys not in <see cref="EditableKeys"/> are
/// silently dropped. Lines that already exist are updated in-place to
/// preserve order and comments; new keys are appended at the end.
/// </summary>
public void Update(IDictionary<string, string> 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<string>();
var applied = new HashSet<string>(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);
}
}
+260
View File
@@ -0,0 +1,260 @@
using BrassAndSigil.Server.Models;
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Drives a "fetch new manifest, drain players, swap mods, restart MC" workflow.
/// Single-flight: one update at a time, guarded by a semaphore. State is exposed
/// so the panel can poll progress; logs go through the existing OnLogLine event
/// (re-streamed via SSE) so they show up in the live console too.
/// </summary>
public sealed class UpdaterService
{
private readonly ServerConfig _config;
private readonly string _configPath;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private CancellationTokenSource? _cts;
public UpdateState State { get; private set; } = new();
public sealed class UpdateState
{
public bool InProgress { get; set; }
public string Phase { get; set; } = "idle";
// "idle" | "countdown" | "stopping" | "syncing" | "installing_loader" | "starting" | "complete" | "failed" | "cancelled"
public int CountdownTotal { get; set; }
public int CountdownRemaining { get; set; }
public string? CurrentVersion { get; set; }
public string? AvailableVersion { get; set; }
public string? Error { get; set; }
public DateTimeOffset? LastFinishedAt { get; set; }
}
public sealed record CheckResult(string? Current, string? Available, bool NeedsUpdate, string? Error);
public UpdaterService(ServerConfig config, string configPath,
ServerProcess proc, Broadcaster broadcast,
Action<string> log)
{
_config = config;
_configPath = configPath;
_proc = proc;
_broadcast = broadcast;
_log = log;
}
/// <summary>Lightweight read: compare local pack-version.json to remote manifest.</summary>
public async Task<CheckResult> CheckAsync(CancellationToken ct = default)
{
try
{
var sync = new ManifestSync();
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
var local = ReadLocalPackVersion(_config.ServerDir);
var current = local;
var available = manifest.Version;
var needs = !string.Equals(current, available, StringComparison.Ordinal);
State.CurrentVersion = current;
State.AvailableVersion = available;
return new CheckResult(current, available, needs, null);
}
catch (Exception ex)
{
return new CheckResult(State.CurrentVersion, State.AvailableVersion, false, ex.Message);
}
}
public bool TryCancel()
{
if (!State.InProgress || _cts is null) return false;
// Only meaningful during countdown phase -- a sync mid-flight isn't safely abortable.
if (State.Phase != "countdown") return false;
_cts.Cancel();
return true;
}
/// <summary>
/// Run the full update flow. Single-flight -- returns false if one is already running.
/// </summary>
public async Task<bool> StartAsync(int delaySeconds)
{
if (!await _gate.WaitAsync(0)) return false;
_cts = new CancellationTokenSource();
var ct = _cts.Token;
State = new UpdateState
{
InProgress = true,
Phase = "countdown",
CountdownTotal = delaySeconds,
CountdownRemaining = delaySeconds,
CurrentVersion = State.CurrentVersion,
AvailableVersion = State.AvailableVersion,
};
try
{
// ── 1. Player-facing countdown ──
if (delaySeconds > 0 && _proc.IsRunning)
{
_log($"[update] Announcing restart in {delaySeconds}s.");
await _broadcast.SayAsync($"Server will restart in {FormatDuration(delaySeconds)} for an update to v{State.AvailableVersion}.", ct);
// Run the action-bar countdown + periodic chat warnings + UI ticker
// in parallel. Action bar (instead of boss bar) avoids stacking on
// top of real boss fight UIs (Ender Dragon, raids, mod bosses).
var actionBar = _broadcast.ActionBarCountdownAsync(
"Server restart for update", delaySeconds, ct);
var warnings = WarnDuringCountdownAsync(delaySeconds, ct);
// Drive State.CountdownRemaining for the UI poller.
var ticker = TickCountdownStateAsync(delaySeconds, ct);
await Task.WhenAll(actionBar, warnings, ticker);
}
// ── 2. Stop MC ──
ct.ThrowIfCancellationRequested();
State.Phase = "stopping";
_log("[update] Stopping Minecraft for update...");
if (_proc.IsRunning)
{
await _broadcast.SayAsync("Server is restarting now.");
await _proc.StopAsync(TimeSpan.FromSeconds(30));
}
// ── 3. Sync mods from manifest ──
ct.ThrowIfCancellationRequested();
State.Phase = "syncing";
_log("[update] Syncing mods from manifest...");
var sync = new ManifestSync();
var progress = new Progress<string>(msg => _log($"[update] {msg}"));
var result = await sync.SyncAsync(_config.ManifestUrl, _config.ServerDir, progress, ct);
_log($"[update] Sync complete: {result.Downloaded} downloaded, {result.Removed} removed.");
// ── 4. Update NeoForge if loader version changed ──
ct.ThrowIfCancellationRequested();
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
if (manifest.Loader is { } loader &&
loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase) &&
LoaderVersionChanged(_config.ServerDir, loader.Version))
{
State.Phase = "installing_loader";
_log($"[update] Reinstalling NeoForge {loader.Version}...");
var nf = new NeoForgeInstaller();
var ok = await nf.InstallAsync(loader.Version, _config.ServerDir, _config.JavaPath, progress, ct);
if (!ok) throw new InvalidOperationException("NeoForge installer failed.");
}
// ── 5. Start MC ──
ct.ThrowIfCancellationRequested();
State.Phase = "starting";
_log("[update] Starting Minecraft...");
_proc.Start();
State.CurrentVersion = manifest.Version;
State.Phase = "complete";
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log("[update] Update complete.");
return true;
}
catch (OperationCanceledException)
{
State.Phase = "cancelled";
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log("[update] Update cancelled.");
// If we cancelled during countdown, MC is still running -- leave it alone.
return false;
}
catch (Exception ex)
{
State.Phase = "failed";
State.Error = ex.Message;
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log($"[update] Failed: {ex.Message}");
// Try to bring MC back up if we stopped it but never restarted.
if (!_proc.IsRunning)
{
try { _proc.Start(); _log("[update] Restored Minecraft after failure."); }
catch (Exception startEx) { _log($"[update] Restore failed too: {startEx.Message}"); }
}
return false;
}
finally
{
_cts?.Dispose();
_cts = null;
_gate.Release();
}
}
private async Task TickCountdownStateAsync(int total, CancellationToken ct)
{
for (int sec = total; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
State.CountdownRemaining = sec;
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
State.CountdownRemaining = 0;
}
private async Task WarnDuringCountdownAsync(int total, CancellationToken ct)
{
// Periodic chat warnings -- independent of the boss bar (visual-but-missable).
// Each milestone fires at an absolute time computed from the start, so the
// delays don't accumulate sequentially across the loop iterations.
var startUtc = DateTime.UtcNow;
var milestones = new[] { 300, 60, 30, 10 };
foreach (var m in milestones)
{
if (m >= total) continue;
var fireAt = startUtc.AddSeconds(total - m);
var wait = fireAt - DateTime.UtcNow;
if (wait > TimeSpan.Zero)
{
try { await Task.Delay(wait, ct); }
catch (OperationCanceledException) { throw; }
}
try { await _broadcast.SayAsync($"Server restart in {FormatDuration(m)}."); }
catch { /* don't bring down the whole update for one failed broadcast */ }
}
}
private static bool LoaderVersionChanged(string serverDir, string newVersion)
{
// Look at the libraries dir for an existing neoforge-<version> path.
// If absent or different version, we should re-install.
var libsRoot = Path.Combine(serverDir, "libraries", "net", "neoforged", "neoforge");
if (!Directory.Exists(libsRoot)) return true;
var versions = Directory.EnumerateDirectories(libsRoot).Select(Path.GetFileName).ToList();
return !versions.Contains(newVersion);
}
private static string? ReadLocalPackVersion(string serverDir)
{
var path = Path.Combine(serverDir, "pack-version.json");
if (!File.Exists(path)) return null;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
}
catch { return null; }
}
private static string FormatDuration(int seconds)
{
if (seconds >= 60) return $"{seconds / 60} minute{(seconds / 60 == 1 ? "" : "s")}";
return $"{seconds} second{(seconds == 1 ? "" : "s")}";
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Text.Json;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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<Request> Load()
{
try
{
if (!File.Exists(FilePath)) return new();
var text = File.ReadAllText(FilePath);
if (string.IsNullOrWhiteSpace(text)) return new();
return JsonSerializer.Deserialize<List<Request>>(text, JsonOpts.CaseInsensitive) ?? new();
}
catch { return new(); }
}
private void Save(List<Request> list)
{
var text = JsonSerializer.Serialize(list, JsonOpts.Pretty);
File.WriteAllText(FilePath, text);
}
public IReadOnlyList<Request> List() { lock (_lock) return Load(); }
public IReadOnlyList<Request> ListPending()
{
lock (_lock)
return Load().Where(r => r.Status == "pending").ToList();
}
/// <summary>Submit a new request. Idempotent on (username, status=pending) -- won't dupe.</summary>
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;
}
}
/// <summary>
/// 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".
/// </summary>
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");
/// <summary>Remove the request entirely (used after the actual /whitelist add fires).</summary>
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;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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();
}
+229
View File
@@ -0,0 +1,229 @@
using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// 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.
/// </summary>
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<string> _log;
private readonly SemaphoreSlim _lock = new(1, 1);
public WorldService(ServerConfig config, ServerProcess proc, BackupService backup,
Broadcaster broadcast, RconManager rcon, Action<string> 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);
/// <summary>
/// What seed strategy a wipe should use:
/// <list type="bullet">
/// <item><c>Keep</c> -- capture the live seed via RCON before wipe and reuse it.</item>
/// <item><c>Random</c> -- clear <c>level-seed</c> so MC picks a fresh random one.</item>
/// <item><c>Custom</c> -- set <c>level-seed</c> to <see cref="WipeOptions.CustomSeed"/>.</item>
/// </list>
/// </summary>
public enum SeedMode { Keep, Random, Custom }
public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed);
/// <summary>
/// Best-effort lookup of the current world seed. Prefers RCON's <c>seed</c>
/// 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.
/// </summary>
public async Task<string?> GetCurrentSeedAsync(CancellationToken ct = default)
{
if (_proc.IsRunning)
{
try
{
var resp = await _rcon.SendCommandAsync("seed", ct);
// Format: "Seed: [<number>]"
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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<WipeResult> 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;
}
}
+50
View File
@@ -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
}
}
+27
View File
@@ -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
<VirtualHost *:80>
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
</VirtualHost>
+284
View File
@@ -0,0 +1,284 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brass &amp; Sigil Launcher -- Matt Sijbers</title>
<meta name="description" content="A private custom Minecraft Java Edition launcher for the Brass & Sigil modpack -- built in C# / .NET 8 / Avalonia." />
<link rel="icon" href="/images/favicon-light.ico" media="(prefers-color-scheme: light)" type="image/x-icon" />
<link rel="icon" href="/images/favicon-dark.ico" media="(prefers-color-scheme: dark)" type="image/x-icon" />
<link rel="stylesheet" href="css/matt.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" />
<style>
html, body {
background: var(--color-primary-darker-10);
overflow: auto;
height: auto;
min-height: 100vh;
max-height: none;
}
.page-wrap {
max-width: 900px;
margin: 0 auto;
padding: clamp(20px, 5vw, 60px) clamp(16px, 5vw, 32px) 80px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 0.95em;
}
.hero {
padding: clamp(20px, 4vw, 36px);
margin-bottom: 24px;
}
.hero h1 {
font-size: clamp(1.6em, 6vw, 2.6em);
text-transform: uppercase;
margin: 0 0 8px;
}
.hero .subtitle {
color: var(--color-secondary-text);
font-size: clamp(0.9em, 3vw, 1.1em);
margin: 0;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.tag {
background: var(--color-primary-darker-20);
color: var(--color-primary-text);
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85em;
font-weight: 500;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 22px;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--color-accent);
color: #fff !important;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
font-size: 0.95em;
text-decoration: none;
transition: background 0.15s linear, transform 0.1s linear;
}
.action-btn:hover {
background: var(--color-accent-lighter-10);
transform: translateY(-1px);
}
.action-btn.secondary {
background: var(--color-primary-darker-20);
color: var(--color-primary-text) !important;
}
.action-btn.secondary:hover {
background: var(--color-primary-darker-30);
}
.whitelist-note {
margin: 18px 0 0;
padding: 10px 14px;
background: var(--color-primary-darker-10);
border-left: 3px solid var(--color-accent);
border-radius: 6px;
color: var(--color-secondary-text);
font-size: 0.9em;
line-height: 1.45;
}
.whitelist-note i { color: var(--color-accent); margin-right: 6px; }
.section {
padding: clamp(18px, 3.5vw, 28px);
margin-bottom: 18px;
}
.section h2 {
margin-top: 0;
margin-bottom: 12px;
}
.section p {
line-height: 1.55;
color: var(--color-primary-text);
}
.facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 8px;
}
.fact {
background: var(--color-primary-darker-10);
border-radius: 12px;
padding: 14px 18px;
}
.fact .label {
font-size: 0.78em;
color: var(--color-secondary-text);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 4px;
}
.fact .value {
font-family: "Ubuntu", sans-serif;
font-weight: 500;
color: var(--color-primary-text);
margin: 0;
}
@media (max-width: 540px) {
.facts { grid-template-columns: 1fr; }
}
ul.feature-list {
padding-left: 1.25em;
margin: 4px 0 0;
}
ul.feature-list li {
color: var(--color-primary-text);
line-height: 1.5;
margin: 4px 0;
}
footer.page-footer {
color: var(--color-secondary-text);
font-size: 0.85em;
text-align: center;
margin-top: 40px;
}
</style>
</head>
<body>
<div class="page-wrap">
<a href="/matt#projects" class="back-link"><i class="fa fa-arrow-left"></i> Back to portfolio</a>
<div class="bevel hero">
<h1>Brass &amp; Sigil Launcher</h1>
<p class="subtitle">A private custom Minecraft Java Edition launcher for a small friend group.</p>
<div class="tag-row">
<span class="tag">C# / .NET 8</span>
<span class="tag">Avalonia</span>
<span class="tag">CmlLib.Core</span>
<span class="tag">Single-file Windows</span>
<span class="tag">Private project</span>
</div>
<div class="action-row">
<a class="action-btn" href="/pack/BrassAndSigil-Launcher.exe">
<i class="fa fa-download"></i> Download launcher
</a>
<a class="action-btn secondary" href="https://sijbers.uk:8443/projects/BS/repos/brass-and-sigil-launcher/browse"
target="_blank" rel="noopener noreferrer">
<i class="fa fa-code"></i> View source code
</a>
<a class="action-btn secondary" href="/matt#projects">
<i class="fa fa-user"></i> Developer portfolio
</a>
</div>
<p class="whitelist-note">
<i class="fa fa-info-circle"></i>
You'll need to be whitelisted on the server to actually join &mdash; message Matt with your Minecraft username.
</p>
</div>
<div class="bevel section">
<h2>About the project</h2>
<p>
Brass &amp; 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).
</p>
<p>
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 &mdash; no installer,
no .NET runtime to install, no separate config files.
</p>
</div>
<div class="bevel section">
<h2>What it does</h2>
<ul class="feature-list">
<li>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.</li>
<li>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.</li>
<li>Installs the right Minecraft version and Forge loader, then launches the game with the configured memory and the player's session.</li>
<li>Auto-updates the modpack on every launch when the manifest changes &mdash; players never have to manually install or update mods.</li>
</ul>
</div>
<div class="bevel section">
<h2>Technical details</h2>
<div class="facts">
<div class="fact">
<p class="label">Language</p>
<p class="value">C# (.NET 8)</p>
</div>
<div class="fact">
<p class="label">UI Framework</p>
<p class="value">Avalonia 12</p>
</div>
<div class="fact">
<p class="label">Minecraft auth</p>
<p class="value">CmlLib.Core.Auth.Microsoft</p>
</div>
<div class="fact">
<p class="label">Game launching</p>
<p class="value">CmlLib.Core 4.x + Forge installer</p>
</div>
<div class="fact">
<p class="label">Distribution</p>
<p class="value">Single-file self-contained .exe</p>
</div>
<div class="fact">
<p class="label">Audience</p>
<p class="value">Private friend group (&lt; 50)</p>
</div>
</div>
</div>
<div class="bevel section">
<h2>Privacy &amp; data</h2>
<p>
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 &mdash; no telemetry, no analytics, no third-party services beyond Microsoft and Mojang.
</p>
<p>
The modpack manifest and mod files are served from a self-hosted Linux server that I personally operate.
</p>
</div>
<div class="bevel section">
<h2>Status</h2>
<p>
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.
</p>
<p>
Source code is publicly available, MIT-licensed, on my self-hosted Bitbucket:
<a href="https://sijbers.uk:8443/projects/BS/repos/brass-and-sigil-launcher/browse"
target="_blank" rel="noopener noreferrer">sijbers.uk:8443/.../brass-and-sigil-launcher</a>.
</p>
</div>
<div class="bevel section" style="text-align: center; font-size: 0.85em;">
<p style="margin: 0; color: var(--color-secondary-text); letter-spacing: 0.04em;">
NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.
</p>
</div>
<footer class="page-footer">
Brass &amp; Sigil Launcher &mdash; a private project by <a href="/matt">Matt Sijbers</a>.
</footer>
</div>
</body>
</html>
+26
View File
@@ -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
+105
View File
@@ -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."
+135
View File
@@ -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"
}
+12
View File
@@ -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
}
+42
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+379
View File
@@ -0,0 +1,379 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Brass &amp; Sigil -- Server Panel</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="topbar">
<div class="topbar-left">
<img class="topbar-icon" src="/favicon.png" alt="" />
<h1>BRASS &amp; SIGIL -- SERVER</h1>
</div>
<div id="statusPill" class="status-pill"><span class="dot"></span><span id="statusText">Connecting…</span></div>
</div>
<div class="layout">
<aside>
<div class="card">
<h2>Status</h2>
<div class="stat-row"><span class="key">PID</span><span id="pid" class="val">--</span></div>
<div class="stat-row"><span class="key">Uptime</span><span id="uptime" class="val">--</span></div>
<div class="stat-row"><span class="key">Pack</span><span id="packVersion" class="val">--</span></div>
<div class="stat-row"><span class="key">Players</span><span id="playerCount" class="val">--</span></div>
<div class="stat-row"><span class="key">World</span><span id="worldSize" class="val">--</span></div>
<div class="actions" style="margin-top: 14px;">
<button id="btnStart" class="ghost-btn">Start</button>
<button id="btnStop" class="danger">Stop</button>
</div>
</div>
<div class="card">
<h2>Resources</h2>
<div class="res-block">
<div class="res-label"><span>Memory</span><span id="memUsage" class="res-val">--</span></div>
<div class="res-bar"><div id="memBar"></div></div>
</div>
<div class="res-block">
<div class="res-label"><span>CPU</span><span id="cpuCurrent" class="res-val">--</span></div>
<div class="res-bar"><div id="cpuBar"></div></div>
<div class="res-sub">
<span>Peak (60s) <strong id="cpuMax">--</strong></span>
<span>Avg (60s) <strong id="cpuAvg">--</strong></span>
</div>
</div>
</div>
<div class="card">
<h2>Players online</h2>
<ul id="players" class="name-list">
<li class="empty-state">No-one online</li>
</ul>
</div>
<div class="card">
<h2>Whitelist <span id="wlReqBadge" class="badge" hidden></span></h2>
<div id="wlRequestsBlock" hidden>
<div class="wl-req-label">Pending requests</div>
<ul id="wlRequests" class="name-list"></ul>
</div>
<ul id="whitelist" class="name-list">
<li class="empty-state">No players whitelisted yet</li>
</ul>
<div class="input-row" style="margin-top: 8px;">
<div class="input-wrap">
<input id="wlInput" type="text" placeholder="Add player by username…" autocomplete="off" maxlength="16" />
</div>
<button id="wlAdd">Add</button>
</div>
</div>
</aside>
<main>
<div class="card">
<h2>Console</h2>
<div id="console" class="console-pane">Connecting to server log…</div>
<div class="input-row">
<div class="input-wrap">
<div id="cmdGhost" class="ghost"></div>
<input id="cmdInput" type="text"
placeholder="Type a server command (e.g. say hello, op alice, whitelist add bob)…"
autocomplete="off" />
<div id="cmdSuggest" class="suggest-list"></div>
</div>
<button id="cmdSend">Send</button>
</div>
<div id="cmdHint" class="hint">
<kbd>Tab</kbd> autocomplete · <kbd></kbd>/<kbd></kbd> history · <kbd>Esc</kbd> dismiss
</div>
</div>
</main>
<aside class="aside-right">
<div class="card">
<h2>Account</h2>
<div class="actions">
<button id="acctChangePw" class="ghost-btn">Change password</button>
<button id="acctLogout" class="ghost-btn">Log out</button>
</div>
<div id="acctChangeForm" class="acct-form" hidden>
<div class="input-wrap" style="margin-top: 10px;">
<input id="acctCurrent" type="password" placeholder="Current password" autocomplete="current-password" />
</div>
<div class="input-wrap" style="margin-top: 8px;">
<input id="acctNew" type="password" placeholder="New password (min 8)" autocomplete="new-password" />
</div>
<div class="input-wrap" style="margin-top: 8px;">
<input id="acctConfirm" type="password" placeholder="Confirm new password" autocomplete="new-password" />
</div>
<div class="actions" style="margin-top: 10px;">
<button id="acctSubmit">Update</button>
<button id="acctCancel" class="ghost-btn">Cancel</button>
</div>
<div id="acctMsg" class="acct-msg"></div>
</div>
</div>
<div class="card" id="updateCard" hidden>
<h2>Modpack update</h2>
<div id="updateInfo" class="update-info">
<div class="stat-row"><span class="key">Current</span><span id="updCurrent" class="val">--</span></div>
<div class="stat-row"><span class="key">Available</span><span id="updAvailable" class="val">--</span></div>
</div>
<p class="update-note">Updating restarts Minecraft. Players see a countdown banner then the server stops, syncs new mods, and starts again.</p>
<div class="input-row" style="margin-top: 8px;">
<div class="input-wrap">
<input id="updDelay" type="number" min="0" max="3600" step="30" value="300" placeholder="Warning seconds" />
</div>
<button id="updStart">Update</button>
</div>
<div id="updProgress" class="update-progress" hidden>
<div id="updPhaseLabel" class="update-phase">Idle</div>
<div class="pg-progress-bar"><div id="updProgressFill"></div></div>
<div id="updStatusText" class="update-status">--</div>
<button id="updCancel" class="ghost-btn" hidden>Cancel countdown</button>
</div>
</div>
<div class="card">
<h2>Server settings</h2>
<div class="stat-row"><span class="key">MOTD</span><span id="ssMotd" class="val">--</span></div>
<div class="stat-row"><span class="key">Difficulty</span><span id="ssDifficulty" class="val">--</span></div>
<div class="stat-row"><span class="key">View / Sim</span><span id="ssDistances" class="val">--</span></div>
<div class="stat-row"><span class="key">Max players</span><span id="ssMaxPlayers" class="val">--</span></div>
<div class="stat-row"><span class="key">Whitelist</span><span id="ssWhitelist" class="val">--</span></div>
<button class="ghost-btn" style="width: 100%; margin-top: 10px;" data-open-modal="modalSettings">Edit settings</button>
</div>
<div class="card">
<h2>World</h2>
<div class="trigger-list">
<button class="ghost-btn" data-open-modal="modalPregen">
<span>Pre-generate</span>
<span id="pgBadge" class="badge" hidden></span>
</button>
<button class="ghost-btn" data-open-modal="modalBackup">
<span>Backups</span>
<span id="bkpBadge" class="badge"></span>
</button>
<button class="ghost-btn" data-open-modal="modalMap">
<span>Map</span>
<span id="mapBadge" class="badge" hidden></span>
</button>
<button class="ghost-btn" data-open-modal="modalWipe">
<span>Wipe world</span>
<span class="badge" style="color: var(--danger); border-color: #6a2814;">danger</span>
</button>
</div>
</div>
</aside>
</div>
<!-- ── Modals ─────────────────────────────────────────────────────── -->
<div class="modal" id="modalPregen" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<div class="modal-header">
<h2>Pre-generate world</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 12px; line-height: 1.45;">
Smooths Distant Horizons by generating chunks ahead of time.
Run once after first start; takes a while (be patient -- server keeps running).
</p>
<div class="input-row" style="margin-top: 4px;">
<div class="input-wrap">
<input id="pgRadius" type="number" min="100" max="20000" step="100" value="1000" placeholder="Radius (blocks)" />
</div>
<button id="pgStart">Start</button>
</div>
<div class="actions" style="margin-top: 8px;">
<button id="pgPause" class="ghost-btn">Pause</button>
<button id="pgContinue" class="ghost-btn">Resume</button>
<button id="pgCancel" class="danger">Cancel</button>
</div>
<div class="pg-status">
<div class="stat-row"><span class="key">State</span><span id="pgState" class="val">Idle</span></div>
<div class="pg-progress-bar"><div id="pgProgressFill"></div></div>
<div class="stat-row"><span class="key">Progress</span><span id="pgProgressText" class="val">--</span></div>
<div class="stat-row"><span class="key">Chunks</span><span id="pgChunks" class="val">--</span></div>
<div class="stat-row"><span class="key">Rate</span><span id="pgRate" class="val">--</span></div>
<div class="stat-row"><span class="key">ETA</span><span id="pgEta" class="val">--</span></div>
</div>
</div>
</div>
<div class="modal" id="modalBackup" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<div class="modal-header">
<h2>Backups</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Stored at</span><span id="backupDir" class="val" style="font-size: 11px;">--</span>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Schedule</span><span id="backupSchedule" class="val" style="font-size: 11px;">--</span>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Next run</span><span id="backupNext" class="val" style="font-size: 11px;">--</span>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Keep</span><span id="backupKeep" class="val" style="font-size: 11px;">--</span>
</div>
<div class="actions" style="margin-top: 12px;">
<button id="bkpEditSchedule" class="ghost-btn" style="flex: 1;">Edit schedule</button>
<button id="bkpCreate">Create now</button>
</div>
<div id="bkpScheduleForm" class="acct-form" hidden style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--card-edge);">
<div class="input-row">
<div class="input-wrap" style="flex: 1;">
<input id="bkpScheduleInput" type="text" placeholder="04:00 | 04:00,16:00 | every 6h | every 30m" />
</div>
<div class="input-wrap" style="width: 80px; flex: 0 0 80px;">
<input id="bkpKeepInput" type="number" min="1" max="365" placeholder="Keep" />
</div>
</div>
<p style="font-size: 11px; color: var(--text-muted); margin: 8px 0 0; line-height: 1.45;">
Hourly = ~24 backups/day. Each backup pauses world saves for a few seconds.
For hourly retention, raise <em>keep</em> to 48+. Empty schedule disables auto-backups.
</p>
<div class="actions" style="margin-top: 8px;">
<button id="bkpScheduleSave">Save</button>
<button id="bkpScheduleCancel" class="ghost-btn">Cancel</button>
</div>
</div>
<ul id="bkpList" class="name-list" style="margin-top: 12px;">
<li class="empty-state">No backups yet</li>
</ul>
<div id="bkpMsg" class="acct-msg"></div>
</div>
</div>
<div class="modal" id="modalSettings" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog" style="max-width: 560px;">
<div class="modal-header">
<h2>Server settings</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px;">
These map to <code>server.properties</code>. MC reads them at startup, so changes need a server restart to take effect.
</p>
<div class="settings-grid">
<label>MOTD<input id="ssfMotd" type="text" /></label>
<label>Gamemode<select id="ssfGamemode">
<option>survival</option><option>creative</option><option>adventure</option><option>spectator</option>
</select></label>
<label>Difficulty<select id="ssfDifficulty">
<option>peaceful</option><option>easy</option><option>normal</option><option>hard</option>
</select></label>
<label>View distance<input id="ssfViewDistance" type="number" min="3" max="32" step="1" /></label>
<label>Sim distance<input id="ssfSimulationDistance" type="number" min="3" max="32" step="1" /></label>
<label>Max players<input id="ssfMaxPlayers" type="number" min="1" max="200" step="1" /></label>
<label>Spawn protection<input id="ssfSpawnProtection" type="number" min="0" max="64" step="1" /></label>
</div>
<div class="settings-checks">
<label class="danger-row"><input id="ssfPvp" type="checkbox" /><span>PvP</span></label>
<label class="danger-row"><input id="ssfHardcore" type="checkbox" /><span>Hardcore</span></label>
<label class="danger-row"><input id="ssfAllowFlight" type="checkbox" /><span>Allow flight</span></label>
<label class="danger-row"><input id="ssfWhiteList" type="checkbox" /><span>Whitelist enabled</span></label>
<label class="danger-row"><input id="ssfEnforceWhitelist" type="checkbox" /><span>Enforce whitelist</span></label>
<label class="danger-row"><input id="ssfEnableCommandBlock" type="checkbox" /><span>Enable command blocks</span></label>
</div>
<div class="actions" style="margin-top: 14px;">
<button id="ssSave" style="flex: 1;">Save</button>
<button id="ssRestart" class="ghost-btn">Save &amp; restart</button>
</div>
<div id="ssMsg" class="acct-msg"></div>
</div>
</div>
<div class="modal" id="modalMap" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<div class="modal-header">
<h2>World map</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px; line-height: 1.45;">
Renders the world to a browsable 3D map (BlueMap). Runs as a separate process -- no impact on the live MC server.
First render of a 5000-block area takes 2-6 hours; subsequent renders are incremental and much faster.
</p>
<div class="stat-row"><span class="key">Phase</span><span id="mapPhase" class="val">Idle</span></div>
<div class="stat-row"><span class="key">Last log</span><span id="mapLastLog" class="val" style="font-size: 10px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;">--</span></div>
<div class="actions" style="margin-top: 14px;">
<button id="mapRender" style="flex: 1;">Render now</button>
<button id="mapCancel" class="danger" hidden>Cancel</button>
<button id="mapOpen" class="ghost-btn">Open map ↗</button>
</div>
<div id="mapMsg" class="acct-msg"></div>
</div>
</div>
<div class="modal" id="modalWipe" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog danger">
<div class="modal-header">
<h2>Wipe world</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p class="danger-note">
Wipes the world directory and restarts the server with a fresh world.
With "Back up first" ticked, the old world is archived to your backup directory before deletion.
Players see a 30-second urgent warning before the wipe begins.
</p>
<label class="danger-row">
<input id="wipeBackup" type="checkbox" checked />
<span>Back up current world before wiping</span>
</label>
<div class="danger-section">
<div class="danger-section-title">World seed</div>
<div class="danger-row" style="margin-bottom: 6px;">
<span>Current:</span>
<code id="wipeCurrentSeed" style="margin-left: 8px;">loading...</code>
</div>
<label class="danger-row">
<input type="radio" name="wipeSeedMode" value="random" checked />
<span>Random new seed (Minecraft picks one)</span>
</label>
<label class="danger-row">
<input type="radio" name="wipeSeedMode" value="keep" />
<span>Keep current seed (regenerate identical world)</span>
</label>
<label class="danger-row">
<input type="radio" name="wipeSeedMode" value="custom" />
<span>Custom seed:</span>
<input id="wipeCustomSeed" type="text" placeholder="e.g. 12345 or 'a phrase'"
style="margin-left: 8px; flex: 1;" disabled />
</label>
</div>
<button id="wipeBtn" class="danger" style="margin-top: 14px; width: 100%;">Wipe world</button>
<div id="wipeMsg" class="acct-msg"></div>
</div>
</div>
<div class="footer">brass-sigil-server v0.1 -- embedded panel</div>
<div id="loginOverlay" class="login-overlay" hidden>
<div class="login-box">
<h2>Brass &amp; Sigil</h2>
<p>Sign in to manage the server.</p>
<div class="input-wrap">
<input id="loginPassword" type="password" autocomplete="current-password" placeholder="Password" />
</div>
<button id="loginSubmit">Sign in</button>
<div id="loginError" class="login-error"></div>
</div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+28
View File
@@ -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 =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
+115
View File
@@ -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;
}
});
}
+263
View File
@@ -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: <required> [optional] <a|b|c> 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. <required> [optional] <a|b|c> for enum choices.
const SIGNATURES = {
help: "[command]",
list: "",
say: "<message>",
tell: "<player> <message>",
msg: "<player> <message>",
me: "<action>",
w: "<player> <message>",
op: "<player>",
deop: "<player>",
whitelist: "<add|remove|list|reload|on|off>",
"whitelist add": "<player>",
"whitelist remove": "<player>",
"whitelist list": "",
"whitelist on": "",
"whitelist off": "",
"whitelist reload": "",
ban: "<player> [reason…]",
"ban-ip": "<ip|player> [reason…]",
pardon: "<player>",
"pardon-ip": "<ip>",
banlist: "[ips|players]",
kick: "<player> [reason…]",
tp: "<target> [destination]",
teleport: "<target> [destination]",
give: "<player> <item> [count]",
clear: "[player] [item]",
kill: "[target]",
gamemode: "<mode> [player]",
"gamemode survival": "[player]",
"gamemode creative": "[player]",
"gamemode adventure": "[player]",
"gamemode spectator": "[player]",
gamerule: "<rule> [value]",
difficulty: "<peaceful|easy|normal|hard>",
weather: "<clear|rain|thunder> [duration]",
time: "<set|add|query> <value>",
seed: "",
spawnpoint: "[player] [pos]",
setworldspawn: "[pos]",
"save-all": "[flush]",
"save-on": "",
"save-off": "",
stop: "",
reload: "",
xp: "<amount> [player]",
experience: "<add|set|query> <player> <amount>",
effect: "<give|clear> <player> <effect>",
enchant: "<player> <enchantment> [level]",
summon: "<entity> [pos]",
fill: "<from> <to> <block>",
setblock: "<pos> <block>",
locate: "<biome|structure> <id>",
tag: "<target> <add|remove|list> [tag]",
chunky: "<start|cancel|pause|continue|world|shape|center|radius|trim|...>",
"chunky start": "[world] [shape] [center_x] [center_z] [radius]",
"chunky cancel": "",
"chunky pause": "",
"chunky continue": "",
"chunky world": "<world>",
"chunky shape": "<square|circle|...>",
"chunky center": "<x> <z>",
"chunky radius": "<radius>",
"chunky trim": "[world] [radius] [trim_radius]",
ftbchunks: "<claim|unclaim|load|unload|admin>",
ftbteams: "<list|info|invite|...>",
kubejs: "<reload|hand|stages|...>",
kjs: "<reload|hand|stages|...>",
};
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 = `<span class="typed">${escapeHtml(v)}</span>${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 ? `<span class="args">${escapeHtml(s.args)}</span>` : "";
return `<div class="suggest-item${i === activeIndex ? " active" : ""}" data-idx="${i}">` +
`<span>${escapeHtml(s.text)}</span>${args}</div>`;
}).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 [];
}
+183
View File
@@ -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 = '<li class="empty-state">No backups yet</li>';
return;
}
els.list.innerHTML = data.backups.map(b => `
<li class="backup-item">
<div class="backup-meta">
<div class="backup-name">${escape(b.name)}</div>
<div class="backup-sub">${fmtSize(b.sizeBytes)} · ${fmtDate(b.createdAt)}</div>
</div>
<div class="backup-actions">
<button class="ghost-btn bkp-restore" data-name="${escape(b.name)}">Restore</button>
<button class="ghost-btn bkp-delete" data-name="${escape(b.name)}">Delete</button>
</div>
</li>
`).join("");
}
function escape(s) {
return String(s).replace(/[&<>"']/g, c =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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);
}
+76
View File
@@ -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.
}
+111
View File
@@ -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;
}
});
}
+114
View File
@@ -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
}
+57
View File
@@ -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"));
});
}
+117
View File
@@ -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 = '<li class="empty-state">No-one online</li>';
} else {
list.innerHTML = state.onlinePlayers.map(n => `<li>${escapeHtml(n)}<span></span></li>`).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 = '<li class="empty-state">No players whitelisted yet</li>';
} else {
list.innerHTML = state.whitelistedPlayers.map(n =>
`<li>${escapeHtml(n)}<button data-name="${escapeHtml(n)}" class="wl-remove">Remove</button></li>`
).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
}
+222
View File
@@ -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 <world> <shape> <cx> <cz> <r>` 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);
});
}
+16
View File
@@ -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); }
});
}
+135
View File
@@ -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);
}
+18
View File
@@ -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()));
}
+138
View File
@@ -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);
}
+86
View File
@@ -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 => `
<li>
<div class="wl-req-meta">${escapeHtml(r.username)}</div>
${r.message ? `<div class="wl-req-msg">"${escapeHtml(r.message)}"</div>` : ""}
<div class="wl-req-actions">
<button data-req-action="approve" data-name="${escapeHtml(r.username)}">Approve</button>
<button class="ghost-btn" data-req-action="deny" data-name="${escapeHtml(r.username)}">Deny</button>
</div>
</li>
`).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); }
}
+521
View File
@@ -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); }