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:
+62
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
@@ -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 |
@@ -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 & 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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><exe-folder>/<InstallDirName>/</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"type": "lithostitched:add_features",
|
||||||
|
"biomes": "#terralith:skylands",
|
||||||
|
"features": "brassandsigil_tweaks:skylands_end_stone_ore",
|
||||||
|
"step": "underground_ores"
|
||||||
|
}
|
||||||
+18
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 -> <serverDir>/../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 <serverDir>/.. (default ~/brass-sigil-server/bluemap).
|
||||||
|
/// Set to a big-disk path on real deployments -- rendered output for a 5000-block
|
||||||
|
/// world is several GB.
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 & 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 & 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 — message Matt with your Minecraft username.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bevel section">
|
||||||
|
<h2>About the project</h2>
|
||||||
|
<p>
|
||||||
|
Brass & Sigil is a private Minecraft modpack centred on the Create mod, aeronautics, tech and magic mods,
|
||||||
|
with Distant Horizons for far-render exploration. This launcher is the desktop client built specifically
|
||||||
|
to distribute the pack to a small friend group (under 50 players, no public release).
|
||||||
|
</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 — 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 — 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 (< 50)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bevel section">
|
||||||
|
<h2>Privacy & 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 — 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 & Sigil Launcher — a private project by <a href="/matt">Matt Sijbers</a>.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 & 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 & 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 & 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 & 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>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Tiny JSON API helper used by every module.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
export async function api(path, opts) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Auth cookie missing or wrong. Surface to the auth module which
|
||||||
|
// shows the login overlay; the caller still gets an error so any
|
||||||
|
// calling code stops cleanly.
|
||||||
|
document.dispatchEvent(new CustomEvent("authrequired"));
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiJson(path, body, method = "POST") {
|
||||||
|
return api(path, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, c =>
|
||||||
|
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
|
({"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
Reference in New Issue
Block a user