Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+40
View File
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BrassAndSigil.Server</RootNamespace>
<AssemblyName>brass-sigil-server</AssemblyName>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults -->
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">linux-x64</RuntimeIdentifier>
<RuntimeIdentifiers>linux-x64;win-x64</RuntimeIdentifiers>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>embedded</DebugType>
<!-- Embed wwwroot/* into the assembly so the published exe is truly single-file. -->
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
<StaticWebAssetsEnabled>false</StaticWebAssetsEnabled>
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.7" />
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<!-- Replace the implicit Web SDK Content rule for wwwroot with EmbeddedResource. -->
<Content Remove="wwwroot\**" />
<EmbeddedResource Include="wwwroot\**" />
<!-- Server-list icon dropped into <serverDir>/server-icon.png on first install. -->
<EmbeddedResource Include="Assets\server-icon.png">
<LogicalName>BrassAndSigil.Server.Assets.server-icon.png</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
+11
View File
@@ -0,0 +1,11 @@
using System.ComponentModel;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
public class BaseCommandSettings : CommandSettings
{
[CommandOption("-c|--config <PATH>")]
[Description("Path to server-config.json (defaults to ./server-config.json)")]
public string ConfigPath { get; set; } = "server-config.json";
}
+90
View File
@@ -0,0 +1,90 @@
using System.Diagnostics;
using BrassAndSigil.Server.Models;
using BrassAndSigil.Server.Services;
using Spectre.Console;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
public sealed class CheckCommand : AsyncCommand<BaseCommandSettings>
{
public override async Task<int> ExecuteAsync(CommandContext context, BaseCommandSettings settings)
{
var config = ServerConfig.Load(settings.ConfigPath);
AnsiConsole.MarkupLine("[bold]Checking server install...[/]");
var ok = true;
// 1. Java available?
var javaVersion = await TryRunForOutputAsync(config.JavaPath, "-version");
if (javaVersion is not null)
AnsiConsole.MarkupLine($" [green]✓[/] Java reachable: {javaVersion.Split('\n')[0].Trim().EscapeMarkup()}");
else
{ AnsiConsole.MarkupLine($" [red]✗[/] Java not found at '{config.JavaPath}'"); ok = false; }
// 2. Server dir
var serverDir = Path.GetFullPath(config.ServerDir);
if (Directory.Exists(serverDir))
AnsiConsole.MarkupLine($" [green]✓[/] Server dir exists: {serverDir}");
else
{ AnsiConsole.MarkupLine($" [yellow]?[/] Server dir missing -- run [yellow]install[/] first"); ok = false; }
// 3. EULA
var eulaPath = Path.Combine(serverDir, "eula.txt");
if (File.Exists(eulaPath) && File.ReadAllText(eulaPath).Contains("eula=true"))
AnsiConsole.MarkupLine(" [green]✓[/] EULA accepted");
else
{ AnsiConsole.MarkupLine(" [yellow]?[/] EULA not accepted (re-run [yellow]install --accept-eula[/])"); ok = false; }
// 4. NeoForge run script
var runScript = Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh");
if (File.Exists(runScript))
AnsiConsole.MarkupLine($" [green]✓[/] Loader start script: {Path.GetFileName(runScript)}");
else
AnsiConsole.MarkupLine($" [yellow]?[/] No {Path.GetFileName(runScript)} -- install the NeoForge server first");
// 5. Manifest reachable
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var resp = await http.GetAsync(config.ManifestUrl);
if (resp.IsSuccessStatusCode)
AnsiConsole.MarkupLine($" [green]✓[/] Manifest reachable: {config.ManifestUrl}");
else
{ AnsiConsole.MarkupLine($" [red]✗[/] Manifest HTTP {(int)resp.StatusCode}: {config.ManifestUrl}"); ok = false; }
}
catch (Exception ex)
{ AnsiConsole.MarkupLine($" [red]✗[/] Manifest fetch error: {ex.Message.EscapeMarkup()}"); ok = false; }
// 6. Pack version on disk
var packVer = Path.Combine(serverDir, "pack-version.json");
if (File.Exists(packVer))
AnsiConsole.MarkupLine($" [green]✓[/] Pack synced: {File.ReadAllText(packVer).Replace("\n", " ").Replace("\r", "").Trim().EscapeMarkup()}");
else
AnsiConsole.MarkupLine(" [yellow]?[/] Pack not synced yet (run [yellow]sync[/])");
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine(ok ? "[green]All required checks passed.[/]" : "[yellow]Some checks failed; see above.[/]");
return ok ? 0 : 1;
}
private static async Task<string?> TryRunForOutputAsync(string fileName, string args)
{
try
{
var p = Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
});
if (p is null) return null;
var output = await p.StandardError.ReadToEndAsync() + await p.StandardOutput.ReadToEndAsync();
await p.WaitForExitAsync();
return output;
}
catch { return null; }
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.ComponentModel;
using BrassAndSigil.Server.Models;
using BrassAndSigil.Server.Services;
using Spectre.Console;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
public sealed class InstallCommand : AsyncCommand<InstallCommand.Settings>
{
public sealed class Settings : BaseCommandSettings
{
[CommandOption("--manifest <URL>")]
[Description("Manifest URL to bootstrap from")]
public string? ManifestUrl { get; set; }
[CommandOption("--server-dir <PATH>")]
[Description("Where to install the server (defaults to ./server)")]
public string? ServerDir { get; set; }
[CommandOption("--memory <MB>")]
[Description("RAM allocation in MB (defaults to 8192)")]
public int? MemoryMB { get; set; }
[CommandOption("--accept-eula")]
[Description("Accept the Minecraft EULA. Required for the server to actually run.")]
public bool AcceptEula { get; set; }
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
var config = ServerConfig.Load(settings.ConfigPath);
if (settings.ManifestUrl != null) config.ManifestUrl = settings.ManifestUrl;
if (settings.ServerDir != null) config.ServerDir = settings.ServerDir;
if (settings.MemoryMB != null) config.MemoryMB = settings.MemoryMB.Value;
if (settings.AcceptEula) config.AcceptEula = true;
AnsiConsole.MarkupLine("[bold yellow]Brass & Sigil Server install[/]");
AnsiConsole.MarkupLine($" Config: {settings.ConfigPath}");
AnsiConsole.MarkupLine($" ServerDir: {Path.GetFullPath(config.ServerDir)}");
AnsiConsole.MarkupLine($" Manifest: {config.ManifestUrl}");
AnsiConsole.MarkupLine($" Memory: {config.MemoryMB} MB");
AnsiConsole.MarkupLine("");
if (!config.AcceptEula)
{
AnsiConsole.MarkupLine("[red]EULA not accepted.[/] Re-run with --accept-eula to confirm you accept the");
AnsiConsole.MarkupLine("Minecraft End User License Agreement: [blue]https://aka.ms/MinecraftEULA[/]");
return 1;
}
Directory.CreateDirectory(config.ServerDir);
// Generate eula.txt
await File.WriteAllTextAsync(
Path.Combine(config.ServerDir, "eula.txt"),
$"# Generated by brass-sigil-server install\n" +
$"# By setting this to true you agree to the Minecraft EULA: https://aka.ms/MinecraftEULA\n" +
$"eula=true\n");
// Generate a default server.properties if none exists yet
var propsPath = Path.Combine(config.ServerDir, "server.properties");
if (!File.Exists(propsPath))
{
await File.WriteAllTextAsync(propsPath, DefaultServerProperties(config));
AnsiConsole.MarkupLine($"[grey]Wrote default server.properties[/]");
}
// Generate a random RCON password if missing
if (string.IsNullOrEmpty(config.RconPassword))
{
config.RconPassword = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant();
UpdateProp(propsPath, "enable-rcon", "true");
UpdateProp(propsPath, "rcon.port", config.RconPort.ToString());
UpdateProp(propsPath, "rcon.password", config.RconPassword);
}
// Save config
config.Save(settings.ConfigPath);
AnsiConsole.MarkupLine($"[grey]Saved config to {settings.ConfigPath}[/]");
// Sync mods
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Syncing mods from manifest...[/]");
var sync = new ManifestSync();
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
try
{
var result = await sync.SyncAsync(config.ManifestUrl, config.ServerDir, progress);
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[green]✓ Sync complete:[/] pack v{result.PackVersion}, " +
$"{result.Downloaded} downloaded, {result.Removed} removed, " +
$"{result.Skipped} client-only mods skipped");
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]✗ Sync failed:[/] {ex.Message.EscapeMarkup()}");
return 1;
}
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold green]Install complete.[/]");
AnsiConsole.MarkupLine("Next steps:");
AnsiConsole.MarkupLine($" 1. Download the matching NeoForge server installer for the manifest's loader version");
AnsiConsole.MarkupLine($" (server side, into {Path.GetFullPath(config.ServerDir)}) -- the modpack manifest");
AnsiConsole.MarkupLine($" doesn't bundle it because each loader has its own installer.");
AnsiConsole.MarkupLine($" [blue]https://maven.neoforged.net/releases/net/neoforged/neoforge/[/]");
AnsiConsole.MarkupLine($" 2. Run the NeoForge installer with --installServer in the server dir");
AnsiConsole.MarkupLine($" 3. Then start: [yellow]brass-sigil-server run[/]");
return 0;
}
private static string DefaultServerProperties(ServerConfig config) => $@"# Brass & Sigil server defaults -- edit as needed
motd=Brass & Sigil
gamemode=survival
difficulty=normal
hardcore=false
pvp=true
online-mode=true
white-list=true
enforce-whitelist=true
max-players=20
view-distance=12
simulation-distance=10
spawn-protection=0
enable-rcon=true
rcon.port={config.RconPort}
rcon.password={config.RconPassword}
broadcast-rcon-to-ops=false
";
private static void UpdateProp(string path, string key, string value)
{
var lines = File.ReadAllLines(path).ToList();
var prefix = $"{key}=";
var idx = lines.FindIndex(l => l.StartsWith(prefix));
if (idx >= 0) lines[idx] = prefix + value;
else lines.Add(prefix + value);
File.WriteAllLines(path, lines);
}
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
using BrassAndSigil.Server.Models;
using Spectre.Console;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
/// <summary>
/// Set or rotate the web panel admin password from the CLI.
/// Useful when first-time-setting up before exposing the panel publicly,
/// or rotating after a suspected leak without going through the panel UI.
/// </summary>
public sealed class SetPasswordCommand : Command<BaseCommandSettings>
{
public override int Execute(CommandContext context, BaseCommandSettings settings)
{
var config = ServerConfig.Load(settings.ConfigPath);
if (Console.IsInputRedirected)
{
AnsiConsole.MarkupLine("[red]set-password requires an interactive terminal.[/]");
return 1;
}
AnsiConsole.MarkupLine("[bold]Set admin password[/]");
if (!string.IsNullOrEmpty(config.WebPassword))
AnsiConsole.MarkupLine("[grey]An existing password is already set; this will overwrite it.[/]");
string pw1, pw2;
while (true)
{
pw1 = AnsiConsole.Prompt(new TextPrompt<string>("New password (min 8 chars):").Secret());
if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; }
pw2 = AnsiConsole.Prompt(new TextPrompt<string>("Confirm:").Secret());
if (pw1 != pw2) { AnsiConsole.MarkupLine("[red]Doesn't match.[/]"); continue; }
break;
}
config.WebPassword = pw1;
config.Save(settings.ConfigPath);
AnsiConsole.MarkupLine($"[green]✓[/] Saved to {settings.ConfigPath}.");
AnsiConsole.MarkupLine("[grey]Restart the server for the new password to take effect.[/]");
return 0;
}
}
+32
View File
@@ -0,0 +1,32 @@
using BrassAndSigil.Server.Models;
using BrassAndSigil.Server.Services;
using Spectre.Console;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
public sealed class SyncCommand : AsyncCommand<BaseCommandSettings>
{
public override async Task<int> ExecuteAsync(CommandContext context, BaseCommandSettings settings)
{
var config = ServerConfig.Load(settings.ConfigPath);
AnsiConsole.MarkupLine($"[bold]Syncing[/] from [blue]{config.ManifestUrl}[/]");
AnsiConsole.MarkupLine($"[grey]Target: {Path.GetFullPath(config.ServerDir)}[/]");
AnsiConsole.MarkupLine("");
var sync = new ManifestSync();
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
try
{
var result = await sync.SyncAsync(config.ManifestUrl, config.ServerDir, progress);
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[green]✓[/] pack v{result.PackVersion} | downloaded={result.Downloaded} removed={result.Removed} client-only-skipped={result.Skipped}");
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]✗[/] {ex.Message.EscapeMarkup()}");
return 1;
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Text.Json.Serialization;
namespace BrassAndSigil.Server.Models;
public sealed class Manifest
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("minecraft")]
public MinecraftSpec Minecraft { get; set; } = new();
[JsonPropertyName("loader")]
public LoaderSpec? Loader { get; set; }
[JsonPropertyName("files")]
public List<ManifestFile> Files { get; set; } = new();
}
public sealed class MinecraftSpec
{
[JsonPropertyName("version")] public string Version { get; set; } = "";
}
public sealed class LoaderSpec
{
[JsonPropertyName("type")] public string Type { get; set; } = "vanilla";
[JsonPropertyName("version")] public string Version { get; set; } = "";
}
public sealed class ManifestFile
{
[JsonPropertyName("path")] public string Path { get; set; } = "";
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("sha1")] public string? Sha1 { get; set; }
[JsonPropertyName("size")] public long? Size { get; set; }
}
public sealed class PackLock
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("version")] public string Version { get; set; } = "";
[JsonPropertyName("minecraft")] public string Minecraft { get; set; } = "";
[JsonPropertyName("loader")] public LoaderSpec Loader { get; set; } = new();
[JsonPropertyName("mods")] public List<LockedMod> Mods { get; set; } = new();
}
public sealed class LockedMod
{
[JsonPropertyName("source")] public string Source { get; set; } = "";
[JsonPropertyName("slug")] public string? Slug { get; set; }
[JsonPropertyName("versionId")] public string? VersionId { get; set; }
[JsonPropertyName("fileId")] public string? FileId { get; set; }
[JsonPropertyName("version")] public string Version { get; set; } = "";
[JsonPropertyName("path")] public string Path { get; set; } = "";
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("sha1")] public string Sha1 { get; set; } = "";
[JsonPropertyName("size")] public long Size { get; set; }
}
+77
View File
@@ -0,0 +1,77 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using BrassAndSigil.Server.Services;
namespace BrassAndSigil.Server.Models;
public sealed class ServerConfig
{
[JsonPropertyName("manifestUrl")]
public string ManifestUrl { get; set; } = "https://sijbers.uk/pack/manifest.json";
[JsonPropertyName("serverDir")]
public string ServerDir { get; set; } = "./server";
[JsonPropertyName("javaPath")]
public string JavaPath { get; set; } = "java";
[JsonPropertyName("memoryMB")]
public int MemoryMB { get; set; } = 8192;
[JsonPropertyName("webPort")]
public int WebPort { get; set; } = 8080;
/// <summary>"localhost" by default -- bind to 0.0.0.0 only behind a reverse proxy.</summary>
[JsonPropertyName("webHost")]
public string WebHost { get; set; } = "localhost";
/// <summary>Shared password for web UI. Required if WebHost is not localhost.</summary>
[JsonPropertyName("webPassword")]
public string? WebPassword { get; set; }
/// <summary>Where world backups land. Empty -> &lt;serverDir&gt;/../backups. Set to a
/// large/slower drive on real deployments -- backups grow over time.</summary>
[JsonPropertyName("backupDir")]
public string? BackupDir { get; set; }
/// <summary>Auto-rotation: keep this many most recent backups, delete older.</summary>
[JsonPropertyName("backupKeep")]
public int BackupKeep { get; set; } = 10;
/// <summary>Daily auto-backup time as "HH:mm" (24-hour, server-local). Null/empty disables.</summary>
[JsonPropertyName("backupSchedule")]
public string? BackupSchedule { get; set; }
/// <summary>
/// Where BlueMap CLI's working dir lives (cli.jar, configs, render output).
/// Empty -> alongside &lt;serverDir&gt;/.. (default ~/brass-sigil-server/bluemap).
/// Set to a big-disk path on real deployments -- rendered output for a 5000-block
/// world is several GB.
/// </summary>
[JsonPropertyName("bluemapDir")]
public string? BlueMapDir { get; set; }
[JsonPropertyName("rconPort")]
public int RconPort { get; set; } = 25575;
[JsonPropertyName("rconPassword")]
public string RconPassword { get; set; } = "";
[JsonPropertyName("acceptEula")]
public bool AcceptEula { get; set; } = false;
public static ServerConfig Load(string path)
{
if (!File.Exists(path)) return new ServerConfig();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<ServerConfig>(json, JsonOpts.CaseInsensitive) ?? new ServerConfig();
}
public void Save(string path)
{
var dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(this, JsonOpts.Pretty);
File.WriteAllText(path, json);
}
}
+52
View File
@@ -0,0 +1,52 @@
using BrassAndSigil.Server.Commands;
using Spectre.Console;
using Spectre.Console.Cli;
// Detect interactive double-click on Windows so we can hold the console open at exit
// (otherwise the window vanishes before the user can read errors).
var interactive = !Console.IsInputRedirected && OperatingSystem.IsWindows();
var app = new CommandApp<RunCommand>();
app.Configure(config =>
{
config.SetApplicationName("brass-sigil-server");
config.SetApplicationVersion("0.1.0");
config.AddCommand<InstallCommand>("install")
.WithDescription("Force a fresh setup: download mods + run the NeoForge installer.");
config.AddCommand<SyncCommand>("sync")
.WithDescription("Update mods to match the current manifest. Server should be stopped first.");
config.AddCommand<RunCommand>("run")
.WithDescription("Run the server daemon (auto-installs anything missing, then serves the web UI).")
.WithAlias("start");
config.AddCommand<CheckCommand>("check")
.WithDescription("Verify install: dependencies, EULA, manifest reachability.");
config.AddCommand<SetPasswordCommand>("set-password")
.WithDescription("Set or rotate the web panel admin password.");
});
int result;
try
{
result = await app.RunAsync(args);
}
catch (Exception ex)
{
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
result = 1;
}
// Hold the console open at exit only when an error occurred during interactive use.
// Successful daemon termination (Ctrl+C, /api/server/stop) closes cleanly.
if (interactive && result != 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[grey]Press any key to close...[/]");
Console.ReadKey(intercept: true);
}
return result;
+202
View File
@@ -0,0 +1,202 @@
using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Auto-backup driven by config.BackupSchedule. Accepted formats:
/// - "HH:mm" single daily slot (e.g. "04:00")
/// - "HH:mm,HH:mm,..." multiple daily slots
/// - "every Nh" every N hours (>= 15 minutes)
/// - "every Nm" every N minutes
/// Wakes once a minute and fires backups when the clock matches the spec.
/// Doesn't catch up if the server was off when a slot passed -- daily/interval
/// backups don't need replay logic.
/// </summary>
public sealed class BackupScheduler : IDisposable
{
private readonly ServerConfig _config;
private readonly BackupService _backup;
private readonly Action<string> _log;
private CancellationTokenSource? _cts;
private Task? _loop;
// Tracking for "fired" state. For interval: just the last fire time. For
// daily-times: which times have fired today, reset at day rollover.
private DateTimeOffset? _lastIntervalFire;
private DateOnly _lastFireDay = DateOnly.MinValue;
private readonly HashSet<TimeOnly> _firedToday = new();
public BackupScheduler(ServerConfig config, BackupService backup, Action<string> log)
{
_config = config;
_backup = backup;
_log = log;
}
public void Start()
{
if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return;
if (Parse(_config.BackupSchedule) == default)
{
_log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled.");
return;
}
_cts?.Cancel();
_cts = new CancellationTokenSource();
_loop = Task.Run(() => RunAsync(_cts.Token));
_log($"[backup-scheduler] Schedule active: {Describe()}");
}
/// <summary>Stop the current loop and re-Start with the latest config values.</summary>
public void Reload()
{
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts?.Dispose();
_cts = null;
_loop = null;
Start();
}
/// <summary>Compute the next future scheduled fire time. Null if no schedule.</summary>
public DateTimeOffset? NextRun()
{
var (interval, times) = Parse(_config.BackupSchedule);
if (interval.HasValue)
{
var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1);
var next = baseTime + interval.Value;
// If we've never fired and we're past the implied first slot, "next" might be
// in the past -- clamp to "imminent" by using now + small buffer.
if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1);
return next.ToLocalTime();
}
if (times is not null)
{
var now = DateTime.Now;
var nowTime = TimeOnly.FromDateTime(now);
// Use Cast<TimeOnly?>().FirstOrDefault() so "no pending" is null rather than 00:00.
var pendingToday = times
.Where(t => t > nowTime && !_firedToday.Contains(t))
.OrderBy(t => t)
.Cast<TimeOnly?>()
.FirstOrDefault();
if (pendingToday.HasValue)
return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan()));
// None left today -- first slot tomorrow.
var firstTomorrow = times.Min();
return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan()));
}
return null;
}
private async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(TimeSpan.FromMinutes(1), ct); }
catch (OperationCanceledException) { break; }
var (interval, times) = Parse(_config.BackupSchedule);
if (interval is null && times is null) continue;
var nowUtc = DateTimeOffset.UtcNow;
var nowLocal = DateTime.Now;
var today = DateOnly.FromDateTime(nowLocal);
var nowTime = TimeOnly.FromDateTime(nowLocal);
bool shouldFire = false;
if (interval.HasValue)
{
shouldFire = !_lastIntervalFire.HasValue
|| (nowUtc - _lastIntervalFire.Value) >= interval.Value;
}
else if (times is not null)
{
if (today != _lastFireDay)
{
_firedToday.Clear();
_lastFireDay = today;
}
foreach (var t in times)
{
if (t <= nowTime && !_firedToday.Contains(t))
{
shouldFire = true;
_firedToday.Add(t);
break;
}
}
}
if (!shouldFire) continue;
_log("[backup-scheduler] Triggering scheduled backup.");
try
{
var result = await _backup.CreateAsync("scheduled", ct: ct);
if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB).");
else _log($"[backup-scheduler] Failed: {result.Error}");
if (interval.HasValue) _lastIntervalFire = nowUtc;
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); }
}
}
/// <summary>Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty.</summary>
private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return (null, null);
var s = input.Trim().ToLowerInvariant();
// every Nh / every Nm
var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$");
if (m.Success)
{
var n = int.Parse(m.Groups[1].Value);
var unit = m.Groups[2].Value;
var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n);
// Sanity floor -- anything below 15 min creates more save-lag than backups are worth.
if (span < TimeSpan.FromMinutes(15)) return (null, null);
if (span > TimeSpan.FromDays(7)) return (null, null);
return (span, null);
}
// Comma-separated HH:mm
var list = new List<TimeOnly>();
foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null);
list.Add(t);
}
return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray());
}
public string Describe()
{
var (interval, times) = Parse(_config.BackupSchedule);
if (interval.HasValue)
{
var totalMin = (int)interval.Value.TotalMinutes;
if (totalMin >= 60 && totalMin % 60 == 0)
{
var h = totalMin / 60;
return h == 1 ? "Every hour" : $"Every {h} hours";
}
return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes";
}
if (times is not null)
{
if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}";
return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm")));
}
return "Disabled";
}
public void Dispose()
{
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts?.Dispose();
}
}
+257
View File
@@ -0,0 +1,257 @@
using System.IO.Compression;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// World backups: ZIP the level-name dir into a separate (typically slower-but-bigger)
/// backup directory. Online backups via /save-all flush + /save-off while the server is
/// running mean players don't see downtime -- just a brief save lag.
/// </summary>
public sealed class BackupService
{
private readonly ServerConfig _config;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action<string> log)
{
_config = config;
_proc = proc;
_broadcast = broadcast;
_log = log;
}
public sealed record BackupInfo(string Name, long SizeBytes, DateTimeOffset CreatedAt);
public sealed record CreateResult(bool Ok, string? Name, long SizeBytes, string? Error);
public string BackupDir => ResolveBackupDir();
public List<BackupInfo> List()
{
var dir = BackupDir;
if (!Directory.Exists(dir)) return new();
return Directory.EnumerateFiles(dir, "*.zip")
.Select(p =>
{
var fi = new FileInfo(p);
return new BackupInfo(fi.Name, fi.Length, new DateTimeOffset(fi.CreationTimeUtc, TimeSpan.Zero));
})
.OrderByDescending(b => b.CreatedAt)
.ToList();
}
/// <summary>
/// Create a ZIP backup of the world dir. Online (no shutdown) when the server is
/// running.
/// <para>
/// <paramref name="flush"/> = false (default): just <c>save-off</c> + brief drain +
/// ZIP + <c>save-on</c>. Near-zero player-visible lag. Backup captures state up to
/// MC's last autosave (within ~5 min) -- fine for hourly snapshots.
/// </para>
/// <para>
/// <paramref name="flush"/> = true: also runs <c>save-all flush</c> first, which
/// synchronously serialises every loaded chunk before the ZIP. Captures state up
/// to NOW. Causes a tick spike of seconds-to-tens-of-seconds depending on world
/// size. Used only for irreversible operations (pre-wipe) where freshness matters.
/// </para>
/// </summary>
public async Task<CreateResult> CreateAsync(string? reason = null, bool flush = false, CancellationToken ct = default)
{
if (!await _gate.WaitAsync(0, ct))
return new CreateResult(false, null, 0, "Another backup is already in progress.");
var dir = ResolveBackupDir();
try
{
Directory.CreateDirectory(dir);
}
catch (Exception ex)
{
_gate.Release();
return new CreateResult(false, null, 0, $"Couldn't create backup dir '{dir}': {ex.Message}");
}
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(_config.ServerDir, levelName);
if (!Directory.Exists(worldDir))
{
_gate.Release();
return new CreateResult(false, null, 0, $"World directory not found at '{worldDir}'.");
}
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var slug = string.IsNullOrWhiteSpace(reason) ? "" : "-" + Slugify(reason);
var name = $"{levelName}-{stamp}{slug}.zip";
var path = Path.Combine(dir, name);
var serverRunning = _proc.IsRunning;
try
{
if (serverRunning)
{
if (flush)
{
// Loud path: fresh state, but pays the tick spike. Tell players.
try { await _broadcast.SayAsync("Saving world for backup (brief lag possible)...", ct); } catch { }
_log("[backup] save-all flush");
await _proc.SendInputAsync("save-all flush", ct);
await Task.Delay(2500, ct);
}
_log("[backup] save-off");
await _proc.SendInputAsync("save-off", ct);
// Brief drain so any save tasks already enqueued can finish before we
// start reading from disk for the ZIP.
await Task.Delay(500, ct);
}
_log($"[backup] Archiving {worldDir} -> {name}");
// Run on a worker thread so the request thread doesn't block on disk I/O.
await Task.Run(() =>
ZipFile.CreateFromDirectory(worldDir, path, CompressionLevel.Fastest, includeBaseDirectory: false), ct);
var size = new FileInfo(path).Length;
_log($"[backup] Created {name} ({size / (1024 * 1024)} MB).");
// No completion broadcast for silent path -- backup was invisible to players,
// no need to tell them it finished. Loud path is wipe-only and the wipe
// sequence has its own messaging.
RotateOldest(dir, _config.BackupKeep);
return new CreateResult(true, name, size, null);
}
catch (Exception ex)
{
_log($"[backup] Failed: {ex.Message}");
try { File.Delete(path); } catch { } // partial archive
return new CreateResult(false, null, 0, ex.Message);
}
finally
{
try
{
if (serverRunning && _proc.IsRunning)
{
_log("[backup] save-on");
await _proc.SendInputAsync("save-on");
}
}
catch { }
_gate.Release();
}
}
/// <summary>
/// Stop the server, move the current world out of the way as a "pre-restore" safety
/// copy, extract the chosen archive, restart.
/// </summary>
public async Task<(bool Ok, string? Error)> RestoreAsync(string backupName, CancellationToken ct = default)
{
if (!await _gate.WaitAsync(0, ct))
return (false, "Another backup operation is already in progress.");
try
{
var dir = ResolveBackupDir();
var path = Path.Combine(dir, Path.GetFileName(backupName));
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(_config.ServerDir, levelName);
if (_proc.IsRunning)
{
_log("[restore] Stopping server before restore...");
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
}
// Always preserve the current world as a pre-restore snapshot in case the
// chosen archive is corrupt or wrong.
if (Directory.Exists(worldDir))
{
var preRestore = $"{worldDir}-prerestore-{DateTime.UtcNow:yyyyMMddHHmmss}";
_log($"[restore] Moving current world to {Path.GetFileName(preRestore)}");
Directory.Move(worldDir, preRestore);
}
_log($"[restore] Extracting {backupName}");
Directory.CreateDirectory(worldDir);
await Task.Run(() => ZipFile.ExtractToDirectory(path, worldDir, overwriteFiles: true), ct);
_log("[restore] Starting server.");
_proc.Start();
return (true, null);
}
catch (Exception ex)
{
_log($"[restore] Failed: {ex.Message}");
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
return (false, ex.Message);
}
finally
{
_gate.Release();
}
}
public (bool Ok, string? Error) Delete(string backupName)
{
try
{
var path = Path.Combine(ResolveBackupDir(), Path.GetFileName(backupName));
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
File.Delete(path);
return (true, null);
}
catch (Exception ex) { return (false, ex.Message); }
}
private string ResolveBackupDir()
{
if (!string.IsNullOrWhiteSpace(_config.BackupDir))
return Path.GetFullPath(_config.BackupDir);
// Default: sibling of serverDir so it survives server-dir wipes.
var serverFull = Path.GetFullPath(_config.ServerDir);
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
return Path.Combine(parent, "backups");
}
private static void RotateOldest(string dir, int keep)
{
if (keep <= 0) return;
try
{
var zips = Directory.EnumerateFiles(dir, "*.zip")
.Select(p => new FileInfo(p))
.OrderByDescending(fi => fi.CreationTimeUtc)
.ToList();
foreach (var old in zips.Skip(keep))
{
try { old.Delete(); }
catch { /* best-effort */ }
}
}
catch { }
}
private static string? ReadLevelName(string serverDir)
{
var path = Path.Combine(serverDir, "server.properties");
if (!File.Exists(path)) return null;
foreach (var line in File.ReadAllLines(path))
{
if (line.StartsWith("level-name=", StringComparison.Ordinal))
return line.Substring("level-name=".Length).Trim();
}
return null;
}
private static string Slugify(string s)
{
var chars = s.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray();
var slug = new string(chars).ToLowerInvariant();
return slug.Length > 32 ? slug.Substring(0, 32) : slug;
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// On-demand snapshot of online players + positions, formatted for BlueMap's
/// live player overlay. Pull-based: the BlueMap web UI polls a JSON file at
/// <c>/map/maps/overworld/live/players.json</c> roughly every 2 s, and the
/// daemon intercepts that path and calls <see cref="SnapshotAsync"/> per
/// request. Closed tab = no requests = no RCON calls -- same model as
/// <c>/api/players</c>, no server-side timer to manage.
/// </summary>
public static class BlueMapPlayers
{
public static async Task<List<object>> SnapshotAsync(RconManager rcon, string serverDir, CancellationToken ct)
{
var listResp = await rcon.SendCommandAsync("list", ct);
// Format: "There are N of a max of M players online: name1, name2, ..."
var colon = listResp.IndexOf(':');
var names = colon < 0
? Array.Empty<string>()
: listResp.Substring(colon + 1)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<object>();
foreach (var name in names)
{
ct.ThrowIfCancellationRequested();
try
{
var posResp = await rcon.SendCommandAsync($"data get entity {name} Pos", ct);
var pos = ParseDoubleTriple(posResp);
if (pos is null) continue;
var rotResp = await rcon.SendCommandAsync($"data get entity {name} Rotation", ct);
var rot = ParseFloatPair(rotResp);
result.Add(new
{
uuid = ResolveUuid(serverDir, name),
name,
foreign = false,
position = new { x = pos.Value.x, y = pos.Value.y, z = pos.Value.z },
rotation = new { pitch = rot?.pitch ?? 0, yaw = rot?.yaw ?? 0, roll = 0.0 },
});
}
catch
{
// One bad player shouldn't drop the rest of the list.
}
}
return result;
}
// ── UUID resolution ──────────────────────────────────────────────────────
// BlueMap uses the UUID for two things: marker identity across polls (so a
// changing UUID makes the marker flash on/off as it thinks the player keeps
// leaving and rejoining) and skin lookup against Mojang's profile API. We
// need the *real* Mojang UUID to satisfy both -- MC writes name→uuid pairs
// into <serverDir>/usercache.json after each successful auth. We cache that
// file's contents in memory and reload on mtime change, since usercache
// updates rarely (player join, periodic refresh).
private sealed class UserCacheEntry
{
public string? Name { get; set; }
public string? Uuid { get; set; }
}
private static readonly object _cacheLock = new();
private static Dictionary<string, string>? _cache; // case-insensitive name → uuid
private static DateTime _cacheLoadedAt = DateTime.MinValue;
private static string? _cachePath;
private static string ResolveUuid(string serverDir, string name)
{
var path = Path.Combine(Path.GetFullPath(serverDir), "usercache.json");
lock (_cacheLock)
{
if (File.Exists(path))
{
var mtime = File.GetLastWriteTimeUtc(path);
if (_cache is null || _cachePath != path || mtime > _cacheLoadedAt)
{
try
{
var json = File.ReadAllText(path);
var entries = JsonSerializer.Deserialize<UserCacheEntry[]>(json, JsonOpts.CaseInsensitive);
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (entries is not null)
foreach (var e in entries)
if (!string.IsNullOrEmpty(e.Name) && !string.IsNullOrEmpty(e.Uuid))
dict[e.Name] = e.Uuid;
_cache = dict;
_cachePath = path;
_cacheLoadedAt = mtime;
}
catch
{
// Corrupt usercache shouldn't take down the marker -- fall through.
}
}
}
if (_cache is not null && _cache.TryGetValue(name, out var uuid)) return uuid;
}
// Fallback when usercache hasn't been written yet (very early after
// first auth) or has been wiped: deterministic UUID derived from the
// name. Stable across polls so the marker doesn't flash; skin lookup
// will 404 against Mojang and BlueMap will show a default head.
return DeriveStableUuid(name).ToString();
}
private static Guid DeriveStableUuid(string name)
{
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes("brass-sigil:" + name.ToLowerInvariant()));
var guidBytes = new byte[16];
Array.Copy(bytes, guidBytes, 16);
return new Guid(guidBytes);
}
// Parse a NBT-style double triple like "[123.4d, 64.0d, -56.7d]" out of an
// RCON `data get` response.
private static (double x, double y, double z)? ParseDoubleTriple(string resp)
{
var m = Regex.Match(resp, @"\[([\-\d\.]+)d?,\s*([\-\d\.]+)d?,\s*([\-\d\.]+)d?\]");
if (!m.Success) return null;
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) return null;
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return null;
if (!double.TryParse(m.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) return null;
return (x, y, z);
}
// Rotation comes as `[yaw, pitch]` in floats.
private static (double pitch, double yaw)? ParseFloatPair(string resp)
{
var m = Regex.Match(resp, @"\[([\-\d\.]+)f?,\s*([\-\d\.]+)f?\]");
if (!m.Success) return null;
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var yaw)) return null;
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pitch)) return null;
return (pitch, yaw);
}
}
+321
View File
@@ -0,0 +1,321 @@
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Wraps BlueMap CLI as an out-of-process renderer.
///
/// The CLI jar is downloaded from GitHub releases on first render (mirrors the
/// JavaInstaller pattern -- keeps the brass-sigil-server binary lean and lets
/// BlueMap update independently). BlueMap 5.20+ requires Java 25, so we also
/// auto-install Adoptium Temurin JRE 25 alongside the JRE 21 we use for MC.
///
/// Renders are kicked off manually from the panel and produce static HTML/JS/PNG
/// output served at /map/. Zero impact on the running MC server (separate JVM,
/// separate memory pool -- only competes for disk I/O during render).
/// </summary>
public sealed class BlueMapService : IDisposable
{
private const string BlueMapVersion = "5.20";
private const string BlueMapJarUrl =
"https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v"
+ BlueMapVersion + "/bluemap-" + BlueMapVersion + "-cli.jar";
private const int BlueMapJavaVersion = 25;
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
private readonly ServerConfig _config;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private Process? _renderProc;
public sealed class RenderState
{
public bool InProgress { get; set; }
public string Phase { get; set; } = "idle";
// "idle" | "extracting" | "configuring" | "rendering" | "complete" | "failed"
public string? Error { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public int? ExitCode { get; set; }
public string? LastLogLine { get; set; }
}
public RenderState State { get; private set; } = new();
public string RootDir
{
get
{
// Configured override (e.g. /mnt/md0p1/brass-sigil/bluemap) wins. Default
// is sibling of serverDir so it doesn't bloat the world dir or the
// server install -- typically ~/brass-sigil-server/bluemap.
if (!string.IsNullOrWhiteSpace(_config.BlueMapDir))
return Path.GetFullPath(_config.BlueMapDir);
var serverFull = Path.GetFullPath(_config.ServerDir);
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
return Path.Combine(parent, "bluemap");
}
}
public string CliJarPath => Path.Combine(RootDir, "cli.jar");
public string WebDir => Path.Combine(RootDir, "web");
public string ConfigDir => Path.Combine(RootDir, "config");
public BlueMapService(ServerConfig config, Action<string> log)
{
_config = config;
_log = log;
}
public bool HasRendered => Directory.Exists(WebDir) &&
File.Exists(Path.Combine(WebDir, "index.html"));
/// <summary>Kick off a render in the background. Returns false if one is already running.</summary>
public bool StartRender()
{
if (!_gate.Wait(0)) return false;
State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow };
_ = Task.Run(RenderAsync);
return true;
}
/// <summary>
/// Cancel an in-progress render by killing the BlueMap process. State on disk
/// is preserved, so the next render resumes from where this one stopped.
/// </summary>
public bool CancelRender()
{
if (!State.InProgress) return false;
try
{
// Kill the whole process tree (the BlueMap CLI may spawn worker JVMs
// for parallel rendering). entireProcessTree=true on Linux uses
// process group; on Windows it walks the tree via Job Objects.
_renderProc?.Kill(entireProcessTree: true);
State.Phase = "cancelled";
State.Error = "Cancelled by user.";
_log("[bluemap] Render cancelled by user.");
return true;
}
catch (Exception ex)
{
_log($"[bluemap] Cancel failed: {ex.Message}");
return false;
}
}
/// <summary>
/// Delete rendered map output + render state. Used after a world wipe -- the
/// old tiles reference terrain that no longer exists. Preserves cli.jar and
/// configs so a follow-up Render still works (and skips re-download +
/// re-config). Returns true if anything was deleted.
/// </summary>
public bool ClearRenderOutput()
{
var dataDir = Path.Combine(RootDir, "data");
var mapsDir = Path.Combine(RootDir, "web", "maps");
var anyDeleted = false;
try
{
if (Directory.Exists(dataDir)) { Directory.Delete(dataDir, true); anyDeleted = true; _log("[bluemap] Cleared render state."); }
if (Directory.Exists(mapsDir)) { Directory.Delete(mapsDir, true); anyDeleted = true; _log("[bluemap] Cleared rendered tiles."); }
}
catch (Exception ex) { _log($"[bluemap] Couldn't clear output: {ex.Message}"); }
return anyDeleted;
}
/// <summary>
/// Called when brass-sigil-server itself shuts down -- kill any in-flight
/// BlueMap process so it doesn't orphan to PID 1 and keep eating CPU after
/// the daemon's gone. Render state on disk is preserved; next start can
/// resume the render exactly where this one was killed.
/// </summary>
public void Dispose()
{
try
{
if (_renderProc is { HasExited: false })
{
_renderProc.Kill(entireProcessTree: true);
_renderProc.WaitForExit(2000);
}
}
catch { }
_renderProc?.Dispose();
_gate.Dispose();
}
private async Task RenderAsync()
{
try
{
Directory.CreateDirectory(RootDir);
State.Phase = "downloading";
await DownloadJarIfMissingAsync();
// BlueMap 5.20 needs Java 25; our MC server runs Java 21. Maintain a
// separate JRE 25 install for BlueMap only.
var bluemapJava = await EnsureJava25Async();
if (bluemapJava is null) throw new InvalidOperationException("Couldn't install JRE 25 for BlueMap.");
// First run: BlueMap with no config writes default configs and exits.
// We need to (a) run it once to seed configs, (b) patch the world path,
// (c) re-run with -r to actually render.
State.Phase = "configuring";
EnsureConfig(bluemapJava);
State.Phase = "rendering";
// -r = render. --mods <modsDir> = pull textures from mod jars so Create
// blocks etc. show real colours instead of magenta/grey fallback.
var modsDir = Path.Combine(Path.GetFullPath(_config.ServerDir), "mods");
var args = new List<string> { "-jar", CliJarPath, "-r" };
if (Directory.Exists(modsDir))
{
args.Add("--mods");
args.Add(modsDir);
}
var psi = new ProcessStartInfo
{
FileName = bluemapJava,
WorkingDirectory = RootDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
_renderProc = new Process { StartInfo = psi, EnableRaisingEvents = true };
_renderProc.OutputDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
_renderProc.ErrorDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
_renderProc.Start();
_renderProc.BeginOutputReadLine();
_renderProc.BeginErrorReadLine();
await _renderProc.WaitForExitAsync();
State.ExitCode = _renderProc.ExitCode;
State.FinishedAt = DateTimeOffset.UtcNow;
if (_renderProc.ExitCode == 0) State.Phase = "complete";
else { State.Phase = "failed"; State.Error = $"BlueMap exited with code {_renderProc.ExitCode}"; }
}
catch (Exception ex)
{
State.Phase = "failed";
State.Error = ex.Message;
State.FinishedAt = DateTimeOffset.UtcNow;
_log($"[bluemap] Failed: {ex.Message}");
}
finally
{
State.InProgress = false;
_renderProc?.Dispose();
_renderProc = null;
_gate.Release();
}
}
private async Task DownloadJarIfMissingAsync()
{
if (File.Exists(CliJarPath)) return;
_log($"[bluemap] Downloading BlueMap CLI v{BlueMapVersion} to {CliJarPath}");
Directory.CreateDirectory(Path.GetDirectoryName(CliJarPath)!);
using var resp = await _http.GetAsync(BlueMapJarUrl, HttpCompletionOption.ResponseHeadersRead);
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync();
await using var dst = File.Create(CliJarPath);
await src.CopyToAsync(dst);
_log($"[bluemap] Downloaded {new FileInfo(CliJarPath).Length / 1024} KB.");
}
/// <summary>Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs).</summary>
private async Task<string?> EnsureJava25Async()
{
var installer = new JavaInstaller();
var serverFull = Path.GetFullPath(_config.ServerDir);
// Look for an existing JRE 25 install first (idempotent across renders).
var existing = installer.FindBundledJava(serverFull, BlueMapJavaVersion);
if (existing is not null) return existing;
var installDir = installer.GetJavaInstallDir(serverFull, BlueMapJavaVersion);
var progress = new Progress<string>(msg => _log("[bluemap] " + msg));
return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default);
}
/// <summary>
/// Run BlueMap with no flags so it writes default configs (bluemap.conf,
/// maps/overworld.conf, etc.), then patch the overworld map's world path
/// to point at our serverDir. Idempotent -- only writes configs that don't
/// exist; existing user edits survive.
/// </summary>
private void EnsureConfig(string javaPath)
{
Directory.CreateDirectory(ConfigDir);
var bluemapConf = Path.Combine(ConfigDir, "core.conf");
var seeded = File.Exists(bluemapConf);
if (!seeded)
{
_log("[bluemap] First run -- generating default configs.");
var psi = new ProcessStartInfo
{
FileName = javaPath,
WorkingDirectory = RootDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-jar");
psi.ArgumentList.Add(CliJarPath);
using var p = Process.Start(psi)!;
p.WaitForExit(60_000);
}
// Patch the overworld map config to reference our world dir.
var serverDirAbs = Path.GetFullPath(_config.ServerDir);
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(serverDirAbs, levelName).Replace('\\', '/');
var mapsDir = Path.Combine(ConfigDir, "maps");
Directory.CreateDirectory(mapsDir);
var owConf = Path.Combine(mapsDir, "overworld.conf");
if (!File.Exists(owConf) || !File.ReadAllText(owConf).Contains(worldDir))
{
File.WriteAllText(owConf, $@"# Generated by brass-sigil-server. Edit at your own risk.
world: ""{worldDir}""
dimension: ""minecraft:overworld""
name: ""Brass and Sigil -- Overworld""
sorting: 0
sky-color: ""#7dabff""
ambient-light: 0
");
_log("[bluemap] Wrote map config: maps/overworld.conf");
}
// Tell core.conf to accept that we read its license/disclaimer (otherwise CLI exits with a notice).
if (File.Exists(bluemapConf))
{
var text = File.ReadAllText(bluemapConf);
if (!text.Contains("accept-download: true"))
{
text = text.Replace("accept-download: false", "accept-download: true");
if (!text.Contains("accept-download:"))
text += "\naccept-download: true\n";
File.WriteAllText(bluemapConf, text);
_log("[bluemap] Set accept-download: true in core.conf");
}
}
}
private static string? ReadLevelName(string serverDir)
{
var path = Path.Combine(serverDir, "server.properties");
if (!File.Exists(path)) return null;
foreach (var line in File.ReadAllLines(path))
{
if (line.StartsWith("level-name=", StringComparison.Ordinal))
return line.Substring("level-name=".Length).Trim();
}
return null;
}
}
+109
View File
@@ -0,0 +1,109 @@
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Pushes player-visible messages and overlays into Minecraft via stdin
/// commands (/say, /title, /bossbar). The boss-bar countdown is the primary
/// primitive the updater uses for restart announcements.
/// </summary>
public sealed class Broadcaster
{
private readonly ServerProcess _proc;
private const string BossBarId = "brass:announce";
public Broadcaster(ServerProcess proc) => _proc = proc;
public Task SayAsync(string message, CancellationToken ct = default)
=> _proc.SendInputAsync($"say {SingleLine(message)}", ct);
public Task ActionBarAsync(string message, CancellationToken ct = default)
=> _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(message)}\"}}", ct);
/// <summary>
/// Re-sends the action bar text once per second so it stays sticky for the
/// full duration. Action bar fades after ~2-3 s of inactivity, so the
/// re-send is mandatory. Doesn't conflict with boss-bar UI for actual
/// boss fights -- preferred over BossBarCountdownAsync for restart warnings.
/// </summary>
public async Task ActionBarCountdownAsync(
string title, int durationSeconds, CancellationToken ct = default)
{
if (durationSeconds <= 0) return;
// Silence /title's "Showing new title for X" chat broadcast for the loop --
// otherwise it spams chat once per second per online player. Restored in
// the finally block. World save typically isn't quick enough to persist
// the off state if we crash mid-flight, but worst case admins can flip
// it back manually with /gamerule sendCommandFeedback true.
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
try
{
for (int sec = durationSeconds; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
var mins = sec / 60;
var secs = sec % 60;
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
await _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(label)}\"}}", ct);
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
}
finally
{
// Clear the action bar AND restore feedback. Both best-effort: if MC
// is stopping these'll fail and that's fine.
try { await _proc.SendInputAsync("title @a actionbar {\"text\":\"\"}"); } catch { }
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
}
}
public Task TitleAsync(string message, CancellationToken ct = default)
=> _proc.SendInputAsync($"title @a title {{\"text\":\"{EscapeJson(message)}\"}}", ct);
/// <summary>
/// Show a draining boss bar at the top of every player's screen for
/// <paramref name="durationSeconds"/>. Updates the bar's name with a
/// "title -- Mm Ss" countdown each second. Returns when the bar is removed.
/// Honours cancellation: bar is removed cleanly even on cancel.
/// </summary>
public async Task BossBarCountdownAsync(
string title, int durationSeconds, string color = "yellow", CancellationToken ct = default)
{
if (durationSeconds <= 0) return;
// Silence /bossbar feedback for the same reason as ActionBarCountdownAsync.
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
await _proc.SendInputAsync($"bossbar add {BossBarId} {{\"text\":\"{EscapeJson(title)}\"}}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} color {color}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} max {durationSeconds}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} value {durationSeconds}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} players @a", ct);
try
{
for (int sec = durationSeconds; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
var mins = sec / 60;
var secs = sec % 60;
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
await _proc.SendInputAsync($"bossbar set {BossBarId} name {{\"text\":\"{EscapeJson(label)}\"}}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} value {sec}", ct);
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
}
finally
{
// Always remove the bar -- even on cancel, so a stuck bar isn't left
// on every player's screen. Use CancellationToken.None for the cleanup.
try { await _proc.SendInputAsync($"bossbar remove {BossBarId}"); } catch { }
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
}
}
private static string EscapeJson(string s) =>
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", " ");
private static string SingleLine(string s) =>
s.Replace("\r", " ").Replace("\n", " ").Trim();
}
+156
View File
@@ -0,0 +1,156 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Runtime.InteropServices;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Downloads + extracts Adoptium Temurin JRE 21 to server/java/. Used as a fallback
/// when system Java is missing or too old. Adoptium's API gives us a stable
/// platform-keyed download URL without needing API keys or auth.
/// </summary>
public sealed class JavaInstaller
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(15) };
public string GetJavaInstallDir(string serverDir) => Path.Combine(serverDir, "java");
public string GetJavaInstallDir(string serverDir, int majorVersion) =>
Path.Combine(serverDir, "java" + majorVersion);
/// <summary>If a previous install put a java executable under serverDir/java/, return its path.</summary>
public string? FindBundledJava(string serverDir)
{
var javaDir = GetJavaInstallDir(serverDir);
if (!Directory.Exists(javaDir)) return null;
var exe = OperatingSystem.IsWindows() ? "java.exe" : "java";
return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault();
}
/// <summary>Find a Java install for a specific major version (e.g. javaXX/jdk-XX*/bin/java).</summary>
public string? FindBundledJava(string serverDir, int majorVersion)
{
var javaDir = GetJavaInstallDir(serverDir, majorVersion);
if (!Directory.Exists(javaDir)) return null;
var exe = OperatingSystem.IsWindows() ? "java.exe" : "java";
return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault();
}
public Task<string?> InstallJre21Async(string serverDir, IProgress<string>? progress, CancellationToken ct)
=> InstallJreAsync(21, serverDir, GetJavaInstallDir(serverDir), progress, ct);
/// <summary>
/// Download + extract Adoptium Temurin JRE for a specific major version into
/// <paramref name="installDir"/>. Used by BlueMap to get JRE 25 alongside the
/// JRE 21 we use for Minecraft.
/// </summary>
public async Task<string?> InstallJreAsync(int majorVersion, string serverDir, string installDir,
IProgress<string>? progress, CancellationToken ct)
{
var javaDir = installDir;
Directory.CreateDirectory(javaDir);
var (url, archiveName, isZip) = PickAdoptiumDownload(majorVersion);
if (url is null)
{
progress?.Report($"[err] No supported Adoptium binary for {RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}.");
return null;
}
var archivePath = Path.Combine(javaDir, archiveName!);
progress?.Report($"Downloading Adoptium Temurin JRE 21 ({(isZip ? "zip" : "tar.gz")})...");
try
{
using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
{
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(archivePath);
await src.CopyToAsync(dst, ct);
}
}
catch (Exception ex)
{
progress?.Report($" [err] Download failed: {ex.Message}");
return null;
}
progress?.Report($" Downloaded {new FileInfo(archivePath).Length:N0} bytes");
progress?.Report("Extracting...");
try
{
if (isZip)
{
ZipFile.ExtractToDirectory(archivePath, javaDir, overwriteFiles: true);
}
else
{
await using var fs = File.OpenRead(archivePath);
await using var gzip = new GZipStream(fs, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(gzip, javaDir, overwriteFiles: true, ct);
}
}
catch (Exception ex)
{
progress?.Report($" [err] Extract failed: {ex.Message}");
return null;
}
try { File.Delete(archivePath); } catch { /* best-effort */ }
var javaExe = FindBundledJava(serverDir);
if (javaExe is null)
{
progress?.Report(" [err] Extracted, but couldn't locate bin/java in the result.");
return null;
}
// On Linux/macOS, make sure java is executable. TarFile preserves mode bits in
// most setups, but be defensive.
if (!OperatingSystem.IsWindows())
{
try
{
File.SetUnixFileMode(javaExe,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
catch { }
}
progress?.Report($"Java {majorVersion} ready: {javaExe}");
return javaExe;
}
private static (string? Url, string? ArchiveName, bool IsZip) PickAdoptiumDownload(int majorVersion)
{
// Adoptium API picks the latest GA release matching our os/arch.
// Docs: https://api.adoptium.net/q/swagger-ui/
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "aarch64",
_ => null
};
if (arch is null) return (null, null, false);
if (OperatingSystem.IsWindows())
{
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/windows/{arch}/jre/hotspot/normal/eclipse",
$"jre{majorVersion}.zip", true);
}
if (OperatingSystem.IsLinux())
{
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/linux/{arch}/jre/hotspot/normal/eclipse",
$"jre{majorVersion}.tar.gz", false);
}
if (OperatingSystem.IsMacOS())
{
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/mac/{arch}/jre/hotspot/normal/eclipse",
$"jre{majorVersion}.tar.gz", false);
}
return (null, null, false);
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Project-wide JSON serializer options. Always case-insensitive on read so that
/// hand-edited config files / API responses don't silently fail to bind a property
/// because of a casing mismatch (e.g. "Command" vs "command", "JavaPath" vs "javaPath").
/// Use this everywhere we call JsonSerializer.Deserialize.
/// </summary>
public static class JsonOpts
{
public static readonly JsonSerializerOptions CaseInsensitive = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static readonly JsonSerializerOptions Pretty = new()
{
WriteIndented = true,
};
}
+199
View File
@@ -0,0 +1,199 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Server-side mod sync. Downloads only mods that the server needs:
/// queries Modrinth's project metadata for each mod's `server_side` field
/// and skips anything marked "unsupported" (Iris, Sodium, JEI, etc).
/// CurseForge mods can't be auto-classified without an API key, so they
/// are downloaded as-is and the server admin can manually delete unwanted ones.
/// </summary>
public sealed class ManifestSync
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };
private const string PackVersionFile = "pack-version.json";
private const string ServerManifestCache = "server-pack.cache.json";
public sealed record SyncResult(int Downloaded, int Removed, int Skipped, string PackVersion);
public async Task<Manifest> FetchManifestAsync(string url, CancellationToken ct = default)
{
var json = await _http.GetStringAsync(url, ct);
var manifest = JsonSerializer.Deserialize<Manifest>(json, JsonOpts.CaseInsensitive)
?? throw new InvalidOperationException("Manifest is empty.");
manifest.Files ??= new();
return manifest;
}
public async Task<SyncResult> SyncAsync(
string manifestUrl, string serverDir, IProgress<string>? progress = null, CancellationToken ct = default)
{
progress?.Report("Fetching manifest...");
var manifest = await FetchManifestAsync(manifestUrl, ct);
progress?.Report($"Pack: {manifest.Name} v{manifest.Version}");
Directory.CreateDirectory(serverDir);
// Resolve which mods are server-side.
var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct);
// Build the filtered list of files to keep on the server.
var keepFiles = manifest.Files
.Where(f => !ShouldSkipFile(f.Path, skipSlugs))
.ToList();
var skippedCount = manifest.Files.Count - keepFiles.Count;
// Prune managed files that aren't in the keep set.
var wantedPaths = new HashSet<string>(
keepFiles.Select(f => f.Path.Replace('\\', '/')),
StringComparer.OrdinalIgnoreCase);
var toRemove = ListManagedFiles(serverDir).Where(p => !wantedPaths.Contains(p)).ToList();
foreach (var rel in toRemove)
{
var full = Path.Combine(serverDir, rel);
try { File.Delete(full); progress?.Report($" Removed: {rel}"); }
catch (Exception ex) { progress?.Report($" Could not remove {rel}: {ex.Message}"); }
}
// Download missing or hash-mismatched files.
var toDownload = new List<ManifestFile>();
foreach (var file in keepFiles)
{
ct.ThrowIfCancellationRequested();
var dest = Path.Combine(serverDir, file.Path);
if (!File.Exists(dest)) { toDownload.Add(file); continue; }
if (!string.IsNullOrEmpty(file.Sha1))
{
var actual = await ComputeSha1Async(dest, ct);
if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase))
toDownload.Add(file);
}
}
progress?.Report(toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} files...");
for (int i = 0; i < toDownload.Count; i++)
{
var file = toDownload[i];
progress?.Report($" [{i + 1}/{toDownload.Count}] {file.Path}");
await DownloadFileAsync(file.Url, Path.Combine(serverDir, file.Path), file.Sha1, ct);
}
// Write pack-version.json marker.
var record = new
{
name = manifest.Name,
version = manifest.Version,
syncedAt = DateTime.UtcNow.ToString("o"),
includedFiles = keepFiles.Count,
skippedFiles = skippedCount
};
await File.WriteAllTextAsync(
Path.Combine(serverDir, PackVersionFile),
JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }),
ct);
return new SyncResult(toDownload.Count, toRemove.Count, skippedCount, manifest.Version ?? "?");
}
private static bool ShouldSkipFile(string filePath, HashSet<string> skipSlugs)
{
if (skipSlugs.Count == 0) return false;
var name = Path.GetFileNameWithoutExtension(filePath).ToLowerInvariant();
// Match if any skip slug appears at the start of the filename (slug-version.jar)
return skipSlugs.Any(slug => name.StartsWith(slug + "-", StringComparison.OrdinalIgnoreCase)
|| name.Equals(slug, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
{
var skip = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var modrinthIds = new HashSet<string>();
foreach (var file in manifest.Files)
{
if (!file.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) continue;
// Modrinth URL pattern: https://cdn.modrinth.com/data/{projectId}/versions/{versionId}/...
var url = file.Url;
const string prefix = "cdn.modrinth.com/data/";
var idx = url.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
if (idx < 0) continue;
var rest = url.Substring(idx + prefix.Length);
var slash = rest.IndexOf('/');
if (slash < 0) continue;
modrinthIds.Add(rest.Substring(0, slash));
}
foreach (var pid in modrinthIds)
{
ct.ThrowIfCancellationRequested();
try
{
var info = await _http.GetFromJsonAsync<JsonElement>(
$"https://api.modrinth.com/v2/project/{pid}", ct);
var slug = info.TryGetProperty("slug", out var s) ? s.GetString() : null;
var serverSide = info.TryGetProperty("server_side", out var ss) ? ss.GetString() : null;
if (!string.IsNullOrEmpty(slug) && string.Equals(serverSide, "unsupported", StringComparison.OrdinalIgnoreCase))
{
skip.Add(slug);
}
}
catch
{
// Best-effort: if we can't classify, keep the mod (safer to ship extra than missing)
}
}
return skip;
}
private static List<string> ListManagedFiles(string serverDir)
{
var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" };
var result = new List<string>();
foreach (var root in roots)
{
var rootDir = Path.Combine(serverDir, root);
if (!Directory.Exists(rootDir)) continue;
foreach (var f in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
result.Add(Path.GetRelativePath(serverDir, f).Replace('\\', '/'));
}
return result;
}
private static async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
var tmp = destPath + ".part";
using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
{
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(tmp);
await src.CopyToAsync(dst, ct);
}
if (!string.IsNullOrEmpty(expectedSha1))
{
var actual = await ComputeSha1Async(tmp, ct);
if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase))
{
File.Delete(tmp);
throw new InvalidOperationException($"Hash mismatch for {Path.GetFileName(destPath)}");
}
}
if (File.Exists(destPath)) File.Delete(destPath);
File.Move(tmp, destPath);
}
private static async Task<string> ComputeSha1Async(string path, CancellationToken ct)
{
using var sha = SHA1.Create();
await using var stream = File.OpenRead(path);
var bytes = await sha.ComputeHashAsync(stream, ct);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+112
View File
@@ -0,0 +1,112 @@
using System.Diagnostics;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Downloads NeoForge's official server installer JAR and runs it with --installServer
/// to produce run.sh/run.bat + the server library tree. Handles Java invocation and
/// streams installer output via a progress callback.
/// </summary>
public sealed class NeoForgeInstaller
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
public bool IsAlreadyInstalled(string serverDir)
{
return File.Exists(Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh"));
}
public async Task<bool> InstallAsync(string version, string serverDir, string javaPath,
IProgress<string>? progress, CancellationToken ct)
{
Directory.CreateDirectory(serverDir);
// 1. Download installer
var installerName = $"neoforge-{version}-installer.jar";
var installerPath = Path.Combine(serverDir, installerName);
var url = $"https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/{installerName}";
if (!File.Exists(installerPath))
{
progress?.Report($"Downloading NeoForge {version} installer...");
var bytes = await _http.GetByteArrayAsync(url, ct);
await File.WriteAllBytesAsync(installerPath, bytes, ct);
progress?.Report($" Saved {bytes.Length:N0} bytes to {installerName}");
}
else
{
progress?.Report($"NeoForge installer already present, skipping download.");
}
// 2. Run installer
progress?.Report("Running NeoForge installer (java -jar ... --installServer)...");
var psi = new ProcessStartInfo
{
FileName = javaPath,
WorkingDirectory = serverDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-jar");
psi.ArgumentList.Add(installerName);
psi.ArgumentList.Add("--installServer");
Process? proc;
try
{
proc = Process.Start(psi);
}
catch (Exception ex)
{
progress?.Report($" [error] Could not start java: {ex.Message}");
return false;
}
if (proc is null)
{
progress?.Report(" [error] Failed to start java.");
return false;
}
var stdoutTask = StreamLines(proc.StandardOutput, line => progress?.Report($" {line}"), ct);
var stderrTask = StreamLines(proc.StandardError, line => progress?.Report($" [err] {line}"), ct);
await proc.WaitForExitAsync(ct);
await Task.WhenAll(stdoutTask, stderrTask);
if (proc.ExitCode != 0)
{
progress?.Report($" [error] NeoForge installer exited with code {proc.ExitCode}");
return false;
}
// 3. Verify run script exists
if (!IsAlreadyInstalled(serverDir))
{
progress?.Report(" [error] NeoForge installer ran but run.sh/run.bat is missing.");
return false;
}
progress?.Report($"NeoForge {version} installed.");
// 4. Clean up the installer JAR (large, no longer needed)
try { File.Delete(installerPath); } catch { /* best-effort */ }
return true;
}
private static async Task StreamLines(StreamReader reader, Action<string> onLine, CancellationToken ct)
{
try
{
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync(ct);
if (line is null) break;
onLine(line);
}
}
catch { }
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Minimal Minecraft RCON client (Source RCON protocol).
/// Used for sending console commands and reading "list" output for player counts.
/// </summary>
public sealed class RconClient : IDisposable
{
private const int SERVERDATA_AUTH = 3;
private const int SERVERDATA_EXECCOMMAND = 2;
private const int SERVERDATA_RESPONSE_VALUE = 0;
private TcpClient? _tcp;
private NetworkStream? _stream;
private int _nextRequestId = 1;
public bool Connected => _tcp?.Connected ?? false;
public async Task ConnectAsync(string host, int port, string password, CancellationToken ct = default)
{
_tcp = new TcpClient();
await _tcp.ConnectAsync(host, port, ct);
_stream = _tcp.GetStream();
var authId = NextId();
await SendPacketAsync(authId, SERVERDATA_AUTH, password, ct);
// Read auth response. Server sends an empty response value first, then the auth result.
var (id1, _, _) = await ReadPacketAsync(ct);
if (id1 == -1) throw new InvalidOperationException("RCON auth failed (bad password)");
// Auth ok if id matches what we sent
}
public async Task<string> SendCommandAsync(string command, CancellationToken ct = default)
{
if (_stream == null) throw new InvalidOperationException("Not connected");
var id = NextId();
await SendPacketAsync(id, SERVERDATA_EXECCOMMAND, command, ct);
var (_, _, body) = await ReadPacketAsync(ct);
return body;
}
private int NextId() => Interlocked.Increment(ref _nextRequestId);
private async Task SendPacketAsync(int id, int type, string body, CancellationToken ct)
{
var bodyBytes = Encoding.UTF8.GetBytes(body);
var packetSize = 4 + 4 + bodyBytes.Length + 2; // id + type + body + 2 null bytes
var buffer = new byte[4 + packetSize];
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), packetSize);
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4, 4), id);
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(8, 4), type);
bodyBytes.CopyTo(buffer.AsSpan(12));
// last two bytes already 0 by default
await _stream!.WriteAsync(buffer, ct);
}
private async Task<(int Id, int Type, string Body)> ReadPacketAsync(CancellationToken ct)
{
var sizeBuf = new byte[4];
await ReadExactAsync(sizeBuf, ct);
var size = BinaryPrimitives.ReadInt32LittleEndian(sizeBuf);
if (size < 10 || size > 1024 * 1024) throw new InvalidOperationException($"Bad RCON packet size {size}");
var pkt = new byte[size];
await ReadExactAsync(pkt, ct);
var id = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(0, 4));
var type = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(4, 4));
var body = Encoding.UTF8.GetString(pkt, 8, size - 10); // strip 2 trailing nulls
return (id, type, body);
}
private async Task ReadExactAsync(byte[] buffer, CancellationToken ct)
{
var read = 0;
while (read < buffer.Length)
{
var n = await _stream!.ReadAsync(buffer.AsMemory(read), ct);
if (n == 0) throw new EndOfStreamException();
read += n;
}
}
public void Dispose()
{
_stream?.Dispose();
_tcp?.Dispose();
}
}
+68
View File
@@ -0,0 +1,68 @@
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Thin reconnecting wrapper around <see cref="RconClient"/>. The original
/// single-connection-with-no-retry pattern caches a dead client whenever the
/// initial connect happens before MC has opened the RCON port (which is normal --
/// boot takes ~30 s). This manager lazily connects on first use, retries on
/// failure, and drops the client when a send throws so the next call reconnects.
/// </summary>
public sealed class RconManager : IDisposable
{
private readonly string _host;
private readonly int _port;
private readonly string _password;
private readonly SemaphoreSlim _lock = new(1, 1);
private RconClient? _client;
public RconManager(string host, int port, string password)
{
_host = host;
_port = port;
_password = password;
}
public async Task<string> SendCommandAsync(string command, CancellationToken ct = default)
{
var client = await EnsureConnectedAsync(ct);
try
{
return await client.SendCommandAsync(command, ct);
}
catch
{
await DropAsync();
throw;
}
}
private async Task<RconClient> EnsureConnectedAsync(CancellationToken ct)
{
await _lock.WaitAsync(ct);
try
{
if (_client is { Connected: true }) return _client;
_client?.Dispose();
_client = new RconClient();
await _client.ConnectAsync(_host, _port, _password, ct);
return _client;
}
finally
{
_lock.Release();
}
}
private async Task DropAsync()
{
await _lock.WaitAsync();
try { _client?.Dispose(); _client = null; }
finally { _lock.Release(); }
}
public void Dispose()
{
_client?.Dispose();
_lock.Dispose();
}
}
+405
View File
@@ -0,0 +1,405 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Manages the Minecraft Java subprocess: spawns it with the right JVM args,
/// captures stdout/stderr into a ring buffer (so the web UI can show recent
/// logs), broadcasts new lines via an event, handles graceful shutdown.
/// </summary>
public sealed class ServerProcess : IDisposable
{
private readonly ServerConfig _config;
private Process? _process;
private readonly ConcurrentQueue<LogLine> _logRing = new();
private const int LogRingSize = 2000;
// Process-tree containment: on Windows, the Job Object kills our subprocess
// automatically when our own process dies -- regardless of how we died.
// Created lazily on first Start() on Windows; destroyed in Dispose.
private static WindowsJobObject? _jobObject;
public event Action<LogLine>? OnLogLine;
public event Action<int>? Exited;
public bool IsRunning => _process is { HasExited: false };
public DateTime? StartedAt { get; private set; }
public int? Pid => _process?.Id;
// Memory + CPU sampling. CpuMetrics is a moving average across calls -- the
// first call after start returns null because we need two samples for a delta.
// We track the Java *descendant* of our shell, not the shell itself, because
// run.sh / run.bat is ~2 MB and useless for stats.
private TimeSpan _lastCpuTime;
private DateTime _lastCpuSampleAt;
private int _lastTrackedPid;
private Process? _trackedJava;
private readonly object _statsLock = new();
private readonly Queue<double> _cpuSamples = new();
private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling
/// <summary>
/// Returns the Java JVM descendant if found, otherwise falls back to the
/// directly-spawned shell process. Cached and re-resolved when the cached
/// pid exits (e.g., MC restart).
/// </summary>
private Process? TrackedProcess
{
get
{
if (_process is null || _process.HasExited) return null;
if (_trackedJava is { HasExited: false }) return _trackedJava;
// Try to find a 'java' descendant of our shell. If not found yet (still
// booting), fall back to the shell -- first stats will read tiny, then
// reset on next call once Java is up.
var found = FindJavaDescendant(_process.Id);
_trackedJava = found;
return found ?? _process;
}
}
public long? MemoryBytes
{
get
{
var p = TrackedProcess;
if (p is null || p.HasExited) return null;
try { p.Refresh(); return p.WorkingSet64; }
catch { return null; }
}
}
/// <summary>
/// System-wide CPU percentage (0-100) plus rolling-window peak and average.
/// Each call samples once and contributes to a 20-entry history (~60 s at 3 s
/// polling). First call after start returns null (need two samples for a delta).
/// </summary>
public (double Current, double Max, double Avg)? CpuMetrics
{
get
{
var p = TrackedProcess;
if (p is null || p.HasExited) return null;
lock (_statsLock)
{
try
{
p.Refresh();
var now = DateTime.UtcNow;
var cpuNow = p.TotalProcessorTime;
if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default)
{
_lastTrackedPid = p.Id;
_lastCpuTime = cpuNow;
_lastCpuSampleAt = now;
_cpuSamples.Clear();
return null;
}
var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds;
var elapsedCpu = (cpuNow - _lastCpuTime).TotalMilliseconds;
_lastCpuTime = cpuNow;
_lastCpuSampleAt = now;
if (elapsedReal <= 0) return (0, 0, 0);
// System-wide: divide by core count so the value is bounded 0-100
// and intuitive ("42% of total CPU capacity"). The user found the
// top-style 0-N*100 range confusing for a fleet view.
var perCore = elapsedCpu / elapsedReal * 100.0;
var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount);
_cpuSamples.Enqueue(systemWide);
while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue();
return (systemWide, _cpuSamples.Max(), _cpuSamples.Average());
}
catch { return null; }
}
}
}
/// <summary>
/// BFS the process tree starting from <paramref name="rootPid"/> looking for
/// a process named like "java". Linux: read /proc/PID/task/PID/children.
/// Windows: enumerate parents via Process objects (no extra deps).
/// </summary>
private static Process? FindJavaDescendant(int rootPid)
{
try
{
if (OperatingSystem.IsLinux())
{
return FindJavaDescendantLinux(rootPid);
}
if (OperatingSystem.IsWindows())
{
return FindJavaDescendantWindows(rootPid);
}
}
catch { }
return null;
}
private static Process? FindJavaDescendantLinux(int rootPid)
{
var visited = new HashSet<int>();
var queue = new Queue<int>();
queue.Enqueue(rootPid);
while (queue.Count > 0)
{
var pid = queue.Dequeue();
if (!visited.Add(pid)) continue;
var childrenPath = $"/proc/{pid}/task/{pid}/children";
if (!File.Exists(childrenPath)) continue;
string raw;
try { raw = File.ReadAllText(childrenPath); } catch { continue; }
foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (!int.TryParse(token, out var cpid)) continue;
Process? p = null;
try { p = Process.GetProcessById(cpid); } catch { continue; }
if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
return p;
queue.Enqueue(cpid);
}
}
return null;
}
private static Process? FindJavaDescendantWindows(int rootPid)
{
// Build a parent->children map by reading every running process's parent
// PID once, then BFS. Slower than Linux's /proc but works without WMI.
var allProcs = Process.GetProcesses();
var byParent = new Dictionary<int, List<Process>>();
foreach (var p in allProcs)
{
int parent;
try { parent = GetParentPidWindows(p); } catch { continue; }
if (parent == 0) continue;
if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List<Process>();
list.Add(p);
}
var visited = new HashSet<int>();
var queue = new Queue<int>();
queue.Enqueue(rootPid);
while (queue.Count > 0)
{
var pid = queue.Dequeue();
if (!visited.Add(pid)) continue;
if (!byParent.TryGetValue(pid, out var children)) continue;
foreach (var c in children)
{
if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
return c;
queue.Enqueue(c.Id);
}
}
return null;
}
[System.Runtime.InteropServices.DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(
IntPtr processHandle, int processInformationClass,
ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct ProcessBasicInformation
{
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr InheritedFromUniqueProcessId; // <-- parent PID
}
private static int GetParentPidWindows(Process p)
{
var info = new ProcessBasicInformation();
int rc = NtQueryInformationProcess(p.Handle, 0, ref info,
System.Runtime.InteropServices.Marshal.SizeOf<ProcessBasicInformation>(), out _);
return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32();
}
public ServerProcess(ServerConfig config) => _config = config;
public bool Start()
{
if (IsRunning) return false;
// Reset cached Java descendant + CPU sampling baseline so the next call
// re-resolves once the new run.sh -> java tree is up.
_trackedJava = null;
_lastTrackedPid = 0;
_lastCpuSampleAt = default;
// The mod loader's start script lives next to the server jar. NeoForge produces
// run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on
// the server side, so we build the equivalent JVM command ourselves.
var startScript = ResolveStartCommand(out var argList);
var startInfo = new ProcessStartInfo
{
FileName = startScript,
WorkingDirectory = Path.GetFullPath(_config.ServerDir),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var a in argList) startInfo.ArgumentList.Add(a);
// CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to
// whatever's on PATH. If we configured a specific Java (or auto-downloaded one),
// prepend its bin dir + set JAVA_HOME so the script picks up the right JVM
// instead of an older system Java.
ApplyJavaEnvironment(startInfo);
_process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false);
_process.ErrorDataReceived += (_, e) => OnLine(e.Data, isError: true);
_process.Exited += (_, _) =>
{
var code = _process?.ExitCode ?? -1;
OnLine($"=== Server exited (code {code}) ===", isError: false);
Exited?.Invoke(code);
};
_process.Start();
// Bind the Java subprocess to a Job Object on Windows so it gets killed
// automatically if we exit ungracefully (X-button, Task Manager, etc.).
if (OperatingSystem.IsWindows())
{
try
{
_jobObject ??= new WindowsJobObject();
_jobObject.AssignProcess(_process);
}
catch (Exception ex)
{
// Non-fatal -- we still try to clean up via Process.Kill in Dispose.
OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true);
}
}
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
StartedAt = DateTime.UtcNow;
return true;
}
private string ResolveStartCommand(out List<string> args)
{
args = new List<string>();
var dir = Path.GetFullPath(_config.ServerDir);
// Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args.
var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh";
var runScript = Path.Combine(dir, runShell);
if (File.Exists(runScript))
{
// Make sure the user_jvm_args.txt has the memory we want
EnsureUserJvmArgs(dir);
// Suppress the Swing server-GUI window -- we use the web panel instead.
// NeoForge's run.bat / run.sh forwards extra args to Minecraft.
args.Add("nogui");
return runScript;
}
// Fallback: invoke java directly on the server jar. This won't work for NeoForge
// (it requires the loader's run script), but it works for vanilla and Forge legacy.
args.Add($"-Xms{_config.MemoryMB}M");
args.Add($"-Xmx{_config.MemoryMB}M");
args.Add("-jar");
args.Add("server.jar");
args.Add("nogui");
return _config.JavaPath;
}
private void ApplyJavaEnvironment(ProcessStartInfo psi)
{
// Only meaningful when we have an absolute path to a specific java binary
// (i.e., not just "java" on PATH).
if (string.IsNullOrEmpty(_config.JavaPath)) return;
if (!Path.IsPathRooted(_config.JavaPath)) return;
if (!File.Exists(_config.JavaPath)) return;
var javaBinDir = Path.GetDirectoryName(_config.JavaPath);
if (string.IsNullOrEmpty(javaBinDir)) return;
var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/
var sep = OperatingSystem.IsWindows() ? ";" : ":";
var existingPath = Environment.GetEnvironmentVariable("PATH") ?? "";
psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}";
if (!string.IsNullOrEmpty(javaHome))
{
psi.Environment["JAVA_HOME"] = javaHome;
}
}
private void EnsureUserJvmArgs(string dir)
{
// NeoForge's run.bat / run.sh reads this file for JVM-level args.
// Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons
// and significantly better than G1 for heavily-modded MC. Requires Java 21+.
var path = Path.Combine(dir, "user_jvm_args.txt");
var content =
$"-Xms{_config.MemoryMB}M\n" +
$"-Xmx{_config.MemoryMB}M\n" +
"-XX:+UseZGC\n" +
"-XX:+ZGenerational\n";
try { File.WriteAllText(path, content); } catch { /* best-effort */ }
}
private void OnLine(string? data, bool isError)
{
if (string.IsNullOrEmpty(data)) return;
var line = new LogLine(DateTimeOffset.UtcNow, isError, data);
_logRing.Enqueue(line);
while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _);
OnLogLine?.Invoke(line);
}
public IReadOnlyList<LogLine> RecentLogs() => _logRing.ToArray();
public async Task SendInputAsync(string command, CancellationToken ct = default)
{
if (_process is null || _process.HasExited) return;
await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct);
await _process.StandardInput.FlushAsync(ct);
}
public async Task<bool> StopAsync(TimeSpan? graceful = null, CancellationToken ct = default)
{
if (!IsRunning) return false;
graceful ??= TimeSpan.FromSeconds(30);
try
{
await SendInputAsync("stop", ct);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(graceful.Value);
try { await _process!.WaitForExitAsync(cts.Token); return true; }
catch (OperationCanceledException)
{
_process?.Kill(entireProcessTree: true);
return false;
}
}
catch
{
try { _process?.Kill(entireProcessTree: true); } catch { }
return false;
}
}
public void Dispose()
{
try { _process?.Kill(entireProcessTree: true); } catch { }
_process?.Dispose();
}
public sealed record LogLine(DateTimeOffset At, bool IsError, string Text);
}
+149
View File
@@ -0,0 +1,149 @@
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Reads and writes Minecraft's <c>server.properties</c>. Editable keys are
/// gated by an allowlist so a compromised panel can't flip security-critical
/// fields like <c>online-mode</c> arbitrarily -- only common gameplay knobs.
/// Preserves comments and key order on write; appends new keys at the end.
/// </summary>
public sealed class ServerPropertiesService
{
private readonly ServerConfig _config;
public ServerPropertiesService(ServerConfig config) => _config = config;
/// <summary>
/// Keys that may be modified via /api/server/settings. Anything outside this
/// set is silently dropped from the update payload -- admin must SSH for those.
/// </summary>
public static readonly IReadOnlySet<string> EditableKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"motd",
"difficulty",
"gamemode",
"view-distance",
"simulation-distance",
"max-players",
"pvp",
"hardcore",
"white-list",
"enforce-whitelist",
"allow-flight",
"enable-command-block",
"spawn-protection",
};
public string PropertiesPath => Path.Combine(Path.GetFullPath(_config.ServerDir), "server.properties");
public Dictionary<string, string> ReadAll()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(PropertiesPath)) return dict;
foreach (var raw in File.ReadAllLines(PropertiesPath))
{
var line = raw.TrimStart();
if (line.Length == 0 || line[0] == '#' || line[0] == '!') continue;
var idx = line.IndexOf('=');
if (idx < 0) continue;
dict[line.Substring(0, idx).Trim()] = line.Substring(idx + 1);
}
return dict;
}
/// <summary>Returns just the editable subset, with values left as raw strings.</summary>
public Dictionary<string, string> ReadEditable()
{
var all = ReadAll();
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in EditableKeys)
{
if (all.TryGetValue(key, out var v)) result[key] = v;
}
return result;
}
/// <summary>
/// Read the current <c>level-seed</c> value, or null if absent / empty.
/// </summary>
public string? GetLevelSeed()
{
if (!File.Exists(PropertiesPath)) return null;
foreach (var raw in File.ReadAllLines(PropertiesPath))
{
var line = raw.TrimStart();
if (!line.StartsWith("level-seed=", StringComparison.Ordinal)) continue;
var v = line.Substring("level-seed=".Length).Trim();
return string.IsNullOrEmpty(v) ? null : v;
}
return null;
}
/// <summary>
/// Direct write of <c>level-seed</c>. Bypasses <see cref="EditableKeys"/>
/// because the seed is set as part of the wipe flow (with confirmation),
/// not by general settings UI -- exposing it through the regular Update()
/// path would let it be flipped from any settings save. Empty string
/// clears the field, which makes Minecraft pick a random seed on next
/// world generation.
/// </summary>
public void SetLevelSeed(string seed)
{
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
var done = false;
for (int i = 0; i < lines.Count; i++)
{
var trimmed = lines[i].TrimStart();
if (trimmed.StartsWith("level-seed=", StringComparison.Ordinal))
{
lines[i] = $"level-seed={seed}";
done = true;
break;
}
}
if (!done) lines.Add($"level-seed={seed}");
File.WriteAllLines(PropertiesPath, lines);
}
/// <summary>
/// Apply updates to the file. Keys not in <see cref="EditableKeys"/> are
/// silently dropped. Lines that already exist are updated in-place to
/// preserve order and comments; new keys are appended at the end.
/// </summary>
public void Update(IDictionary<string, string> updates)
{
// Filter to allowed keys only.
var filtered = updates
.Where(kv => EditableKeys.Contains(kv.Key))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
if (filtered.Count == 0) return;
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
var applied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < lines.Count; i++)
{
var raw = lines[i];
var trimmed = raw.TrimStart();
if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == '!') continue;
var idx = trimmed.IndexOf('=');
if (idx < 0) continue;
var key = trimmed.Substring(0, idx).Trim();
if (filtered.TryGetValue(key, out var newValue))
{
lines[i] = $"{key}={newValue}";
applied.Add(key);
}
}
foreach (var (key, value) in filtered)
{
if (!applied.Contains(key)) lines.Add($"{key}={value}");
}
File.WriteAllLines(PropertiesPath, lines);
}
}
+260
View File
@@ -0,0 +1,260 @@
using BrassAndSigil.Server.Models;
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Drives a "fetch new manifest, drain players, swap mods, restart MC" workflow.
/// Single-flight: one update at a time, guarded by a semaphore. State is exposed
/// so the panel can poll progress; logs go through the existing OnLogLine event
/// (re-streamed via SSE) so they show up in the live console too.
/// </summary>
public sealed class UpdaterService
{
private readonly ServerConfig _config;
private readonly string _configPath;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private CancellationTokenSource? _cts;
public UpdateState State { get; private set; } = new();
public sealed class UpdateState
{
public bool InProgress { get; set; }
public string Phase { get; set; } = "idle";
// "idle" | "countdown" | "stopping" | "syncing" | "installing_loader" | "starting" | "complete" | "failed" | "cancelled"
public int CountdownTotal { get; set; }
public int CountdownRemaining { get; set; }
public string? CurrentVersion { get; set; }
public string? AvailableVersion { get; set; }
public string? Error { get; set; }
public DateTimeOffset? LastFinishedAt { get; set; }
}
public sealed record CheckResult(string? Current, string? Available, bool NeedsUpdate, string? Error);
public UpdaterService(ServerConfig config, string configPath,
ServerProcess proc, Broadcaster broadcast,
Action<string> log)
{
_config = config;
_configPath = configPath;
_proc = proc;
_broadcast = broadcast;
_log = log;
}
/// <summary>Lightweight read: compare local pack-version.json to remote manifest.</summary>
public async Task<CheckResult> CheckAsync(CancellationToken ct = default)
{
try
{
var sync = new ManifestSync();
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
var local = ReadLocalPackVersion(_config.ServerDir);
var current = local;
var available = manifest.Version;
var needs = !string.Equals(current, available, StringComparison.Ordinal);
State.CurrentVersion = current;
State.AvailableVersion = available;
return new CheckResult(current, available, needs, null);
}
catch (Exception ex)
{
return new CheckResult(State.CurrentVersion, State.AvailableVersion, false, ex.Message);
}
}
public bool TryCancel()
{
if (!State.InProgress || _cts is null) return false;
// Only meaningful during countdown phase -- a sync mid-flight isn't safely abortable.
if (State.Phase != "countdown") return false;
_cts.Cancel();
return true;
}
/// <summary>
/// Run the full update flow. Single-flight -- returns false if one is already running.
/// </summary>
public async Task<bool> StartAsync(int delaySeconds)
{
if (!await _gate.WaitAsync(0)) return false;
_cts = new CancellationTokenSource();
var ct = _cts.Token;
State = new UpdateState
{
InProgress = true,
Phase = "countdown",
CountdownTotal = delaySeconds,
CountdownRemaining = delaySeconds,
CurrentVersion = State.CurrentVersion,
AvailableVersion = State.AvailableVersion,
};
try
{
// ── 1. Player-facing countdown ──
if (delaySeconds > 0 && _proc.IsRunning)
{
_log($"[update] Announcing restart in {delaySeconds}s.");
await _broadcast.SayAsync($"Server will restart in {FormatDuration(delaySeconds)} for an update to v{State.AvailableVersion}.", ct);
// Run the action-bar countdown + periodic chat warnings + UI ticker
// in parallel. Action bar (instead of boss bar) avoids stacking on
// top of real boss fight UIs (Ender Dragon, raids, mod bosses).
var actionBar = _broadcast.ActionBarCountdownAsync(
"Server restart for update", delaySeconds, ct);
var warnings = WarnDuringCountdownAsync(delaySeconds, ct);
// Drive State.CountdownRemaining for the UI poller.
var ticker = TickCountdownStateAsync(delaySeconds, ct);
await Task.WhenAll(actionBar, warnings, ticker);
}
// ── 2. Stop MC ──
ct.ThrowIfCancellationRequested();
State.Phase = "stopping";
_log("[update] Stopping Minecraft for update...");
if (_proc.IsRunning)
{
await _broadcast.SayAsync("Server is restarting now.");
await _proc.StopAsync(TimeSpan.FromSeconds(30));
}
// ── 3. Sync mods from manifest ──
ct.ThrowIfCancellationRequested();
State.Phase = "syncing";
_log("[update] Syncing mods from manifest...");
var sync = new ManifestSync();
var progress = new Progress<string>(msg => _log($"[update] {msg}"));
var result = await sync.SyncAsync(_config.ManifestUrl, _config.ServerDir, progress, ct);
_log($"[update] Sync complete: {result.Downloaded} downloaded, {result.Removed} removed.");
// ── 4. Update NeoForge if loader version changed ──
ct.ThrowIfCancellationRequested();
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
if (manifest.Loader is { } loader &&
loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase) &&
LoaderVersionChanged(_config.ServerDir, loader.Version))
{
State.Phase = "installing_loader";
_log($"[update] Reinstalling NeoForge {loader.Version}...");
var nf = new NeoForgeInstaller();
var ok = await nf.InstallAsync(loader.Version, _config.ServerDir, _config.JavaPath, progress, ct);
if (!ok) throw new InvalidOperationException("NeoForge installer failed.");
}
// ── 5. Start MC ──
ct.ThrowIfCancellationRequested();
State.Phase = "starting";
_log("[update] Starting Minecraft...");
_proc.Start();
State.CurrentVersion = manifest.Version;
State.Phase = "complete";
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log("[update] Update complete.");
return true;
}
catch (OperationCanceledException)
{
State.Phase = "cancelled";
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log("[update] Update cancelled.");
// If we cancelled during countdown, MC is still running -- leave it alone.
return false;
}
catch (Exception ex)
{
State.Phase = "failed";
State.Error = ex.Message;
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log($"[update] Failed: {ex.Message}");
// Try to bring MC back up if we stopped it but never restarted.
if (!_proc.IsRunning)
{
try { _proc.Start(); _log("[update] Restored Minecraft after failure."); }
catch (Exception startEx) { _log($"[update] Restore failed too: {startEx.Message}"); }
}
return false;
}
finally
{
_cts?.Dispose();
_cts = null;
_gate.Release();
}
}
private async Task TickCountdownStateAsync(int total, CancellationToken ct)
{
for (int sec = total; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
State.CountdownRemaining = sec;
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
State.CountdownRemaining = 0;
}
private async Task WarnDuringCountdownAsync(int total, CancellationToken ct)
{
// Periodic chat warnings -- independent of the boss bar (visual-but-missable).
// Each milestone fires at an absolute time computed from the start, so the
// delays don't accumulate sequentially across the loop iterations.
var startUtc = DateTime.UtcNow;
var milestones = new[] { 300, 60, 30, 10 };
foreach (var m in milestones)
{
if (m >= total) continue;
var fireAt = startUtc.AddSeconds(total - m);
var wait = fireAt - DateTime.UtcNow;
if (wait > TimeSpan.Zero)
{
try { await Task.Delay(wait, ct); }
catch (OperationCanceledException) { throw; }
}
try { await _broadcast.SayAsync($"Server restart in {FormatDuration(m)}."); }
catch { /* don't bring down the whole update for one failed broadcast */ }
}
}
private static bool LoaderVersionChanged(string serverDir, string newVersion)
{
// Look at the libraries dir for an existing neoforge-<version> path.
// If absent or different version, we should re-install.
var libsRoot = Path.Combine(serverDir, "libraries", "net", "neoforged", "neoforge");
if (!Directory.Exists(libsRoot)) return true;
var versions = Directory.EnumerateDirectories(libsRoot).Select(Path.GetFileName).ToList();
return !versions.Contains(newVersion);
}
private static string? ReadLocalPackVersion(string serverDir)
{
var path = Path.Combine(serverDir, "pack-version.json");
if (!File.Exists(path)) return null;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
}
catch { return null; }
}
private static string FormatDuration(int seconds)
{
if (seconds >= 60) return $"{seconds / 60} minute{(seconds / 60 == 1 ? "" : "s")}";
return $"{seconds} second{(seconds == 1 ? "" : "s")}";
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Text.Json;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Tracks "I want to play" requests from friends, pending admin approval.
/// State is a flat JSON file in the server dir so it survives daemon restarts
/// without needing a database. Single-flight gate prevents concurrent-write
/// corruption when admin and friend act at the same time.
///
/// State machine: (none) -> pending -> approved | denied
/// "approved" means the admin clicked Approve; the actual /whitelist add
/// command goes through the existing whitelist endpoint, which removes the
/// request from the pending list.
/// </summary>
public sealed class WhitelistRequestService
{
private readonly ServerConfig _config;
private readonly object _lock = new();
public WhitelistRequestService(ServerConfig config) => _config = config;
public sealed class Request
{
public string Username { get; set; } = "";
public string? Message { get; set; }
public string Status { get; set; } = "pending"; // pending | approved | denied
public DateTimeOffset RequestedAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public string? RemoteIp { get; set; } // for admin diagnosis if needed
}
private string FilePath =>
Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist-requests.json");
private List<Request> Load()
{
try
{
if (!File.Exists(FilePath)) return new();
var text = File.ReadAllText(FilePath);
if (string.IsNullOrWhiteSpace(text)) return new();
return JsonSerializer.Deserialize<List<Request>>(text, JsonOpts.CaseInsensitive) ?? new();
}
catch { return new(); }
}
private void Save(List<Request> list)
{
var text = JsonSerializer.Serialize(list, JsonOpts.Pretty);
File.WriteAllText(FilePath, text);
}
public IReadOnlyList<Request> List() { lock (_lock) return Load(); }
public IReadOnlyList<Request> ListPending()
{
lock (_lock)
return Load().Where(r => r.Status == "pending").ToList();
}
/// <summary>Submit a new request. Idempotent on (username, status=pending) -- won't dupe.</summary>
public Request Submit(string username, string? message, string? remoteIp)
{
lock (_lock)
{
var list = Load();
// Drop any prior request for this username (case-insensitive) so the
// most recent one wins regardless of previous state. Keeps the file
// from growing if a friend re-requests after a denial.
list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
var req = new Request
{
Username = username,
Message = string.IsNullOrWhiteSpace(message) ? null : message,
Status = "pending",
RequestedAt = DateTimeOffset.UtcNow,
RemoteIp = remoteIp,
};
list.Add(req);
Save(list);
return req;
}
}
/// <summary>
/// Effective status for the launcher. If the username is in the actual
/// whitelist.json (regardless of whether they ever filed a request), returns
/// "approved" -- that's what the friend's launcher cares about. Otherwise
/// falls back to whatever request record we have, or "unknown".
/// </summary>
public string StatusFor(string username)
{
if (IsActuallyWhitelisted(username)) return "approved";
lock (_lock)
{
var match = Load().FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
return match?.Status ?? "unknown";
}
}
private bool IsActuallyWhitelisted(string username)
{
var path = Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist.json");
if (!File.Exists(path)) return false;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.EnumerateArray().Any(e =>
e.TryGetProperty("name", out var n) &&
string.Equals(n.GetString(), username, StringComparison.OrdinalIgnoreCase));
}
catch { return false; }
}
public bool MarkApproved(string username) => SetStatus(username, "approved");
public bool MarkDenied(string username) => SetStatus(username, "denied");
/// <summary>Remove the request entirely (used after the actual /whitelist add fires).</summary>
public bool Remove(string username)
{
lock (_lock)
{
var list = Load();
var removed = list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (removed > 0) Save(list);
return removed > 0;
}
}
private bool SetStatus(string username, string status)
{
lock (_lock)
{
var list = Load();
var match = list.FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (match is null) return false;
match.Status = status;
match.ResolvedAt = DateTimeOffset.UtcNow;
Save(list);
return true;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Wraps a Windows Job Object configured with KILL_ON_JOB_CLOSE so that any process
/// assigned to it dies the moment our process does -- regardless of *how* we died
/// (X-button on the console, Task Manager End Task, parent BSOD, etc.). Without
/// this, a Java subprocess can outlive us and keep the server files locked.
///
/// On Linux, use systemd's cgroup management instead; the equivalent guarantee
/// comes for free when the tool runs as a systemd unit.
/// </summary>
public sealed class WindowsJobObject : IDisposable
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
private IntPtr _handle;
private bool _disposed;
public WindowsJobObject()
{
if (!OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("WindowsJobObject only works on Windows.");
_handle = CreateJobObject(IntPtr.Zero, null);
if (_handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
{
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
}
};
var size = Marshal.SizeOf(info);
var ptr = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(info, ptr, fDeleteOld: false);
if (!SetInformationJobObject(_handle, JobObjectExtendedLimitInformation, ptr, (uint)size))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
}
finally
{
Marshal.FreeHGlobal(ptr);
}
}
public void AssignProcess(Process process)
{
if (_disposed) throw new ObjectDisposedException(nameof(WindowsJobObject));
if (!AssignProcessToJobObject(_handle, process.Handle))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_handle != IntPtr.Zero)
{
CloseHandle(_handle); // Closing the last handle triggers KILL_ON_JOB_CLOSE
_handle = IntPtr.Zero;
}
GC.SuppressFinalize(this);
}
~WindowsJobObject() => Dispose();
}
+229
View File
@@ -0,0 +1,229 @@
using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Destructive world operations -- wipe and replace. Always single-flight, always
/// stops the server first, and offers a rename-as-backup default so an accidental
/// click doesn't lose data permanently.
/// </summary>
public sealed class WorldService
{
private readonly ServerConfig _config;
private readonly ServerProcess _proc;
private readonly BackupService _backup;
private readonly Broadcaster _broadcast;
private readonly RconManager _rcon;
private readonly BlueMapService? _bluemap;
private readonly Action<string> _log;
private readonly SemaphoreSlim _lock = new(1, 1);
public WorldService(ServerConfig config, ServerProcess proc, BackupService backup,
Broadcaster broadcast, RconManager rcon, Action<string> log,
BlueMapService? bluemap = null)
{
_config = config;
_proc = proc;
_backup = backup;
_broadcast = broadcast;
_rcon = rcon;
_bluemap = bluemap;
_log = log;
}
public sealed record WipeResult(bool Ok, string? BackupName, string? SeedUsed, string? Error);
/// <summary>
/// What seed strategy a wipe should use:
/// <list type="bullet">
/// <item><c>Keep</c> -- capture the live seed via RCON before wipe and reuse it.</item>
/// <item><c>Random</c> -- clear <c>level-seed</c> so MC picks a fresh random one.</item>
/// <item><c>Custom</c> -- set <c>level-seed</c> to <see cref="WipeOptions.CustomSeed"/>.</item>
/// </list>
/// </summary>
public enum SeedMode { Keep, Random, Custom }
public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed);
/// <summary>
/// Best-effort lookup of the current world seed. Prefers RCON's <c>seed</c>
/// command (always returns the actual generated seed even when
/// server.properties has level-seed empty); falls back to the configured
/// level-seed value if RCON is unavailable (server stopped, no MC, etc.).
/// Returns null when neither is available.
/// </summary>
public async Task<string?> GetCurrentSeedAsync(CancellationToken ct = default)
{
if (_proc.IsRunning)
{
try
{
var resp = await _rcon.SendCommandAsync("seed", ct);
// Format: "Seed: [<number>]"
var m = Regex.Match(resp, @"Seed:\s*\[(-?\d+)\]");
if (m.Success) return m.Groups[1].Value;
}
catch { /* fall through to properties */ }
}
return new ServerPropertiesService(_config).GetLevelSeed();
}
// Caching world size: scanning a large world dir is O(file count). 30 s cache
// dampens that to roughly once per status poll cycle on busy panels.
private long _cachedSize;
private DateTime _cachedAt = DateTime.MinValue;
public long GetWorldSizeBytes()
{
if ((DateTime.UtcNow - _cachedAt) < TimeSpan.FromSeconds(30)) return _cachedSize;
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(Path.GetFullPath(_config.ServerDir), levelName);
if (!Directory.Exists(worldDir)) { _cachedSize = 0; _cachedAt = DateTime.UtcNow; return 0; }
long total = 0;
try
{
foreach (var f in Directory.EnumerateFiles(worldDir, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(f).Length; } catch { }
}
}
catch { }
_cachedSize = total;
_cachedAt = DateTime.UtcNow;
return total;
}
/// <summary>
/// Stop the server, optionally archive the world via BackupService, delete the
/// world folder(s), apply the chosen seed strategy, then restart. Backups go
/// to the configured backup dir (typically a slower-but-bigger drive) rather
/// than next to the world dir.
/// </summary>
public async Task<WipeResult> WipeWorldAsync(WipeOptions options, int warningSeconds = 30, CancellationToken ct = default)
{
if (!await _lock.WaitAsync(0, ct))
return new WipeResult(false, null, null, "Another wipe is already in progress.");
try
{
var serverDir = Path.GetFullPath(_config.ServerDir);
var levelName = ReadLevelName(serverDir) ?? "world";
var primaryWorld = Path.Combine(serverDir, levelName);
var props = new ServerPropertiesService(_config);
// ── Decide what seed the new world will use, BEFORE we stop the server ──
// Keep mode needs RCON, which only works while MC is alive.
string seedToWrite; // value for level-seed= line; "" means random
string? capturedSeed = null;
switch (options.Mode)
{
case SeedMode.Keep:
capturedSeed = await GetCurrentSeedAsync(ct);
if (string.IsNullOrEmpty(capturedSeed))
{
return new WipeResult(false, null, null,
"Couldn't read current seed (RCON unreachable + level-seed empty). Stop the server, set level-seed manually, or pick Random/Custom.");
}
seedToWrite = capturedSeed;
_log($"[wipe] Keep-seed mode: captured current seed {capturedSeed} for reuse.");
break;
case SeedMode.Custom:
var custom = options.CustomSeed?.Trim() ?? "";
if (string.IsNullOrEmpty(custom))
return new WipeResult(false, null, null, "Custom seed mode selected but no seed provided.");
seedToWrite = custom;
_log($"[wipe] Custom-seed mode: new world will use seed {custom}.");
break;
default: // Random
seedToWrite = "";
_log("[wipe] Random-seed mode: clearing level-seed so MC generates a fresh seed.");
break;
}
// Loud, urgent player warning before any irreversible action -- wipe is
// destructive even with a backup (admins still need to restore manually).
if (warningSeconds > 0 && _proc.IsRunning)
{
_log($"[wipe] Announcing {warningSeconds}s wipe warning to players...");
try
{
await _broadcast.SayAsync(
$"WORLD WIPE in {warningSeconds} seconds. Disconnect now if you want to keep your current world!", ct);
await _broadcast.ActionBarCountdownAsync("WORLD WIPING", warningSeconds, ct);
await _broadcast.SayAsync("Wiping world now.");
}
catch (OperationCanceledException) { throw; }
catch { /* don't abort wipe over a broadcast error */ }
}
string? backupName = null;
if (options.Backup && Directory.Exists(primaryWorld))
{
_log("[wipe] Creating backup before wipe...");
// flush:true here -- we're about to delete the world. Capture every block
// and every move up to right now, even at the cost of a tick spike.
var br = await _backup.CreateAsync("pre-wipe", flush: true, ct: ct);
if (!br.Ok)
return new WipeResult(false, null, null, $"Backup failed: {br.Error}. Wipe aborted to preserve data.");
backupName = br.Name;
}
if (_proc.IsRunning)
{
_log("[wipe] Stopping server...");
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
}
if (Directory.Exists(primaryWorld))
{
_log($"[wipe] Deleting {primaryWorld}");
Directory.Delete(primaryWorld, recursive: true);
}
// Legacy sibling dirs (rare on modern NeoForge but cheap to handle)
foreach (var altSuffix in new[] { "_nether", "_the_end" })
{
var altDir = Path.Combine(serverDir, levelName + altSuffix);
if (!Directory.Exists(altDir)) continue;
_log($"[wipe] Deleting {altDir}");
Directory.Delete(altDir, recursive: true);
}
// Apply the seed AFTER deletion but BEFORE restart -- MC reads
// server.properties at startup to determine the new world's seed.
props.SetLevelSeed(seedToWrite);
// Map output for the now-deleted world is stale -- clear it so the next
// render starts fresh against the new terrain.
_bluemap?.ClearRenderOutput();
_log("[wipe] World wiped. Restarting server -- Minecraft will generate a fresh world.");
_proc.Start();
// For Random mode we don't know the exact new seed yet (MC picks at startup);
// return the level-seed value we wrote, which is "" for random.
return new WipeResult(true, backupName, string.IsNullOrEmpty(seedToWrite) ? null : seedToWrite, null);
}
catch (Exception ex)
{
_log($"[wipe] Failed: {ex.Message}");
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
return new WipeResult(false, null, null, ex.Message);
}
finally
{
_lock.Release();
}
}
private static string? ReadLevelName(string serverDir)
{
var path = Path.Combine(serverDir, "server.properties");
if (!File.Exists(path)) return null;
foreach (var line in File.ReadAllLines(path))
{
if (line.StartsWith("level-name=", StringComparison.Ordinal))
return line.Substring("level-name=".Length).Trim();
}
return null;
}
}
+50
View File
@@ -0,0 +1,50 @@
# Caddyfile for the brass-sigil-server web panel.
#
# Caddy auto-fetches and renews a Let's Encrypt cert for your domain,
# so HTTPS just works once DNS is pointed at the server and ports 80 + 443
# are open.
#
# Prereqs:
# 1. A domain name (e.g. panel.example.com) with an A/AAAA record pointing
# at this server's public IP. Let's Encrypt does NOT issue certs for
# raw IPs -- you need a hostname.
# 2. Inbound 80 (for the HTTP-01 ACME challenge) and 443 (for the panel)
# open in your firewall and in any cloud security group.
# 3. Caddy installed:
# sudo apt install caddy # Debian / Ubuntu
# brew install caddy # macOS
# winget install CaddyServer.Caddy # Windows
# 4. brass-sigil-server running on localhost:8080 with webHost: localhost
# and webPassword set (use `brass-sigil-server set-password` if you
# haven't already).
#
# Install:
# Linux package: replace /etc/caddy/Caddyfile with this file, then
# sudo systemctl reload caddy
# Manual: caddy run --config Caddyfile
panel.example.com {
encode gzip
reverse_proxy localhost:8080 {
# SSE log stream uses chunked streaming responses -- Caddy must not
# buffer them, otherwise console updates arrive in batches every minute
# instead of in real time.
flush_interval -1
# Pass the real client IP through. brass-sigil-server's ForwardedHeaders
# middleware honours this so the per-IP login rate limit partitions
# correctly (10 attempts / minute / IP).
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
}
# Sensible hardening defaults.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
+27
View File
@@ -0,0 +1,27 @@
# /etc/apache2/sites-available/bns-admin.sijbers.uk.conf
#
# Reverse-proxy vhost for the brass-sigil-server admin panel.
# certbot will manage the SSL config (certificate paths, redirect from :80, etc.)
# when run as: sudo certbot --apache -d bns-admin.sijbers.uk
<VirtualHost *:80>
ServerName bns-admin.sijbers.uk
ProxyPreserveHost On
ProxyRequests Off
# SSE log stream needs streaming responses, not buffered ones.
SetEnv no-gzip 1
SetEnv proxy-sendcl 1
# `flushpackets=on` is the SSE-critical bit on Apache -- pushes each chunk
# straight through instead of batching for ~60 s.
ProxyPass / http://127.0.0.1:8080/ flushpackets=on keepalive=On
ProxyPassReverse / http://127.0.0.1:8080/
# So brass-sigil-server's rate limiter sees the real client IP, not 127.0.0.1.
RequestHeader set X-Forwarded-Proto "https"
ErrorLog ${APACHE_LOG_DIR}/bns-admin.sijbers.uk-error.log
CustomLog ${APACHE_LOG_DIR}/bns-admin.sijbers.uk-access.log combined
</VirtualHost>
+284
View File
@@ -0,0 +1,284 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brass &amp; Sigil Launcher -- Matt Sijbers</title>
<meta name="description" content="A private custom Minecraft Java Edition launcher for the Brass & Sigil modpack -- built in C# / .NET 8 / Avalonia." />
<link rel="icon" href="/images/favicon-light.ico" media="(prefers-color-scheme: light)" type="image/x-icon" />
<link rel="icon" href="/images/favicon-dark.ico" media="(prefers-color-scheme: dark)" type="image/x-icon" />
<link rel="stylesheet" href="css/matt.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" />
<style>
html, body {
background: var(--color-primary-darker-10);
overflow: auto;
height: auto;
min-height: 100vh;
max-height: none;
}
.page-wrap {
max-width: 900px;
margin: 0 auto;
padding: clamp(20px, 5vw, 60px) clamp(16px, 5vw, 32px) 80px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 0.95em;
}
.hero {
padding: clamp(20px, 4vw, 36px);
margin-bottom: 24px;
}
.hero h1 {
font-size: clamp(1.6em, 6vw, 2.6em);
text-transform: uppercase;
margin: 0 0 8px;
}
.hero .subtitle {
color: var(--color-secondary-text);
font-size: clamp(0.9em, 3vw, 1.1em);
margin: 0;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.tag {
background: var(--color-primary-darker-20);
color: var(--color-primary-text);
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85em;
font-weight: 500;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 22px;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--color-accent);
color: #fff !important;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
font-size: 0.95em;
text-decoration: none;
transition: background 0.15s linear, transform 0.1s linear;
}
.action-btn:hover {
background: var(--color-accent-lighter-10);
transform: translateY(-1px);
}
.action-btn.secondary {
background: var(--color-primary-darker-20);
color: var(--color-primary-text) !important;
}
.action-btn.secondary:hover {
background: var(--color-primary-darker-30);
}
.whitelist-note {
margin: 18px 0 0;
padding: 10px 14px;
background: var(--color-primary-darker-10);
border-left: 3px solid var(--color-accent);
border-radius: 6px;
color: var(--color-secondary-text);
font-size: 0.9em;
line-height: 1.45;
}
.whitelist-note i { color: var(--color-accent); margin-right: 6px; }
.section {
padding: clamp(18px, 3.5vw, 28px);
margin-bottom: 18px;
}
.section h2 {
margin-top: 0;
margin-bottom: 12px;
}
.section p {
line-height: 1.55;
color: var(--color-primary-text);
}
.facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 8px;
}
.fact {
background: var(--color-primary-darker-10);
border-radius: 12px;
padding: 14px 18px;
}
.fact .label {
font-size: 0.78em;
color: var(--color-secondary-text);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 4px;
}
.fact .value {
font-family: "Ubuntu", sans-serif;
font-weight: 500;
color: var(--color-primary-text);
margin: 0;
}
@media (max-width: 540px) {
.facts { grid-template-columns: 1fr; }
}
ul.feature-list {
padding-left: 1.25em;
margin: 4px 0 0;
}
ul.feature-list li {
color: var(--color-primary-text);
line-height: 1.5;
margin: 4px 0;
}
footer.page-footer {
color: var(--color-secondary-text);
font-size: 0.85em;
text-align: center;
margin-top: 40px;
}
</style>
</head>
<body>
<div class="page-wrap">
<a href="/matt#projects" class="back-link"><i class="fa fa-arrow-left"></i> Back to portfolio</a>
<div class="bevel hero">
<h1>Brass &amp; Sigil Launcher</h1>
<p class="subtitle">A private custom Minecraft Java Edition launcher for a small friend group.</p>
<div class="tag-row">
<span class="tag">C# / .NET 8</span>
<span class="tag">Avalonia</span>
<span class="tag">CmlLib.Core</span>
<span class="tag">Single-file Windows</span>
<span class="tag">Private project</span>
</div>
<div class="action-row">
<a class="action-btn" href="/pack/BrassAndSigil-Launcher.exe">
<i class="fa fa-download"></i> Download launcher
</a>
<a class="action-btn secondary" href="https://sijbers.uk:8443/projects/BS/repos/brass-and-sigil-launcher/browse"
target="_blank" rel="noopener noreferrer">
<i class="fa fa-code"></i> View source code
</a>
<a class="action-btn secondary" href="/matt#projects">
<i class="fa fa-user"></i> Developer portfolio
</a>
</div>
<p class="whitelist-note">
<i class="fa fa-info-circle"></i>
You'll need to be whitelisted on the server to actually join &mdash; message Matt with your Minecraft username.
</p>
</div>
<div class="bevel section">
<h2>About the project</h2>
<p>
Brass &amp; Sigil is a private Minecraft modpack centred on the Create mod, aeronautics, tech and magic mods,
with Distant Horizons for far-render exploration. This launcher is the desktop client built specifically
to distribute the pack to a small friend group (under 50 players, no public release).
</p>
<p>
The launcher is a native Windows application written in C# on .NET 8, using the Avalonia UI framework.
It ships as a single self-contained executable so friends can just download and run it &mdash; no installer,
no .NET runtime to install, no separate config files.
</p>
</div>
<div class="bevel section">
<h2>What it does</h2>
<ul class="feature-list">
<li>Fetches a JSON manifest from a self-hosted server and syncs the modpack files (mods, configs, resourcepacks) to the player's local install directory, using SHA-1 hashing so only changed files are downloaded.</li>
<li>Authenticates the player with their own personal Microsoft account via the standard MSAL OAuth flow, so only legitimate Minecraft Java Edition owners can sign in.</li>
<li>Installs the right Minecraft version and Forge loader, then launches the game with the configured memory and the player's session.</li>
<li>Auto-updates the modpack on every launch when the manifest changes &mdash; players never have to manually install or update mods.</li>
</ul>
</div>
<div class="bevel section">
<h2>Technical details</h2>
<div class="facts">
<div class="fact">
<p class="label">Language</p>
<p class="value">C# (.NET 8)</p>
</div>
<div class="fact">
<p class="label">UI Framework</p>
<p class="value">Avalonia 12</p>
</div>
<div class="fact">
<p class="label">Minecraft auth</p>
<p class="value">CmlLib.Core.Auth.Microsoft</p>
</div>
<div class="fact">
<p class="label">Game launching</p>
<p class="value">CmlLib.Core 4.x + Forge installer</p>
</div>
<div class="fact">
<p class="label">Distribution</p>
<p class="value">Single-file self-contained .exe</p>
</div>
<div class="fact">
<p class="label">Audience</p>
<p class="value">Private friend group (&lt; 50)</p>
</div>
</div>
</div>
<div class="bevel section">
<h2>Privacy &amp; data</h2>
<p>
The launcher does not collect, store, or transmit any user data beyond what the standard Microsoft and
Minecraft authentication flows require. Auth tokens are cached locally on the player's machine via the
MSAL token cache &mdash; no telemetry, no analytics, no third-party services beyond Microsoft and Mojang.
</p>
<p>
The modpack manifest and mod files are served from a self-hosted Linux server that I personally operate.
</p>
</div>
<div class="bevel section">
<h2>Status</h2>
<p>
Active development. The launcher is functional end-to-end (manifest sync, Microsoft auth, Forge install,
game launch) and is currently being prepared for distribution to a small group of friends.
</p>
<p>
Source code is publicly available, MIT-licensed, on my self-hosted Bitbucket:
<a href="https://sijbers.uk:8443/projects/BS/repos/brass-and-sigil-launcher/browse"
target="_blank" rel="noopener noreferrer">sijbers.uk:8443/.../brass-and-sigil-launcher</a>.
</p>
</div>
<div class="bevel section" style="text-align: center; font-size: 0.85em;">
<p style="margin: 0; color: var(--color-secondary-text); letter-spacing: 0.04em;">
NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.
</p>
</div>
<footer class="page-footer">
Brass &amp; Sigil Launcher &mdash; a private project by <a href="/matt">Matt Sijbers</a>.
</footer>
</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Brass & Sigil Minecraft server (with web admin panel)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=matt
Group=matt
WorkingDirectory=/home/matt/brass-sigil-server
ExecStart=/home/matt/brass-sigil-server/brass-sigil-server run
Restart=on-failure
RestartSec=10s
# Give the JVM enough room to start up gracefully on Stop (sends "stop" to MC,
# waits for clean shutdown, then escalates to SIGTERM/SIGKILL).
TimeoutStopSec=60s
KillMode=mixed
# Tighten attack surface -- typical for a service running as a regular user.
PrivateTmp=true
ProtectSystem=full
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Sudo cleanup script -- review before running.
# Removes dormant game servers (ARK, Valheim, Terraria), trims journald logs,
# caps snap revision retention. Does NOT touch Bitbucket, TeamViewer, or the
# matt-wakeford.co.uk / sijbers.uk / diceheart.com websites.
#
# Run with: sudo bash /tmp/cleanup-sudo.sh
#
# Set -e: abort on first error so we don't cascade-damage state.
set -euo pipefail
echo "=== Pre-cleanup disk ==="
df -h / | grep -v Filesystem
# ────────────────────────────────────────────────
# 1. ARK server -- install, user, broken systemd unit
# ────────────────────────────────────────────────
echo
echo "=== ARK ==="
if systemctl list-unit-files ark.service &>/dev/null; then
echo " Disabling ark.service"
systemctl disable --now ark.service 2>/dev/null || true
fi
if [[ -f /lib/systemd/system/ark.service ]]; then
echo " Removing /lib/systemd/system/ark.service"
rm -f /lib/systemd/system/ark.service
fi
if [[ -f /etc/systemd/system/ark.service ]]; then
echo " Removing /etc/systemd/system/ark.service"
rm -f /etc/systemd/system/ark.service
fi
if id ark &>/dev/null; then
# Make sure no processes are running as ark before userdel.
pkill -u ark 2>/dev/null || true
sleep 1
echo " Removing 'ark' user + home"
userdel -r ark 2>/dev/null || userdel ark
fi
# ────────────────────────────────────────────────
# 2. Valheim (vhserver) -- LGSM stack
# ────────────────────────────────────────────────
echo
echo "=== Valheim (vhserver) ==="
if id vhserver &>/dev/null; then
pkill -u vhserver 2>/dev/null || true
sleep 1
echo " Removing 'vhserver' user + home (incl. orphaned LGSM backups)"
userdel -r vhserver 2>/dev/null || userdel vhserver
fi
# ────────────────────────────────────────────────
# 3. Terraria -- empty home, never used
# ────────────────────────────────────────────────
echo
echo "=== Terraria ==="
if id Terraria &>/dev/null; then
pkill -u Terraria 2>/dev/null || true
echo " Removing 'Terraria' user + home"
userdel -r Terraria 2>/dev/null || userdel Terraria
fi
# ────────────────────────────────────────────────
# 4. systemd reload to forget the gone units
# ────────────────────────────────────────────────
echo
echo "=== systemctl daemon-reload ==="
systemctl daemon-reload
systemctl reset-failed 2>/dev/null || true
# ────────────────────────────────────────────────
# 5. journald -- cap to 500 MB
# ────────────────────────────────────────────────
echo
echo "=== journald ==="
echo " Trimming /var/log/journal to 500 MB"
journalctl --vacuum-size=500M
# ────────────────────────────────────────────────
# 6. snap -- only keep current + 1 prior revision
# ────────────────────────────────────────────────
echo
echo "=== snap retention ==="
echo " Setting refresh.retain=2 (snap will auto-clean older revs over time)"
snap set system refresh.retain=2
# Force-clean orphaned _old.snap files older than 30 days. Snap will redo
# this organically but we can prod it now to reclaim immediately.
echo " Forcing snap to drop disabled revisions"
for snap in $(snap list --all | awk '/disabled/{print $1, $3}'); do
if [[ "$snap" == "disabled" ]]; then continue; fi
done
# Easier path: ask snap directly.
LANG=C snap list --all | awk '/disabled/{print $1, $3}' | \
while read -r name rev; do
echo " snap remove --revision=$rev $name"
snap remove --revision="$rev" "$name" || true
done
# ────────────────────────────────────────────────
echo
echo "=== Post-cleanup disk ==="
df -h / | grep -v Filesystem
echo
echo "Done. Bitbucket, TeamViewer, and websites untouched."
+135
View File
@@ -0,0 +1,135 @@
{
"name": "Brass and Sigil",
"version": "0.6.1",
"minecraft": {
"version": "1.21.1"
},
"loader": {
"type": "neoforge",
"version": "21.1.228"
},
"files": [
{
"path": "mods/create-1.21.1-6.0.10.jar",
"url": "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar",
"sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353",
"size": 19123767
},
{
"path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"url": "https://cdn.modrinth.com/data/oWaK0Q19/versions/YhZLrAFC/create-aeronautics-bundled-1.21.1-1.2.1.jar",
"sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9",
"size": 33030286
},
{
"path": "mods/sable-neoforge-1.21.1-1.2.2.jar",
"url": "https://cdn.modrinth.com/data/T9PomCSv/versions/3FMsUjO4/sable-neoforge-1.21.1-1.2.2.jar",
"sha1": "c5ecd3fcf60a31d84112c708abe29e341b2d1b73",
"size": 12719293
},
{
"path": "mods/createbigcannons-5.11.3+mc.1.21.1.jar",
"url": "https://cdn.modrinth.com/data/GWp4jCJj/versions/bsGaXKEd/createbigcannons-5.11.3%2Bmc.1.21.1.jar",
"sha1": "8b61fa850e260bdeb5d360576123f98c260afa50",
"size": 3715787
},
{
"path": "mods/tfmg-1.2.0.jar",
"url": "https://cdn.modrinth.com/data/USgVjXsk/versions/uDi14nbt/tfmg-1.2.0.jar",
"sha1": "b520f3687f60a69eb265ff5b9a16759b9e124103",
"size": 4924243
},
{
"path": "mods/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
"url": "https://cdn.modrinth.com/data/uCdwusMi/versions/KkaaQtTD/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
"sha1": "1ff0a8920e52add541471f7b32d0d389997145ba",
"size": 30019727
},
{
"path": "mods/sodium-neoforge-0.6.13+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/Pb3OXVqC/sodium-neoforge-0.6.13%2Bmc1.21.1.jar",
"sha1": "38af70fa4dc4b2aaac636e92fdba3bedd5a025e1",
"size": 1162994
},
{
"path": "mods/iris-neoforge-1.8.12+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/YL57xq9U/versions/t3ruzodq/iris-neoforge-1.8.12%2Bmc1.21.1.jar",
"sha1": "a3e6355915c7d3b2bc392724795113e51d289378",
"size": 2438548
},
{
"path": "mods/modernfix-neoforge-5.27.4+mc1.21.1.jar",
"url": "https://cdn.modrinth.com/data/nmDcB62a/versions/6U8JVjdw/modernfix-neoforge-5.27.4%2Bmc1.21.1.jar",
"sha1": "2f39363f0d6d5a5ccc2a9e0f50ad3385611c3cb7",
"size": 562051
},
{
"path": "mods/ferritecore-7.0.3-neoforge.jar",
"url": "https://cdn.modrinth.com/data/uXXizFIs/versions/x7kQWVju/ferritecore-7.0.3-neoforge.jar",
"sha1": "9563692efb708b6b568df27a01ec52f6311928ef",
"size": 121559
},
{
"path": "mods/architectury-13.0.8-neoforge.jar",
"url": "https://cdn.modrinth.com/data/lhGA9TYQ/versions/ZxYGwlk0/architectury-13.0.8-neoforge.jar",
"sha1": "6ca11d3cc136bf69bb8f4d56982481eb85b5100b",
"size": 584004
},
{
"path": "mods/rhino-2101.2.7-build.81.jar",
"url": "https://cdn.modrinth.com/data/sk9knFPE/versions/ZdLtebKH/rhino-2101.2.7-build.81.jar",
"sha1": "480235a9f7749f68ce6fec3b9c3cac3428b92a4a",
"size": 882033
},
{
"path": "mods/ritchiesprojectilelib-2.1.2+mc.1.21.1-neoforge.jar",
"url": "https://cdn.modrinth.com/data/B3pb093D/versions/hZ6B2Z0x/ritchiesprojectilelib-2.1.2%2Bmc.1.21.1-neoforge.jar",
"sha1": "ec2e4996f8bee8714173e603e379fef8a6901765",
"size": 76369
},
{
"path": "mods/kubejs-neoforge-2101.7.2-build.363.jar",
"url": "https://cdn.modrinth.com/data/umyGl7zF/versions/Fe9CjPws/kubejs-neoforge-2101.7.2-build.363.jar",
"sha1": "d4e88254e8c26687d4c6aeb4dfa9c2ad70f260a2",
"size": 2270442
},
{
"path": "mods/jei-1.21.1-neoforge-19.27.0.340.jar",
"url": "https://cdn.modrinth.com/data/u6dRKJwZ/versions/YAcQ6elZ/jei-1.21.1-neoforge-19.27.0.340.jar",
"sha1": "27d0d85e7e32e926fc3664ab6815df5cdabb7941",
"size": 1529391
},
{
"path": "mods/Jade-1.21.1-NeoForge-15.10.5.jar",
"url": "https://cdn.modrinth.com/data/nvQzSEkH/versions/yd8FKCmx/Jade-1.21.1-NeoForge-15.10.5.jar",
"sha1": "d5bf134b3dbde9f5258666823900e21341dc0a50",
"size": 725742
},
{
"path": "mods/Chunky-NeoForge-1.4.23.jar",
"url": "https://cdn.modrinth.com/data/fALzjamp/versions/LuFhm4eU/Chunky-NeoForge-1.4.23.jar",
"sha1": "ab0c74743a653020fe2dfc4986b43e893947f3e9",
"size": 340572
},
{
"path": "mods/ftb-library-neoforge-2101.1.31.jar",
"url": "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar",
"sha1": "686d4e784c28c14f7760cc22b2de6a8573b56b74",
"size": 1411181
},
{
"path": "mods/ftb-teams-neoforge-2101.1.9.jar",
"url": "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar",
"sha1": "328e04bf1a445870aacea8fe7637670f84272a8f",
"size": 291847
},
{
"path": "mods/ftb-chunks-neoforge-2101.1.14.jar",
"url": "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar",
"sha1": "908b63b11d0e00ae6c9557d3fe6440bdbcf21bb7",
"size": 642340
}
],
"launcherVersion": "0.1.0",
"launcherUrl": "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe"
}
+12
View File
@@ -0,0 +1,12 @@
{
"manifestUrl": "https://sijbers.uk/pack/manifest.json",
"serverDir": "./server",
"javaPath": "java",
"memoryMB": 8192,
"webPort": 8080,
"webHost": "localhost",
"webPassword": null,
"rconPort": 25575,
"rconPassword": "",
"acceptEula": false
}
+42
View File
@@ -0,0 +1,42 @@
"use strict";
import { tickStatus, tickPlayers, tickWhitelist, refreshWhitelistSoon } from "./modules/panels.js";
import { setupConsole } from "./modules/console.js";
import { setupAutocomplete } from "./modules/autocomplete.js";
import { setupWhitelistActions } from "./modules/whitelist.js";
import { setupServerControls } from "./modules/serverControls.js";
import { setupPregen } from "./modules/pregen.js";
import { setupAuth } from "./modules/auth.js";
import { setupUpdate } from "./modules/update.js";
import { setupDanger } from "./modules/danger.js";
import { setupBackup } from "./modules/backup.js";
import { setupModalTriggers } from "./modules/modal.js";
import { setupSettings } from "./modules/settings.js";
import { setupMap } from "./modules/map.js";
setupModalTriggers();
setupAuth();
setupConsole();
setupAutocomplete();
setupWhitelistActions(refreshWhitelistSoon);
setupServerControls();
setupPregen();
setupUpdate();
setupBackup();
setupDanger();
setupSettings();
setupMap();
// First paint
tickStatus();
tickPlayers();
tickWhitelist();
// Polling cadence:
// status 3 s -- pid/uptime/pack version (cheap, doesn't change much)
// players 10 s -- RCON `list` call; players join/leave infrequently
// whitelist 30 s -- file read; mostly relies on refresh-on-action via add/remove
// (Logs are NOT polled -- they stream live via Server-Sent Events from /api/logs/stream.)
setInterval(tickStatus, 3000);
setInterval(tickPlayers, 10000);
setInterval(tickWhitelist, 30000);
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

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