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:
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>BrassAndSigil.Server</RootNamespace>
|
||||
<AssemblyName>brass-sigil-server</AssemblyName>
|
||||
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
|
||||
|
||||
<!-- Single-file self-contained publish defaults -->
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">linux-x64</RuntimeIdentifier>
|
||||
<RuntimeIdentifiers>linux-x64;win-x64</RuntimeIdentifiers>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>embedded</DebugType>
|
||||
|
||||
<!-- Embed wwwroot/* into the assembly so the published exe is truly single-file. -->
|
||||
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
|
||||
<StaticWebAssetsEnabled>false</StaticWebAssetsEnabled>
|
||||
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.7" />
|
||||
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Replace the implicit Web SDK Content rule for wwwroot with EmbeddedResource. -->
|
||||
<Content Remove="wwwroot\**" />
|
||||
<EmbeddedResource Include="wwwroot\**" />
|
||||
<!-- Server-list icon dropped into <serverDir>/server-icon.png on first install. -->
|
||||
<EmbeddedResource Include="Assets\server-icon.png">
|
||||
<LogicalName>BrassAndSigil.Server.Assets.server-icon.png</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel;
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
namespace BrassAndSigil.Server.Commands;
|
||||
|
||||
public class BaseCommandSettings : CommandSettings
|
||||
{
|
||||
[CommandOption("-c|--config <PATH>")]
|
||||
[Description("Path to server-config.json (defaults to ./server-config.json)")]
|
||||
public string ConfigPath { get; set; } = "server-config.json";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Diagnostics;
|
||||
using BrassAndSigil.Server.Models;
|
||||
using BrassAndSigil.Server.Services;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
namespace BrassAndSigil.Server.Commands;
|
||||
|
||||
public sealed class CheckCommand : AsyncCommand<BaseCommandSettings>
|
||||
{
|
||||
public override async Task<int> ExecuteAsync(CommandContext context, BaseCommandSettings settings)
|
||||
{
|
||||
var config = ServerConfig.Load(settings.ConfigPath);
|
||||
AnsiConsole.MarkupLine("[bold]Checking server install...[/]");
|
||||
var ok = true;
|
||||
|
||||
// 1. Java available?
|
||||
var javaVersion = await TryRunForOutputAsync(config.JavaPath, "-version");
|
||||
if (javaVersion is not null)
|
||||
AnsiConsole.MarkupLine($" [green]✓[/] Java reachable: {javaVersion.Split('\n')[0].Trim().EscapeMarkup()}");
|
||||
else
|
||||
{ AnsiConsole.MarkupLine($" [red]✗[/] Java not found at '{config.JavaPath}'"); ok = false; }
|
||||
|
||||
// 2. Server dir
|
||||
var serverDir = Path.GetFullPath(config.ServerDir);
|
||||
if (Directory.Exists(serverDir))
|
||||
AnsiConsole.MarkupLine($" [green]✓[/] Server dir exists: {serverDir}");
|
||||
else
|
||||
{ AnsiConsole.MarkupLine($" [yellow]?[/] Server dir missing -- run [yellow]install[/] first"); ok = false; }
|
||||
|
||||
// 3. EULA
|
||||
var eulaPath = Path.Combine(serverDir, "eula.txt");
|
||||
if (File.Exists(eulaPath) && File.ReadAllText(eulaPath).Contains("eula=true"))
|
||||
AnsiConsole.MarkupLine(" [green]✓[/] EULA accepted");
|
||||
else
|
||||
{ AnsiConsole.MarkupLine(" [yellow]?[/] EULA not accepted (re-run [yellow]install --accept-eula[/])"); ok = false; }
|
||||
|
||||
// 4. NeoForge run script
|
||||
var runScript = Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh");
|
||||
if (File.Exists(runScript))
|
||||
AnsiConsole.MarkupLine($" [green]✓[/] Loader start script: {Path.GetFileName(runScript)}");
|
||||
else
|
||||
AnsiConsole.MarkupLine($" [yellow]?[/] No {Path.GetFileName(runScript)} -- install the NeoForge server first");
|
||||
|
||||
// 5. Manifest reachable
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var resp = await http.GetAsync(config.ManifestUrl);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
AnsiConsole.MarkupLine($" [green]✓[/] Manifest reachable: {config.ManifestUrl}");
|
||||
else
|
||||
{ AnsiConsole.MarkupLine($" [red]✗[/] Manifest HTTP {(int)resp.StatusCode}: {config.ManifestUrl}"); ok = false; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{ AnsiConsole.MarkupLine($" [red]✗[/] Manifest fetch error: {ex.Message.EscapeMarkup()}"); ok = false; }
|
||||
|
||||
// 6. Pack version on disk
|
||||
var packVer = Path.Combine(serverDir, "pack-version.json");
|
||||
if (File.Exists(packVer))
|
||||
AnsiConsole.MarkupLine($" [green]✓[/] Pack synced: {File.ReadAllText(packVer).Replace("\n", " ").Replace("\r", "").Trim().EscapeMarkup()}");
|
||||
else
|
||||
AnsiConsole.MarkupLine(" [yellow]?[/] Pack not synced yet (run [yellow]sync[/])");
|
||||
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine(ok ? "[green]All required checks passed.[/]" : "[yellow]Some checks failed; see above.[/]");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<string?> TryRunForOutputAsync(string fileName, string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (p is null) return null;
|
||||
var output = await p.StandardError.ReadToEndAsync() + await p.StandardOutput.ReadToEndAsync();
|
||||
await p.WaitForExitAsync();
|
||||
return output;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.ComponentModel;
|
||||
using BrassAndSigil.Server.Models;
|
||||
using BrassAndSigil.Server.Services;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
namespace BrassAndSigil.Server.Commands;
|
||||
|
||||
public sealed class InstallCommand : AsyncCommand<InstallCommand.Settings>
|
||||
{
|
||||
public sealed class Settings : BaseCommandSettings
|
||||
{
|
||||
[CommandOption("--manifest <URL>")]
|
||||
[Description("Manifest URL to bootstrap from")]
|
||||
public string? ManifestUrl { get; set; }
|
||||
|
||||
[CommandOption("--server-dir <PATH>")]
|
||||
[Description("Where to install the server (defaults to ./server)")]
|
||||
public string? ServerDir { get; set; }
|
||||
|
||||
[CommandOption("--memory <MB>")]
|
||||
[Description("RAM allocation in MB (defaults to 8192)")]
|
||||
public int? MemoryMB { get; set; }
|
||||
|
||||
[CommandOption("--accept-eula")]
|
||||
[Description("Accept the Minecraft EULA. Required for the server to actually run.")]
|
||||
public bool AcceptEula { get; set; }
|
||||
}
|
||||
|
||||
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
|
||||
{
|
||||
var config = ServerConfig.Load(settings.ConfigPath);
|
||||
if (settings.ManifestUrl != null) config.ManifestUrl = settings.ManifestUrl;
|
||||
if (settings.ServerDir != null) config.ServerDir = settings.ServerDir;
|
||||
if (settings.MemoryMB != null) config.MemoryMB = settings.MemoryMB.Value;
|
||||
if (settings.AcceptEula) config.AcceptEula = true;
|
||||
|
||||
AnsiConsole.MarkupLine("[bold yellow]Brass & Sigil Server install[/]");
|
||||
AnsiConsole.MarkupLine($" Config: {settings.ConfigPath}");
|
||||
AnsiConsole.MarkupLine($" ServerDir: {Path.GetFullPath(config.ServerDir)}");
|
||||
AnsiConsole.MarkupLine($" Manifest: {config.ManifestUrl}");
|
||||
AnsiConsole.MarkupLine($" Memory: {config.MemoryMB} MB");
|
||||
AnsiConsole.MarkupLine("");
|
||||
|
||||
if (!config.AcceptEula)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]EULA not accepted.[/] Re-run with --accept-eula to confirm you accept the");
|
||||
AnsiConsole.MarkupLine("Minecraft End User License Agreement: [blue]https://aka.ms/MinecraftEULA[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(config.ServerDir);
|
||||
|
||||
// Generate eula.txt
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(config.ServerDir, "eula.txt"),
|
||||
$"# Generated by brass-sigil-server install\n" +
|
||||
$"# By setting this to true you agree to the Minecraft EULA: https://aka.ms/MinecraftEULA\n" +
|
||||
$"eula=true\n");
|
||||
|
||||
// Generate a default server.properties if none exists yet
|
||||
var propsPath = Path.Combine(config.ServerDir, "server.properties");
|
||||
if (!File.Exists(propsPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(propsPath, DefaultServerProperties(config));
|
||||
AnsiConsole.MarkupLine($"[grey]Wrote default server.properties[/]");
|
||||
}
|
||||
|
||||
// Generate a random RCON password if missing
|
||||
if (string.IsNullOrEmpty(config.RconPassword))
|
||||
{
|
||||
config.RconPassword = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant();
|
||||
UpdateProp(propsPath, "enable-rcon", "true");
|
||||
UpdateProp(propsPath, "rcon.port", config.RconPort.ToString());
|
||||
UpdateProp(propsPath, "rcon.password", config.RconPassword);
|
||||
}
|
||||
|
||||
// Save config
|
||||
config.Save(settings.ConfigPath);
|
||||
AnsiConsole.MarkupLine($"[grey]Saved config to {settings.ConfigPath}[/]");
|
||||
|
||||
// Sync mods
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[bold]Syncing mods from manifest...[/]");
|
||||
var sync = new ManifestSync();
|
||||
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
|
||||
try
|
||||
{
|
||||
var result = await sync.SyncAsync(config.ManifestUrl, config.ServerDir, progress);
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine($"[green]✓ Sync complete:[/] pack v{result.PackVersion}, " +
|
||||
$"{result.Downloaded} downloaded, {result.Removed} removed, " +
|
||||
$"{result.Skipped} client-only mods skipped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]✗ Sync failed:[/] {ex.Message.EscapeMarkup()}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[bold green]Install complete.[/]");
|
||||
AnsiConsole.MarkupLine("Next steps:");
|
||||
AnsiConsole.MarkupLine($" 1. Download the matching NeoForge server installer for the manifest's loader version");
|
||||
AnsiConsole.MarkupLine($" (server side, into {Path.GetFullPath(config.ServerDir)}) -- the modpack manifest");
|
||||
AnsiConsole.MarkupLine($" doesn't bundle it because each loader has its own installer.");
|
||||
AnsiConsole.MarkupLine($" [blue]https://maven.neoforged.net/releases/net/neoforged/neoforge/[/]");
|
||||
AnsiConsole.MarkupLine($" 2. Run the NeoForge installer with --installServer in the server dir");
|
||||
AnsiConsole.MarkupLine($" 3. Then start: [yellow]brass-sigil-server run[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string DefaultServerProperties(ServerConfig config) => $@"# Brass & Sigil server defaults -- edit as needed
|
||||
motd=Brass & Sigil
|
||||
gamemode=survival
|
||||
difficulty=normal
|
||||
hardcore=false
|
||||
pvp=true
|
||||
online-mode=true
|
||||
white-list=true
|
||||
enforce-whitelist=true
|
||||
max-players=20
|
||||
view-distance=12
|
||||
simulation-distance=10
|
||||
spawn-protection=0
|
||||
enable-rcon=true
|
||||
rcon.port={config.RconPort}
|
||||
rcon.password={config.RconPassword}
|
||||
broadcast-rcon-to-ops=false
|
||||
";
|
||||
|
||||
private static void UpdateProp(string path, string key, string value)
|
||||
{
|
||||
var lines = File.ReadAllLines(path).ToList();
|
||||
var prefix = $"{key}=";
|
||||
var idx = lines.FindIndex(l => l.StartsWith(prefix));
|
||||
if (idx >= 0) lines[idx] = prefix + value;
|
||||
else lines.Add(prefix + value);
|
||||
File.WriteAllLines(path, lines);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
using BrassAndSigil.Server.Models;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
namespace BrassAndSigil.Server.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Set or rotate the web panel admin password from the CLI.
|
||||
/// Useful when first-time-setting up before exposing the panel publicly,
|
||||
/// or rotating after a suspected leak without going through the panel UI.
|
||||
/// </summary>
|
||||
public sealed class SetPasswordCommand : Command<BaseCommandSettings>
|
||||
{
|
||||
public override int Execute(CommandContext context, BaseCommandSettings settings)
|
||||
{
|
||||
var config = ServerConfig.Load(settings.ConfigPath);
|
||||
|
||||
if (Console.IsInputRedirected)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]set-password requires an interactive terminal.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[bold]Set admin password[/]");
|
||||
if (!string.IsNullOrEmpty(config.WebPassword))
|
||||
AnsiConsole.MarkupLine("[grey]An existing password is already set; this will overwrite it.[/]");
|
||||
|
||||
string pw1, pw2;
|
||||
while (true)
|
||||
{
|
||||
pw1 = AnsiConsole.Prompt(new TextPrompt<string>("New password (min 8 chars):").Secret());
|
||||
if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; }
|
||||
pw2 = AnsiConsole.Prompt(new TextPrompt<string>("Confirm:").Secret());
|
||||
if (pw1 != pw2) { AnsiConsole.MarkupLine("[red]Doesn't match.[/]"); continue; }
|
||||
break;
|
||||
}
|
||||
|
||||
config.WebPassword = pw1;
|
||||
config.Save(settings.ConfigPath);
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] Saved to {settings.ConfigPath}.");
|
||||
AnsiConsole.MarkupLine("[grey]Restart the server for the new password to take effect.[/]");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using BrassAndSigil.Server.Models;
|
||||
using BrassAndSigil.Server.Services;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
namespace BrassAndSigil.Server.Commands;
|
||||
|
||||
public sealed class SyncCommand : AsyncCommand<BaseCommandSettings>
|
||||
{
|
||||
public override async Task<int> ExecuteAsync(CommandContext context, BaseCommandSettings settings)
|
||||
{
|
||||
var config = ServerConfig.Load(settings.ConfigPath);
|
||||
AnsiConsole.MarkupLine($"[bold]Syncing[/] from [blue]{config.ManifestUrl}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Target: {Path.GetFullPath(config.ServerDir)}[/]");
|
||||
AnsiConsole.MarkupLine("");
|
||||
|
||||
var sync = new ManifestSync();
|
||||
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
|
||||
try
|
||||
{
|
||||
var result = await sync.SyncAsync(config.ManifestUrl, config.ServerDir, progress);
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] pack v{result.PackVersion} | downloaded={result.Downloaded} removed={result.Removed} client-only-skipped={result.Skipped}");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]✗[/] {ex.Message.EscapeMarkup()}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace BrassAndSigil.Server.Models;
|
||||
|
||||
public sealed class Manifest
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("minecraft")]
|
||||
public MinecraftSpec Minecraft { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("loader")]
|
||||
public LoaderSpec? Loader { get; set; }
|
||||
|
||||
[JsonPropertyName("files")]
|
||||
public List<ManifestFile> Files { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MinecraftSpec
|
||||
{
|
||||
[JsonPropertyName("version")] public string Version { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LoaderSpec
|
||||
{
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "vanilla";
|
||||
[JsonPropertyName("version")] public string Version { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ManifestFile
|
||||
{
|
||||
[JsonPropertyName("path")] public string Path { get; set; } = "";
|
||||
[JsonPropertyName("url")] public string Url { get; set; } = "";
|
||||
[JsonPropertyName("sha1")] public string? Sha1 { get; set; }
|
||||
[JsonPropertyName("size")] public long? Size { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PackLock
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("version")] public string Version { get; set; } = "";
|
||||
[JsonPropertyName("minecraft")] public string Minecraft { get; set; } = "";
|
||||
[JsonPropertyName("loader")] public LoaderSpec Loader { get; set; } = new();
|
||||
[JsonPropertyName("mods")] public List<LockedMod> Mods { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LockedMod
|
||||
{
|
||||
[JsonPropertyName("source")] public string Source { get; set; } = "";
|
||||
[JsonPropertyName("slug")] public string? Slug { get; set; }
|
||||
[JsonPropertyName("versionId")] public string? VersionId { get; set; }
|
||||
[JsonPropertyName("fileId")] public string? FileId { get; set; }
|
||||
[JsonPropertyName("version")] public string Version { get; set; } = "";
|
||||
[JsonPropertyName("path")] public string Path { get; set; } = "";
|
||||
[JsonPropertyName("url")] public string Url { get; set; } = "";
|
||||
[JsonPropertyName("sha1")] public string Sha1 { get; set; } = "";
|
||||
[JsonPropertyName("size")] public long Size { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BrassAndSigil.Server.Services;
|
||||
|
||||
namespace BrassAndSigil.Server.Models;
|
||||
|
||||
public sealed class ServerConfig
|
||||
{
|
||||
[JsonPropertyName("manifestUrl")]
|
||||
public string ManifestUrl { get; set; } = "https://sijbers.uk/pack/manifest.json";
|
||||
|
||||
[JsonPropertyName("serverDir")]
|
||||
public string ServerDir { get; set; } = "./server";
|
||||
|
||||
[JsonPropertyName("javaPath")]
|
||||
public string JavaPath { get; set; } = "java";
|
||||
|
||||
[JsonPropertyName("memoryMB")]
|
||||
public int MemoryMB { get; set; } = 8192;
|
||||
|
||||
[JsonPropertyName("webPort")]
|
||||
public int WebPort { get; set; } = 8080;
|
||||
|
||||
/// <summary>"localhost" by default -- bind to 0.0.0.0 only behind a reverse proxy.</summary>
|
||||
[JsonPropertyName("webHost")]
|
||||
public string WebHost { get; set; } = "localhost";
|
||||
|
||||
/// <summary>Shared password for web UI. Required if WebHost is not localhost.</summary>
|
||||
[JsonPropertyName("webPassword")]
|
||||
public string? WebPassword { get; set; }
|
||||
|
||||
/// <summary>Where world backups land. Empty -> <serverDir>/../backups. Set to a
|
||||
/// large/slower drive on real deployments -- backups grow over time.</summary>
|
||||
[JsonPropertyName("backupDir")]
|
||||
public string? BackupDir { get; set; }
|
||||
|
||||
/// <summary>Auto-rotation: keep this many most recent backups, delete older.</summary>
|
||||
[JsonPropertyName("backupKeep")]
|
||||
public int BackupKeep { get; set; } = 10;
|
||||
|
||||
/// <summary>Daily auto-backup time as "HH:mm" (24-hour, server-local). Null/empty disables.</summary>
|
||||
[JsonPropertyName("backupSchedule")]
|
||||
public string? BackupSchedule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where BlueMap CLI's working dir lives (cli.jar, configs, render output).
|
||||
/// Empty -> alongside <serverDir>/.. (default ~/brass-sigil-server/bluemap).
|
||||
/// Set to a big-disk path on real deployments -- rendered output for a 5000-block
|
||||
/// world is several GB.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bluemapDir")]
|
||||
public string? BlueMapDir { get; set; }
|
||||
|
||||
[JsonPropertyName("rconPort")]
|
||||
public int RconPort { get; set; } = 25575;
|
||||
|
||||
[JsonPropertyName("rconPassword")]
|
||||
public string RconPassword { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("acceptEula")]
|
||||
public bool AcceptEula { get; set; } = false;
|
||||
|
||||
public static ServerConfig Load(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return new ServerConfig();
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<ServerConfig>(json, JsonOpts.CaseInsensitive) ?? new ServerConfig();
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
var json = JsonSerializer.Serialize(this, JsonOpts.Pretty);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using BrassAndSigil.Server.Commands;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Cli;
|
||||
|
||||
// Detect interactive double-click on Windows so we can hold the console open at exit
|
||||
// (otherwise the window vanishes before the user can read errors).
|
||||
var interactive = !Console.IsInputRedirected && OperatingSystem.IsWindows();
|
||||
|
||||
var app = new CommandApp<RunCommand>();
|
||||
app.Configure(config =>
|
||||
{
|
||||
config.SetApplicationName("brass-sigil-server");
|
||||
config.SetApplicationVersion("0.1.0");
|
||||
|
||||
config.AddCommand<InstallCommand>("install")
|
||||
.WithDescription("Force a fresh setup: download mods + run the NeoForge installer.");
|
||||
|
||||
config.AddCommand<SyncCommand>("sync")
|
||||
.WithDescription("Update mods to match the current manifest. Server should be stopped first.");
|
||||
|
||||
config.AddCommand<RunCommand>("run")
|
||||
.WithDescription("Run the server daemon (auto-installs anything missing, then serves the web UI).")
|
||||
.WithAlias("start");
|
||||
|
||||
config.AddCommand<CheckCommand>("check")
|
||||
.WithDescription("Verify install: dependencies, EULA, manifest reachability.");
|
||||
|
||||
config.AddCommand<SetPasswordCommand>("set-password")
|
||||
.WithDescription("Set or rotate the web panel admin password.");
|
||||
});
|
||||
|
||||
int result;
|
||||
try
|
||||
{
|
||||
result = await app.RunAsync(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
|
||||
result = 1;
|
||||
}
|
||||
|
||||
// Hold the console open at exit only when an error occurred during interactive use.
|
||||
// Successful daemon termination (Ctrl+C, /api/server/stop) closes cleanly.
|
||||
if (interactive && result != 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("");
|
||||
AnsiConsole.MarkupLine("[grey]Press any key to close...[/]");
|
||||
Console.ReadKey(intercept: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-backup driven by config.BackupSchedule. Accepted formats:
|
||||
/// - "HH:mm" single daily slot (e.g. "04:00")
|
||||
/// - "HH:mm,HH:mm,..." multiple daily slots
|
||||
/// - "every Nh" every N hours (>= 15 minutes)
|
||||
/// - "every Nm" every N minutes
|
||||
/// Wakes once a minute and fires backups when the clock matches the spec.
|
||||
/// Doesn't catch up if the server was off when a slot passed -- daily/interval
|
||||
/// backups don't need replay logic.
|
||||
/// </summary>
|
||||
public sealed class BackupScheduler : IDisposable
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly BackupService _backup;
|
||||
private readonly Action<string> _log;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
// Tracking for "fired" state. For interval: just the last fire time. For
|
||||
// daily-times: which times have fired today, reset at day rollover.
|
||||
private DateTimeOffset? _lastIntervalFire;
|
||||
private DateOnly _lastFireDay = DateOnly.MinValue;
|
||||
private readonly HashSet<TimeOnly> _firedToday = new();
|
||||
|
||||
public BackupScheduler(ServerConfig config, BackupService backup, Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
_backup = backup;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return;
|
||||
if (Parse(_config.BackupSchedule) == default)
|
||||
{
|
||||
_log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled.");
|
||||
return;
|
||||
}
|
||||
_cts?.Cancel();
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = Task.Run(() => RunAsync(_cts.Token));
|
||||
_log($"[backup-scheduler] Schedule active: {Describe()}");
|
||||
}
|
||||
|
||||
/// <summary>Stop the current loop and re-Start with the latest config values.</summary>
|
||||
public void Reload()
|
||||
{
|
||||
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
_loop = null;
|
||||
Start();
|
||||
}
|
||||
|
||||
/// <summary>Compute the next future scheduled fire time. Null if no schedule.</summary>
|
||||
public DateTimeOffset? NextRun()
|
||||
{
|
||||
var (interval, times) = Parse(_config.BackupSchedule);
|
||||
if (interval.HasValue)
|
||||
{
|
||||
var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1);
|
||||
var next = baseTime + interval.Value;
|
||||
// If we've never fired and we're past the implied first slot, "next" might be
|
||||
// in the past -- clamp to "imminent" by using now + small buffer.
|
||||
if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1);
|
||||
return next.ToLocalTime();
|
||||
}
|
||||
if (times is not null)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nowTime = TimeOnly.FromDateTime(now);
|
||||
// Use Cast<TimeOnly?>().FirstOrDefault() so "no pending" is null rather than 00:00.
|
||||
var pendingToday = times
|
||||
.Where(t => t > nowTime && !_firedToday.Contains(t))
|
||||
.OrderBy(t => t)
|
||||
.Cast<TimeOnly?>()
|
||||
.FirstOrDefault();
|
||||
if (pendingToday.HasValue)
|
||||
return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan()));
|
||||
// None left today -- first slot tomorrow.
|
||||
var firstTomorrow = times.Min();
|
||||
return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromMinutes(1), ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
var (interval, times) = Parse(_config.BackupSchedule);
|
||||
if (interval is null && times is null) continue;
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var nowLocal = DateTime.Now;
|
||||
var today = DateOnly.FromDateTime(nowLocal);
|
||||
var nowTime = TimeOnly.FromDateTime(nowLocal);
|
||||
|
||||
bool shouldFire = false;
|
||||
if (interval.HasValue)
|
||||
{
|
||||
shouldFire = !_lastIntervalFire.HasValue
|
||||
|| (nowUtc - _lastIntervalFire.Value) >= interval.Value;
|
||||
}
|
||||
else if (times is not null)
|
||||
{
|
||||
if (today != _lastFireDay)
|
||||
{
|
||||
_firedToday.Clear();
|
||||
_lastFireDay = today;
|
||||
}
|
||||
foreach (var t in times)
|
||||
{
|
||||
if (t <= nowTime && !_firedToday.Contains(t))
|
||||
{
|
||||
shouldFire = true;
|
||||
_firedToday.Add(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFire) continue;
|
||||
|
||||
_log("[backup-scheduler] Triggering scheduled backup.");
|
||||
try
|
||||
{
|
||||
var result = await _backup.CreateAsync("scheduled", ct: ct);
|
||||
if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB).");
|
||||
else _log($"[backup-scheduler] Failed: {result.Error}");
|
||||
if (interval.HasValue) _lastIntervalFire = nowUtc;
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty.</summary>
|
||||
private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return (null, null);
|
||||
var s = input.Trim().ToLowerInvariant();
|
||||
|
||||
// every Nh / every Nm
|
||||
var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$");
|
||||
if (m.Success)
|
||||
{
|
||||
var n = int.Parse(m.Groups[1].Value);
|
||||
var unit = m.Groups[2].Value;
|
||||
var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n);
|
||||
// Sanity floor -- anything below 15 min creates more save-lag than backups are worth.
|
||||
if (span < TimeSpan.FromMinutes(15)) return (null, null);
|
||||
if (span > TimeSpan.FromDays(7)) return (null, null);
|
||||
return (span, null);
|
||||
}
|
||||
|
||||
// Comma-separated HH:mm
|
||||
var list = new List<TimeOnly>();
|
||||
foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null);
|
||||
list.Add(t);
|
||||
}
|
||||
return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray());
|
||||
}
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
var (interval, times) = Parse(_config.BackupSchedule);
|
||||
if (interval.HasValue)
|
||||
{
|
||||
var totalMin = (int)interval.Value.TotalMinutes;
|
||||
if (totalMin >= 60 && totalMin % 60 == 0)
|
||||
{
|
||||
var h = totalMin / 60;
|
||||
return h == 1 ? "Every hour" : $"Every {h} hours";
|
||||
}
|
||||
return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes";
|
||||
}
|
||||
if (times is not null)
|
||||
{
|
||||
if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}";
|
||||
return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm")));
|
||||
}
|
||||
return "Disabled";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.IO.Compression;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// World backups: ZIP the level-name dir into a separate (typically slower-but-bigger)
|
||||
/// backup directory. Online backups via /save-all flush + /save-off while the server is
|
||||
/// running mean players don't see downtime -- just a brief save lag.
|
||||
/// </summary>
|
||||
public sealed class BackupService
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly ServerProcess _proc;
|
||||
private readonly Broadcaster _broadcast;
|
||||
private readonly Action<string> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
_proc = proc;
|
||||
_broadcast = broadcast;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public sealed record BackupInfo(string Name, long SizeBytes, DateTimeOffset CreatedAt);
|
||||
public sealed record CreateResult(bool Ok, string? Name, long SizeBytes, string? Error);
|
||||
|
||||
public string BackupDir => ResolveBackupDir();
|
||||
|
||||
public List<BackupInfo> List()
|
||||
{
|
||||
var dir = BackupDir;
|
||||
if (!Directory.Exists(dir)) return new();
|
||||
return Directory.EnumerateFiles(dir, "*.zip")
|
||||
.Select(p =>
|
||||
{
|
||||
var fi = new FileInfo(p);
|
||||
return new BackupInfo(fi.Name, fi.Length, new DateTimeOffset(fi.CreationTimeUtc, TimeSpan.Zero));
|
||||
})
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a ZIP backup of the world dir. Online (no shutdown) when the server is
|
||||
/// running.
|
||||
/// <para>
|
||||
/// <paramref name="flush"/> = false (default): just <c>save-off</c> + brief drain +
|
||||
/// ZIP + <c>save-on</c>. Near-zero player-visible lag. Backup captures state up to
|
||||
/// MC's last autosave (within ~5 min) -- fine for hourly snapshots.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="flush"/> = true: also runs <c>save-all flush</c> first, which
|
||||
/// synchronously serialises every loaded chunk before the ZIP. Captures state up
|
||||
/// to NOW. Causes a tick spike of seconds-to-tens-of-seconds depending on world
|
||||
/// size. Used only for irreversible operations (pre-wipe) where freshness matters.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<CreateResult> CreateAsync(string? reason = null, bool flush = false, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct))
|
||||
return new CreateResult(false, null, 0, "Another backup is already in progress.");
|
||||
|
||||
var dir = ResolveBackupDir();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_gate.Release();
|
||||
return new CreateResult(false, null, 0, $"Couldn't create backup dir '{dir}': {ex.Message}");
|
||||
}
|
||||
|
||||
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
|
||||
var worldDir = Path.Combine(_config.ServerDir, levelName);
|
||||
if (!Directory.Exists(worldDir))
|
||||
{
|
||||
_gate.Release();
|
||||
return new CreateResult(false, null, 0, $"World directory not found at '{worldDir}'.");
|
||||
}
|
||||
|
||||
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var slug = string.IsNullOrWhiteSpace(reason) ? "" : "-" + Slugify(reason);
|
||||
var name = $"{levelName}-{stamp}{slug}.zip";
|
||||
var path = Path.Combine(dir, name);
|
||||
|
||||
var serverRunning = _proc.IsRunning;
|
||||
try
|
||||
{
|
||||
if (serverRunning)
|
||||
{
|
||||
if (flush)
|
||||
{
|
||||
// Loud path: fresh state, but pays the tick spike. Tell players.
|
||||
try { await _broadcast.SayAsync("Saving world for backup (brief lag possible)...", ct); } catch { }
|
||||
_log("[backup] save-all flush");
|
||||
await _proc.SendInputAsync("save-all flush", ct);
|
||||
await Task.Delay(2500, ct);
|
||||
}
|
||||
_log("[backup] save-off");
|
||||
await _proc.SendInputAsync("save-off", ct);
|
||||
// Brief drain so any save tasks already enqueued can finish before we
|
||||
// start reading from disk for the ZIP.
|
||||
await Task.Delay(500, ct);
|
||||
}
|
||||
|
||||
_log($"[backup] Archiving {worldDir} -> {name}");
|
||||
// Run on a worker thread so the request thread doesn't block on disk I/O.
|
||||
await Task.Run(() =>
|
||||
ZipFile.CreateFromDirectory(worldDir, path, CompressionLevel.Fastest, includeBaseDirectory: false), ct);
|
||||
|
||||
var size = new FileInfo(path).Length;
|
||||
_log($"[backup] Created {name} ({size / (1024 * 1024)} MB).");
|
||||
// No completion broadcast for silent path -- backup was invisible to players,
|
||||
// no need to tell them it finished. Loud path is wipe-only and the wipe
|
||||
// sequence has its own messaging.
|
||||
|
||||
RotateOldest(dir, _config.BackupKeep);
|
||||
|
||||
return new CreateResult(true, name, size, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"[backup] Failed: {ex.Message}");
|
||||
try { File.Delete(path); } catch { } // partial archive
|
||||
return new CreateResult(false, null, 0, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (serverRunning && _proc.IsRunning)
|
||||
{
|
||||
_log("[backup] save-on");
|
||||
await _proc.SendInputAsync("save-on");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop the server, move the current world out of the way as a "pre-restore" safety
|
||||
/// copy, extract the chosen archive, restart.
|
||||
/// </summary>
|
||||
public async Task<(bool Ok, string? Error)> RestoreAsync(string backupName, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct))
|
||||
return (false, "Another backup operation is already in progress.");
|
||||
|
||||
try
|
||||
{
|
||||
var dir = ResolveBackupDir();
|
||||
var path = Path.Combine(dir, Path.GetFileName(backupName));
|
||||
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
|
||||
|
||||
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
|
||||
var worldDir = Path.Combine(_config.ServerDir, levelName);
|
||||
|
||||
if (_proc.IsRunning)
|
||||
{
|
||||
_log("[restore] Stopping server before restore...");
|
||||
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
|
||||
}
|
||||
|
||||
// Always preserve the current world as a pre-restore snapshot in case the
|
||||
// chosen archive is corrupt or wrong.
|
||||
if (Directory.Exists(worldDir))
|
||||
{
|
||||
var preRestore = $"{worldDir}-prerestore-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
_log($"[restore] Moving current world to {Path.GetFileName(preRestore)}");
|
||||
Directory.Move(worldDir, preRestore);
|
||||
}
|
||||
|
||||
_log($"[restore] Extracting {backupName}");
|
||||
Directory.CreateDirectory(worldDir);
|
||||
await Task.Run(() => ZipFile.ExtractToDirectory(path, worldDir, overwriteFiles: true), ct);
|
||||
|
||||
_log("[restore] Starting server.");
|
||||
_proc.Start();
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"[restore] Failed: {ex.Message}");
|
||||
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
|
||||
return (false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Ok, string? Error) Delete(string backupName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(ResolveBackupDir(), Path.GetFileName(backupName));
|
||||
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
|
||||
File.Delete(path);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
private string ResolveBackupDir()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_config.BackupDir))
|
||||
return Path.GetFullPath(_config.BackupDir);
|
||||
// Default: sibling of serverDir so it survives server-dir wipes.
|
||||
var serverFull = Path.GetFullPath(_config.ServerDir);
|
||||
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
|
||||
return Path.Combine(parent, "backups");
|
||||
}
|
||||
|
||||
private static void RotateOldest(string dir, int keep)
|
||||
{
|
||||
if (keep <= 0) return;
|
||||
try
|
||||
{
|
||||
var zips = Directory.EnumerateFiles(dir, "*.zip")
|
||||
.Select(p => new FileInfo(p))
|
||||
.OrderByDescending(fi => fi.CreationTimeUtc)
|
||||
.ToList();
|
||||
foreach (var old in zips.Skip(keep))
|
||||
{
|
||||
try { old.Delete(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string? ReadLevelName(string serverDir)
|
||||
{
|
||||
var path = Path.Combine(serverDir, "server.properties");
|
||||
if (!File.Exists(path)) return null;
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (line.StartsWith("level-name=", StringComparison.Ordinal))
|
||||
return line.Substring("level-name=".Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Slugify(string s)
|
||||
{
|
||||
var chars = s.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray();
|
||||
var slug = new string(chars).ToLowerInvariant();
|
||||
return slug.Length > 32 ? slug.Substring(0, 32) : slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// On-demand snapshot of online players + positions, formatted for BlueMap's
|
||||
/// live player overlay. Pull-based: the BlueMap web UI polls a JSON file at
|
||||
/// <c>/map/maps/overworld/live/players.json</c> roughly every 2 s, and the
|
||||
/// daemon intercepts that path and calls <see cref="SnapshotAsync"/> per
|
||||
/// request. Closed tab = no requests = no RCON calls -- same model as
|
||||
/// <c>/api/players</c>, no server-side timer to manage.
|
||||
/// </summary>
|
||||
public static class BlueMapPlayers
|
||||
{
|
||||
public static async Task<List<object>> SnapshotAsync(RconManager rcon, string serverDir, CancellationToken ct)
|
||||
{
|
||||
var listResp = await rcon.SendCommandAsync("list", ct);
|
||||
// Format: "There are N of a max of M players online: name1, name2, ..."
|
||||
var colon = listResp.IndexOf(':');
|
||||
var names = colon < 0
|
||||
? Array.Empty<string>()
|
||||
: listResp.Substring(colon + 1)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var posResp = await rcon.SendCommandAsync($"data get entity {name} Pos", ct);
|
||||
var pos = ParseDoubleTriple(posResp);
|
||||
if (pos is null) continue;
|
||||
var rotResp = await rcon.SendCommandAsync($"data get entity {name} Rotation", ct);
|
||||
var rot = ParseFloatPair(rotResp);
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
uuid = ResolveUuid(serverDir, name),
|
||||
name,
|
||||
foreign = false,
|
||||
position = new { x = pos.Value.x, y = pos.Value.y, z = pos.Value.z },
|
||||
rotation = new { pitch = rot?.pitch ?? 0, yaw = rot?.yaw ?? 0, roll = 0.0 },
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// One bad player shouldn't drop the rest of the list.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── UUID resolution ──────────────────────────────────────────────────────
|
||||
// BlueMap uses the UUID for two things: marker identity across polls (so a
|
||||
// changing UUID makes the marker flash on/off as it thinks the player keeps
|
||||
// leaving and rejoining) and skin lookup against Mojang's profile API. We
|
||||
// need the *real* Mojang UUID to satisfy both -- MC writes name→uuid pairs
|
||||
// into <serverDir>/usercache.json after each successful auth. We cache that
|
||||
// file's contents in memory and reload on mtime change, since usercache
|
||||
// updates rarely (player join, periodic refresh).
|
||||
private sealed class UserCacheEntry
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Uuid { get; set; }
|
||||
}
|
||||
|
||||
private static readonly object _cacheLock = new();
|
||||
private static Dictionary<string, string>? _cache; // case-insensitive name → uuid
|
||||
private static DateTime _cacheLoadedAt = DateTime.MinValue;
|
||||
private static string? _cachePath;
|
||||
|
||||
private static string ResolveUuid(string serverDir, string name)
|
||||
{
|
||||
var path = Path.Combine(Path.GetFullPath(serverDir), "usercache.json");
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var mtime = File.GetLastWriteTimeUtc(path);
|
||||
if (_cache is null || _cachePath != path || mtime > _cacheLoadedAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var entries = JsonSerializer.Deserialize<UserCacheEntry[]>(json, JsonOpts.CaseInsensitive);
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (entries is not null)
|
||||
foreach (var e in entries)
|
||||
if (!string.IsNullOrEmpty(e.Name) && !string.IsNullOrEmpty(e.Uuid))
|
||||
dict[e.Name] = e.Uuid;
|
||||
_cache = dict;
|
||||
_cachePath = path;
|
||||
_cacheLoadedAt = mtime;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Corrupt usercache shouldn't take down the marker -- fall through.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_cache is not null && _cache.TryGetValue(name, out var uuid)) return uuid;
|
||||
}
|
||||
|
||||
// Fallback when usercache hasn't been written yet (very early after
|
||||
// first auth) or has been wiped: deterministic UUID derived from the
|
||||
// name. Stable across polls so the marker doesn't flash; skin lookup
|
||||
// will 404 against Mojang and BlueMap will show a default head.
|
||||
return DeriveStableUuid(name).ToString();
|
||||
}
|
||||
|
||||
private static Guid DeriveStableUuid(string name)
|
||||
{
|
||||
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes("brass-sigil:" + name.ToLowerInvariant()));
|
||||
var guidBytes = new byte[16];
|
||||
Array.Copy(bytes, guidBytes, 16);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
|
||||
// Parse a NBT-style double triple like "[123.4d, 64.0d, -56.7d]" out of an
|
||||
// RCON `data get` response.
|
||||
private static (double x, double y, double z)? ParseDoubleTriple(string resp)
|
||||
{
|
||||
var m = Regex.Match(resp, @"\[([\-\d\.]+)d?,\s*([\-\d\.]+)d?,\s*([\-\d\.]+)d?\]");
|
||||
if (!m.Success) return null;
|
||||
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) return null;
|
||||
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return null;
|
||||
if (!double.TryParse(m.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) return null;
|
||||
return (x, y, z);
|
||||
}
|
||||
|
||||
// Rotation comes as `[yaw, pitch]` in floats.
|
||||
private static (double pitch, double yaw)? ParseFloatPair(string resp)
|
||||
{
|
||||
var m = Regex.Match(resp, @"\[([\-\d\.]+)f?,\s*([\-\d\.]+)f?\]");
|
||||
if (!m.Success) return null;
|
||||
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var yaw)) return null;
|
||||
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pitch)) return null;
|
||||
return (pitch, yaw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System.Diagnostics;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps BlueMap CLI as an out-of-process renderer.
|
||||
///
|
||||
/// The CLI jar is downloaded from GitHub releases on first render (mirrors the
|
||||
/// JavaInstaller pattern -- keeps the brass-sigil-server binary lean and lets
|
||||
/// BlueMap update independently). BlueMap 5.20+ requires Java 25, so we also
|
||||
/// auto-install Adoptium Temurin JRE 25 alongside the JRE 21 we use for MC.
|
||||
///
|
||||
/// Renders are kicked off manually from the panel and produce static HTML/JS/PNG
|
||||
/// output served at /map/. Zero impact on the running MC server (separate JVM,
|
||||
/// separate memory pool -- only competes for disk I/O during render).
|
||||
/// </summary>
|
||||
public sealed class BlueMapService : IDisposable
|
||||
{
|
||||
private const string BlueMapVersion = "5.20";
|
||||
private const string BlueMapJarUrl =
|
||||
"https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v"
|
||||
+ BlueMapVersion + "/bluemap-" + BlueMapVersion + "-cli.jar";
|
||||
private const int BlueMapJavaVersion = 25;
|
||||
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
|
||||
|
||||
private readonly ServerConfig _config;
|
||||
private readonly Action<string> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private Process? _renderProc;
|
||||
public sealed class RenderState
|
||||
{
|
||||
public bool InProgress { get; set; }
|
||||
public string Phase { get; set; } = "idle";
|
||||
// "idle" | "extracting" | "configuring" | "rendering" | "complete" | "failed"
|
||||
public string? Error { get; set; }
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
public DateTimeOffset? FinishedAt { get; set; }
|
||||
public int? ExitCode { get; set; }
|
||||
public string? LastLogLine { get; set; }
|
||||
}
|
||||
public RenderState State { get; private set; } = new();
|
||||
|
||||
public string RootDir
|
||||
{
|
||||
get
|
||||
{
|
||||
// Configured override (e.g. /mnt/md0p1/brass-sigil/bluemap) wins. Default
|
||||
// is sibling of serverDir so it doesn't bloat the world dir or the
|
||||
// server install -- typically ~/brass-sigil-server/bluemap.
|
||||
if (!string.IsNullOrWhiteSpace(_config.BlueMapDir))
|
||||
return Path.GetFullPath(_config.BlueMapDir);
|
||||
var serverFull = Path.GetFullPath(_config.ServerDir);
|
||||
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
|
||||
return Path.Combine(parent, "bluemap");
|
||||
}
|
||||
}
|
||||
public string CliJarPath => Path.Combine(RootDir, "cli.jar");
|
||||
public string WebDir => Path.Combine(RootDir, "web");
|
||||
public string ConfigDir => Path.Combine(RootDir, "config");
|
||||
|
||||
public BlueMapService(ServerConfig config, Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public bool HasRendered => Directory.Exists(WebDir) &&
|
||||
File.Exists(Path.Combine(WebDir, "index.html"));
|
||||
|
||||
/// <summary>Kick off a render in the background. Returns false if one is already running.</summary>
|
||||
public bool StartRender()
|
||||
{
|
||||
if (!_gate.Wait(0)) return false;
|
||||
State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow };
|
||||
_ = Task.Run(RenderAsync);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel an in-progress render by killing the BlueMap process. State on disk
|
||||
/// is preserved, so the next render resumes from where this one stopped.
|
||||
/// </summary>
|
||||
public bool CancelRender()
|
||||
{
|
||||
if (!State.InProgress) return false;
|
||||
try
|
||||
{
|
||||
// Kill the whole process tree (the BlueMap CLI may spawn worker JVMs
|
||||
// for parallel rendering). entireProcessTree=true on Linux uses
|
||||
// process group; on Windows it walks the tree via Job Objects.
|
||||
_renderProc?.Kill(entireProcessTree: true);
|
||||
State.Phase = "cancelled";
|
||||
State.Error = "Cancelled by user.";
|
||||
_log("[bluemap] Render cancelled by user.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"[bluemap] Cancel failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete rendered map output + render state. Used after a world wipe -- the
|
||||
/// old tiles reference terrain that no longer exists. Preserves cli.jar and
|
||||
/// configs so a follow-up Render still works (and skips re-download +
|
||||
/// re-config). Returns true if anything was deleted.
|
||||
/// </summary>
|
||||
public bool ClearRenderOutput()
|
||||
{
|
||||
var dataDir = Path.Combine(RootDir, "data");
|
||||
var mapsDir = Path.Combine(RootDir, "web", "maps");
|
||||
var anyDeleted = false;
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(dataDir)) { Directory.Delete(dataDir, true); anyDeleted = true; _log("[bluemap] Cleared render state."); }
|
||||
if (Directory.Exists(mapsDir)) { Directory.Delete(mapsDir, true); anyDeleted = true; _log("[bluemap] Cleared rendered tiles."); }
|
||||
}
|
||||
catch (Exception ex) { _log($"[bluemap] Couldn't clear output: {ex.Message}"); }
|
||||
return anyDeleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when brass-sigil-server itself shuts down -- kill any in-flight
|
||||
/// BlueMap process so it doesn't orphan to PID 1 and keep eating CPU after
|
||||
/// the daemon's gone. Render state on disk is preserved; next start can
|
||||
/// resume the render exactly where this one was killed.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderProc is { HasExited: false })
|
||||
{
|
||||
_renderProc.Kill(entireProcessTree: true);
|
||||
_renderProc.WaitForExit(2000);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_renderProc?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
|
||||
private async Task RenderAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(RootDir);
|
||||
|
||||
State.Phase = "downloading";
|
||||
await DownloadJarIfMissingAsync();
|
||||
|
||||
// BlueMap 5.20 needs Java 25; our MC server runs Java 21. Maintain a
|
||||
// separate JRE 25 install for BlueMap only.
|
||||
var bluemapJava = await EnsureJava25Async();
|
||||
if (bluemapJava is null) throw new InvalidOperationException("Couldn't install JRE 25 for BlueMap.");
|
||||
|
||||
// First run: BlueMap with no config writes default configs and exits.
|
||||
// We need to (a) run it once to seed configs, (b) patch the world path,
|
||||
// (c) re-run with -r to actually render.
|
||||
State.Phase = "configuring";
|
||||
EnsureConfig(bluemapJava);
|
||||
|
||||
State.Phase = "rendering";
|
||||
// -r = render. --mods <modsDir> = pull textures from mod jars so Create
|
||||
// blocks etc. show real colours instead of magenta/grey fallback.
|
||||
var modsDir = Path.Combine(Path.GetFullPath(_config.ServerDir), "mods");
|
||||
var args = new List<string> { "-jar", CliJarPath, "-r" };
|
||||
if (Directory.Exists(modsDir))
|
||||
{
|
||||
args.Add("--mods");
|
||||
args.Add(modsDir);
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = bluemapJava,
|
||||
WorkingDirectory = RootDir,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
_renderProc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
_renderProc.OutputDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
|
||||
_renderProc.ErrorDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
|
||||
|
||||
_renderProc.Start();
|
||||
_renderProc.BeginOutputReadLine();
|
||||
_renderProc.BeginErrorReadLine();
|
||||
await _renderProc.WaitForExitAsync();
|
||||
|
||||
State.ExitCode = _renderProc.ExitCode;
|
||||
State.FinishedAt = DateTimeOffset.UtcNow;
|
||||
if (_renderProc.ExitCode == 0) State.Phase = "complete";
|
||||
else { State.Phase = "failed"; State.Error = $"BlueMap exited with code {_renderProc.ExitCode}"; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State.Phase = "failed";
|
||||
State.Error = ex.Message;
|
||||
State.FinishedAt = DateTimeOffset.UtcNow;
|
||||
_log($"[bluemap] Failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
State.InProgress = false;
|
||||
_renderProc?.Dispose();
|
||||
_renderProc = null;
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadJarIfMissingAsync()
|
||||
{
|
||||
if (File.Exists(CliJarPath)) return;
|
||||
_log($"[bluemap] Downloading BlueMap CLI v{BlueMapVersion} to {CliJarPath}");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CliJarPath)!);
|
||||
using var resp = await _http.GetAsync(BlueMapJarUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
await using var src = await resp.Content.ReadAsStreamAsync();
|
||||
await using var dst = File.Create(CliJarPath);
|
||||
await src.CopyToAsync(dst);
|
||||
_log($"[bluemap] Downloaded {new FileInfo(CliJarPath).Length / 1024} KB.");
|
||||
}
|
||||
|
||||
/// <summary>Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs).</summary>
|
||||
private async Task<string?> EnsureJava25Async()
|
||||
{
|
||||
var installer = new JavaInstaller();
|
||||
var serverFull = Path.GetFullPath(_config.ServerDir);
|
||||
// Look for an existing JRE 25 install first (idempotent across renders).
|
||||
var existing = installer.FindBundledJava(serverFull, BlueMapJavaVersion);
|
||||
if (existing is not null) return existing;
|
||||
var installDir = installer.GetJavaInstallDir(serverFull, BlueMapJavaVersion);
|
||||
var progress = new Progress<string>(msg => _log("[bluemap] " + msg));
|
||||
return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run BlueMap with no flags so it writes default configs (bluemap.conf,
|
||||
/// maps/overworld.conf, etc.), then patch the overworld map's world path
|
||||
/// to point at our serverDir. Idempotent -- only writes configs that don't
|
||||
/// exist; existing user edits survive.
|
||||
/// </summary>
|
||||
private void EnsureConfig(string javaPath)
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDir);
|
||||
var bluemapConf = Path.Combine(ConfigDir, "core.conf");
|
||||
var seeded = File.Exists(bluemapConf);
|
||||
if (!seeded)
|
||||
{
|
||||
_log("[bluemap] First run -- generating default configs.");
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = javaPath,
|
||||
WorkingDirectory = RootDir,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-jar");
|
||||
psi.ArgumentList.Add(CliJarPath);
|
||||
using var p = Process.Start(psi)!;
|
||||
p.WaitForExit(60_000);
|
||||
}
|
||||
|
||||
// Patch the overworld map config to reference our world dir.
|
||||
var serverDirAbs = Path.GetFullPath(_config.ServerDir);
|
||||
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
|
||||
var worldDir = Path.Combine(serverDirAbs, levelName).Replace('\\', '/');
|
||||
|
||||
var mapsDir = Path.Combine(ConfigDir, "maps");
|
||||
Directory.CreateDirectory(mapsDir);
|
||||
var owConf = Path.Combine(mapsDir, "overworld.conf");
|
||||
if (!File.Exists(owConf) || !File.ReadAllText(owConf).Contains(worldDir))
|
||||
{
|
||||
File.WriteAllText(owConf, $@"# Generated by brass-sigil-server. Edit at your own risk.
|
||||
world: ""{worldDir}""
|
||||
dimension: ""minecraft:overworld""
|
||||
name: ""Brass and Sigil -- Overworld""
|
||||
sorting: 0
|
||||
sky-color: ""#7dabff""
|
||||
ambient-light: 0
|
||||
");
|
||||
_log("[bluemap] Wrote map config: maps/overworld.conf");
|
||||
}
|
||||
|
||||
// Tell core.conf to accept that we read its license/disclaimer (otherwise CLI exits with a notice).
|
||||
if (File.Exists(bluemapConf))
|
||||
{
|
||||
var text = File.ReadAllText(bluemapConf);
|
||||
if (!text.Contains("accept-download: true"))
|
||||
{
|
||||
text = text.Replace("accept-download: false", "accept-download: true");
|
||||
if (!text.Contains("accept-download:"))
|
||||
text += "\naccept-download: true\n";
|
||||
File.WriteAllText(bluemapConf, text);
|
||||
_log("[bluemap] Set accept-download: true in core.conf");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadLevelName(string serverDir)
|
||||
{
|
||||
var path = Path.Combine(serverDir, "server.properties");
|
||||
if (!File.Exists(path)) return null;
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (line.StartsWith("level-name=", StringComparison.Ordinal))
|
||||
return line.Substring("level-name=".Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes player-visible messages and overlays into Minecraft via stdin
|
||||
/// commands (/say, /title, /bossbar). The boss-bar countdown is the primary
|
||||
/// primitive the updater uses for restart announcements.
|
||||
/// </summary>
|
||||
public sealed class Broadcaster
|
||||
{
|
||||
private readonly ServerProcess _proc;
|
||||
private const string BossBarId = "brass:announce";
|
||||
|
||||
public Broadcaster(ServerProcess proc) => _proc = proc;
|
||||
|
||||
public Task SayAsync(string message, CancellationToken ct = default)
|
||||
=> _proc.SendInputAsync($"say {SingleLine(message)}", ct);
|
||||
|
||||
public Task ActionBarAsync(string message, CancellationToken ct = default)
|
||||
=> _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(message)}\"}}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Re-sends the action bar text once per second so it stays sticky for the
|
||||
/// full duration. Action bar fades after ~2-3 s of inactivity, so the
|
||||
/// re-send is mandatory. Doesn't conflict with boss-bar UI for actual
|
||||
/// boss fights -- preferred over BossBarCountdownAsync for restart warnings.
|
||||
/// </summary>
|
||||
public async Task ActionBarCountdownAsync(
|
||||
string title, int durationSeconds, CancellationToken ct = default)
|
||||
{
|
||||
if (durationSeconds <= 0) return;
|
||||
// Silence /title's "Showing new title for X" chat broadcast for the loop --
|
||||
// otherwise it spams chat once per second per online player. Restored in
|
||||
// the finally block. World save typically isn't quick enough to persist
|
||||
// the off state if we crash mid-flight, but worst case admins can flip
|
||||
// it back manually with /gamerule sendCommandFeedback true.
|
||||
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
|
||||
try
|
||||
{
|
||||
for (int sec = durationSeconds; sec > 0; sec--)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var mins = sec / 60;
|
||||
var secs = sec % 60;
|
||||
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
|
||||
await _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(label)}\"}}", ct);
|
||||
try { await Task.Delay(1000, ct); }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clear the action bar AND restore feedback. Both best-effort: if MC
|
||||
// is stopping these'll fail and that's fine.
|
||||
try { await _proc.SendInputAsync("title @a actionbar {\"text\":\"\"}"); } catch { }
|
||||
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public Task TitleAsync(string message, CancellationToken ct = default)
|
||||
=> _proc.SendInputAsync($"title @a title {{\"text\":\"{EscapeJson(message)}\"}}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Show a draining boss bar at the top of every player's screen for
|
||||
/// <paramref name="durationSeconds"/>. Updates the bar's name with a
|
||||
/// "title -- Mm Ss" countdown each second. Returns when the bar is removed.
|
||||
/// Honours cancellation: bar is removed cleanly even on cancel.
|
||||
/// </summary>
|
||||
public async Task BossBarCountdownAsync(
|
||||
string title, int durationSeconds, string color = "yellow", CancellationToken ct = default)
|
||||
{
|
||||
if (durationSeconds <= 0) return;
|
||||
|
||||
// Silence /bossbar feedback for the same reason as ActionBarCountdownAsync.
|
||||
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
|
||||
await _proc.SendInputAsync($"bossbar add {BossBarId} {{\"text\":\"{EscapeJson(title)}\"}}", ct);
|
||||
await _proc.SendInputAsync($"bossbar set {BossBarId} color {color}", ct);
|
||||
await _proc.SendInputAsync($"bossbar set {BossBarId} max {durationSeconds}", ct);
|
||||
await _proc.SendInputAsync($"bossbar set {BossBarId} value {durationSeconds}", ct);
|
||||
await _proc.SendInputAsync($"bossbar set {BossBarId} players @a", ct);
|
||||
|
||||
try
|
||||
{
|
||||
for (int sec = durationSeconds; sec > 0; sec--)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var mins = sec / 60;
|
||||
var secs = sec % 60;
|
||||
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
|
||||
await _proc.SendInputAsync($"bossbar set {BossBarId} name {{\"text\":\"{EscapeJson(label)}\"}}", ct);
|
||||
await _proc.SendInputAsync($"bossbar set {BossBarId} value {sec}", ct);
|
||||
try { await Task.Delay(1000, ct); }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always remove the bar -- even on cancel, so a stuck bar isn't left
|
||||
// on every player's screen. Use CancellationToken.None for the cleanup.
|
||||
try { await _proc.SendInputAsync($"bossbar remove {BossBarId}"); } catch { }
|
||||
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeJson(string s) =>
|
||||
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", " ");
|
||||
|
||||
private static string SingleLine(string s) =>
|
||||
s.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads + extracts Adoptium Temurin JRE 21 to server/java/. Used as a fallback
|
||||
/// when system Java is missing or too old. Adoptium's API gives us a stable
|
||||
/// platform-keyed download URL without needing API keys or auth.
|
||||
/// </summary>
|
||||
public sealed class JavaInstaller
|
||||
{
|
||||
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(15) };
|
||||
|
||||
public string GetJavaInstallDir(string serverDir) => Path.Combine(serverDir, "java");
|
||||
public string GetJavaInstallDir(string serverDir, int majorVersion) =>
|
||||
Path.Combine(serverDir, "java" + majorVersion);
|
||||
|
||||
/// <summary>If a previous install put a java executable under serverDir/java/, return its path.</summary>
|
||||
public string? FindBundledJava(string serverDir)
|
||||
{
|
||||
var javaDir = GetJavaInstallDir(serverDir);
|
||||
if (!Directory.Exists(javaDir)) return null;
|
||||
var exe = OperatingSystem.IsWindows() ? "java.exe" : "java";
|
||||
return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Find a Java install for a specific major version (e.g. javaXX/jdk-XX*/bin/java).</summary>
|
||||
public string? FindBundledJava(string serverDir, int majorVersion)
|
||||
{
|
||||
var javaDir = GetJavaInstallDir(serverDir, majorVersion);
|
||||
if (!Directory.Exists(javaDir)) return null;
|
||||
var exe = OperatingSystem.IsWindows() ? "java.exe" : "java";
|
||||
return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault();
|
||||
}
|
||||
|
||||
public Task<string?> InstallJre21Async(string serverDir, IProgress<string>? progress, CancellationToken ct)
|
||||
=> InstallJreAsync(21, serverDir, GetJavaInstallDir(serverDir), progress, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Download + extract Adoptium Temurin JRE for a specific major version into
|
||||
/// <paramref name="installDir"/>. Used by BlueMap to get JRE 25 alongside the
|
||||
/// JRE 21 we use for Minecraft.
|
||||
/// </summary>
|
||||
public async Task<string?> InstallJreAsync(int majorVersion, string serverDir, string installDir,
|
||||
IProgress<string>? progress, CancellationToken ct)
|
||||
{
|
||||
var javaDir = installDir;
|
||||
Directory.CreateDirectory(javaDir);
|
||||
|
||||
var (url, archiveName, isZip) = PickAdoptiumDownload(majorVersion);
|
||||
if (url is null)
|
||||
{
|
||||
progress?.Report($"[err] No supported Adoptium binary for {RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var archivePath = Path.Combine(javaDir, archiveName!);
|
||||
progress?.Report($"Downloading Adoptium Temurin JRE 21 ({(isZip ? "zip" : "tar.gz")})...");
|
||||
|
||||
try
|
||||
{
|
||||
using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
|
||||
{
|
||||
resp.EnsureSuccessStatusCode();
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(archivePath);
|
||||
await src.CopyToAsync(dst, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress?.Report($" [err] Download failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
progress?.Report($" Downloaded {new FileInfo(archivePath).Length:N0} bytes");
|
||||
progress?.Report("Extracting...");
|
||||
|
||||
try
|
||||
{
|
||||
if (isZip)
|
||||
{
|
||||
ZipFile.ExtractToDirectory(archivePath, javaDir, overwriteFiles: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var fs = File.OpenRead(archivePath);
|
||||
await using var gzip = new GZipStream(fs, CompressionMode.Decompress);
|
||||
await TarFile.ExtractToDirectoryAsync(gzip, javaDir, overwriteFiles: true, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress?.Report($" [err] Extract failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
try { File.Delete(archivePath); } catch { /* best-effort */ }
|
||||
|
||||
var javaExe = FindBundledJava(serverDir);
|
||||
if (javaExe is null)
|
||||
{
|
||||
progress?.Report(" [err] Extracted, but couldn't locate bin/java in the result.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// On Linux/macOS, make sure java is executable. TarFile preserves mode bits in
|
||||
// most setups, but be defensive.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(javaExe,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
progress?.Report($"Java {majorVersion} ready: {javaExe}");
|
||||
return javaExe;
|
||||
}
|
||||
|
||||
private static (string? Url, string? ArchiveName, bool IsZip) PickAdoptiumDownload(int majorVersion)
|
||||
{
|
||||
// Adoptium API picks the latest GA release matching our os/arch.
|
||||
// Docs: https://api.adoptium.net/q/swagger-ui/
|
||||
var arch = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "aarch64",
|
||||
_ => null
|
||||
};
|
||||
if (arch is null) return (null, null, false);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/windows/{arch}/jre/hotspot/normal/eclipse",
|
||||
$"jre{majorVersion}.zip", true);
|
||||
}
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/linux/{arch}/jre/hotspot/normal/eclipse",
|
||||
$"jre{majorVersion}.tar.gz", false);
|
||||
}
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/mac/{arch}/jre/hotspot/normal/eclipse",
|
||||
$"jre{majorVersion}.tar.gz", false);
|
||||
}
|
||||
return (null, null, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Project-wide JSON serializer options. Always case-insensitive on read so that
|
||||
/// hand-edited config files / API responses don't silently fail to bind a property
|
||||
/// because of a casing mismatch (e.g. "Command" vs "command", "JavaPath" vs "javaPath").
|
||||
/// Use this everywhere we call JsonSerializer.Deserialize.
|
||||
/// </summary>
|
||||
public static class JsonOpts
|
||||
{
|
||||
public static readonly JsonSerializerOptions CaseInsensitive = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static readonly JsonSerializerOptions Pretty = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side mod sync. Downloads only mods that the server needs:
|
||||
/// queries Modrinth's project metadata for each mod's `server_side` field
|
||||
/// and skips anything marked "unsupported" (Iris, Sodium, JEI, etc).
|
||||
/// CurseForge mods can't be auto-classified without an API key, so they
|
||||
/// are downloaded as-is and the server admin can manually delete unwanted ones.
|
||||
/// </summary>
|
||||
public sealed class ManifestSync
|
||||
{
|
||||
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };
|
||||
private const string PackVersionFile = "pack-version.json";
|
||||
private const string ServerManifestCache = "server-pack.cache.json";
|
||||
|
||||
public sealed record SyncResult(int Downloaded, int Removed, int Skipped, string PackVersion);
|
||||
|
||||
public async Task<Manifest> FetchManifestAsync(string url, CancellationToken ct = default)
|
||||
{
|
||||
var json = await _http.GetStringAsync(url, ct);
|
||||
var manifest = JsonSerializer.Deserialize<Manifest>(json, JsonOpts.CaseInsensitive)
|
||||
?? throw new InvalidOperationException("Manifest is empty.");
|
||||
manifest.Files ??= new();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncAsync(
|
||||
string manifestUrl, string serverDir, IProgress<string>? progress = null, CancellationToken ct = default)
|
||||
{
|
||||
progress?.Report("Fetching manifest...");
|
||||
var manifest = await FetchManifestAsync(manifestUrl, ct);
|
||||
|
||||
progress?.Report($"Pack: {manifest.Name} v{manifest.Version}");
|
||||
Directory.CreateDirectory(serverDir);
|
||||
|
||||
// Resolve which mods are server-side.
|
||||
var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct);
|
||||
|
||||
// Build the filtered list of files to keep on the server.
|
||||
var keepFiles = manifest.Files
|
||||
.Where(f => !ShouldSkipFile(f.Path, skipSlugs))
|
||||
.ToList();
|
||||
var skippedCount = manifest.Files.Count - keepFiles.Count;
|
||||
|
||||
// Prune managed files that aren't in the keep set.
|
||||
var wantedPaths = new HashSet<string>(
|
||||
keepFiles.Select(f => f.Path.Replace('\\', '/')),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var toRemove = ListManagedFiles(serverDir).Where(p => !wantedPaths.Contains(p)).ToList();
|
||||
foreach (var rel in toRemove)
|
||||
{
|
||||
var full = Path.Combine(serverDir, rel);
|
||||
try { File.Delete(full); progress?.Report($" Removed: {rel}"); }
|
||||
catch (Exception ex) { progress?.Report($" Could not remove {rel}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Download missing or hash-mismatched files.
|
||||
var toDownload = new List<ManifestFile>();
|
||||
foreach (var file in keepFiles)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var dest = Path.Combine(serverDir, file.Path);
|
||||
if (!File.Exists(dest)) { toDownload.Add(file); continue; }
|
||||
if (!string.IsNullOrEmpty(file.Sha1))
|
||||
{
|
||||
var actual = await ComputeSha1Async(dest, ct);
|
||||
if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase))
|
||||
toDownload.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} files...");
|
||||
for (int i = 0; i < toDownload.Count; i++)
|
||||
{
|
||||
var file = toDownload[i];
|
||||
progress?.Report($" [{i + 1}/{toDownload.Count}] {file.Path}");
|
||||
await DownloadFileAsync(file.Url, Path.Combine(serverDir, file.Path), file.Sha1, ct);
|
||||
}
|
||||
|
||||
// Write pack-version.json marker.
|
||||
var record = new
|
||||
{
|
||||
name = manifest.Name,
|
||||
version = manifest.Version,
|
||||
syncedAt = DateTime.UtcNow.ToString("o"),
|
||||
includedFiles = keepFiles.Count,
|
||||
skippedFiles = skippedCount
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(serverDir, PackVersionFile),
|
||||
JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }),
|
||||
ct);
|
||||
|
||||
return new SyncResult(toDownload.Count, toRemove.Count, skippedCount, manifest.Version ?? "?");
|
||||
}
|
||||
|
||||
private static bool ShouldSkipFile(string filePath, HashSet<string> skipSlugs)
|
||||
{
|
||||
if (skipSlugs.Count == 0) return false;
|
||||
var name = Path.GetFileNameWithoutExtension(filePath).ToLowerInvariant();
|
||||
// Match if any skip slug appears at the start of the filename (slug-version.jar)
|
||||
return skipSlugs.Any(slug => name.StartsWith(slug + "-", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals(slug, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
|
||||
private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
|
||||
{
|
||||
var skip = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var modrinthIds = new HashSet<string>();
|
||||
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
if (!file.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
// Modrinth URL pattern: https://cdn.modrinth.com/data/{projectId}/versions/{versionId}/...
|
||||
var url = file.Url;
|
||||
const string prefix = "cdn.modrinth.com/data/";
|
||||
var idx = url.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0) continue;
|
||||
var rest = url.Substring(idx + prefix.Length);
|
||||
var slash = rest.IndexOf('/');
|
||||
if (slash < 0) continue;
|
||||
modrinthIds.Add(rest.Substring(0, slash));
|
||||
}
|
||||
|
||||
foreach (var pid in modrinthIds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var info = await _http.GetFromJsonAsync<JsonElement>(
|
||||
$"https://api.modrinth.com/v2/project/{pid}", ct);
|
||||
var slug = info.TryGetProperty("slug", out var s) ? s.GetString() : null;
|
||||
var serverSide = info.TryGetProperty("server_side", out var ss) ? ss.GetString() : null;
|
||||
if (!string.IsNullOrEmpty(slug) && string.Equals(serverSide, "unsupported", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
skip.Add(slug);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: if we can't classify, keep the mod (safer to ship extra than missing)
|
||||
}
|
||||
}
|
||||
|
||||
return skip;
|
||||
}
|
||||
|
||||
private static List<string> ListManagedFiles(string serverDir)
|
||||
{
|
||||
var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" };
|
||||
var result = new List<string>();
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var rootDir = Path.Combine(serverDir, root);
|
||||
if (!Directory.Exists(rootDir)) continue;
|
||||
foreach (var f in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
|
||||
result.Add(Path.GetRelativePath(serverDir, f).Replace('\\', '/'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
|
||||
var tmp = destPath + ".part";
|
||||
using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
|
||||
{
|
||||
resp.EnsureSuccessStatusCode();
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(tmp);
|
||||
await src.CopyToAsync(dst, ct);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(expectedSha1))
|
||||
{
|
||||
var actual = await ComputeSha1Async(tmp, ct);
|
||||
if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(tmp);
|
||||
throw new InvalidOperationException($"Hash mismatch for {Path.GetFileName(destPath)}");
|
||||
}
|
||||
}
|
||||
if (File.Exists(destPath)) File.Delete(destPath);
|
||||
File.Move(tmp, destPath);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha1Async(string path, CancellationToken ct)
|
||||
{
|
||||
using var sha = SHA1.Create();
|
||||
await using var stream = File.OpenRead(path);
|
||||
var bytes = await sha.ComputeHashAsync(stream, ct);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads NeoForge's official server installer JAR and runs it with --installServer
|
||||
/// to produce run.sh/run.bat + the server library tree. Handles Java invocation and
|
||||
/// streams installer output via a progress callback.
|
||||
/// </summary>
|
||||
public sealed class NeoForgeInstaller
|
||||
{
|
||||
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
|
||||
|
||||
public bool IsAlreadyInstalled(string serverDir)
|
||||
{
|
||||
return File.Exists(Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh"));
|
||||
}
|
||||
|
||||
public async Task<bool> InstallAsync(string version, string serverDir, string javaPath,
|
||||
IProgress<string>? progress, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(serverDir);
|
||||
|
||||
// 1. Download installer
|
||||
var installerName = $"neoforge-{version}-installer.jar";
|
||||
var installerPath = Path.Combine(serverDir, installerName);
|
||||
var url = $"https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/{installerName}";
|
||||
|
||||
if (!File.Exists(installerPath))
|
||||
{
|
||||
progress?.Report($"Downloading NeoForge {version} installer...");
|
||||
var bytes = await _http.GetByteArrayAsync(url, ct);
|
||||
await File.WriteAllBytesAsync(installerPath, bytes, ct);
|
||||
progress?.Report($" Saved {bytes.Length:N0} bytes to {installerName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
progress?.Report($"NeoForge installer already present, skipping download.");
|
||||
}
|
||||
|
||||
// 2. Run installer
|
||||
progress?.Report("Running NeoForge installer (java -jar ... --installServer)...");
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = javaPath,
|
||||
WorkingDirectory = serverDir,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-jar");
|
||||
psi.ArgumentList.Add(installerName);
|
||||
psi.ArgumentList.Add("--installServer");
|
||||
|
||||
Process? proc;
|
||||
try
|
||||
{
|
||||
proc = Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress?.Report($" [error] Could not start java: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
if (proc is null)
|
||||
{
|
||||
progress?.Report(" [error] Failed to start java.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var stdoutTask = StreamLines(proc.StandardOutput, line => progress?.Report($" {line}"), ct);
|
||||
var stderrTask = StreamLines(proc.StandardError, line => progress?.Report($" [err] {line}"), ct);
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
await Task.WhenAll(stdoutTask, stderrTask);
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
progress?.Report($" [error] NeoForge installer exited with code {proc.ExitCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Verify run script exists
|
||||
if (!IsAlreadyInstalled(serverDir))
|
||||
{
|
||||
progress?.Report(" [error] NeoForge installer ran but run.sh/run.bat is missing.");
|
||||
return false;
|
||||
}
|
||||
|
||||
progress?.Report($"NeoForge {version} installed.");
|
||||
|
||||
// 4. Clean up the installer JAR (large, no longer needed)
|
||||
try { File.Delete(installerPath); } catch { /* best-effort */ }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task StreamLines(StreamReader reader, Action<string> onLine, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(ct);
|
||||
if (line is null) break;
|
||||
onLine(line);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Minecraft RCON client (Source RCON protocol).
|
||||
/// Used for sending console commands and reading "list" output for player counts.
|
||||
/// </summary>
|
||||
public sealed class RconClient : IDisposable
|
||||
{
|
||||
private const int SERVERDATA_AUTH = 3;
|
||||
private const int SERVERDATA_EXECCOMMAND = 2;
|
||||
private const int SERVERDATA_RESPONSE_VALUE = 0;
|
||||
|
||||
private TcpClient? _tcp;
|
||||
private NetworkStream? _stream;
|
||||
private int _nextRequestId = 1;
|
||||
|
||||
public bool Connected => _tcp?.Connected ?? false;
|
||||
|
||||
public async Task ConnectAsync(string host, int port, string password, CancellationToken ct = default)
|
||||
{
|
||||
_tcp = new TcpClient();
|
||||
await _tcp.ConnectAsync(host, port, ct);
|
||||
_stream = _tcp.GetStream();
|
||||
|
||||
var authId = NextId();
|
||||
await SendPacketAsync(authId, SERVERDATA_AUTH, password, ct);
|
||||
|
||||
// Read auth response. Server sends an empty response value first, then the auth result.
|
||||
var (id1, _, _) = await ReadPacketAsync(ct);
|
||||
if (id1 == -1) throw new InvalidOperationException("RCON auth failed (bad password)");
|
||||
// Auth ok if id matches what we sent
|
||||
}
|
||||
|
||||
public async Task<string> SendCommandAsync(string command, CancellationToken ct = default)
|
||||
{
|
||||
if (_stream == null) throw new InvalidOperationException("Not connected");
|
||||
var id = NextId();
|
||||
await SendPacketAsync(id, SERVERDATA_EXECCOMMAND, command, ct);
|
||||
var (_, _, body) = await ReadPacketAsync(ct);
|
||||
return body;
|
||||
}
|
||||
|
||||
private int NextId() => Interlocked.Increment(ref _nextRequestId);
|
||||
|
||||
private async Task SendPacketAsync(int id, int type, string body, CancellationToken ct)
|
||||
{
|
||||
var bodyBytes = Encoding.UTF8.GetBytes(body);
|
||||
var packetSize = 4 + 4 + bodyBytes.Length + 2; // id + type + body + 2 null bytes
|
||||
var buffer = new byte[4 + packetSize];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), packetSize);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4, 4), id);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(8, 4), type);
|
||||
bodyBytes.CopyTo(buffer.AsSpan(12));
|
||||
// last two bytes already 0 by default
|
||||
await _stream!.WriteAsync(buffer, ct);
|
||||
}
|
||||
|
||||
private async Task<(int Id, int Type, string Body)> ReadPacketAsync(CancellationToken ct)
|
||||
{
|
||||
var sizeBuf = new byte[4];
|
||||
await ReadExactAsync(sizeBuf, ct);
|
||||
var size = BinaryPrimitives.ReadInt32LittleEndian(sizeBuf);
|
||||
if (size < 10 || size > 1024 * 1024) throw new InvalidOperationException($"Bad RCON packet size {size}");
|
||||
|
||||
var pkt = new byte[size];
|
||||
await ReadExactAsync(pkt, ct);
|
||||
var id = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(0, 4));
|
||||
var type = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(4, 4));
|
||||
var body = Encoding.UTF8.GetString(pkt, 8, size - 10); // strip 2 trailing nulls
|
||||
return (id, type, body);
|
||||
}
|
||||
|
||||
private async Task ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = await _stream!.ReadAsync(buffer.AsMemory(read), ct);
|
||||
if (n == 0) throw new EndOfStreamException();
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stream?.Dispose();
|
||||
_tcp?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin reconnecting wrapper around <see cref="RconClient"/>. The original
|
||||
/// single-connection-with-no-retry pattern caches a dead client whenever the
|
||||
/// initial connect happens before MC has opened the RCON port (which is normal --
|
||||
/// boot takes ~30 s). This manager lazily connects on first use, retries on
|
||||
/// failure, and drops the client when a send throws so the next call reconnects.
|
||||
/// </summary>
|
||||
public sealed class RconManager : IDisposable
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly string _password;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private RconClient? _client;
|
||||
|
||||
public RconManager(string host, int port, string password)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_password = password;
|
||||
}
|
||||
|
||||
public async Task<string> SendCommandAsync(string command, CancellationToken ct = default)
|
||||
{
|
||||
var client = await EnsureConnectedAsync(ct);
|
||||
try
|
||||
{
|
||||
return await client.SendCommandAsync(command, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DropAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RconClient> EnsureConnectedAsync(CancellationToken ct)
|
||||
{
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_client is { Connected: true }) return _client;
|
||||
_client?.Dispose();
|
||||
_client = new RconClient();
|
||||
await _client.ConnectAsync(_host, _port, _password, ct);
|
||||
return _client;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DropAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try { _client?.Dispose(); _client = null; }
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client?.Dispose();
|
||||
_lock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the Minecraft Java subprocess: spawns it with the right JVM args,
|
||||
/// captures stdout/stderr into a ring buffer (so the web UI can show recent
|
||||
/// logs), broadcasts new lines via an event, handles graceful shutdown.
|
||||
/// </summary>
|
||||
public sealed class ServerProcess : IDisposable
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private Process? _process;
|
||||
private readonly ConcurrentQueue<LogLine> _logRing = new();
|
||||
private const int LogRingSize = 2000;
|
||||
|
||||
// Process-tree containment: on Windows, the Job Object kills our subprocess
|
||||
// automatically when our own process dies -- regardless of how we died.
|
||||
// Created lazily on first Start() on Windows; destroyed in Dispose.
|
||||
private static WindowsJobObject? _jobObject;
|
||||
|
||||
public event Action<LogLine>? OnLogLine;
|
||||
public event Action<int>? Exited;
|
||||
|
||||
public bool IsRunning => _process is { HasExited: false };
|
||||
public DateTime? StartedAt { get; private set; }
|
||||
public int? Pid => _process?.Id;
|
||||
|
||||
// Memory + CPU sampling. CpuMetrics is a moving average across calls -- the
|
||||
// first call after start returns null because we need two samples for a delta.
|
||||
// We track the Java *descendant* of our shell, not the shell itself, because
|
||||
// run.sh / run.bat is ~2 MB and useless for stats.
|
||||
private TimeSpan _lastCpuTime;
|
||||
private DateTime _lastCpuSampleAt;
|
||||
private int _lastTrackedPid;
|
||||
private Process? _trackedJava;
|
||||
private readonly object _statsLock = new();
|
||||
private readonly Queue<double> _cpuSamples = new();
|
||||
private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Java JVM descendant if found, otherwise falls back to the
|
||||
/// directly-spawned shell process. Cached and re-resolved when the cached
|
||||
/// pid exits (e.g., MC restart).
|
||||
/// </summary>
|
||||
private Process? TrackedProcess
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_process is null || _process.HasExited) return null;
|
||||
if (_trackedJava is { HasExited: false }) return _trackedJava;
|
||||
// Try to find a 'java' descendant of our shell. If not found yet (still
|
||||
// booting), fall back to the shell -- first stats will read tiny, then
|
||||
// reset on next call once Java is up.
|
||||
var found = FindJavaDescendant(_process.Id);
|
||||
_trackedJava = found;
|
||||
return found ?? _process;
|
||||
}
|
||||
}
|
||||
|
||||
public long? MemoryBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
var p = TrackedProcess;
|
||||
if (p is null || p.HasExited) return null;
|
||||
try { p.Refresh(); return p.WorkingSet64; }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System-wide CPU percentage (0-100) plus rolling-window peak and average.
|
||||
/// Each call samples once and contributes to a 20-entry history (~60 s at 3 s
|
||||
/// polling). First call after start returns null (need two samples for a delta).
|
||||
/// </summary>
|
||||
public (double Current, double Max, double Avg)? CpuMetrics
|
||||
{
|
||||
get
|
||||
{
|
||||
var p = TrackedProcess;
|
||||
if (p is null || p.HasExited) return null;
|
||||
lock (_statsLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
p.Refresh();
|
||||
var now = DateTime.UtcNow;
|
||||
var cpuNow = p.TotalProcessorTime;
|
||||
if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default)
|
||||
{
|
||||
_lastTrackedPid = p.Id;
|
||||
_lastCpuTime = cpuNow;
|
||||
_lastCpuSampleAt = now;
|
||||
_cpuSamples.Clear();
|
||||
return null;
|
||||
}
|
||||
var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds;
|
||||
var elapsedCpu = (cpuNow - _lastCpuTime).TotalMilliseconds;
|
||||
_lastCpuTime = cpuNow;
|
||||
_lastCpuSampleAt = now;
|
||||
if (elapsedReal <= 0) return (0, 0, 0);
|
||||
|
||||
// System-wide: divide by core count so the value is bounded 0-100
|
||||
// and intuitive ("42% of total CPU capacity"). The user found the
|
||||
// top-style 0-N*100 range confusing for a fleet view.
|
||||
var perCore = elapsedCpu / elapsedReal * 100.0;
|
||||
var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount);
|
||||
|
||||
_cpuSamples.Enqueue(systemWide);
|
||||
while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue();
|
||||
|
||||
return (systemWide, _cpuSamples.Max(), _cpuSamples.Average());
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BFS the process tree starting from <paramref name="rootPid"/> looking for
|
||||
/// a process named like "java". Linux: read /proc/PID/task/PID/children.
|
||||
/// Windows: enumerate parents via Process objects (no extra deps).
|
||||
/// </summary>
|
||||
private static Process? FindJavaDescendant(int rootPid)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return FindJavaDescendantLinux(rootPid);
|
||||
}
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return FindJavaDescendantWindows(rootPid);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Process? FindJavaDescendantLinux(int rootPid)
|
||||
{
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(rootPid);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var pid = queue.Dequeue();
|
||||
if (!visited.Add(pid)) continue;
|
||||
var childrenPath = $"/proc/{pid}/task/{pid}/children";
|
||||
if (!File.Exists(childrenPath)) continue;
|
||||
string raw;
|
||||
try { raw = File.ReadAllText(childrenPath); } catch { continue; }
|
||||
foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!int.TryParse(token, out var cpid)) continue;
|
||||
Process? p = null;
|
||||
try { p = Process.GetProcessById(cpid); } catch { continue; }
|
||||
if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
|
||||
return p;
|
||||
queue.Enqueue(cpid);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Process? FindJavaDescendantWindows(int rootPid)
|
||||
{
|
||||
// Build a parent->children map by reading every running process's parent
|
||||
// PID once, then BFS. Slower than Linux's /proc but works without WMI.
|
||||
var allProcs = Process.GetProcesses();
|
||||
var byParent = new Dictionary<int, List<Process>>();
|
||||
foreach (var p in allProcs)
|
||||
{
|
||||
int parent;
|
||||
try { parent = GetParentPidWindows(p); } catch { continue; }
|
||||
if (parent == 0) continue;
|
||||
if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List<Process>();
|
||||
list.Add(p);
|
||||
}
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(rootPid);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var pid = queue.Dequeue();
|
||||
if (!visited.Add(pid)) continue;
|
||||
if (!byParent.TryGetValue(pid, out var children)) continue;
|
||||
foreach (var c in children)
|
||||
{
|
||||
if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
|
||||
return c;
|
||||
queue.Enqueue(c.Id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("ntdll.dll")]
|
||||
private static extern int NtQueryInformationProcess(
|
||||
IntPtr processHandle, int processInformationClass,
|
||||
ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);
|
||||
|
||||
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
||||
private struct ProcessBasicInformation
|
||||
{
|
||||
public IntPtr Reserved1;
|
||||
public IntPtr PebBaseAddress;
|
||||
public IntPtr Reserved2_0;
|
||||
public IntPtr Reserved2_1;
|
||||
public IntPtr UniqueProcessId;
|
||||
public IntPtr InheritedFromUniqueProcessId; // <-- parent PID
|
||||
}
|
||||
|
||||
private static int GetParentPidWindows(Process p)
|
||||
{
|
||||
var info = new ProcessBasicInformation();
|
||||
int rc = NtQueryInformationProcess(p.Handle, 0, ref info,
|
||||
System.Runtime.InteropServices.Marshal.SizeOf<ProcessBasicInformation>(), out _);
|
||||
return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32();
|
||||
}
|
||||
|
||||
public ServerProcess(ServerConfig config) => _config = config;
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
if (IsRunning) return false;
|
||||
|
||||
// Reset cached Java descendant + CPU sampling baseline so the next call
|
||||
// re-resolves once the new run.sh -> java tree is up.
|
||||
_trackedJava = null;
|
||||
_lastTrackedPid = 0;
|
||||
_lastCpuSampleAt = default;
|
||||
|
||||
// The mod loader's start script lives next to the server jar. NeoForge produces
|
||||
// run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on
|
||||
// the server side, so we build the equivalent JVM command ourselves.
|
||||
var startScript = ResolveStartCommand(out var argList);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = startScript,
|
||||
WorkingDirectory = Path.GetFullPath(_config.ServerDir),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in argList) startInfo.ArgumentList.Add(a);
|
||||
|
||||
// CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to
|
||||
// whatever's on PATH. If we configured a specific Java (or auto-downloaded one),
|
||||
// prepend its bin dir + set JAVA_HOME so the script picks up the right JVM
|
||||
// instead of an older system Java.
|
||||
ApplyJavaEnvironment(startInfo);
|
||||
|
||||
_process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
_process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false);
|
||||
_process.ErrorDataReceived += (_, e) => OnLine(e.Data, isError: true);
|
||||
_process.Exited += (_, _) =>
|
||||
{
|
||||
var code = _process?.ExitCode ?? -1;
|
||||
OnLine($"=== Server exited (code {code}) ===", isError: false);
|
||||
Exited?.Invoke(code);
|
||||
};
|
||||
|
||||
_process.Start();
|
||||
|
||||
// Bind the Java subprocess to a Job Object on Windows so it gets killed
|
||||
// automatically if we exit ungracefully (X-button, Task Manager, etc.).
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
_jobObject ??= new WindowsJobObject();
|
||||
_jobObject.AssignProcess(_process);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal -- we still try to clean up via Process.Kill in Dispose.
|
||||
OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
StartedAt = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string ResolveStartCommand(out List<string> args)
|
||||
{
|
||||
args = new List<string>();
|
||||
var dir = Path.GetFullPath(_config.ServerDir);
|
||||
|
||||
// Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args.
|
||||
var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh";
|
||||
var runScript = Path.Combine(dir, runShell);
|
||||
if (File.Exists(runScript))
|
||||
{
|
||||
// Make sure the user_jvm_args.txt has the memory we want
|
||||
EnsureUserJvmArgs(dir);
|
||||
// Suppress the Swing server-GUI window -- we use the web panel instead.
|
||||
// NeoForge's run.bat / run.sh forwards extra args to Minecraft.
|
||||
args.Add("nogui");
|
||||
return runScript;
|
||||
}
|
||||
|
||||
// Fallback: invoke java directly on the server jar. This won't work for NeoForge
|
||||
// (it requires the loader's run script), but it works for vanilla and Forge legacy.
|
||||
args.Add($"-Xms{_config.MemoryMB}M");
|
||||
args.Add($"-Xmx{_config.MemoryMB}M");
|
||||
args.Add("-jar");
|
||||
args.Add("server.jar");
|
||||
args.Add("nogui");
|
||||
return _config.JavaPath;
|
||||
}
|
||||
|
||||
private void ApplyJavaEnvironment(ProcessStartInfo psi)
|
||||
{
|
||||
// Only meaningful when we have an absolute path to a specific java binary
|
||||
// (i.e., not just "java" on PATH).
|
||||
if (string.IsNullOrEmpty(_config.JavaPath)) return;
|
||||
if (!Path.IsPathRooted(_config.JavaPath)) return;
|
||||
if (!File.Exists(_config.JavaPath)) return;
|
||||
|
||||
var javaBinDir = Path.GetDirectoryName(_config.JavaPath);
|
||||
if (string.IsNullOrEmpty(javaBinDir)) return;
|
||||
var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/
|
||||
|
||||
var sep = OperatingSystem.IsWindows() ? ";" : ":";
|
||||
var existingPath = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}";
|
||||
if (!string.IsNullOrEmpty(javaHome))
|
||||
{
|
||||
psi.Environment["JAVA_HOME"] = javaHome;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureUserJvmArgs(string dir)
|
||||
{
|
||||
// NeoForge's run.bat / run.sh reads this file for JVM-level args.
|
||||
// Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons
|
||||
// and significantly better than G1 for heavily-modded MC. Requires Java 21+.
|
||||
var path = Path.Combine(dir, "user_jvm_args.txt");
|
||||
var content =
|
||||
$"-Xms{_config.MemoryMB}M\n" +
|
||||
$"-Xmx{_config.MemoryMB}M\n" +
|
||||
"-XX:+UseZGC\n" +
|
||||
"-XX:+ZGenerational\n";
|
||||
try { File.WriteAllText(path, content); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private void OnLine(string? data, bool isError)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
var line = new LogLine(DateTimeOffset.UtcNow, isError, data);
|
||||
_logRing.Enqueue(line);
|
||||
while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _);
|
||||
OnLogLine?.Invoke(line);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LogLine> RecentLogs() => _logRing.ToArray();
|
||||
|
||||
public async Task SendInputAsync(string command, CancellationToken ct = default)
|
||||
{
|
||||
if (_process is null || _process.HasExited) return;
|
||||
await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct);
|
||||
await _process.StandardInput.FlushAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> StopAsync(TimeSpan? graceful = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsRunning) return false;
|
||||
graceful ??= TimeSpan.FromSeconds(30);
|
||||
try
|
||||
{
|
||||
await SendInputAsync("stop", ct);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(graceful.Value);
|
||||
try { await _process!.WaitForExitAsync(cts.Token); return true; }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_process?.Kill(entireProcessTree: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { _process?.Kill(entireProcessTree: true); } catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _process?.Kill(entireProcessTree: true); } catch { }
|
||||
_process?.Dispose();
|
||||
}
|
||||
|
||||
public sealed record LogLine(DateTimeOffset At, bool IsError, string Text);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes Minecraft's <c>server.properties</c>. Editable keys are
|
||||
/// gated by an allowlist so a compromised panel can't flip security-critical
|
||||
/// fields like <c>online-mode</c> arbitrarily -- only common gameplay knobs.
|
||||
/// Preserves comments and key order on write; appends new keys at the end.
|
||||
/// </summary>
|
||||
public sealed class ServerPropertiesService
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
|
||||
public ServerPropertiesService(ServerConfig config) => _config = config;
|
||||
|
||||
/// <summary>
|
||||
/// Keys that may be modified via /api/server/settings. Anything outside this
|
||||
/// set is silently dropped from the update payload -- admin must SSH for those.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> EditableKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"motd",
|
||||
"difficulty",
|
||||
"gamemode",
|
||||
"view-distance",
|
||||
"simulation-distance",
|
||||
"max-players",
|
||||
"pvp",
|
||||
"hardcore",
|
||||
"white-list",
|
||||
"enforce-whitelist",
|
||||
"allow-flight",
|
||||
"enable-command-block",
|
||||
"spawn-protection",
|
||||
};
|
||||
|
||||
public string PropertiesPath => Path.Combine(Path.GetFullPath(_config.ServerDir), "server.properties");
|
||||
|
||||
public Dictionary<string, string> ReadAll()
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!File.Exists(PropertiesPath)) return dict;
|
||||
foreach (var raw in File.ReadAllLines(PropertiesPath))
|
||||
{
|
||||
var line = raw.TrimStart();
|
||||
if (line.Length == 0 || line[0] == '#' || line[0] == '!') continue;
|
||||
var idx = line.IndexOf('=');
|
||||
if (idx < 0) continue;
|
||||
dict[line.Substring(0, idx).Trim()] = line.Substring(idx + 1);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>Returns just the editable subset, with values left as raw strings.</summary>
|
||||
public Dictionary<string, string> ReadEditable()
|
||||
{
|
||||
var all = ReadAll();
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var key in EditableKeys)
|
||||
{
|
||||
if (all.TryGetValue(key, out var v)) result[key] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current <c>level-seed</c> value, or null if absent / empty.
|
||||
/// </summary>
|
||||
public string? GetLevelSeed()
|
||||
{
|
||||
if (!File.Exists(PropertiesPath)) return null;
|
||||
foreach (var raw in File.ReadAllLines(PropertiesPath))
|
||||
{
|
||||
var line = raw.TrimStart();
|
||||
if (!line.StartsWith("level-seed=", StringComparison.Ordinal)) continue;
|
||||
var v = line.Substring("level-seed=".Length).Trim();
|
||||
return string.IsNullOrEmpty(v) ? null : v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct write of <c>level-seed</c>. Bypasses <see cref="EditableKeys"/>
|
||||
/// because the seed is set as part of the wipe flow (with confirmation),
|
||||
/// not by general settings UI -- exposing it through the regular Update()
|
||||
/// path would let it be flipped from any settings save. Empty string
|
||||
/// clears the field, which makes Minecraft pick a random seed on next
|
||||
/// world generation.
|
||||
/// </summary>
|
||||
public void SetLevelSeed(string seed)
|
||||
{
|
||||
var lines = File.Exists(PropertiesPath)
|
||||
? File.ReadAllLines(PropertiesPath).ToList()
|
||||
: new List<string>();
|
||||
var done = false;
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var trimmed = lines[i].TrimStart();
|
||||
if (trimmed.StartsWith("level-seed=", StringComparison.Ordinal))
|
||||
{
|
||||
lines[i] = $"level-seed={seed}";
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!done) lines.Add($"level-seed={seed}");
|
||||
File.WriteAllLines(PropertiesPath, lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply updates to the file. Keys not in <see cref="EditableKeys"/> are
|
||||
/// silently dropped. Lines that already exist are updated in-place to
|
||||
/// preserve order and comments; new keys are appended at the end.
|
||||
/// </summary>
|
||||
public void Update(IDictionary<string, string> updates)
|
||||
{
|
||||
// Filter to allowed keys only.
|
||||
var filtered = updates
|
||||
.Where(kv => EditableKeys.Contains(kv.Key))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
if (filtered.Count == 0) return;
|
||||
|
||||
var lines = File.Exists(PropertiesPath)
|
||||
? File.ReadAllLines(PropertiesPath).ToList()
|
||||
: new List<string>();
|
||||
|
||||
var applied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var raw = lines[i];
|
||||
var trimmed = raw.TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == '!') continue;
|
||||
var idx = trimmed.IndexOf('=');
|
||||
if (idx < 0) continue;
|
||||
var key = trimmed.Substring(0, idx).Trim();
|
||||
if (filtered.TryGetValue(key, out var newValue))
|
||||
{
|
||||
lines[i] = $"{key}={newValue}";
|
||||
applied.Add(key);
|
||||
}
|
||||
}
|
||||
foreach (var (key, value) in filtered)
|
||||
{
|
||||
if (!applied.Contains(key)) lines.Add($"{key}={value}");
|
||||
}
|
||||
File.WriteAllLines(PropertiesPath, lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using BrassAndSigil.Server.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Drives a "fetch new manifest, drain players, swap mods, restart MC" workflow.
|
||||
/// Single-flight: one update at a time, guarded by a semaphore. State is exposed
|
||||
/// so the panel can poll progress; logs go through the existing OnLogLine event
|
||||
/// (re-streamed via SSE) so they show up in the live console too.
|
||||
/// </summary>
|
||||
public sealed class UpdaterService
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly string _configPath;
|
||||
private readonly ServerProcess _proc;
|
||||
private readonly Broadcaster _broadcast;
|
||||
private readonly Action<string> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
public UpdateState State { get; private set; } = new();
|
||||
|
||||
public sealed class UpdateState
|
||||
{
|
||||
public bool InProgress { get; set; }
|
||||
public string Phase { get; set; } = "idle";
|
||||
// "idle" | "countdown" | "stopping" | "syncing" | "installing_loader" | "starting" | "complete" | "failed" | "cancelled"
|
||||
public int CountdownTotal { get; set; }
|
||||
public int CountdownRemaining { get; set; }
|
||||
public string? CurrentVersion { get; set; }
|
||||
public string? AvailableVersion { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public DateTimeOffset? LastFinishedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed record CheckResult(string? Current, string? Available, bool NeedsUpdate, string? Error);
|
||||
|
||||
public UpdaterService(ServerConfig config, string configPath,
|
||||
ServerProcess proc, Broadcaster broadcast,
|
||||
Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
_configPath = configPath;
|
||||
_proc = proc;
|
||||
_broadcast = broadcast;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>Lightweight read: compare local pack-version.json to remote manifest.</summary>
|
||||
public async Task<CheckResult> CheckAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sync = new ManifestSync();
|
||||
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
|
||||
var local = ReadLocalPackVersion(_config.ServerDir);
|
||||
var current = local;
|
||||
var available = manifest.Version;
|
||||
var needs = !string.Equals(current, available, StringComparison.Ordinal);
|
||||
State.CurrentVersion = current;
|
||||
State.AvailableVersion = available;
|
||||
return new CheckResult(current, available, needs, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CheckResult(State.CurrentVersion, State.AvailableVersion, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCancel()
|
||||
{
|
||||
if (!State.InProgress || _cts is null) return false;
|
||||
// Only meaningful during countdown phase -- a sync mid-flight isn't safely abortable.
|
||||
if (State.Phase != "countdown") return false;
|
||||
_cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the full update flow. Single-flight -- returns false if one is already running.
|
||||
/// </summary>
|
||||
public async Task<bool> StartAsync(int delaySeconds)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return false;
|
||||
_cts = new CancellationTokenSource();
|
||||
var ct = _cts.Token;
|
||||
|
||||
State = new UpdateState
|
||||
{
|
||||
InProgress = true,
|
||||
Phase = "countdown",
|
||||
CountdownTotal = delaySeconds,
|
||||
CountdownRemaining = delaySeconds,
|
||||
CurrentVersion = State.CurrentVersion,
|
||||
AvailableVersion = State.AvailableVersion,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// ── 1. Player-facing countdown ──
|
||||
if (delaySeconds > 0 && _proc.IsRunning)
|
||||
{
|
||||
_log($"[update] Announcing restart in {delaySeconds}s.");
|
||||
await _broadcast.SayAsync($"Server will restart in {FormatDuration(delaySeconds)} for an update to v{State.AvailableVersion}.", ct);
|
||||
|
||||
// Run the action-bar countdown + periodic chat warnings + UI ticker
|
||||
// in parallel. Action bar (instead of boss bar) avoids stacking on
|
||||
// top of real boss fight UIs (Ender Dragon, raids, mod bosses).
|
||||
var actionBar = _broadcast.ActionBarCountdownAsync(
|
||||
"Server restart for update", delaySeconds, ct);
|
||||
|
||||
var warnings = WarnDuringCountdownAsync(delaySeconds, ct);
|
||||
|
||||
// Drive State.CountdownRemaining for the UI poller.
|
||||
var ticker = TickCountdownStateAsync(delaySeconds, ct);
|
||||
|
||||
await Task.WhenAll(actionBar, warnings, ticker);
|
||||
}
|
||||
|
||||
// ── 2. Stop MC ──
|
||||
ct.ThrowIfCancellationRequested();
|
||||
State.Phase = "stopping";
|
||||
_log("[update] Stopping Minecraft for update...");
|
||||
if (_proc.IsRunning)
|
||||
{
|
||||
await _broadcast.SayAsync("Server is restarting now.");
|
||||
await _proc.StopAsync(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
// ── 3. Sync mods from manifest ──
|
||||
ct.ThrowIfCancellationRequested();
|
||||
State.Phase = "syncing";
|
||||
_log("[update] Syncing mods from manifest...");
|
||||
var sync = new ManifestSync();
|
||||
var progress = new Progress<string>(msg => _log($"[update] {msg}"));
|
||||
var result = await sync.SyncAsync(_config.ManifestUrl, _config.ServerDir, progress, ct);
|
||||
_log($"[update] Sync complete: {result.Downloaded} downloaded, {result.Removed} removed.");
|
||||
|
||||
// ── 4. Update NeoForge if loader version changed ──
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
|
||||
if (manifest.Loader is { } loader &&
|
||||
loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase) &&
|
||||
LoaderVersionChanged(_config.ServerDir, loader.Version))
|
||||
{
|
||||
State.Phase = "installing_loader";
|
||||
_log($"[update] Reinstalling NeoForge {loader.Version}...");
|
||||
var nf = new NeoForgeInstaller();
|
||||
var ok = await nf.InstallAsync(loader.Version, _config.ServerDir, _config.JavaPath, progress, ct);
|
||||
if (!ok) throw new InvalidOperationException("NeoForge installer failed.");
|
||||
}
|
||||
|
||||
// ── 5. Start MC ──
|
||||
ct.ThrowIfCancellationRequested();
|
||||
State.Phase = "starting";
|
||||
_log("[update] Starting Minecraft...");
|
||||
_proc.Start();
|
||||
State.CurrentVersion = manifest.Version;
|
||||
|
||||
State.Phase = "complete";
|
||||
State.InProgress = false;
|
||||
State.LastFinishedAt = DateTimeOffset.UtcNow;
|
||||
_log("[update] Update complete.");
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
State.Phase = "cancelled";
|
||||
State.InProgress = false;
|
||||
State.LastFinishedAt = DateTimeOffset.UtcNow;
|
||||
_log("[update] Update cancelled.");
|
||||
// If we cancelled during countdown, MC is still running -- leave it alone.
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State.Phase = "failed";
|
||||
State.Error = ex.Message;
|
||||
State.InProgress = false;
|
||||
State.LastFinishedAt = DateTimeOffset.UtcNow;
|
||||
_log($"[update] Failed: {ex.Message}");
|
||||
// Try to bring MC back up if we stopped it but never restarted.
|
||||
if (!_proc.IsRunning)
|
||||
{
|
||||
try { _proc.Start(); _log("[update] Restored Minecraft after failure."); }
|
||||
catch (Exception startEx) { _log($"[update] Restore failed too: {startEx.Message}"); }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickCountdownStateAsync(int total, CancellationToken ct)
|
||||
{
|
||||
for (int sec = total; sec > 0; sec--)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
State.CountdownRemaining = sec;
|
||||
try { await Task.Delay(1000, ct); }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
}
|
||||
State.CountdownRemaining = 0;
|
||||
}
|
||||
|
||||
private async Task WarnDuringCountdownAsync(int total, CancellationToken ct)
|
||||
{
|
||||
// Periodic chat warnings -- independent of the boss bar (visual-but-missable).
|
||||
// Each milestone fires at an absolute time computed from the start, so the
|
||||
// delays don't accumulate sequentially across the loop iterations.
|
||||
var startUtc = DateTime.UtcNow;
|
||||
var milestones = new[] { 300, 60, 30, 10 };
|
||||
foreach (var m in milestones)
|
||||
{
|
||||
if (m >= total) continue;
|
||||
var fireAt = startUtc.AddSeconds(total - m);
|
||||
var wait = fireAt - DateTime.UtcNow;
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(wait, ct); }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
}
|
||||
try { await _broadcast.SayAsync($"Server restart in {FormatDuration(m)}."); }
|
||||
catch { /* don't bring down the whole update for one failed broadcast */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LoaderVersionChanged(string serverDir, string newVersion)
|
||||
{
|
||||
// Look at the libraries dir for an existing neoforge-<version> path.
|
||||
// If absent or different version, we should re-install.
|
||||
var libsRoot = Path.Combine(serverDir, "libraries", "net", "neoforged", "neoforge");
|
||||
if (!Directory.Exists(libsRoot)) return true;
|
||||
var versions = Directory.EnumerateDirectories(libsRoot).Select(Path.GetFileName).ToList();
|
||||
return !versions.Contains(newVersion);
|
||||
}
|
||||
|
||||
private static string? ReadLocalPackVersion(string serverDir)
|
||||
{
|
||||
var path = Path.Combine(serverDir, "pack-version.json");
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string FormatDuration(int seconds)
|
||||
{
|
||||
if (seconds >= 60) return $"{seconds / 60} minute{(seconds / 60 == 1 ? "" : "s")}";
|
||||
return $"{seconds} second{(seconds == 1 ? "" : "s")}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text.Json;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks "I want to play" requests from friends, pending admin approval.
|
||||
/// State is a flat JSON file in the server dir so it survives daemon restarts
|
||||
/// without needing a database. Single-flight gate prevents concurrent-write
|
||||
/// corruption when admin and friend act at the same time.
|
||||
///
|
||||
/// State machine: (none) -> pending -> approved | denied
|
||||
/// "approved" means the admin clicked Approve; the actual /whitelist add
|
||||
/// command goes through the existing whitelist endpoint, which removes the
|
||||
/// request from the pending list.
|
||||
/// </summary>
|
||||
public sealed class WhitelistRequestService
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public WhitelistRequestService(ServerConfig config) => _config = config;
|
||||
|
||||
public sealed class Request
|
||||
{
|
||||
public string Username { get; set; } = "";
|
||||
public string? Message { get; set; }
|
||||
public string Status { get; set; } = "pending"; // pending | approved | denied
|
||||
public DateTimeOffset RequestedAt { get; set; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
public string? RemoteIp { get; set; } // for admin diagnosis if needed
|
||||
}
|
||||
|
||||
private string FilePath =>
|
||||
Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist-requests.json");
|
||||
|
||||
private List<Request> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath)) return new();
|
||||
var text = File.ReadAllText(FilePath);
|
||||
if (string.IsNullOrWhiteSpace(text)) return new();
|
||||
return JsonSerializer.Deserialize<List<Request>>(text, JsonOpts.CaseInsensitive) ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private void Save(List<Request> list)
|
||||
{
|
||||
var text = JsonSerializer.Serialize(list, JsonOpts.Pretty);
|
||||
File.WriteAllText(FilePath, text);
|
||||
}
|
||||
|
||||
public IReadOnlyList<Request> List() { lock (_lock) return Load(); }
|
||||
|
||||
public IReadOnlyList<Request> ListPending()
|
||||
{
|
||||
lock (_lock)
|
||||
return Load().Where(r => r.Status == "pending").ToList();
|
||||
}
|
||||
|
||||
/// <summary>Submit a new request. Idempotent on (username, status=pending) -- won't dupe.</summary>
|
||||
public Request Submit(string username, string? message, string? remoteIp)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var list = Load();
|
||||
// Drop any prior request for this username (case-insensitive) so the
|
||||
// most recent one wins regardless of previous state. Keeps the file
|
||||
// from growing if a friend re-requests after a denial.
|
||||
list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||
var req = new Request
|
||||
{
|
||||
Username = username,
|
||||
Message = string.IsNullOrWhiteSpace(message) ? null : message,
|
||||
Status = "pending",
|
||||
RequestedAt = DateTimeOffset.UtcNow,
|
||||
RemoteIp = remoteIp,
|
||||
};
|
||||
list.Add(req);
|
||||
Save(list);
|
||||
return req;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective status for the launcher. If the username is in the actual
|
||||
/// whitelist.json (regardless of whether they ever filed a request), returns
|
||||
/// "approved" -- that's what the friend's launcher cares about. Otherwise
|
||||
/// falls back to whatever request record we have, or "unknown".
|
||||
/// </summary>
|
||||
public string StatusFor(string username)
|
||||
{
|
||||
if (IsActuallyWhitelisted(username)) return "approved";
|
||||
lock (_lock)
|
||||
{
|
||||
var match = Load().FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||
return match?.Status ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsActuallyWhitelisted(string username)
|
||||
{
|
||||
var path = Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist.json");
|
||||
if (!File.Exists(path)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
return doc.RootElement.EnumerateArray().Any(e =>
|
||||
e.TryGetProperty("name", out var n) &&
|
||||
string.Equals(n.GetString(), username, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public bool MarkApproved(string username) => SetStatus(username, "approved");
|
||||
public bool MarkDenied(string username) => SetStatus(username, "denied");
|
||||
|
||||
/// <summary>Remove the request entirely (used after the actual /whitelist add fires).</summary>
|
||||
public bool Remove(string username)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var list = Load();
|
||||
var removed = list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||
if (removed > 0) Save(list);
|
||||
return removed > 0;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SetStatus(string username, string status)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var list = Load();
|
||||
var match = list.FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null) return false;
|
||||
match.Status = status;
|
||||
match.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
Save(list);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a Windows Job Object configured with KILL_ON_JOB_CLOSE so that any process
|
||||
/// assigned to it dies the moment our process does -- regardless of *how* we died
|
||||
/// (X-button on the console, Task Manager End Task, parent BSOD, etc.). Without
|
||||
/// this, a Java subprocess can outlive us and keep the server files locked.
|
||||
///
|
||||
/// On Linux, use systemd's cgroup management instead; the equivalent guarantee
|
||||
/// comes for free when the tool runs as a systemd unit.
|
||||
/// </summary>
|
||||
public sealed class WindowsJobObject : IDisposable
|
||||
{
|
||||
private const int JobObjectExtendedLimitInformation = 9;
|
||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct IO_COUNTERS
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
{
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
private IntPtr _handle;
|
||||
private bool _disposed;
|
||||
|
||||
public WindowsJobObject()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
throw new PlatformNotSupportedException("WindowsJobObject only works on Windows.");
|
||||
|
||||
_handle = CreateJobObject(IntPtr.Zero, null);
|
||||
if (_handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
|
||||
|
||||
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
{
|
||||
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
{
|
||||
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
}
|
||||
};
|
||||
var size = Marshal.SizeOf(info);
|
||||
var ptr = Marshal.AllocHGlobal(size);
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(info, ptr, fDeleteOld: false);
|
||||
if (!SetInformationJobObject(_handle, JobObjectExtendedLimitInformation, ptr, (uint)size))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
}
|
||||
|
||||
public void AssignProcess(Process process)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(WindowsJobObject));
|
||||
if (!AssignProcessToJobObject(_handle, process.Handle))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_handle != IntPtr.Zero)
|
||||
{
|
||||
CloseHandle(_handle); // Closing the last handle triggers KILL_ON_JOB_CLOSE
|
||||
_handle = IntPtr.Zero;
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~WindowsJobObject() => Dispose();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Destructive world operations -- wipe and replace. Always single-flight, always
|
||||
/// stops the server first, and offers a rename-as-backup default so an accidental
|
||||
/// click doesn't lose data permanently.
|
||||
/// </summary>
|
||||
public sealed class WorldService
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly ServerProcess _proc;
|
||||
private readonly BackupService _backup;
|
||||
private readonly Broadcaster _broadcast;
|
||||
private readonly RconManager _rcon;
|
||||
private readonly BlueMapService? _bluemap;
|
||||
private readonly Action<string> _log;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public WorldService(ServerConfig config, ServerProcess proc, BackupService backup,
|
||||
Broadcaster broadcast, RconManager rcon, Action<string> log,
|
||||
BlueMapService? bluemap = null)
|
||||
{
|
||||
_config = config;
|
||||
_proc = proc;
|
||||
_backup = backup;
|
||||
_broadcast = broadcast;
|
||||
_rcon = rcon;
|
||||
_bluemap = bluemap;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public sealed record WipeResult(bool Ok, string? BackupName, string? SeedUsed, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// What seed strategy a wipe should use:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Keep</c> -- capture the live seed via RCON before wipe and reuse it.</item>
|
||||
/// <item><c>Random</c> -- clear <c>level-seed</c> so MC picks a fresh random one.</item>
|
||||
/// <item><c>Custom</c> -- set <c>level-seed</c> to <see cref="WipeOptions.CustomSeed"/>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum SeedMode { Keep, Random, Custom }
|
||||
|
||||
public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort lookup of the current world seed. Prefers RCON's <c>seed</c>
|
||||
/// command (always returns the actual generated seed even when
|
||||
/// server.properties has level-seed empty); falls back to the configured
|
||||
/// level-seed value if RCON is unavailable (server stopped, no MC, etc.).
|
||||
/// Returns null when neither is available.
|
||||
/// </summary>
|
||||
public async Task<string?> GetCurrentSeedAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_proc.IsRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _rcon.SendCommandAsync("seed", ct);
|
||||
// Format: "Seed: [<number>]"
|
||||
var m = Regex.Match(resp, @"Seed:\s*\[(-?\d+)\]");
|
||||
if (m.Success) return m.Groups[1].Value;
|
||||
}
|
||||
catch { /* fall through to properties */ }
|
||||
}
|
||||
return new ServerPropertiesService(_config).GetLevelSeed();
|
||||
}
|
||||
|
||||
// Caching world size: scanning a large world dir is O(file count). 30 s cache
|
||||
// dampens that to roughly once per status poll cycle on busy panels.
|
||||
private long _cachedSize;
|
||||
private DateTime _cachedAt = DateTime.MinValue;
|
||||
|
||||
public long GetWorldSizeBytes()
|
||||
{
|
||||
if ((DateTime.UtcNow - _cachedAt) < TimeSpan.FromSeconds(30)) return _cachedSize;
|
||||
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
|
||||
var worldDir = Path.Combine(Path.GetFullPath(_config.ServerDir), levelName);
|
||||
if (!Directory.Exists(worldDir)) { _cachedSize = 0; _cachedAt = DateTime.UtcNow; return 0; }
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(worldDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try { total += new FileInfo(f).Length; } catch { }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_cachedSize = total;
|
||||
_cachedAt = DateTime.UtcNow;
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop the server, optionally archive the world via BackupService, delete the
|
||||
/// world folder(s), apply the chosen seed strategy, then restart. Backups go
|
||||
/// to the configured backup dir (typically a slower-but-bigger drive) rather
|
||||
/// than next to the world dir.
|
||||
/// </summary>
|
||||
public async Task<WipeResult> WipeWorldAsync(WipeOptions options, int warningSeconds = 30, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _lock.WaitAsync(0, ct))
|
||||
return new WipeResult(false, null, null, "Another wipe is already in progress.");
|
||||
|
||||
try
|
||||
{
|
||||
var serverDir = Path.GetFullPath(_config.ServerDir);
|
||||
var levelName = ReadLevelName(serverDir) ?? "world";
|
||||
var primaryWorld = Path.Combine(serverDir, levelName);
|
||||
var props = new ServerPropertiesService(_config);
|
||||
|
||||
// ── Decide what seed the new world will use, BEFORE we stop the server ──
|
||||
// Keep mode needs RCON, which only works while MC is alive.
|
||||
string seedToWrite; // value for level-seed= line; "" means random
|
||||
string? capturedSeed = null;
|
||||
switch (options.Mode)
|
||||
{
|
||||
case SeedMode.Keep:
|
||||
capturedSeed = await GetCurrentSeedAsync(ct);
|
||||
if (string.IsNullOrEmpty(capturedSeed))
|
||||
{
|
||||
return new WipeResult(false, null, null,
|
||||
"Couldn't read current seed (RCON unreachable + level-seed empty). Stop the server, set level-seed manually, or pick Random/Custom.");
|
||||
}
|
||||
seedToWrite = capturedSeed;
|
||||
_log($"[wipe] Keep-seed mode: captured current seed {capturedSeed} for reuse.");
|
||||
break;
|
||||
case SeedMode.Custom:
|
||||
var custom = options.CustomSeed?.Trim() ?? "";
|
||||
if (string.IsNullOrEmpty(custom))
|
||||
return new WipeResult(false, null, null, "Custom seed mode selected but no seed provided.");
|
||||
seedToWrite = custom;
|
||||
_log($"[wipe] Custom-seed mode: new world will use seed {custom}.");
|
||||
break;
|
||||
default: // Random
|
||||
seedToWrite = "";
|
||||
_log("[wipe] Random-seed mode: clearing level-seed so MC generates a fresh seed.");
|
||||
break;
|
||||
}
|
||||
|
||||
// Loud, urgent player warning before any irreversible action -- wipe is
|
||||
// destructive even with a backup (admins still need to restore manually).
|
||||
if (warningSeconds > 0 && _proc.IsRunning)
|
||||
{
|
||||
_log($"[wipe] Announcing {warningSeconds}s wipe warning to players...");
|
||||
try
|
||||
{
|
||||
await _broadcast.SayAsync(
|
||||
$"WORLD WIPE in {warningSeconds} seconds. Disconnect now if you want to keep your current world!", ct);
|
||||
await _broadcast.ActionBarCountdownAsync("WORLD WIPING", warningSeconds, ct);
|
||||
await _broadcast.SayAsync("Wiping world now.");
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { /* don't abort wipe over a broadcast error */ }
|
||||
}
|
||||
|
||||
string? backupName = null;
|
||||
if (options.Backup && Directory.Exists(primaryWorld))
|
||||
{
|
||||
_log("[wipe] Creating backup before wipe...");
|
||||
// flush:true here -- we're about to delete the world. Capture every block
|
||||
// and every move up to right now, even at the cost of a tick spike.
|
||||
var br = await _backup.CreateAsync("pre-wipe", flush: true, ct: ct);
|
||||
if (!br.Ok)
|
||||
return new WipeResult(false, null, null, $"Backup failed: {br.Error}. Wipe aborted to preserve data.");
|
||||
backupName = br.Name;
|
||||
}
|
||||
|
||||
if (_proc.IsRunning)
|
||||
{
|
||||
_log("[wipe] Stopping server...");
|
||||
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
|
||||
}
|
||||
|
||||
if (Directory.Exists(primaryWorld))
|
||||
{
|
||||
_log($"[wipe] Deleting {primaryWorld}");
|
||||
Directory.Delete(primaryWorld, recursive: true);
|
||||
}
|
||||
// Legacy sibling dirs (rare on modern NeoForge but cheap to handle)
|
||||
foreach (var altSuffix in new[] { "_nether", "_the_end" })
|
||||
{
|
||||
var altDir = Path.Combine(serverDir, levelName + altSuffix);
|
||||
if (!Directory.Exists(altDir)) continue;
|
||||
_log($"[wipe] Deleting {altDir}");
|
||||
Directory.Delete(altDir, recursive: true);
|
||||
}
|
||||
|
||||
// Apply the seed AFTER deletion but BEFORE restart -- MC reads
|
||||
// server.properties at startup to determine the new world's seed.
|
||||
props.SetLevelSeed(seedToWrite);
|
||||
|
||||
// Map output for the now-deleted world is stale -- clear it so the next
|
||||
// render starts fresh against the new terrain.
|
||||
_bluemap?.ClearRenderOutput();
|
||||
|
||||
_log("[wipe] World wiped. Restarting server -- Minecraft will generate a fresh world.");
|
||||
_proc.Start();
|
||||
// For Random mode we don't know the exact new seed yet (MC picks at startup);
|
||||
// return the level-seed value we wrote, which is "" for random.
|
||||
return new WipeResult(true, backupName, string.IsNullOrEmpty(seedToWrite) ? null : seedToWrite, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"[wipe] Failed: {ex.Message}");
|
||||
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
|
||||
return new WipeResult(false, null, null, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadLevelName(string serverDir)
|
||||
{
|
||||
var path = Path.Combine(serverDir, "server.properties");
|
||||
if (!File.Exists(path)) return null;
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (line.StartsWith("level-name=", StringComparison.Ordinal))
|
||||
return line.Substring("level-name=".Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Caddyfile for the brass-sigil-server web panel.
|
||||
#
|
||||
# Caddy auto-fetches and renews a Let's Encrypt cert for your domain,
|
||||
# so HTTPS just works once DNS is pointed at the server and ports 80 + 443
|
||||
# are open.
|
||||
#
|
||||
# Prereqs:
|
||||
# 1. A domain name (e.g. panel.example.com) with an A/AAAA record pointing
|
||||
# at this server's public IP. Let's Encrypt does NOT issue certs for
|
||||
# raw IPs -- you need a hostname.
|
||||
# 2. Inbound 80 (for the HTTP-01 ACME challenge) and 443 (for the panel)
|
||||
# open in your firewall and in any cloud security group.
|
||||
# 3. Caddy installed:
|
||||
# sudo apt install caddy # Debian / Ubuntu
|
||||
# brew install caddy # macOS
|
||||
# winget install CaddyServer.Caddy # Windows
|
||||
# 4. brass-sigil-server running on localhost:8080 with webHost: localhost
|
||||
# and webPassword set (use `brass-sigil-server set-password` if you
|
||||
# haven't already).
|
||||
#
|
||||
# Install:
|
||||
# Linux package: replace /etc/caddy/Caddyfile with this file, then
|
||||
# sudo systemctl reload caddy
|
||||
# Manual: caddy run --config Caddyfile
|
||||
|
||||
panel.example.com {
|
||||
encode gzip
|
||||
|
||||
reverse_proxy localhost:8080 {
|
||||
# SSE log stream uses chunked streaming responses -- Caddy must not
|
||||
# buffer them, otherwise console updates arrive in batches every minute
|
||||
# instead of in real time.
|
||||
flush_interval -1
|
||||
|
||||
# Pass the real client IP through. brass-sigil-server's ForwardedHeaders
|
||||
# middleware honours this so the per-IP login rate limit partitions
|
||||
# correctly (10 attempts / minute / IP).
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Sensible hardening defaults.
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
-Server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# /etc/apache2/sites-available/bns-admin.sijbers.uk.conf
|
||||
#
|
||||
# Reverse-proxy vhost for the brass-sigil-server admin panel.
|
||||
# certbot will manage the SSL config (certificate paths, redirect from :80, etc.)
|
||||
# when run as: sudo certbot --apache -d bns-admin.sijbers.uk
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName bns-admin.sijbers.uk
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
|
||||
# SSE log stream needs streaming responses, not buffered ones.
|
||||
SetEnv no-gzip 1
|
||||
SetEnv proxy-sendcl 1
|
||||
|
||||
# `flushpackets=on` is the SSE-critical bit on Apache -- pushes each chunk
|
||||
# straight through instead of batching for ~60 s.
|
||||
ProxyPass / http://127.0.0.1:8080/ flushpackets=on keepalive=On
|
||||
ProxyPassReverse / http://127.0.0.1:8080/
|
||||
|
||||
# So brass-sigil-server's rate limiter sees the real client IP, not 127.0.0.1.
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/bns-admin.sijbers.uk-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/bns-admin.sijbers.uk-access.log combined
|
||||
</VirtualHost>
|
||||
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Brass & Sigil Launcher -- Matt Sijbers</title>
|
||||
<meta name="description" content="A private custom Minecraft Java Edition launcher for the Brass & Sigil modpack -- built in C# / .NET 8 / Avalonia." />
|
||||
<link rel="icon" href="/images/favicon-light.ico" media="(prefers-color-scheme: light)" type="image/x-icon" />
|
||||
<link rel="icon" href="/images/favicon-dark.ico" media="(prefers-color-scheme: dark)" type="image/x-icon" />
|
||||
<link rel="stylesheet" href="css/matt.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" />
|
||||
<style>
|
||||
html, body {
|
||||
background: var(--color-primary-darker-10);
|
||||
overflow: auto;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
max-height: none;
|
||||
}
|
||||
.page-wrap {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(20px, 5vw, 60px) clamp(16px, 5vw, 32px) 80px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.hero {
|
||||
padding: clamp(20px, 4vw, 36px);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(1.6em, 6vw, 2.6em);
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.hero .subtitle {
|
||||
color: var(--color-secondary-text);
|
||||
font-size: clamp(0.9em, 3vw, 1.1em);
|
||||
margin: 0;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.tag {
|
||||
background: var(--color-primary-darker-20);
|
||||
color: var(--color-primary-text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--color-accent);
|
||||
color: #fff !important;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s linear, transform 0.1s linear;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--color-accent-lighter-10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.action-btn.secondary {
|
||||
background: var(--color-primary-darker-20);
|
||||
color: var(--color-primary-text) !important;
|
||||
}
|
||||
.action-btn.secondary:hover {
|
||||
background: var(--color-primary-darker-30);
|
||||
}
|
||||
.whitelist-note {
|
||||
margin: 18px 0 0;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-primary-darker-10);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
border-radius: 6px;
|
||||
color: var(--color-secondary-text);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.whitelist-note i { color: var(--color-accent); margin-right: 6px; }
|
||||
.section {
|
||||
padding: clamp(18px, 3.5vw, 28px);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section p {
|
||||
line-height: 1.55;
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.fact {
|
||||
background: var(--color-primary-darker-10);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.fact .label {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-secondary-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.fact .value {
|
||||
font-family: "Ubuntu", sans-serif;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary-text);
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.facts { grid-template-columns: 1fr; }
|
||||
}
|
||||
ul.feature-list {
|
||||
padding-left: 1.25em;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
ul.feature-list li {
|
||||
color: var(--color-primary-text);
|
||||
line-height: 1.5;
|
||||
margin: 4px 0;
|
||||
}
|
||||
footer.page-footer {
|
||||
color: var(--color-secondary-text);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page-wrap">
|
||||
<a href="/matt#projects" class="back-link"><i class="fa fa-arrow-left"></i> Back to portfolio</a>
|
||||
|
||||
<div class="bevel hero">
|
||||
<h1>Brass & Sigil Launcher</h1>
|
||||
<p class="subtitle">A private custom Minecraft Java Edition launcher for a small friend group.</p>
|
||||
<div class="tag-row">
|
||||
<span class="tag">C# / .NET 8</span>
|
||||
<span class="tag">Avalonia</span>
|
||||
<span class="tag">CmlLib.Core</span>
|
||||
<span class="tag">Single-file Windows</span>
|
||||
<span class="tag">Private project</span>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<a class="action-btn" href="/pack/BrassAndSigil-Launcher.exe">
|
||||
<i class="fa fa-download"></i> Download launcher
|
||||
</a>
|
||||
<a class="action-btn secondary" href="https://sijbers.uk:8443/projects/BS/repos/brass-and-sigil-launcher/browse"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<i class="fa fa-code"></i> View source code
|
||||
</a>
|
||||
<a class="action-btn secondary" href="/matt#projects">
|
||||
<i class="fa fa-user"></i> Developer portfolio
|
||||
</a>
|
||||
</div>
|
||||
<p class="whitelist-note">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
You'll need to be whitelisted on the server to actually join — message Matt with your Minecraft username.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bevel section">
|
||||
<h2>About the project</h2>
|
||||
<p>
|
||||
Brass & Sigil is a private Minecraft modpack centred on the Create mod, aeronautics, tech and magic mods,
|
||||
with Distant Horizons for far-render exploration. This launcher is the desktop client built specifically
|
||||
to distribute the pack to a small friend group (under 50 players, no public release).
|
||||
</p>
|
||||
<p>
|
||||
The launcher is a native Windows application written in C# on .NET 8, using the Avalonia UI framework.
|
||||
It ships as a single self-contained executable so friends can just download and run it — no installer,
|
||||
no .NET runtime to install, no separate config files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bevel section">
|
||||
<h2>What it does</h2>
|
||||
<ul class="feature-list">
|
||||
<li>Fetches a JSON manifest from a self-hosted server and syncs the modpack files (mods, configs, resourcepacks) to the player's local install directory, using SHA-1 hashing so only changed files are downloaded.</li>
|
||||
<li>Authenticates the player with their own personal Microsoft account via the standard MSAL OAuth flow, so only legitimate Minecraft Java Edition owners can sign in.</li>
|
||||
<li>Installs the right Minecraft version and Forge loader, then launches the game with the configured memory and the player's session.</li>
|
||||
<li>Auto-updates the modpack on every launch when the manifest changes — players never have to manually install or update mods.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bevel section">
|
||||
<h2>Technical details</h2>
|
||||
<div class="facts">
|
||||
<div class="fact">
|
||||
<p class="label">Language</p>
|
||||
<p class="value">C# (.NET 8)</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<p class="label">UI Framework</p>
|
||||
<p class="value">Avalonia 12</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<p class="label">Minecraft auth</p>
|
||||
<p class="value">CmlLib.Core.Auth.Microsoft</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<p class="label">Game launching</p>
|
||||
<p class="value">CmlLib.Core 4.x + Forge installer</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<p class="label">Distribution</p>
|
||||
<p class="value">Single-file self-contained .exe</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<p class="label">Audience</p>
|
||||
<p class="value">Private friend group (< 50)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bevel section">
|
||||
<h2>Privacy & data</h2>
|
||||
<p>
|
||||
The launcher does not collect, store, or transmit any user data beyond what the standard Microsoft and
|
||||
Minecraft authentication flows require. Auth tokens are cached locally on the player's machine via the
|
||||
MSAL token cache — no telemetry, no analytics, no third-party services beyond Microsoft and Mojang.
|
||||
</p>
|
||||
<p>
|
||||
The modpack manifest and mod files are served from a self-hosted Linux server that I personally operate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bevel section">
|
||||
<h2>Status</h2>
|
||||
<p>
|
||||
Active development. The launcher is functional end-to-end (manifest sync, Microsoft auth, Forge install,
|
||||
game launch) and is currently being prepared for distribution to a small group of friends.
|
||||
</p>
|
||||
<p>
|
||||
Source code is publicly available, MIT-licensed, on my self-hosted Bitbucket:
|
||||
<a href="https://sijbers.uk:8443/projects/BS/repos/brass-and-sigil-launcher/browse"
|
||||
target="_blank" rel="noopener noreferrer">sijbers.uk:8443/.../brass-and-sigil-launcher</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bevel section" style="text-align: center; font-size: 0.85em;">
|
||||
<p style="margin: 0; color: var(--color-secondary-text); letter-spacing: 0.04em;">
|
||||
NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer class="page-footer">
|
||||
Brass & Sigil Launcher — a private project by <a href="/matt">Matt Sijbers</a>.
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Brass & Sigil Minecraft server (with web admin panel)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=matt
|
||||
Group=matt
|
||||
WorkingDirectory=/home/matt/brass-sigil-server
|
||||
ExecStart=/home/matt/brass-sigil-server/brass-sigil-server run
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
# Give the JVM enough room to start up gracefully on Stop (sends "stop" to MC,
|
||||
# waits for clean shutdown, then escalates to SIGTERM/SIGKILL).
|
||||
TimeoutStopSec=60s
|
||||
KillMode=mixed
|
||||
|
||||
# Tighten attack surface -- typical for a service running as a regular user.
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sudo cleanup script -- review before running.
|
||||
# Removes dormant game servers (ARK, Valheim, Terraria), trims journald logs,
|
||||
# caps snap revision retention. Does NOT touch Bitbucket, TeamViewer, or the
|
||||
# matt-wakeford.co.uk / sijbers.uk / diceheart.com websites.
|
||||
#
|
||||
# Run with: sudo bash /tmp/cleanup-sudo.sh
|
||||
#
|
||||
# Set -e: abort on first error so we don't cascade-damage state.
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Pre-cleanup disk ==="
|
||||
df -h / | grep -v Filesystem
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 1. ARK server -- install, user, broken systemd unit
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== ARK ==="
|
||||
if systemctl list-unit-files ark.service &>/dev/null; then
|
||||
echo " Disabling ark.service"
|
||||
systemctl disable --now ark.service 2>/dev/null || true
|
||||
fi
|
||||
if [[ -f /lib/systemd/system/ark.service ]]; then
|
||||
echo " Removing /lib/systemd/system/ark.service"
|
||||
rm -f /lib/systemd/system/ark.service
|
||||
fi
|
||||
if [[ -f /etc/systemd/system/ark.service ]]; then
|
||||
echo " Removing /etc/systemd/system/ark.service"
|
||||
rm -f /etc/systemd/system/ark.service
|
||||
fi
|
||||
if id ark &>/dev/null; then
|
||||
# Make sure no processes are running as ark before userdel.
|
||||
pkill -u ark 2>/dev/null || true
|
||||
sleep 1
|
||||
echo " Removing 'ark' user + home"
|
||||
userdel -r ark 2>/dev/null || userdel ark
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 2. Valheim (vhserver) -- LGSM stack
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Valheim (vhserver) ==="
|
||||
if id vhserver &>/dev/null; then
|
||||
pkill -u vhserver 2>/dev/null || true
|
||||
sleep 1
|
||||
echo " Removing 'vhserver' user + home (incl. orphaned LGSM backups)"
|
||||
userdel -r vhserver 2>/dev/null || userdel vhserver
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 3. Terraria -- empty home, never used
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Terraria ==="
|
||||
if id Terraria &>/dev/null; then
|
||||
pkill -u Terraria 2>/dev/null || true
|
||||
echo " Removing 'Terraria' user + home"
|
||||
userdel -r Terraria 2>/dev/null || userdel Terraria
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 4. systemd reload to forget the gone units
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== systemctl daemon-reload ==="
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed 2>/dev/null || true
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 5. journald -- cap to 500 MB
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== journald ==="
|
||||
echo " Trimming /var/log/journal to 500 MB"
|
||||
journalctl --vacuum-size=500M
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 6. snap -- only keep current + 1 prior revision
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== snap retention ==="
|
||||
echo " Setting refresh.retain=2 (snap will auto-clean older revs over time)"
|
||||
snap set system refresh.retain=2
|
||||
|
||||
# Force-clean orphaned _old.snap files older than 30 days. Snap will redo
|
||||
# this organically but we can prod it now to reclaim immediately.
|
||||
echo " Forcing snap to drop disabled revisions"
|
||||
for snap in $(snap list --all | awk '/disabled/{print $1, $3}'); do
|
||||
if [[ "$snap" == "disabled" ]]; then continue; fi
|
||||
done
|
||||
# Easier path: ask snap directly.
|
||||
LANG=C snap list --all | awk '/disabled/{print $1, $3}' | \
|
||||
while read -r name rev; do
|
||||
echo " snap remove --revision=$rev $name"
|
||||
snap remove --revision="$rev" "$name" || true
|
||||
done
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
echo
|
||||
echo "=== Post-cleanup disk ==="
|
||||
df -h / | grep -v Filesystem
|
||||
echo
|
||||
echo "Done. Bitbucket, TeamViewer, and websites untouched."
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "Brass and Sigil",
|
||||
"version": "0.6.1",
|
||||
"minecraft": {
|
||||
"version": "1.21.1"
|
||||
},
|
||||
"loader": {
|
||||
"type": "neoforge",
|
||||
"version": "21.1.228"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "mods/create-1.21.1-6.0.10.jar",
|
||||
"url": "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar",
|
||||
"sha1": "0e97e49837bed766e6f28a4c95b04885d6acc353",
|
||||
"size": 19123767
|
||||
},
|
||||
{
|
||||
"path": "mods/create-aeronautics-bundled-1.21.1-1.2.1.jar",
|
||||
"url": "https://cdn.modrinth.com/data/oWaK0Q19/versions/YhZLrAFC/create-aeronautics-bundled-1.21.1-1.2.1.jar",
|
||||
"sha1": "fdf1ae69e8b6437e0196b3a35dd2325aa904aba9",
|
||||
"size": 33030286
|
||||
},
|
||||
{
|
||||
"path": "mods/sable-neoforge-1.21.1-1.2.2.jar",
|
||||
"url": "https://cdn.modrinth.com/data/T9PomCSv/versions/3FMsUjO4/sable-neoforge-1.21.1-1.2.2.jar",
|
||||
"sha1": "c5ecd3fcf60a31d84112c708abe29e341b2d1b73",
|
||||
"size": 12719293
|
||||
},
|
||||
{
|
||||
"path": "mods/createbigcannons-5.11.3+mc.1.21.1.jar",
|
||||
"url": "https://cdn.modrinth.com/data/GWp4jCJj/versions/bsGaXKEd/createbigcannons-5.11.3%2Bmc.1.21.1.jar",
|
||||
"sha1": "8b61fa850e260bdeb5d360576123f98c260afa50",
|
||||
"size": 3715787
|
||||
},
|
||||
{
|
||||
"path": "mods/tfmg-1.2.0.jar",
|
||||
"url": "https://cdn.modrinth.com/data/USgVjXsk/versions/uDi14nbt/tfmg-1.2.0.jar",
|
||||
"sha1": "b520f3687f60a69eb265ff5b9a16759b9e124103",
|
||||
"size": 4924243
|
||||
},
|
||||
{
|
||||
"path": "mods/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
|
||||
"url": "https://cdn.modrinth.com/data/uCdwusMi/versions/KkaaQtTD/DistantHorizons-3.0.2-b-1.21.1-fabric-neoforge.jar",
|
||||
"sha1": "1ff0a8920e52add541471f7b32d0d389997145ba",
|
||||
"size": 30019727
|
||||
},
|
||||
{
|
||||
"path": "mods/sodium-neoforge-0.6.13+mc1.21.1.jar",
|
||||
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/Pb3OXVqC/sodium-neoforge-0.6.13%2Bmc1.21.1.jar",
|
||||
"sha1": "38af70fa4dc4b2aaac636e92fdba3bedd5a025e1",
|
||||
"size": 1162994
|
||||
},
|
||||
{
|
||||
"path": "mods/iris-neoforge-1.8.12+mc1.21.1.jar",
|
||||
"url": "https://cdn.modrinth.com/data/YL57xq9U/versions/t3ruzodq/iris-neoforge-1.8.12%2Bmc1.21.1.jar",
|
||||
"sha1": "a3e6355915c7d3b2bc392724795113e51d289378",
|
||||
"size": 2438548
|
||||
},
|
||||
{
|
||||
"path": "mods/modernfix-neoforge-5.27.4+mc1.21.1.jar",
|
||||
"url": "https://cdn.modrinth.com/data/nmDcB62a/versions/6U8JVjdw/modernfix-neoforge-5.27.4%2Bmc1.21.1.jar",
|
||||
"sha1": "2f39363f0d6d5a5ccc2a9e0f50ad3385611c3cb7",
|
||||
"size": 562051
|
||||
},
|
||||
{
|
||||
"path": "mods/ferritecore-7.0.3-neoforge.jar",
|
||||
"url": "https://cdn.modrinth.com/data/uXXizFIs/versions/x7kQWVju/ferritecore-7.0.3-neoforge.jar",
|
||||
"sha1": "9563692efb708b6b568df27a01ec52f6311928ef",
|
||||
"size": 121559
|
||||
},
|
||||
{
|
||||
"path": "mods/architectury-13.0.8-neoforge.jar",
|
||||
"url": "https://cdn.modrinth.com/data/lhGA9TYQ/versions/ZxYGwlk0/architectury-13.0.8-neoforge.jar",
|
||||
"sha1": "6ca11d3cc136bf69bb8f4d56982481eb85b5100b",
|
||||
"size": 584004
|
||||
},
|
||||
{
|
||||
"path": "mods/rhino-2101.2.7-build.81.jar",
|
||||
"url": "https://cdn.modrinth.com/data/sk9knFPE/versions/ZdLtebKH/rhino-2101.2.7-build.81.jar",
|
||||
"sha1": "480235a9f7749f68ce6fec3b9c3cac3428b92a4a",
|
||||
"size": 882033
|
||||
},
|
||||
{
|
||||
"path": "mods/ritchiesprojectilelib-2.1.2+mc.1.21.1-neoforge.jar",
|
||||
"url": "https://cdn.modrinth.com/data/B3pb093D/versions/hZ6B2Z0x/ritchiesprojectilelib-2.1.2%2Bmc.1.21.1-neoforge.jar",
|
||||
"sha1": "ec2e4996f8bee8714173e603e379fef8a6901765",
|
||||
"size": 76369
|
||||
},
|
||||
{
|
||||
"path": "mods/kubejs-neoforge-2101.7.2-build.363.jar",
|
||||
"url": "https://cdn.modrinth.com/data/umyGl7zF/versions/Fe9CjPws/kubejs-neoforge-2101.7.2-build.363.jar",
|
||||
"sha1": "d4e88254e8c26687d4c6aeb4dfa9c2ad70f260a2",
|
||||
"size": 2270442
|
||||
},
|
||||
{
|
||||
"path": "mods/jei-1.21.1-neoforge-19.27.0.340.jar",
|
||||
"url": "https://cdn.modrinth.com/data/u6dRKJwZ/versions/YAcQ6elZ/jei-1.21.1-neoforge-19.27.0.340.jar",
|
||||
"sha1": "27d0d85e7e32e926fc3664ab6815df5cdabb7941",
|
||||
"size": 1529391
|
||||
},
|
||||
{
|
||||
"path": "mods/Jade-1.21.1-NeoForge-15.10.5.jar",
|
||||
"url": "https://cdn.modrinth.com/data/nvQzSEkH/versions/yd8FKCmx/Jade-1.21.1-NeoForge-15.10.5.jar",
|
||||
"sha1": "d5bf134b3dbde9f5258666823900e21341dc0a50",
|
||||
"size": 725742
|
||||
},
|
||||
{
|
||||
"path": "mods/Chunky-NeoForge-1.4.23.jar",
|
||||
"url": "https://cdn.modrinth.com/data/fALzjamp/versions/LuFhm4eU/Chunky-NeoForge-1.4.23.jar",
|
||||
"sha1": "ab0c74743a653020fe2dfc4986b43e893947f3e9",
|
||||
"size": 340572
|
||||
},
|
||||
{
|
||||
"path": "mods/ftb-library-neoforge-2101.1.31.jar",
|
||||
"url": "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar",
|
||||
"sha1": "686d4e784c28c14f7760cc22b2de6a8573b56b74",
|
||||
"size": 1411181
|
||||
},
|
||||
{
|
||||
"path": "mods/ftb-teams-neoforge-2101.1.9.jar",
|
||||
"url": "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar",
|
||||
"sha1": "328e04bf1a445870aacea8fe7637670f84272a8f",
|
||||
"size": 291847
|
||||
},
|
||||
{
|
||||
"path": "mods/ftb-chunks-neoforge-2101.1.14.jar",
|
||||
"url": "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar",
|
||||
"sha1": "908b63b11d0e00ae6c9557d3fe6440bdbcf21bb7",
|
||||
"size": 642340
|
||||
}
|
||||
],
|
||||
"launcherVersion": "0.1.0",
|
||||
"launcherUrl": "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"manifestUrl": "https://sijbers.uk/pack/manifest.json",
|
||||
"serverDir": "./server",
|
||||
"javaPath": "java",
|
||||
"memoryMB": 8192,
|
||||
"webPort": 8080,
|
||||
"webHost": "localhost",
|
||||
"webPassword": null,
|
||||
"rconPort": 25575,
|
||||
"rconPassword": "",
|
||||
"acceptEula": false
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
|
||||
import { tickStatus, tickPlayers, tickWhitelist, refreshWhitelistSoon } from "./modules/panels.js";
|
||||
import { setupConsole } from "./modules/console.js";
|
||||
import { setupAutocomplete } from "./modules/autocomplete.js";
|
||||
import { setupWhitelistActions } from "./modules/whitelist.js";
|
||||
import { setupServerControls } from "./modules/serverControls.js";
|
||||
import { setupPregen } from "./modules/pregen.js";
|
||||
import { setupAuth } from "./modules/auth.js";
|
||||
import { setupUpdate } from "./modules/update.js";
|
||||
import { setupDanger } from "./modules/danger.js";
|
||||
import { setupBackup } from "./modules/backup.js";
|
||||
import { setupModalTriggers } from "./modules/modal.js";
|
||||
import { setupSettings } from "./modules/settings.js";
|
||||
import { setupMap } from "./modules/map.js";
|
||||
|
||||
setupModalTriggers();
|
||||
setupAuth();
|
||||
setupConsole();
|
||||
setupAutocomplete();
|
||||
setupWhitelistActions(refreshWhitelistSoon);
|
||||
setupServerControls();
|
||||
setupPregen();
|
||||
setupUpdate();
|
||||
setupBackup();
|
||||
setupDanger();
|
||||
setupSettings();
|
||||
setupMap();
|
||||
|
||||
// First paint
|
||||
tickStatus();
|
||||
tickPlayers();
|
||||
tickWhitelist();
|
||||
|
||||
// Polling cadence:
|
||||
// status 3 s -- pid/uptime/pack version (cheap, doesn't change much)
|
||||
// players 10 s -- RCON `list` call; players join/leave infrequently
|
||||
// whitelist 30 s -- file read; mostly relies on refresh-on-action via add/remove
|
||||
// (Logs are NOT polled -- they stream live via Server-Sent Events from /api/logs/stream.)
|
||||
setInterval(tickStatus, 3000);
|
||||
setInterval(tickPlayers, 10000);
|
||||
setInterval(tickWhitelist, 30000);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,379 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Brass & Sigil -- Server Panel</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
<img class="topbar-icon" src="/favicon.png" alt="" />
|
||||
<h1>BRASS & SIGIL -- SERVER</h1>
|
||||
</div>
|
||||
<div id="statusPill" class="status-pill"><span class="dot"></span><span id="statusText">Connecting…</span></div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
<div class="stat-row"><span class="key">PID</span><span id="pid" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Uptime</span><span id="uptime" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Pack</span><span id="packVersion" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Players</span><span id="playerCount" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">World</span><span id="worldSize" class="val">--</span></div>
|
||||
<div class="actions" style="margin-top: 14px;">
|
||||
<button id="btnStart" class="ghost-btn">Start</button>
|
||||
<button id="btnStop" class="danger">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Resources</h2>
|
||||
<div class="res-block">
|
||||
<div class="res-label"><span>Memory</span><span id="memUsage" class="res-val">--</span></div>
|
||||
<div class="res-bar"><div id="memBar"></div></div>
|
||||
</div>
|
||||
<div class="res-block">
|
||||
<div class="res-label"><span>CPU</span><span id="cpuCurrent" class="res-val">--</span></div>
|
||||
<div class="res-bar"><div id="cpuBar"></div></div>
|
||||
<div class="res-sub">
|
||||
<span>Peak (60s) <strong id="cpuMax">--</strong></span>
|
||||
<span>Avg (60s) <strong id="cpuAvg">--</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Players online</h2>
|
||||
<ul id="players" class="name-list">
|
||||
<li class="empty-state">No-one online</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Whitelist <span id="wlReqBadge" class="badge" hidden></span></h2>
|
||||
<div id="wlRequestsBlock" hidden>
|
||||
<div class="wl-req-label">Pending requests</div>
|
||||
<ul id="wlRequests" class="name-list"></ul>
|
||||
</div>
|
||||
<ul id="whitelist" class="name-list">
|
||||
<li class="empty-state">No players whitelisted yet</li>
|
||||
</ul>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
<div class="input-wrap">
|
||||
<input id="wlInput" type="text" placeholder="Add player by username…" autocomplete="off" maxlength="16" />
|
||||
</div>
|
||||
<button id="wlAdd">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
<h2>Console</h2>
|
||||
<div id="console" class="console-pane">Connecting to server log…</div>
|
||||
<div class="input-row">
|
||||
<div class="input-wrap">
|
||||
<div id="cmdGhost" class="ghost"></div>
|
||||
<input id="cmdInput" type="text"
|
||||
placeholder="Type a server command (e.g. say hello, op alice, whitelist add bob)…"
|
||||
autocomplete="off" />
|
||||
<div id="cmdSuggest" class="suggest-list"></div>
|
||||
</div>
|
||||
<button id="cmdSend">Send</button>
|
||||
</div>
|
||||
<div id="cmdHint" class="hint">
|
||||
<kbd>Tab</kbd> autocomplete · <kbd>↑</kbd>/<kbd>↓</kbd> history · <kbd>Esc</kbd> dismiss
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="aside-right">
|
||||
<div class="card">
|
||||
<h2>Account</h2>
|
||||
<div class="actions">
|
||||
<button id="acctChangePw" class="ghost-btn">Change password</button>
|
||||
<button id="acctLogout" class="ghost-btn">Log out</button>
|
||||
</div>
|
||||
<div id="acctChangeForm" class="acct-form" hidden>
|
||||
<div class="input-wrap" style="margin-top: 10px;">
|
||||
<input id="acctCurrent" type="password" placeholder="Current password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="input-wrap" style="margin-top: 8px;">
|
||||
<input id="acctNew" type="password" placeholder="New password (min 8)" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="input-wrap" style="margin-top: 8px;">
|
||||
<input id="acctConfirm" type="password" placeholder="Confirm new password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 10px;">
|
||||
<button id="acctSubmit">Update</button>
|
||||
<button id="acctCancel" class="ghost-btn">Cancel</button>
|
||||
</div>
|
||||
<div id="acctMsg" class="acct-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="updateCard" hidden>
|
||||
<h2>Modpack update</h2>
|
||||
<div id="updateInfo" class="update-info">
|
||||
<div class="stat-row"><span class="key">Current</span><span id="updCurrent" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Available</span><span id="updAvailable" class="val">--</span></div>
|
||||
</div>
|
||||
<p class="update-note">Updating restarts Minecraft. Players see a countdown banner then the server stops, syncs new mods, and starts again.</p>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
<div class="input-wrap">
|
||||
<input id="updDelay" type="number" min="0" max="3600" step="30" value="300" placeholder="Warning seconds" />
|
||||
</div>
|
||||
<button id="updStart">Update</button>
|
||||
</div>
|
||||
<div id="updProgress" class="update-progress" hidden>
|
||||
<div id="updPhaseLabel" class="update-phase">Idle</div>
|
||||
<div class="pg-progress-bar"><div id="updProgressFill"></div></div>
|
||||
<div id="updStatusText" class="update-status">--</div>
|
||||
<button id="updCancel" class="ghost-btn" hidden>Cancel countdown</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Server settings</h2>
|
||||
<div class="stat-row"><span class="key">MOTD</span><span id="ssMotd" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Difficulty</span><span id="ssDifficulty" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">View / Sim</span><span id="ssDistances" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Max players</span><span id="ssMaxPlayers" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Whitelist</span><span id="ssWhitelist" class="val">--</span></div>
|
||||
<button class="ghost-btn" style="width: 100%; margin-top: 10px;" data-open-modal="modalSettings">Edit settings</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>World</h2>
|
||||
<div class="trigger-list">
|
||||
<button class="ghost-btn" data-open-modal="modalPregen">
|
||||
<span>Pre-generate</span>
|
||||
<span id="pgBadge" class="badge" hidden></span>
|
||||
</button>
|
||||
<button class="ghost-btn" data-open-modal="modalBackup">
|
||||
<span>Backups</span>
|
||||
<span id="bkpBadge" class="badge"></span>
|
||||
</button>
|
||||
<button class="ghost-btn" data-open-modal="modalMap">
|
||||
<span>Map</span>
|
||||
<span id="mapBadge" class="badge" hidden></span>
|
||||
</button>
|
||||
<button class="ghost-btn" data-open-modal="modalWipe">
|
||||
<span>Wipe world</span>
|
||||
<span class="badge" style="color: var(--danger); border-color: #6a2814;">danger</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ── Modals ─────────────────────────────────────────────────────── -->
|
||||
|
||||
<div class="modal" id="modalPregen" hidden>
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h2>Pre-generate world</h2>
|
||||
<button class="modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 12px; line-height: 1.45;">
|
||||
Smooths Distant Horizons by generating chunks ahead of time.
|
||||
Run once after first start; takes a while (be patient -- server keeps running).
|
||||
</p>
|
||||
<div class="input-row" style="margin-top: 4px;">
|
||||
<div class="input-wrap">
|
||||
<input id="pgRadius" type="number" min="100" max="20000" step="100" value="1000" placeholder="Radius (blocks)" />
|
||||
</div>
|
||||
<button id="pgStart">Start</button>
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 8px;">
|
||||
<button id="pgPause" class="ghost-btn">Pause</button>
|
||||
<button id="pgContinue" class="ghost-btn">Resume</button>
|
||||
<button id="pgCancel" class="danger">Cancel</button>
|
||||
</div>
|
||||
<div class="pg-status">
|
||||
<div class="stat-row"><span class="key">State</span><span id="pgState" class="val">Idle</span></div>
|
||||
<div class="pg-progress-bar"><div id="pgProgressFill"></div></div>
|
||||
<div class="stat-row"><span class="key">Progress</span><span id="pgProgressText" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Chunks</span><span id="pgChunks" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">Rate</span><span id="pgRate" class="val">--</span></div>
|
||||
<div class="stat-row"><span class="key">ETA</span><span id="pgEta" class="val">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modalBackup" hidden>
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h2>Backups</h2>
|
||||
<button class="modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="stat-row" style="font-size: 11px;">
|
||||
<span class="key">Stored at</span><span id="backupDir" class="val" style="font-size: 11px;">--</span>
|
||||
</div>
|
||||
<div class="stat-row" style="font-size: 11px;">
|
||||
<span class="key">Schedule</span><span id="backupSchedule" class="val" style="font-size: 11px;">--</span>
|
||||
</div>
|
||||
<div class="stat-row" style="font-size: 11px;">
|
||||
<span class="key">Next run</span><span id="backupNext" class="val" style="font-size: 11px;">--</span>
|
||||
</div>
|
||||
<div class="stat-row" style="font-size: 11px;">
|
||||
<span class="key">Keep</span><span id="backupKeep" class="val" style="font-size: 11px;">--</span>
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<button id="bkpEditSchedule" class="ghost-btn" style="flex: 1;">Edit schedule</button>
|
||||
<button id="bkpCreate">Create now</button>
|
||||
</div>
|
||||
<div id="bkpScheduleForm" class="acct-form" hidden style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--card-edge);">
|
||||
<div class="input-row">
|
||||
<div class="input-wrap" style="flex: 1;">
|
||||
<input id="bkpScheduleInput" type="text" placeholder="04:00 | 04:00,16:00 | every 6h | every 30m" />
|
||||
</div>
|
||||
<div class="input-wrap" style="width: 80px; flex: 0 0 80px;">
|
||||
<input id="bkpKeepInput" type="number" min="1" max="365" placeholder="Keep" />
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: 11px; color: var(--text-muted); margin: 8px 0 0; line-height: 1.45;">
|
||||
Hourly = ~24 backups/day. Each backup pauses world saves for a few seconds.
|
||||
For hourly retention, raise <em>keep</em> to 48+. Empty schedule disables auto-backups.
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 8px;">
|
||||
<button id="bkpScheduleSave">Save</button>
|
||||
<button id="bkpScheduleCancel" class="ghost-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="bkpList" class="name-list" style="margin-top: 12px;">
|
||||
<li class="empty-state">No backups yet</li>
|
||||
</ul>
|
||||
<div id="bkpMsg" class="acct-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modalSettings" hidden>
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog" style="max-width: 560px;">
|
||||
<div class="modal-header">
|
||||
<h2>Server settings</h2>
|
||||
<button class="modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px;">
|
||||
These map to <code>server.properties</code>. MC reads them at startup, so changes need a server restart to take effect.
|
||||
</p>
|
||||
<div class="settings-grid">
|
||||
<label>MOTD<input id="ssfMotd" type="text" /></label>
|
||||
<label>Gamemode<select id="ssfGamemode">
|
||||
<option>survival</option><option>creative</option><option>adventure</option><option>spectator</option>
|
||||
</select></label>
|
||||
<label>Difficulty<select id="ssfDifficulty">
|
||||
<option>peaceful</option><option>easy</option><option>normal</option><option>hard</option>
|
||||
</select></label>
|
||||
<label>View distance<input id="ssfViewDistance" type="number" min="3" max="32" step="1" /></label>
|
||||
<label>Sim distance<input id="ssfSimulationDistance" type="number" min="3" max="32" step="1" /></label>
|
||||
<label>Max players<input id="ssfMaxPlayers" type="number" min="1" max="200" step="1" /></label>
|
||||
<label>Spawn protection<input id="ssfSpawnProtection" type="number" min="0" max="64" step="1" /></label>
|
||||
</div>
|
||||
<div class="settings-checks">
|
||||
<label class="danger-row"><input id="ssfPvp" type="checkbox" /><span>PvP</span></label>
|
||||
<label class="danger-row"><input id="ssfHardcore" type="checkbox" /><span>Hardcore</span></label>
|
||||
<label class="danger-row"><input id="ssfAllowFlight" type="checkbox" /><span>Allow flight</span></label>
|
||||
<label class="danger-row"><input id="ssfWhiteList" type="checkbox" /><span>Whitelist enabled</span></label>
|
||||
<label class="danger-row"><input id="ssfEnforceWhitelist" type="checkbox" /><span>Enforce whitelist</span></label>
|
||||
<label class="danger-row"><input id="ssfEnableCommandBlock" type="checkbox" /><span>Enable command blocks</span></label>
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 14px;">
|
||||
<button id="ssSave" style="flex: 1;">Save</button>
|
||||
<button id="ssRestart" class="ghost-btn">Save & restart</button>
|
||||
</div>
|
||||
<div id="ssMsg" class="acct-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modalMap" hidden>
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h2>World map</h2>
|
||||
<button class="modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px; line-height: 1.45;">
|
||||
Renders the world to a browsable 3D map (BlueMap). Runs as a separate process -- no impact on the live MC server.
|
||||
First render of a 5000-block area takes 2-6 hours; subsequent renders are incremental and much faster.
|
||||
</p>
|
||||
<div class="stat-row"><span class="key">Phase</span><span id="mapPhase" class="val">Idle</span></div>
|
||||
<div class="stat-row"><span class="key">Last log</span><span id="mapLastLog" class="val" style="font-size: 10px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;">--</span></div>
|
||||
<div class="actions" style="margin-top: 14px;">
|
||||
<button id="mapRender" style="flex: 1;">Render now</button>
|
||||
<button id="mapCancel" class="danger" hidden>Cancel</button>
|
||||
<button id="mapOpen" class="ghost-btn">Open map ↗</button>
|
||||
</div>
|
||||
<div id="mapMsg" class="acct-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modalWipe" hidden>
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog danger">
|
||||
<div class="modal-header">
|
||||
<h2>Wipe world</h2>
|
||||
<button class="modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p class="danger-note">
|
||||
Wipes the world directory and restarts the server with a fresh world.
|
||||
With "Back up first" ticked, the old world is archived to your backup directory before deletion.
|
||||
Players see a 30-second urgent warning before the wipe begins.
|
||||
</p>
|
||||
<label class="danger-row">
|
||||
<input id="wipeBackup" type="checkbox" checked />
|
||||
<span>Back up current world before wiping</span>
|
||||
</label>
|
||||
|
||||
<div class="danger-section">
|
||||
<div class="danger-section-title">World seed</div>
|
||||
<div class="danger-row" style="margin-bottom: 6px;">
|
||||
<span>Current:</span>
|
||||
<code id="wipeCurrentSeed" style="margin-left: 8px;">loading...</code>
|
||||
</div>
|
||||
<label class="danger-row">
|
||||
<input type="radio" name="wipeSeedMode" value="random" checked />
|
||||
<span>Random new seed (Minecraft picks one)</span>
|
||||
</label>
|
||||
<label class="danger-row">
|
||||
<input type="radio" name="wipeSeedMode" value="keep" />
|
||||
<span>Keep current seed (regenerate identical world)</span>
|
||||
</label>
|
||||
<label class="danger-row">
|
||||
<input type="radio" name="wipeSeedMode" value="custom" />
|
||||
<span>Custom seed:</span>
|
||||
<input id="wipeCustomSeed" type="text" placeholder="e.g. 12345 or 'a phrase'"
|
||||
style="margin-left: 8px; flex: 1;" disabled />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button id="wipeBtn" class="danger" style="margin-top: 14px; width: 100%;">Wipe world</button>
|
||||
<div id="wipeMsg" class="acct-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">brass-sigil-server v0.1 -- embedded panel</div>
|
||||
|
||||
<div id="loginOverlay" class="login-overlay" hidden>
|
||||
<div class="login-box">
|
||||
<h2>Brass & Sigil</h2>
|
||||
<p>Sign in to manage the server.</p>
|
||||
<div class="input-wrap">
|
||||
<input id="loginPassword" type="password" autocomplete="current-password" placeholder="Password" />
|
||||
</div>
|
||||
<button id="loginSubmit">Sign in</button>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
// Tiny JSON API helper used by every module.
|
||||
"use strict";
|
||||
|
||||
export async function api(path, opts) {
|
||||
const res = await fetch(path, opts);
|
||||
if (res.status === 401) {
|
||||
// Auth cookie missing or wrong. Surface to the auth module which
|
||||
// shows the login overlay; the caller still gets an error so any
|
||||
// calling code stops cleanly.
|
||||
document.dispatchEvent(new CustomEvent("authrequired"));
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function apiJson(path, body, method = "POST") {
|
||||
return api(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, c =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Login overlay + Account panel (logout / change password).
|
||||
//
|
||||
// Cookie is set server-side as HttpOnly so JS never sees it -- that defeats
|
||||
// XSS-based exfiltration. We only briefly hold the password during input.
|
||||
"use strict";
|
||||
|
||||
// We deliberately don't import apiJson here -- change-password returns its
|
||||
// own error messages and we want to surface them verbatim to the user.
|
||||
|
||||
let overlayShown = false;
|
||||
function showOverlay() {
|
||||
if (overlayShown) return;
|
||||
overlayShown = true;
|
||||
const overlay = document.getElementById("loginOverlay");
|
||||
if (overlay) {
|
||||
overlay.hidden = false;
|
||||
document.getElementById("loginPassword")?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function setupAuth() {
|
||||
document.addEventListener("authrequired", showOverlay);
|
||||
setupLoginForm();
|
||||
setupAccountPanel();
|
||||
}
|
||||
|
||||
function setupLoginForm() {
|
||||
const overlay = document.getElementById("loginOverlay");
|
||||
const input = document.getElementById("loginPassword");
|
||||
const button = document.getElementById("loginSubmit");
|
||||
const errorEl = document.getElementById("loginError");
|
||||
if (!overlay || !input || !button || !errorEl) return;
|
||||
|
||||
async function tryLogin() {
|
||||
const pw = input.value;
|
||||
if (!pw) return;
|
||||
errorEl.textContent = "";
|
||||
button.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: pw }),
|
||||
});
|
||||
if (res.status === 401) { errorEl.textContent = "Wrong password."; input.select(); return; }
|
||||
if (res.status === 429) { errorEl.textContent = "Too many attempts. Wait a minute."; return; }
|
||||
if (!res.ok) { errorEl.textContent = `Error ${res.status}`; return; }
|
||||
// Server set the cookie. Reload so SSE / pollers pick it up.
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener("click", tryLogin);
|
||||
input.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); });
|
||||
}
|
||||
|
||||
function setupAccountPanel() {
|
||||
const logoutBtn = document.getElementById("acctLogout");
|
||||
const changeBtn = document.getElementById("acctChangePw");
|
||||
const form = document.getElementById("acctChangeForm");
|
||||
const cur = document.getElementById("acctCurrent");
|
||||
const nxt = document.getElementById("acctNew");
|
||||
const cnf = document.getElementById("acctConfirm");
|
||||
const submit = document.getElementById("acctSubmit");
|
||||
const cancel = document.getElementById("acctCancel");
|
||||
const msg = document.getElementById("acctMsg");
|
||||
if (!logoutBtn || !changeBtn) return;
|
||||
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Log out of the panel?")) return;
|
||||
try { await fetch("/api/auth/logout", { method: "POST" }); }
|
||||
finally { location.reload(); }
|
||||
});
|
||||
|
||||
changeBtn.addEventListener("click", () => {
|
||||
form.hidden = !form.hidden;
|
||||
msg.textContent = "";
|
||||
if (!form.hidden) cur.focus();
|
||||
});
|
||||
cancel.addEventListener("click", () => {
|
||||
form.hidden = true;
|
||||
cur.value = nxt.value = cnf.value = "";
|
||||
msg.textContent = "";
|
||||
});
|
||||
|
||||
submit.addEventListener("click", async () => {
|
||||
msg.className = "acct-msg";
|
||||
msg.textContent = "";
|
||||
if (nxt.value.length < 8) { msg.textContent = "New password must be at least 8 characters."; return; }
|
||||
if (nxt.value !== cnf.value) { msg.textContent = "New password and confirmation don't match."; return; }
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current: cur.value, next: nxt.value }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { msg.textContent = body.error || `Error ${res.status}`; return; }
|
||||
msg.className = "acct-msg ok";
|
||||
msg.textContent = "Password changed.";
|
||||
cur.value = nxt.value = cnf.value = "";
|
||||
setTimeout(() => { form.hidden = true; msg.textContent = ""; }, 1500);
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Tab-completion + suggestion dropdown for the console command input.
|
||||
// - Ghost text shows the top match inline (Tab to accept)
|
||||
// - A dropdown list shows up to N matches with their argument signature
|
||||
// (MC-style: <required> [optional] <a|b|c> for choices)
|
||||
// - Click a list item to insert it; arrow keys + Enter also navigate the list
|
||||
// when it's open
|
||||
"use strict";
|
||||
|
||||
import { state } from "./state.js";
|
||||
import { escapeHtml } from "./api.js";
|
||||
|
||||
const COMMANDS = [
|
||||
"help", "list", "say", "tell", "msg", "me", "w",
|
||||
"op", "deop",
|
||||
"whitelist", "ban", "ban-ip", "pardon", "pardon-ip", "banlist",
|
||||
"kick",
|
||||
"tp", "teleport",
|
||||
"give", "clear", "kill",
|
||||
"gamemode", "gamerule", "difficulty",
|
||||
"weather", "time", "seed", "spawnpoint", "setworldspawn",
|
||||
"save-all", "save-on", "save-off", "stop", "reload",
|
||||
"xp", "experience", "effect", "enchant",
|
||||
"summon", "data", "execute", "fill", "setblock", "locate", "tag",
|
||||
"ftbchunks", "ftbteams",
|
||||
"chunky",
|
||||
"kubejs", "kjs",
|
||||
];
|
||||
|
||||
const SUBCOMMANDS = {
|
||||
whitelist: ["add", "remove", "list", "reload", "on", "off"],
|
||||
gamemode: ["survival", "creative", "adventure", "spectator"],
|
||||
weather: ["clear", "rain", "thunder"],
|
||||
difficulty: ["peaceful", "easy", "normal", "hard"],
|
||||
time: ["set", "add", "query"],
|
||||
chunky: ["start", "cancel", "pause", "continue", "world", "shape", "center", "radius", "force_load", "force_unload", "trim", "help"],
|
||||
ftbchunks: ["claim", "unclaim", "load", "unload", "admin"],
|
||||
};
|
||||
|
||||
const TAKES_PLAYER_AT = {
|
||||
"op": 1, "deop": 1, "tp": 1, "teleport": 1, "kick": 1,
|
||||
"ban": 1, "pardon": 1, "kill": 1,
|
||||
"tell": 1, "msg": 1, "w": 1,
|
||||
"give": 1, "clear": 1, "effect": 1, "enchant": 1, "xp": 1, "experience": 1,
|
||||
"whitelist add": 2, "whitelist remove": 2,
|
||||
"gamemode survival": 2, "gamemode creative": 2, "gamemode adventure": 2, "gamemode spectator": 2,
|
||||
};
|
||||
|
||||
// MC-style argument signatures for each command. Shown as a hint after the name
|
||||
// in the suggestion list. <required> [optional] <a|b|c> for enum choices.
|
||||
const SIGNATURES = {
|
||||
help: "[command]",
|
||||
list: "",
|
||||
say: "<message>",
|
||||
tell: "<player> <message>",
|
||||
msg: "<player> <message>",
|
||||
me: "<action>",
|
||||
w: "<player> <message>",
|
||||
op: "<player>",
|
||||
deop: "<player>",
|
||||
whitelist: "<add|remove|list|reload|on|off>",
|
||||
"whitelist add": "<player>",
|
||||
"whitelist remove": "<player>",
|
||||
"whitelist list": "",
|
||||
"whitelist on": "",
|
||||
"whitelist off": "",
|
||||
"whitelist reload": "",
|
||||
ban: "<player> [reason…]",
|
||||
"ban-ip": "<ip|player> [reason…]",
|
||||
pardon: "<player>",
|
||||
"pardon-ip": "<ip>",
|
||||
banlist: "[ips|players]",
|
||||
kick: "<player> [reason…]",
|
||||
tp: "<target> [destination]",
|
||||
teleport: "<target> [destination]",
|
||||
give: "<player> <item> [count]",
|
||||
clear: "[player] [item]",
|
||||
kill: "[target]",
|
||||
gamemode: "<mode> [player]",
|
||||
"gamemode survival": "[player]",
|
||||
"gamemode creative": "[player]",
|
||||
"gamemode adventure": "[player]",
|
||||
"gamemode spectator": "[player]",
|
||||
gamerule: "<rule> [value]",
|
||||
difficulty: "<peaceful|easy|normal|hard>",
|
||||
weather: "<clear|rain|thunder> [duration]",
|
||||
time: "<set|add|query> <value>",
|
||||
seed: "",
|
||||
spawnpoint: "[player] [pos]",
|
||||
setworldspawn: "[pos]",
|
||||
"save-all": "[flush]",
|
||||
"save-on": "",
|
||||
"save-off": "",
|
||||
stop: "",
|
||||
reload: "",
|
||||
xp: "<amount> [player]",
|
||||
experience: "<add|set|query> <player> <amount>",
|
||||
effect: "<give|clear> <player> <effect>",
|
||||
enchant: "<player> <enchantment> [level]",
|
||||
summon: "<entity> [pos]",
|
||||
fill: "<from> <to> <block>",
|
||||
setblock: "<pos> <block>",
|
||||
locate: "<biome|structure> <id>",
|
||||
tag: "<target> <add|remove|list> [tag]",
|
||||
chunky: "<start|cancel|pause|continue|world|shape|center|radius|trim|...>",
|
||||
"chunky start": "[world] [shape] [center_x] [center_z] [radius]",
|
||||
"chunky cancel": "",
|
||||
"chunky pause": "",
|
||||
"chunky continue": "",
|
||||
"chunky world": "<world>",
|
||||
"chunky shape": "<square|circle|...>",
|
||||
"chunky center": "<x> <z>",
|
||||
"chunky radius": "<radius>",
|
||||
"chunky trim": "[world] [radius] [trim_radius]",
|
||||
ftbchunks: "<claim|unclaim|load|unload|admin>",
|
||||
ftbteams: "<list|info|invite|...>",
|
||||
kubejs: "<reload|hand|stages|...>",
|
||||
kjs: "<reload|hand|stages|...>",
|
||||
};
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
let activeIndex = 0;
|
||||
let currentSuggestions = [];
|
||||
|
||||
export function setupAutocomplete() {
|
||||
const cmdInput = document.getElementById("cmdInput");
|
||||
const cmdGhost = document.getElementById("cmdGhost");
|
||||
const cmdSuggest = document.getElementById("cmdSuggest");
|
||||
|
||||
function refresh() {
|
||||
const v = cmdInput.value;
|
||||
currentSuggestions = computeAllSuggestions(v).slice(0, MAX_SUGGESTIONS);
|
||||
activeIndex = 0;
|
||||
|
||||
// Inline ghost = top suggestion (only if it extends what they typed)
|
||||
const top = currentSuggestions[0];
|
||||
if (top && top.text.startsWith(v) && top.text !== v) {
|
||||
const suffix = top.text.substring(v.length);
|
||||
cmdGhost.innerHTML = `<span class="typed">${escapeHtml(v)}</span>${escapeHtml(suffix)}`;
|
||||
cmdInput.dataset.suggestion = top.text;
|
||||
} else {
|
||||
cmdGhost.innerHTML = "";
|
||||
cmdInput.dataset.suggestion = "";
|
||||
}
|
||||
|
||||
renderList(cmdSuggest, currentSuggestions);
|
||||
}
|
||||
|
||||
cmdInput.addEventListener("input", refresh);
|
||||
cmdInput.addEventListener("focus", refresh);
|
||||
cmdInput.addEventListener("blur", () => {
|
||||
// Delay so a click on a list item registers before we hide
|
||||
setTimeout(() => cmdSuggest.classList.remove("show"), 150);
|
||||
});
|
||||
|
||||
cmdInput.addEventListener("keydown", e => {
|
||||
if (e.key === "Tab") {
|
||||
const sug = currentSuggestions[activeIndex];
|
||||
if (sug) {
|
||||
e.preventDefault();
|
||||
cmdInput.value = sug.text + " ";
|
||||
refresh();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
cmdGhost.innerHTML = "";
|
||||
cmdInput.dataset.suggestion = "";
|
||||
cmdSuggest.classList.remove("show");
|
||||
} else if (e.key === "ArrowDown" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % currentSuggestions.length;
|
||||
highlightActive(cmdSuggest);
|
||||
} else if (e.key === "ArrowUp" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
|
||||
highlightActive(cmdSuggest);
|
||||
}
|
||||
// Note: Enter is handled by console.js (sends the command)
|
||||
});
|
||||
|
||||
cmdSuggest.addEventListener("mousedown", e => {
|
||||
// mousedown (not click) so we beat the input blur handler
|
||||
const item = e.target.closest(".suggest-item");
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const idx = parseInt(item.dataset.idx, 10);
|
||||
const sug = currentSuggestions[idx];
|
||||
if (sug) {
|
||||
cmdInput.value = sug.text + " ";
|
||||
cmdInput.focus();
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function highlightActive(listEl) {
|
||||
[...listEl.querySelectorAll(".suggest-item")].forEach((el, i) => {
|
||||
el.classList.toggle("active", i === activeIndex);
|
||||
if (i === activeIndex) el.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(listEl, suggestions) {
|
||||
if (suggestions.length === 0) {
|
||||
listEl.classList.remove("show");
|
||||
listEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = suggestions.map((s, i) => {
|
||||
const args = s.args ? `<span class="args">${escapeHtml(s.args)}</span>` : "";
|
||||
return `<div class="suggest-item${i === activeIndex ? " active" : ""}" data-idx="${i}">` +
|
||||
`<span>${escapeHtml(s.text)}</span>${args}</div>`;
|
||||
}).join("");
|
||||
listEl.classList.add("show");
|
||||
}
|
||||
|
||||
// Returns an array of {text, args} suggestions ordered by relevance.
|
||||
// args is the MC-style hint shown next to the name.
|
||||
function computeAllSuggestions(input) {
|
||||
if (!input) return [];
|
||||
const stripped = input.startsWith("/") ? input.substring(1) : input;
|
||||
const tokens = stripped.split(" ");
|
||||
const partial = tokens[tokens.length - 1].toLowerCase();
|
||||
const completed = tokens.slice(0, -1);
|
||||
const prefix = input.startsWith("/") ? "/" : "";
|
||||
|
||||
// First token: command name
|
||||
if (completed.length === 0) {
|
||||
const matches = COMMANDS.filter(c => c.startsWith(partial)).sort();
|
||||
return matches.map(name => ({
|
||||
text: prefix + name,
|
||||
args: SIGNATURES[name] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// Subcommands
|
||||
const headLower = completed[0].toLowerCase();
|
||||
if (completed.length === 1 && SUBCOMMANDS[headLower]) {
|
||||
const subs = SUBCOMMANDS[headLower].filter(s => s.startsWith(partial));
|
||||
return subs.map(sub => ({
|
||||
text: prefix + [...completed, sub].join(" "),
|
||||
args: SIGNATURES[`${headLower} ${sub}`] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// Player-name positions
|
||||
const cmdKey = completed.join(" ").toLowerCase();
|
||||
const playerSlot = TAKES_PLAYER_AT[cmdKey];
|
||||
if (playerSlot !== undefined && tokens.length === playerSlot + 1) {
|
||||
const matches = state.knownPlayers.filter(p => p.toLowerCase().startsWith(partial));
|
||||
return matches.map(p => ({
|
||||
text: prefix + [...completed, p].join(" "),
|
||||
args: "",
|
||||
}));
|
||||
}
|
||||
|
||||
// No structured suggestion at this position -- but still show the current
|
||||
// command's signature as a contextual hint
|
||||
const sig = SIGNATURES[cmdKey];
|
||||
if (sig) {
|
||||
return [{ text: input, args: sig }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// World backup management -- list, create, restore, delete.
|
||||
//
|
||||
// Backups are server-online (no downtime) -- the daemon issues `save-all flush`
|
||||
// + `save-off`, archives the world, then `save-on`. Restore *does* stop the
|
||||
// server (it has to), and snapshots the current world to a `-prerestore-*` dir
|
||||
// before extracting so a wrong restore is recoverable.
|
||||
"use strict";
|
||||
|
||||
import { api } from "./api.js";
|
||||
|
||||
const els = {};
|
||||
let lastSchedule = null;
|
||||
let lastKeep = null;
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
}
|
||||
|
||||
function fmtRelativeFuture(iso) {
|
||||
if (!iso) return "--";
|
||||
const target = new Date(iso).getTime();
|
||||
const ms = target - Date.now();
|
||||
if (ms <= 0) return "imminent";
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `in ${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `in ${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
const rem = min % 60;
|
||||
if (hr < 24) return rem ? `in ${hr}h ${rem}m` : `in ${hr}h`;
|
||||
const days = Math.floor(hr / 24);
|
||||
return `in ${days}d ${hr % 24}h`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let data;
|
||||
try { data = await api("/api/backup/list"); }
|
||||
catch { return; }
|
||||
|
||||
els.dir.textContent = data.dir || "--";
|
||||
// Server returns a human description ("Daily at 04:00", "Every 6 hours", "Disabled").
|
||||
els.schedule.textContent = data.description || (data.schedule ? `Daily at ${data.schedule}` : "Disabled");
|
||||
els.next.textContent = data.nextRun ? fmtRelativeFuture(data.nextRun) : "--";
|
||||
els.keep.textContent = data.keep != null ? `${data.keep} latest` : "--";
|
||||
lastSchedule = data.schedule || "";
|
||||
lastKeep = data.keep ?? 14;
|
||||
|
||||
// Right-sidebar badge: count of backups
|
||||
const badge = document.getElementById("bkpBadge");
|
||||
if (badge) badge.textContent = data.backups?.length ? `${data.backups.length}` : "0";
|
||||
|
||||
if (!data.backups || data.backups.length === 0) {
|
||||
els.list.innerHTML = '<li class="empty-state">No backups yet</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.list.innerHTML = data.backups.map(b => `
|
||||
<li class="backup-item">
|
||||
<div class="backup-meta">
|
||||
<div class="backup-name">${escape(b.name)}</div>
|
||||
<div class="backup-sub">${fmtSize(b.sizeBytes)} · ${fmtDate(b.createdAt)}</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="ghost-btn bkp-restore" data-name="${escape(b.name)}">Restore</button>
|
||||
<button class="ghost-btn bkp-delete" data-name="${escape(b.name)}">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function escape(s) {
|
||||
return String(s).replace(/[&<>"']/g, c =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
|
||||
function showMsg(text, ok = false) {
|
||||
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
|
||||
els.msg.textContent = text;
|
||||
}
|
||||
|
||||
async function postJson(path, body) {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { ok: res.ok, status: res.status, body: await res.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
export function setupBackup() {
|
||||
els.dir = document.getElementById("backupDir");
|
||||
els.list = document.getElementById("bkpList");
|
||||
els.create = document.getElementById("bkpCreate");
|
||||
els.msg = document.getElementById("bkpMsg");
|
||||
els.schedule = document.getElementById("backupSchedule");
|
||||
els.next = document.getElementById("backupNext");
|
||||
els.keep = document.getElementById("backupKeep");
|
||||
els.editBtn = document.getElementById("bkpEditSchedule");
|
||||
els.form = document.getElementById("bkpScheduleForm");
|
||||
els.input = document.getElementById("bkpScheduleInput");
|
||||
els.keepInput = document.getElementById("bkpKeepInput");
|
||||
els.saveBtn = document.getElementById("bkpScheduleSave");
|
||||
els.cancelBtn = document.getElementById("bkpScheduleCancel");
|
||||
if (!els.create) return;
|
||||
|
||||
els.editBtn?.addEventListener("click", () => {
|
||||
els.form.hidden = !els.form.hidden;
|
||||
if (!els.form.hidden) {
|
||||
els.input.value = lastSchedule || "";
|
||||
els.keepInput.value = lastKeep ?? 14;
|
||||
els.input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
els.cancelBtn?.addEventListener("click", () => {
|
||||
els.form.hidden = true;
|
||||
showMsg("");
|
||||
});
|
||||
|
||||
els.saveBtn?.addEventListener("click", async () => {
|
||||
const sched = els.input.value.trim();
|
||||
const keep = parseInt(els.keepInput.value, 10);
|
||||
const r = await postJson("/api/backup/schedule", {
|
||||
schedule: sched,
|
||||
keep: Number.isFinite(keep) ? keep : undefined,
|
||||
});
|
||||
if (!r.ok || r.body.ok === false) {
|
||||
showMsg(r.body.error || `Error ${r.status}`);
|
||||
} else {
|
||||
showMsg(sched ? `Schedule saved: daily at ${sched}` : "Schedule disabled.", true);
|
||||
els.form.hidden = true;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
els.create.addEventListener("click", async () => {
|
||||
const reason = prompt("Optional reason / label for this backup (e.g. 'pre-update'). Leave blank for none:");
|
||||
if (reason === null) return; // user cancelled
|
||||
showMsg("Creating backup -- this may take a minute on a large world...");
|
||||
els.create.disabled = true;
|
||||
const r = await postJson("/api/backup/create", { reason: reason.trim() || null });
|
||||
els.create.disabled = false;
|
||||
if (!r.ok || r.body.ok === false) {
|
||||
showMsg(r.body.error || `Error ${r.status}`);
|
||||
} else {
|
||||
showMsg(`Backup created: ${r.body.name} (${fmtSize(r.body.sizeBytes)})`, true);
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
els.list.addEventListener("click", async e => {
|
||||
const restore = e.target.closest(".bkp-restore");
|
||||
const del = e.target.closest(".bkp-delete");
|
||||
if (restore) {
|
||||
const name = restore.dataset.name;
|
||||
if (!confirm(`Restore from ${name}?\n\nServer will stop, current world is moved to a "-prerestore" folder for safety, then the backup is extracted and server starts again.`))
|
||||
return;
|
||||
showMsg("Restoring -- this stops the server...");
|
||||
const r = await postJson("/api/backup/restore", { name });
|
||||
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
|
||||
else showMsg("Restore complete. Server is starting.", true);
|
||||
}
|
||||
if (del) {
|
||||
const name = del.dataset.name;
|
||||
if (!confirm(`Delete backup ${name}? This cannot be undone.`)) return;
|
||||
const r = await postJson("/api/backup/delete", { name });
|
||||
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
|
||||
else { showMsg("Deleted.", true); refresh(); }
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
// Backups don't change often; light poll to pick up new ones if scheduled
|
||||
// backups are added later, or just to refresh after an external mv/rm.
|
||||
setInterval(refresh, 30000);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Live log streaming via Server-Sent Events + command-input wiring.
|
||||
// EventSource gives us instant push (no 1-second polling lag) and reconnects
|
||||
// automatically if the connection drops.
|
||||
"use strict";
|
||||
|
||||
import { api, apiJson, escapeHtml } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
const consoleEl = () => document.getElementById("console");
|
||||
|
||||
export function setupConsole() {
|
||||
consoleEl().textContent = "Connecting to server log…";
|
||||
|
||||
const es = new EventSource("/api/logs/stream");
|
||||
let firstEvent = true;
|
||||
es.addEventListener("log", e => {
|
||||
if (firstEvent) { consoleEl().textContent = ""; firstEvent = false; }
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
const ts = new Date(d.t).toLocaleTimeString();
|
||||
const div = document.createElement("div");
|
||||
if (d.e) div.className = "err";
|
||||
div.textContent = `[${ts}] ${d.m}`;
|
||||
consoleEl().appendChild(div);
|
||||
consoleEl().scrollTop = consoleEl().scrollHeight;
|
||||
// Trim very old lines so the DOM doesn't grow unbounded
|
||||
while (consoleEl().childNodes.length > 5000) {
|
||||
consoleEl().removeChild(consoleEl().firstChild);
|
||||
}
|
||||
// Re-broadcast so other modules (e.g. pregen) can react to log lines
|
||||
// without opening a second SSE connection.
|
||||
document.dispatchEvent(new CustomEvent("serverlog", { detail: d }));
|
||||
} catch {}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// EventSource will retry automatically.
|
||||
};
|
||||
|
||||
// Command input
|
||||
const cmdInput = document.getElementById("cmdInput");
|
||||
document.getElementById("cmdSend").addEventListener("click", sendCommand);
|
||||
cmdInput.addEventListener("keydown", onCmdKeyDown);
|
||||
}
|
||||
|
||||
async function sendCommand() {
|
||||
const cmdInput = document.getElementById("cmdInput");
|
||||
const v = cmdInput.value.trim();
|
||||
if (!v) return;
|
||||
try {
|
||||
await apiJson("/api/command", { command: v });
|
||||
state.cmdHistory.push(v);
|
||||
state.cmdHistoryIdx = state.cmdHistory.length;
|
||||
cmdInput.value = "";
|
||||
cmdInput.dispatchEvent(new Event("input")); // refresh ghost text
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
function onCmdKeyDown(e) {
|
||||
const cmdInput = document.getElementById("cmdInput");
|
||||
if (e.key === "Enter") {
|
||||
sendCommand();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (state.cmdHistory.length === 0) return;
|
||||
e.preventDefault();
|
||||
state.cmdHistoryIdx = Math.max(0, state.cmdHistoryIdx - 1);
|
||||
cmdInput.value = state.cmdHistory[state.cmdHistoryIdx] || "";
|
||||
cmdInput.dispatchEvent(new Event("input"));
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (state.cmdHistory.length === 0) return;
|
||||
e.preventDefault();
|
||||
state.cmdHistoryIdx = Math.min(state.cmdHistory.length, state.cmdHistoryIdx + 1);
|
||||
cmdInput.value = state.cmdHistory[state.cmdHistoryIdx] || "";
|
||||
cmdInput.dispatchEvent(new Event("input"));
|
||||
}
|
||||
// Note: Tab is handled by the autocomplete module's keydown listener.
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Danger zone -- destructive operations.
|
||||
// Currently: world wipe. Always type-to-confirm to prevent accidental clicks.
|
||||
"use strict";
|
||||
|
||||
export function setupDanger() {
|
||||
const btn = document.getElementById("wipeBtn");
|
||||
const cb = document.getElementById("wipeBackup");
|
||||
const msg = document.getElementById("wipeMsg");
|
||||
const seedDisplay = document.getElementById("wipeCurrentSeed");
|
||||
const customInput = document.getElementById("wipeCustomSeed");
|
||||
if (!btn) return;
|
||||
|
||||
// Enable the custom-seed text field only when its radio is selected.
|
||||
document.querySelectorAll('input[name="wipeSeedMode"]').forEach(radio => {
|
||||
radio.addEventListener("change", () => {
|
||||
const mode = document.querySelector('input[name="wipeSeedMode"]:checked')?.value;
|
||||
customInput.disabled = (mode !== "custom");
|
||||
if (mode === "custom") customInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch current seed each time the wipe modal becomes visible. Watching
|
||||
// the wipe section's ancestor modal works without coupling to the modal
|
||||
// module's open/close API.
|
||||
const refreshSeed = async () => {
|
||||
seedDisplay.textContent = "loading...";
|
||||
try {
|
||||
const res = await fetch("/api/world/seed");
|
||||
const body = await res.json();
|
||||
seedDisplay.textContent = body.seed
|
||||
? body.seed
|
||||
: "(unknown -- server stopped or seed not set)";
|
||||
} catch (e) {
|
||||
seedDisplay.textContent = "(failed to read)";
|
||||
}
|
||||
};
|
||||
// Refresh on first load + whenever the modal becomes visible. Modal markup
|
||||
// uses a wrapping div with "[hidden]" attr, so we observe attribute changes.
|
||||
refreshSeed();
|
||||
const modal = btn.closest(".modal");
|
||||
if (modal) {
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
if (m.attributeName === "hidden" && !modal.hasAttribute("hidden")) {
|
||||
refreshSeed();
|
||||
}
|
||||
}
|
||||
}).observe(modal, { attributes: true });
|
||||
}
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
msg.className = "acct-msg";
|
||||
msg.textContent = "";
|
||||
|
||||
const mode = document.querySelector('input[name="wipeSeedMode"]:checked')?.value || "random";
|
||||
const customSeed = (customInput.value || "").trim();
|
||||
if (mode === "custom" && !customSeed) {
|
||||
msg.textContent = "Custom seed selected but the field is empty.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a confirmation prompt that reflects the chosen seed strategy
|
||||
// so the user sees exactly what's about to happen.
|
||||
let seedNote = "";
|
||||
if (mode === "keep") seedNote = `Same seed (${seedDisplay.textContent}) will be reused.\n`;
|
||||
else if (mode === "custom") seedNote = `Seed will be set to: ${customSeed}\n`;
|
||||
else seedNote = "A new random seed will be generated.\n";
|
||||
|
||||
const typed = prompt(
|
||||
"Type WIPE (uppercase, exactly) to confirm world wipe.\n" +
|
||||
"Server will stop, world will be replaced, server will restart.\n\n" +
|
||||
seedNote
|
||||
);
|
||||
if (typed !== "WIPE") {
|
||||
if (typed != null) msg.textContent = "Confirmation didn't match -- nothing wiped.";
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
msg.textContent = "Wiping...";
|
||||
try {
|
||||
const res = await fetch("/api/world/wipe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
confirm: typed,
|
||||
backup: cb.checked,
|
||||
seedMode: mode,
|
||||
customSeed: mode === "custom" ? customSeed : null,
|
||||
}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body.ok === false) {
|
||||
msg.textContent = body.error || `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
msg.className = "acct-msg ok";
|
||||
const parts = ["World wiped."];
|
||||
if (body.seedUsed) parts.push(`Seed: ${body.seedUsed}.`);
|
||||
if (body.backupName) parts.push(`Backup: ${body.backupName}.`);
|
||||
parts.push("Server restarting...");
|
||||
msg.textContent = parts.join(" ");
|
||||
// Refresh the seed display so user sees the new value once MC is back.
|
||||
setTimeout(refreshSeed, 5000);
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// World map (BlueMap) controls.
|
||||
//
|
||||
// Render runs out-of-process via BlueMap CLI. Status polled every 3 s while
|
||||
// the modal is open OR while a render is in progress. The "Open map" button
|
||||
// only opens the new tab if there's actually rendered output -- otherwise we
|
||||
// pop a friendly message saying "render first".
|
||||
"use strict";
|
||||
|
||||
import { api } from "./api.js";
|
||||
|
||||
const els = {};
|
||||
let pollTimer = null;
|
||||
let modalOpen = false;
|
||||
let lastHasOutput = false;
|
||||
|
||||
function setPolling(intervalMs) {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(tick, intervalMs);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
let s;
|
||||
try { s = await api("/api/map/status"); }
|
||||
catch { return; }
|
||||
|
||||
lastHasOutput = !!s.hasOutput;
|
||||
document.getElementById("mapBadge").hidden = !s.inProgress;
|
||||
if (s.inProgress) document.getElementById("mapBadge").textContent = "rendering";
|
||||
|
||||
if (!modalOpen) return; // don't bother updating modal DOM if hidden
|
||||
|
||||
els.phase.textContent = phaseLabel(s.phase);
|
||||
els.lastLog.textContent = s.lastLogLine ?? "--";
|
||||
els.render.disabled = s.inProgress;
|
||||
els.render.textContent = s.inProgress ? "Rendering…" : "Render now";
|
||||
els.cancel.hidden = !s.inProgress;
|
||||
|
||||
if (s.phase === "complete" || s.phase === "failed" || s.phase === "cancelled") {
|
||||
if (s.phase === "failed" && s.error) showMsg("Failed: " + s.error);
|
||||
else if (s.phase === "cancelled") showMsg("Cancelled. Next render resumes from this point.");
|
||||
else if (s.phase === "complete") showMsg("Render complete.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function phaseLabel(phase) {
|
||||
switch (phase) {
|
||||
case "downloading": return "Downloading CLI";
|
||||
case "extracting": return "Extracting CLI";
|
||||
case "configuring": return "Configuring";
|
||||
case "rendering": return "Rendering";
|
||||
case "complete": return "Complete";
|
||||
case "failed": return "Failed";
|
||||
case "cancelled": return "Cancelled";
|
||||
default: return "Idle";
|
||||
}
|
||||
}
|
||||
|
||||
function showMsg(text, ok = false) {
|
||||
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
|
||||
els.msg.textContent = text;
|
||||
}
|
||||
|
||||
export function setupMap() {
|
||||
els.phase = document.getElementById("mapPhase");
|
||||
els.lastLog = document.getElementById("mapLastLog");
|
||||
els.render = document.getElementById("mapRender");
|
||||
els.cancel = document.getElementById("mapCancel");
|
||||
els.open = document.getElementById("mapOpen");
|
||||
els.msg = document.getElementById("mapMsg");
|
||||
if (!els.render) return;
|
||||
|
||||
els.cancel.addEventListener("click", async () => {
|
||||
if (!confirm("Cancel the render? It's resumable -- next time you click Render, BlueMap continues from where it stopped.")) return;
|
||||
try {
|
||||
const res = await fetch("/api/map/cancel", { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body.ok === false) showMsg(body.error || `Error ${res.status}`);
|
||||
else { showMsg("Cancelling…"); tick(); }
|
||||
} catch (e) { showMsg(e.message); }
|
||||
});
|
||||
|
||||
// Track modal open/close so we can poll faster when the user is watching.
|
||||
const modal = document.getElementById("modalMap");
|
||||
new MutationObserver(() => {
|
||||
modalOpen = !modal.hidden;
|
||||
if (modalOpen) tick();
|
||||
}).observe(modal, { attributes: true, attributeFilter: ["hidden"] });
|
||||
|
||||
els.render.addEventListener("click", async () => {
|
||||
showMsg("Starting render…");
|
||||
els.render.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/map/render", { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body.ok === false) {
|
||||
showMsg(body.error || `Error ${res.status}`);
|
||||
els.render.disabled = false;
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
} catch (e) { showMsg(e.message); els.render.disabled = false; }
|
||||
});
|
||||
|
||||
els.open.addEventListener("click", () => {
|
||||
if (!lastHasOutput) {
|
||||
showMsg("No map output yet -- click Render now first.");
|
||||
return;
|
||||
}
|
||||
window.open("/map/", "_blank", "noopener");
|
||||
});
|
||||
|
||||
tick();
|
||||
setPolling(3000); // light poll keeps the badge fresh + catches background renders
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Tiny modal helper. Registers a single document-level Esc + backdrop-click
|
||||
// handler so individual modals don't have to. Public API: openModal(id) /
|
||||
// closeModal(id) / closeAllModals().
|
||||
"use strict";
|
||||
|
||||
let bound = false;
|
||||
|
||||
function bindGlobal() {
|
||||
if (bound) return;
|
||||
bound = true;
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape") closeAllModals();
|
||||
});
|
||||
document.addEventListener("click", e => {
|
||||
// Backdrop click closes the topmost open modal.
|
||||
const backdrop = e.target.closest(".modal-backdrop");
|
||||
if (backdrop) closeModal(backdrop.parentElement.id);
|
||||
const closeBtn = e.target.closest(".modal-close");
|
||||
if (closeBtn) closeModal(closeBtn.closest(".modal").id);
|
||||
});
|
||||
}
|
||||
|
||||
export function openModal(id) {
|
||||
bindGlobal();
|
||||
const m = document.getElementById(id);
|
||||
if (!m) return;
|
||||
m.hidden = false;
|
||||
document.body.classList.add("modal-open");
|
||||
// Focus first input/button for keyboard users.
|
||||
setTimeout(() => {
|
||||
const focusable = m.querySelector("input, button:not(.modal-close), select, textarea");
|
||||
focusable?.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
export function closeModal(id) {
|
||||
const m = document.getElementById(id);
|
||||
if (!m) return;
|
||||
m.hidden = true;
|
||||
if (!document.querySelector(".modal:not([hidden])")) {
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAllModals() {
|
||||
document.querySelectorAll(".modal:not([hidden])").forEach(m => m.hidden = true);
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
|
||||
/// Wires `data-open-modal="someId"` on any element to opening the modal.
|
||||
export function setupModalTriggers() {
|
||||
bindGlobal();
|
||||
document.addEventListener("click", e => {
|
||||
const trigger = e.target.closest("[data-open-modal]");
|
||||
if (trigger) openModal(trigger.getAttribute("data-open-modal"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Status / players / whitelist sidebar panels. Polled (not streamed) because the
|
||||
// data they show changes infrequently. Logs use SSE -- see console.js.
|
||||
"use strict";
|
||||
|
||||
import { api, escapeHtml } from "./api.js";
|
||||
import { state, rebuildKnownPlayers } from "./state.js";
|
||||
|
||||
export async function tickStatus() {
|
||||
const pill = document.getElementById("statusPill");
|
||||
const text = document.getElementById("statusText");
|
||||
const memEl = document.getElementById("memUsage");
|
||||
const memBar = document.getElementById("memBar");
|
||||
const cpuCur = document.getElementById("cpuCurrent");
|
||||
const cpuBar = document.getElementById("cpuBar");
|
||||
const cpuMax = document.getElementById("cpuMax");
|
||||
const cpuAvg = document.getElementById("cpuAvg");
|
||||
|
||||
function renderResources(s) {
|
||||
if (s.memoryBytes != null) {
|
||||
const usedGB = s.memoryBytes / (1024 ** 3);
|
||||
const maxGB = s.memoryMaxMB ? s.memoryMaxMB / 1024 : null;
|
||||
memEl.textContent = maxGB
|
||||
? `${usedGB.toFixed(2)} / ${maxGB.toFixed(1)} GB`
|
||||
: `${usedGB.toFixed(2)} GB`;
|
||||
memBar.style.width = maxGB ? `${Math.min(100, (usedGB / maxGB) * 100)}%` : "0%";
|
||||
} else {
|
||||
memEl.textContent = "--";
|
||||
memBar.style.width = "0%";
|
||||
}
|
||||
if (s.cpu) {
|
||||
cpuCur.textContent = `${s.cpu.current.toFixed(1)} %`;
|
||||
cpuBar.style.width = `${Math.min(100, s.cpu.current)}%`;
|
||||
cpuMax.textContent = `${s.cpu.max.toFixed(1)}%`;
|
||||
cpuAvg.textContent = `${s.cpu.avg.toFixed(1)}%`;
|
||||
} else {
|
||||
cpuCur.textContent = "--";
|
||||
cpuBar.style.width = "0%";
|
||||
cpuMax.textContent = "--";
|
||||
cpuAvg.textContent = "--";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const s = await api("/api/status");
|
||||
if (s.running) {
|
||||
pill.className = "status-pill online";
|
||||
text.textContent = "Online";
|
||||
document.getElementById("pid").textContent = s.pid ?? "--";
|
||||
const secs = Math.floor(s.uptime ?? 0);
|
||||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60);
|
||||
document.getElementById("uptime").textContent = `${h}h ${m}m`;
|
||||
renderResources(s);
|
||||
} else {
|
||||
pill.className = "status-pill offline";
|
||||
text.textContent = "Offline";
|
||||
document.getElementById("pid").textContent = "--";
|
||||
document.getElementById("uptime").textContent = "--";
|
||||
renderResources({ memoryBytes: null, cpu: null, memoryMaxMB: null });
|
||||
}
|
||||
const pv = s.packVersion;
|
||||
document.getElementById("packVersion").textContent = pv?.name ? `${pv.name} v${pv.version}` : "--";
|
||||
|
||||
// World size -- even when server is offline we can still report disk usage.
|
||||
const worldEl = document.getElementById("worldSize");
|
||||
if (worldEl) {
|
||||
const b = s.worldSizeBytes;
|
||||
if (b == null || b === 0) worldEl.textContent = "--";
|
||||
else if (b < 1024 * 1024) worldEl.textContent = `${(b / 1024).toFixed(0)} KB`;
|
||||
else if (b < 1024 * 1024 * 1024) worldEl.textContent = `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
else worldEl.textContent = `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
} catch {
|
||||
pill.className = "status-pill offline";
|
||||
text.textContent = "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
export async function tickPlayers() {
|
||||
try {
|
||||
const p = await api("/api/players");
|
||||
state.onlinePlayers = (p.players || []).slice();
|
||||
document.getElementById("playerCount").textContent = p.online >= 0 ? p.online : "?";
|
||||
const list = document.getElementById("players");
|
||||
if (state.onlinePlayers.length === 0) {
|
||||
list.innerHTML = '<li class="empty-state">No-one online</li>';
|
||||
} else {
|
||||
list.innerHTML = state.onlinePlayers.map(n => `<li>${escapeHtml(n)}<span></span></li>`).join("");
|
||||
}
|
||||
} catch {}
|
||||
rebuildKnownPlayers();
|
||||
}
|
||||
|
||||
export async function tickWhitelist() {
|
||||
try {
|
||||
const w = await api("/api/whitelist");
|
||||
state.whitelistedPlayers = (w.players || []).slice();
|
||||
const list = document.getElementById("whitelist");
|
||||
if (state.whitelistedPlayers.length === 0) {
|
||||
list.innerHTML = '<li class="empty-state">No players whitelisted yet</li>';
|
||||
} else {
|
||||
list.innerHTML = state.whitelistedPlayers.map(n =>
|
||||
`<li>${escapeHtml(n)}<button data-name="${escapeHtml(n)}" class="wl-remove">Remove</button></li>`
|
||||
).join("");
|
||||
}
|
||||
} catch {}
|
||||
rebuildKnownPlayers();
|
||||
}
|
||||
|
||||
// MC takes ~1-2 s to look up a UUID via Mojang and write whitelist.json.
|
||||
// Refresh shortly after a user-triggered add/remove instead of waiting for the
|
||||
// 30-second polling tick.
|
||||
let pendingRefresh;
|
||||
export function refreshWhitelistSoon() {
|
||||
clearTimeout(pendingRefresh);
|
||||
pendingRefresh = setTimeout(tickWhitelist, 1500);
|
||||
setTimeout(tickWhitelist, 4000); // belt-and-braces if Mojang is slow
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// World pre-generation controls + live status display.
|
||||
//
|
||||
// We use the canonical config-then-start sequence rather than the all-in-one
|
||||
// `chunky start <world> <shape> <cx> <cz> <r>` form because the all-in-one
|
||||
// form's argument order varies between Chunky versions, and Brigadier silently
|
||||
// prints the usage hint instead of erroring when it doesn't match.
|
||||
//
|
||||
// Status is parsed from Chunky's own log lines (re-broadcast by console.js as
|
||||
// the `serverlog` custom event) -- no separate polling endpoint is needed.
|
||||
//
|
||||
// Chunky is intentionally only invoked from this panel -- it can punch holes
|
||||
// in chunks if it crashes mid-run, so we don't want it ticking on its own.
|
||||
"use strict";
|
||||
|
||||
import { apiJson } from "./api.js";
|
||||
|
||||
async function send(cmd) {
|
||||
await apiJson("/api/command", { command: cmd });
|
||||
}
|
||||
|
||||
async function startPregen(radius) {
|
||||
await send("chunky world minecraft:overworld");
|
||||
await send("chunky shape square");
|
||||
await send("chunky center 0 0");
|
||||
await send(`chunky radius ${radius}`);
|
||||
await send("chunky start");
|
||||
}
|
||||
|
||||
// ─────────── status display ───────────
|
||||
|
||||
const els = {};
|
||||
|
||||
function setState(label, cssClass) {
|
||||
if (!els.state) return;
|
||||
els.state.textContent = label;
|
||||
els.state.className = "val " + cssClass;
|
||||
applyButtonStates(cssClass);
|
||||
}
|
||||
|
||||
/// Enable/disable the Start/Pause/Resume/Cancel buttons based on the current
|
||||
/// pregen state. Called whenever setState changes the displayed status.
|
||||
function applyButtonStates(cssClass) {
|
||||
if (!els.btnStart) return;
|
||||
// Map the state CSS class to a logical state name. Default = idle.
|
||||
let s = "idle";
|
||||
if (cssClass === "pg-state-running") s = "running";
|
||||
else if (cssClass === "pg-state-paused") s = "paused";
|
||||
else if (cssClass === "pg-state-cancelling") s = "cancelling";
|
||||
|
||||
els.btnStart.disabled = s !== "idle";
|
||||
els.btnPause.disabled = s !== "running";
|
||||
els.btnContinue.disabled = s !== "paused";
|
||||
els.btnCancel.disabled = !(s === "running" || s === "paused");
|
||||
}
|
||||
|
||||
function resetMetrics() {
|
||||
if (!els.progressFill) return;
|
||||
els.progressFill.style.width = "0%";
|
||||
els.progressText.textContent = "--";
|
||||
els.chunks.textContent = "--";
|
||||
els.rate.textContent = "--";
|
||||
els.eta.textContent = "--";
|
||||
}
|
||||
|
||||
// Parse a Chunky log line. Returns an object describing what changed, or null
|
||||
// if this line isn't a Chunky message we recognise (or is for a different world).
|
||||
//
|
||||
// Chunky supports one task per world running concurrently, so we narrow our
|
||||
// display to the overworld -- that's the only world the Start button targets,
|
||||
// and it keeps the UI sane if someone kicks off other worlds via raw command.
|
||||
//
|
||||
// Real Chunky lines look roughly like:
|
||||
// "[Chunky] Task running for minecraft:overworld at 0,0. Progress: 12.50% (1234/9876 chunks), 45.20 cps, ETA: 0h 1m 30s"
|
||||
// "[Chunky] Task started for minecraft:overworld."
|
||||
// "[Chunky] Task stopped for minecraft:overworld."
|
||||
// "[Chunky] Task paused for minecraft:overworld."
|
||||
// "[Chunky] No task running."
|
||||
const TARGET_WORLD = "minecraft:overworld";
|
||||
|
||||
function parseChunky(text) {
|
||||
if (!/Chunky|chunky/.test(text)) return null;
|
||||
|
||||
// If a world is named, only react when it's the one we're tracking.
|
||||
// Lines without a world (e.g. "No task running.") fall through.
|
||||
const worldMatch = text.match(/(minecraft:[a-z_]+|the_nether|the_end|overworld)/i);
|
||||
if (worldMatch) {
|
||||
const w = worldMatch[1].toLowerCase();
|
||||
const normalised = w.startsWith("minecraft:") ? w : `minecraft:${w}`;
|
||||
if (normalised !== TARGET_WORLD) return null;
|
||||
}
|
||||
|
||||
// State transitions
|
||||
if (/Task started/i.test(text)) return { state: "running" };
|
||||
if (/Task paused/i.test(text)) return { state: "paused" };
|
||||
if (/Task (stopped|cancelled|canceled|completed|finished)/i.test(text))
|
||||
return { state: "idle", clear: true };
|
||||
if (/No task running/i.test(text)) return { state: "idle", clear: true };
|
||||
|
||||
// Progress line: try to extract whatever pieces are present
|
||||
const out = {};
|
||||
let matched = false;
|
||||
|
||||
const pct = text.match(/(\d+(?:\.\d+)?)\s*%/);
|
||||
if (pct) { out.percent = parseFloat(pct[1]); matched = true; }
|
||||
|
||||
const chunks = text.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*chunks?/i);
|
||||
if (chunks) {
|
||||
out.done = chunks[1].replace(/,/g, "");
|
||||
out.total = chunks[2].replace(/,/g, "");
|
||||
matched = true;
|
||||
}
|
||||
|
||||
const cps = text.match(/(\d+(?:\.\d+)?)\s*cps/i);
|
||||
if (cps) { out.cps = parseFloat(cps[1]); matched = true; }
|
||||
|
||||
const eta = text.match(/ETA[:\s]+([^,)\n]+?)(?=[,)\n]|$)/i);
|
||||
if (eta) { out.eta = eta[1].trim(); matched = true; }
|
||||
|
||||
if (matched) { out.state = "running"; return out; }
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyParsed(p) {
|
||||
if (!els.state) return;
|
||||
if (p.state === "running") setState("Running", "pg-state-running");
|
||||
if (p.state === "paused") setState("Paused", "pg-state-paused");
|
||||
if (p.state === "idle") setState("Idle", "pg-state-idle");
|
||||
if (p.clear) resetMetrics();
|
||||
|
||||
if (p.percent != null) {
|
||||
els.progressFill.style.width = `${Math.min(100, p.percent)}%`;
|
||||
els.progressText.textContent = `${p.percent.toFixed(2)}%`;
|
||||
}
|
||||
if (p.done && p.total) {
|
||||
const fmt = n => Number(n).toLocaleString();
|
||||
els.chunks.textContent = `${fmt(p.done)} / ${fmt(p.total)}`;
|
||||
}
|
||||
if (p.cps != null) els.rate.textContent = `${p.cps.toFixed(1)} chunks/s`;
|
||||
if (p.eta) els.eta.textContent = p.eta;
|
||||
}
|
||||
|
||||
function setupCollapsible() {
|
||||
// Persist collapsed state per card across reloads via localStorage.
|
||||
document.querySelectorAll(".card.collapsible").forEach(card => {
|
||||
const id = card.id || "";
|
||||
const storageKey = id ? `bs-collapsed:${id}` : null;
|
||||
const startCollapsed = storageKey && localStorage.getItem(storageKey) === "1";
|
||||
if (!startCollapsed) card.classList.add("expanded");
|
||||
const toggle = card.querySelector(".collapsible-toggle");
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener("click", () => {
|
||||
card.classList.toggle("expanded");
|
||||
if (storageKey) {
|
||||
localStorage.setItem(storageKey,
|
||||
card.classList.contains("expanded") ? "0" : "1");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function setupPregen() {
|
||||
setupCollapsible();
|
||||
els.state = document.getElementById("pgState");
|
||||
els.progressFill = document.getElementById("pgProgressFill");
|
||||
els.progressText = document.getElementById("pgProgressText");
|
||||
els.chunks = document.getElementById("pgChunks");
|
||||
els.rate = document.getElementById("pgRate");
|
||||
els.eta = document.getElementById("pgEta");
|
||||
els.btnStart = document.getElementById("pgStart");
|
||||
els.btnPause = document.getElementById("pgPause");
|
||||
els.btnContinue = document.getElementById("pgContinue");
|
||||
els.btnCancel = document.getElementById("pgCancel");
|
||||
|
||||
// Idle by default -- disable everything except Start.
|
||||
applyButtonStates("pg-state-idle");
|
||||
|
||||
const radiusInput = document.getElementById("pgRadius");
|
||||
|
||||
document.getElementById("pgStart").addEventListener("click", async () => {
|
||||
const r = parseInt(radiusInput.value, 10);
|
||||
if (!Number.isFinite(r) || r < 100) {
|
||||
alert("Enter a radius of at least 100 blocks.");
|
||||
return;
|
||||
}
|
||||
if (r > 20000 && !confirm(`Radius ${r} is large and may take hours. Continue?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setState("Starting…", "pg-state-running");
|
||||
resetMetrics();
|
||||
await startPregen(r);
|
||||
} catch (e) {
|
||||
setState("Idle", "pg-state-idle");
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("pgPause").addEventListener("click", async () => {
|
||||
try { await send("chunky pause"); } catch (e) { alert(e.message); }
|
||||
});
|
||||
document.getElementById("pgContinue").addEventListener("click", async () => {
|
||||
try {
|
||||
await send("chunky continue");
|
||||
setState("Running", "pg-state-running");
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
document.getElementById("pgCancel").addEventListener("click", async () => {
|
||||
if (!confirm("Cancel the current pre-generation run?")) return;
|
||||
try {
|
||||
setState("Cancelling…", "pg-state-cancelling");
|
||||
await send("chunky cancel");
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
// Subscribe to the shared SSE re-broadcast from console.js
|
||||
document.addEventListener("serverlog", e => {
|
||||
const msg = e.detail?.m;
|
||||
if (typeof msg !== "string") return;
|
||||
const parsed = parseChunky(msg);
|
||||
if (parsed) applyParsed(parsed);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Start / stop buttons.
|
||||
"use strict";
|
||||
|
||||
import { api } from "./api.js";
|
||||
|
||||
export function setupServerControls() {
|
||||
document.getElementById("btnStart").addEventListener("click", async () => {
|
||||
try { await api("/api/server/start", { method: "POST" }); }
|
||||
catch (e) { alert(e.message); }
|
||||
});
|
||||
document.getElementById("btnStop").addEventListener("click", async () => {
|
||||
if (!confirm("Stop the server?")) return;
|
||||
try { await api("/api/server/stop", { method: "POST" }); }
|
||||
catch (e) { alert(e.message); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Server settings: read/write a curated subset of server.properties.
|
||||
// Changes require an MC restart -- Save writes only, Save & restart bounces MC.
|
||||
"use strict";
|
||||
|
||||
import { api } from "./api.js";
|
||||
|
||||
const els = {};
|
||||
|
||||
// Map of input element ID -> server.properties key. Keeps the form ↔ file
|
||||
// translation in one place; new fields can be added by adding a row here +
|
||||
// matching elements in index.html.
|
||||
const FIELDS = [
|
||||
{ id: "ssfMotd", key: "motd", type: "string" },
|
||||
{ id: "ssfGamemode", key: "gamemode", type: "string" },
|
||||
{ id: "ssfDifficulty", key: "difficulty", type: "string" },
|
||||
{ id: "ssfViewDistance", key: "view-distance", type: "int" },
|
||||
{ id: "ssfSimulationDistance", key: "simulation-distance", type: "int" },
|
||||
{ id: "ssfMaxPlayers", key: "max-players", type: "int" },
|
||||
{ id: "ssfSpawnProtection", key: "spawn-protection", type: "int" },
|
||||
{ id: "ssfPvp", key: "pvp", type: "bool" },
|
||||
{ id: "ssfHardcore", key: "hardcore", type: "bool" },
|
||||
{ id: "ssfAllowFlight", key: "allow-flight", type: "bool" },
|
||||
{ id: "ssfWhiteList", key: "white-list", type: "bool" },
|
||||
{ id: "ssfEnforceWhitelist", key: "enforce-whitelist", type: "bool" },
|
||||
{ id: "ssfEnableCommandBlock", key: "enable-command-block", type: "bool" },
|
||||
];
|
||||
|
||||
function readForm() {
|
||||
const out = {};
|
||||
for (const f of FIELDS) {
|
||||
const el = document.getElementById(f.id);
|
||||
if (!el) continue;
|
||||
if (f.type === "bool") out[f.key] = el.checked ? "true" : "false";
|
||||
else if (f.type === "int") {
|
||||
const v = parseInt(el.value, 10);
|
||||
if (Number.isFinite(v)) out[f.key] = String(v);
|
||||
} else {
|
||||
out[f.key] = el.value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeForm(values) {
|
||||
for (const f of FIELDS) {
|
||||
const el = document.getElementById(f.id);
|
||||
if (!el) continue;
|
||||
const v = values[f.key];
|
||||
if (v === undefined) continue;
|
||||
if (f.type === "bool") el.checked = (v === "true");
|
||||
else el.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(values) {
|
||||
document.getElementById("ssMotd").textContent = values["motd"] ?? "--";
|
||||
document.getElementById("ssDifficulty").textContent = values["difficulty"] ?? "--";
|
||||
document.getElementById("ssDistances").textContent =
|
||||
`${values["view-distance"] ?? "--"} / ${values["simulation-distance"] ?? "--"}`;
|
||||
document.getElementById("ssMaxPlayers").textContent = values["max-players"] ?? "--";
|
||||
const wl = values["white-list"] === "true";
|
||||
const enf = values["enforce-whitelist"] === "true";
|
||||
document.getElementById("ssWhitelist").textContent =
|
||||
wl ? (enf ? "enforced" : "enabled") : "off";
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await api("/api/server/settings");
|
||||
renderSummary(data.values || {});
|
||||
writeForm(data.values || {});
|
||||
} catch { /* ignore -- panel just shows last-known */ }
|
||||
}
|
||||
|
||||
async function postSettings() {
|
||||
const payload = readForm();
|
||||
const res = await fetch("/api/server/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return { ok: res.ok, body: await res.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
function showMsg(text, ok = false) {
|
||||
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
|
||||
els.msg.textContent = text;
|
||||
}
|
||||
|
||||
export function setupSettings() {
|
||||
els.msg = document.getElementById("ssMsg");
|
||||
els.save = document.getElementById("ssSave");
|
||||
els.restart = document.getElementById("ssRestart");
|
||||
if (!els.save) return;
|
||||
|
||||
els.save.addEventListener("click", async () => {
|
||||
showMsg("Saving...");
|
||||
els.save.disabled = true;
|
||||
try {
|
||||
const r = await postSettings();
|
||||
if (!r.ok || r.body.ok === false) {
|
||||
showMsg(r.body.error || `Error ${r.body.status ?? ""}`);
|
||||
return;
|
||||
}
|
||||
showMsg(r.body.restartRequired
|
||||
? "Saved. Restart for changes to take effect."
|
||||
: "Saved.", true);
|
||||
refresh();
|
||||
} catch (e) { showMsg(e.message); }
|
||||
finally { els.save.disabled = false; }
|
||||
});
|
||||
|
||||
els.restart.addEventListener("click", async () => {
|
||||
if (!confirm("Save changes and restart the server now? Players will be disconnected briefly.")) return;
|
||||
showMsg("Saving + restarting...");
|
||||
els.save.disabled = true; els.restart.disabled = true;
|
||||
try {
|
||||
const r = await postSettings();
|
||||
if (!r.ok || r.body.ok === false) {
|
||||
showMsg(r.body.error || `Save failed: ${r.body.status ?? ""}`);
|
||||
return;
|
||||
}
|
||||
const rr = await fetch("/api/server/restart", { method: "POST" });
|
||||
const rb = await rr.json().catch(() => ({}));
|
||||
if (!rr.ok || rb.ok === false) showMsg("Saved, but restart failed: " + (rb.error || rr.status));
|
||||
else showMsg("Saved + restarting. New settings live in ~30s.", true);
|
||||
refresh();
|
||||
} catch (e) { showMsg(e.message); }
|
||||
finally { els.save.disabled = false; els.restart.disabled = false; }
|
||||
});
|
||||
|
||||
refresh();
|
||||
// Light poll: pick up out-of-band edits to server.properties.
|
||||
setInterval(refresh, 30000);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Shared in-memory state -- the union of online + whitelisted players is what
|
||||
// tab-completion matches against, so we keep it centralised here.
|
||||
"use strict";
|
||||
|
||||
export const state = {
|
||||
onlinePlayers: [],
|
||||
whitelistedPlayers: [],
|
||||
knownPlayers: [], // sorted union, for autocomplete
|
||||
cmdHistory: [],
|
||||
cmdHistoryIdx: -1,
|
||||
};
|
||||
|
||||
export function rebuildKnownPlayers() {
|
||||
const set = new Set();
|
||||
state.onlinePlayers.forEach(n => set.add(n));
|
||||
state.whitelistedPlayers.forEach(n => set.add(n));
|
||||
state.knownPlayers = [...set].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Modpack update controls.
|
||||
//
|
||||
// The card hides itself when there's no update available and reveals when the
|
||||
// manifest reports a newer pack version. Polls /api/update/status (every 5 s
|
||||
// when idle, every 1 s when an update is in-flight) to keep state fresh.
|
||||
"use strict";
|
||||
|
||||
import { api, apiJson } from "./api.js";
|
||||
|
||||
const els = {};
|
||||
let pollTimer = null;
|
||||
let pollInterval = 5000;
|
||||
|
||||
function setPolling(intervalMs) {
|
||||
if (intervalMs === pollInterval && pollTimer) return;
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollInterval = intervalMs;
|
||||
pollTimer = setInterval(tick, intervalMs);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
let s;
|
||||
try { s = await api("/api/update/status"); }
|
||||
catch { return; }
|
||||
|
||||
els.current.textContent = s.current ?? "--";
|
||||
els.available.textContent = s.available ?? "--";
|
||||
|
||||
const card = els.card;
|
||||
if (s.needsUpdate || s.inProgress) {
|
||||
card.hidden = false;
|
||||
card.classList.toggle("has-update", s.needsUpdate && !s.inProgress);
|
||||
} else {
|
||||
card.hidden = true;
|
||||
}
|
||||
|
||||
if (s.inProgress) {
|
||||
els.progress.hidden = false;
|
||||
els.start.disabled = true;
|
||||
els.delay.disabled = true;
|
||||
els.phase.textContent = phaseLabel(s.phase);
|
||||
|
||||
const showCancel = s.phase === "countdown";
|
||||
els.cancel.hidden = !showCancel;
|
||||
|
||||
if (s.phase === "countdown" && s.countdownTotal > 0) {
|
||||
const elapsed = s.countdownTotal - s.countdownRemaining;
|
||||
const pct = (elapsed / s.countdownTotal) * 100;
|
||||
els.fill.style.width = `${pct}%`;
|
||||
els.status.textContent = `Restarting in ${formatSeconds(s.countdownRemaining)}`;
|
||||
} else {
|
||||
// Indeterminate during sync / loader install / start phases --
|
||||
// just show 100% and a phase-specific status string.
|
||||
els.fill.style.width = "100%";
|
||||
els.status.textContent = phaseStatus(s.phase);
|
||||
}
|
||||
setPolling(1000);
|
||||
} else {
|
||||
els.progress.hidden = true;
|
||||
els.start.disabled = !s.needsUpdate;
|
||||
els.delay.disabled = false;
|
||||
if (s.phase === "failed" && s.error) {
|
||||
els.progress.hidden = false;
|
||||
els.phase.textContent = "FAILED";
|
||||
els.status.textContent = s.error;
|
||||
els.fill.style.width = "0%";
|
||||
}
|
||||
setPolling(5000);
|
||||
}
|
||||
}
|
||||
|
||||
function phaseLabel(phase) {
|
||||
switch (phase) {
|
||||
case "countdown": return "COUNTDOWN";
|
||||
case "stopping": return "STOPPING";
|
||||
case "syncing": return "SYNCING MODS";
|
||||
case "installing_loader": return "INSTALLING LOADER";
|
||||
case "starting": return "STARTING";
|
||||
case "complete": return "COMPLETE";
|
||||
case "failed": return "FAILED";
|
||||
case "cancelled": return "CANCELLED";
|
||||
default: return "WORKING";
|
||||
}
|
||||
}
|
||||
|
||||
function phaseStatus(phase) {
|
||||
switch (phase) {
|
||||
case "stopping": return "Stopping Minecraft cleanly...";
|
||||
case "syncing": return "Syncing mods from manifest...";
|
||||
case "installing_loader": return "Re-running NeoForge installer...";
|
||||
case "starting": return "Starting Minecraft...";
|
||||
case "complete": return "Update complete.";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatSeconds(s) {
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60), r = s % 60;
|
||||
return `${m}m ${String(r).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function setupUpdate() {
|
||||
els.card = document.getElementById("updateCard");
|
||||
els.current = document.getElementById("updCurrent");
|
||||
els.available = document.getElementById("updAvailable");
|
||||
els.delay = document.getElementById("updDelay");
|
||||
els.start = document.getElementById("updStart");
|
||||
els.progress = document.getElementById("updProgress");
|
||||
els.phase = document.getElementById("updPhaseLabel");
|
||||
els.fill = document.getElementById("updProgressFill");
|
||||
els.status = document.getElementById("updStatusText");
|
||||
els.cancel = document.getElementById("updCancel");
|
||||
|
||||
els.start.addEventListener("click", async () => {
|
||||
const delay = parseInt(els.delay.value, 10);
|
||||
if (!Number.isFinite(delay) || delay < 0) {
|
||||
alert("Enter a non-negative warning duration.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Update modpack? Players get a ${delay}s warning, then the server restarts.`)) return;
|
||||
try {
|
||||
await apiJson("/api/update/start", { delaySeconds: delay });
|
||||
await tick();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
els.cancel.addEventListener("click", async () => {
|
||||
if (!confirm("Cancel the countdown? Update will be aborted; server stays running.")) return;
|
||||
try {
|
||||
await apiJson("/api/update/cancel", {});
|
||||
await tick();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
tick();
|
||||
setPolling(5000);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Whitelist add / remove via the API; refreshes the panel display shortly after
|
||||
// each action (server takes ~1-2 s to look up UUID via Mojang and write whitelist.json).
|
||||
"use strict";
|
||||
|
||||
import { api, apiJson, escapeHtml } from "./api.js";
|
||||
|
||||
export function setupWhitelistActions(refreshSoon) {
|
||||
const wlInput = document.getElementById("wlInput");
|
||||
document.getElementById("wlAdd").addEventListener("click", () => addWhitelisted(refreshSoon));
|
||||
wlInput.addEventListener("keydown", e => { if (e.key === "Enter") addWhitelisted(refreshSoon); });
|
||||
|
||||
// Delegated removal -- list items are re-rendered each tick, no static binding.
|
||||
document.getElementById("whitelist").addEventListener("click", async e => {
|
||||
const btn = e.target.closest(".wl-remove");
|
||||
if (!btn) return;
|
||||
const name = btn.dataset.name;
|
||||
if (!name) return;
|
||||
if (!confirm(`Remove ${name} from whitelist?`)) return;
|
||||
try {
|
||||
await apiJson("/api/whitelist/remove", { name });
|
||||
refreshSoon();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
// Pending whitelist requests from friends. Approve adds to whitelist + clears
|
||||
// the request; Deny just marks denied so the friend's launcher knows.
|
||||
const reqsList = document.getElementById("wlRequests");
|
||||
const reqsBlock = document.getElementById("wlRequestsBlock");
|
||||
const reqsBadge = document.getElementById("wlReqBadge");
|
||||
|
||||
reqsList?.addEventListener("click", async e => {
|
||||
const btn = e.target.closest("button[data-req-action]");
|
||||
if (!btn) return;
|
||||
const name = btn.dataset.name;
|
||||
const action = btn.dataset.reqAction; // "approve" | "deny"
|
||||
if (!name || !action) return;
|
||||
if (action === "deny" && !confirm(`Deny ${name}'s request?`)) return;
|
||||
try {
|
||||
await apiJson(`/api/whitelist/requests/${action}`, { name });
|
||||
await refreshRequests();
|
||||
// Approving fires /whitelist add via stdin -- let the server-side write
|
||||
// ~1-2 s of grace before re-reading whitelist.json.
|
||||
if (action === "approve") refreshSoon();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
async function refreshRequests() {
|
||||
if (!reqsList || !reqsBlock || !reqsBadge) return;
|
||||
let data;
|
||||
try { data = await api("/api/whitelist/requests"); }
|
||||
catch { return; }
|
||||
const reqs = data.requests || [];
|
||||
if (reqs.length === 0) {
|
||||
reqsBlock.hidden = true;
|
||||
reqsBadge.hidden = true;
|
||||
return;
|
||||
}
|
||||
reqsBlock.hidden = false;
|
||||
reqsBadge.hidden = false;
|
||||
reqsBadge.textContent = String(reqs.length);
|
||||
reqsList.innerHTML = reqs.map(r => `
|
||||
<li>
|
||||
<div class="wl-req-meta">${escapeHtml(r.username)}</div>
|
||||
${r.message ? `<div class="wl-req-msg">"${escapeHtml(r.message)}"</div>` : ""}
|
||||
<div class="wl-req-actions">
|
||||
<button data-req-action="approve" data-name="${escapeHtml(r.username)}">Approve</button>
|
||||
<button class="ghost-btn" data-req-action="deny" data-name="${escapeHtml(r.username)}">Deny</button>
|
||||
</div>
|
||||
</li>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
refreshRequests();
|
||||
setInterval(refreshRequests, 15000);
|
||||
}
|
||||
|
||||
async function addWhitelisted(refreshSoon) {
|
||||
const inp = document.getElementById("wlInput");
|
||||
const name = inp.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await apiJson("/api/whitelist/add", { name });
|
||||
inp.value = "";
|
||||
refreshSoon();
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
:root {
|
||||
--bg-deep: #070b16;
|
||||
--bg: #0b1220;
|
||||
--card: #13192a;
|
||||
--card-edge: #2a3552;
|
||||
--text: #e8dfc8;
|
||||
--text-muted: #7a8497;
|
||||
--brass: #d4a24c;
|
||||
--brass-hi: #e8b95c;
|
||||
--brass-lo: #5c4519;
|
||||
--magic: #5dd4e8;
|
||||
--danger: #b94228;
|
||||
--ok: #4ade80;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--brass-lo);
|
||||
background: linear-gradient(180deg, #0f1626 0%, var(--bg-deep) 100%);
|
||||
}
|
||||
.topbar h1 {
|
||||
font-size: 16px; margin: 0;
|
||||
color: var(--brass-hi);
|
||||
font-weight: 600; letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-radius: 99px;
|
||||
background: var(--bg-deep); border: 1px solid var(--card-edge);
|
||||
font-size: 13px;
|
||||
}
|
||||
.status-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
|
||||
.status-pill.online .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
||||
.status-pill.offline .dot { background: var(--danger); }
|
||||
|
||||
.layout {
|
||||
max-width: 1400px; margin: 22px auto; padding: 0 22px;
|
||||
display: grid; grid-template-columns: 280px 1fr 280px; gap: 18px;
|
||||
}
|
||||
/* Below the 3-column breakpoint: drop the right sidebar to a new full-width row
|
||||
under the main + left sidebar so cards still get reasonable horizontal space. */
|
||||
@media (max-width: 1100px) {
|
||||
.layout { grid-template-columns: 280px 1fr; }
|
||||
.aside-right { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.aside-right { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%);
|
||||
border: 1px solid var(--brass-lo);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
.card + .card { margin-top: 14px; }
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--brass-hi); font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-row { display: flex; justify-content: space-between; margin: 6px 0; font-size: 13px; }
|
||||
.stat-row .key { color: var(--text-muted); }
|
||||
.stat-row .val { color: var(--text); font-family: "SF Mono", Consolas, monospace; }
|
||||
|
||||
.name-list { list-style: none; padding: 0; margin: 0; }
|
||||
.name-list li {
|
||||
padding: 6px 8px; background: var(--bg-deep); border-radius: 4px;
|
||||
margin-bottom: 4px; font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.name-list li button {
|
||||
background: transparent; border: 1px solid var(--card-edge);
|
||||
color: var(--text-muted); padding: 2px 8px; font-size: 11px;
|
||||
border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.name-list li button:hover { color: var(--danger); border-color: var(--danger); }
|
||||
.empty-state { color: var(--text-muted); font-size: 13px; padding: 8px 0; font-style: italic; }
|
||||
|
||||
.console-pane {
|
||||
background: #050810;
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-family: "SF Mono", Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12px; line-height: 1.4;
|
||||
color: #b7c0d6;
|
||||
height: 480px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.console-pane .err { color: #ff8a72; }
|
||||
|
||||
.input-row { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.input-wrap {
|
||||
flex: 1; position: relative;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.input-wrap:focus-within { border-color: var(--brass); }
|
||||
|
||||
.ghost {
|
||||
position: absolute; inset: 0;
|
||||
padding: 8px 12px;
|
||||
font-family: "SF Mono", Consolas, monospace; font-size: 13px;
|
||||
line-height: normal;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ghost .typed { color: transparent; }
|
||||
|
||||
.input-wrap input {
|
||||
width: 100%; box-sizing: border-box;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
font-family: "SF Mono", Consolas, monospace; font-size: 13px;
|
||||
position: relative;
|
||||
}
|
||||
.input-wrap input:focus { outline: none; }
|
||||
/* Hide the browser's built-in number-input spinner -- looks out of place against the dark theme */
|
||||
.input-wrap input[type="number"]::-webkit-inner-spin-button,
|
||||
.input-wrap input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none; margin: 0;
|
||||
}
|
||||
.input-wrap input[type="number"] { -moz-appearance: textfield; }
|
||||
|
||||
/* Suggestion dropdown -- shown below the command input with multiple matches */
|
||||
.suggest-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0; right: 0;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--brass-lo);
|
||||
border-radius: 4px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
}
|
||||
.suggest-list.show { display: block; }
|
||||
.suggest-item {
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.suggest-item + .suggest-item {
|
||||
border-top: 1px solid #1a2436;
|
||||
}
|
||||
.suggest-item:hover, .suggest-item.active {
|
||||
background: var(--card);
|
||||
color: var(--brass-hi);
|
||||
}
|
||||
.suggest-item .args {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
.suggest-item .args em { font-style: normal; color: var(--brass); }
|
||||
.suggest-empty { padding: 8px 12px; color: var(--text-muted); font-size: 12px; font-style: italic; }
|
||||
|
||||
.hint {
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
padding: 6px 0 0 4px; min-height: 16px;
|
||||
}
|
||||
.hint kbd {
|
||||
background: var(--bg-deep); border: 1px solid var(--card-edge);
|
||||
padding: 1px 4px; border-radius: 2px; font-size: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 50%, var(--brass-lo) 100%);
|
||||
color: #1a140f;
|
||||
border: 1px solid var(--brass-lo);
|
||||
padding: 8px 16px; border-radius: 4px;
|
||||
font-weight: 600; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger {
|
||||
background: linear-gradient(180deg, #d65a3e 0%, var(--danger) 50%, #6a2814 100%);
|
||||
color: #fff;
|
||||
border-color: #6a2814;
|
||||
}
|
||||
button.ghost-btn {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text);
|
||||
border-color: var(--card-edge);
|
||||
}
|
||||
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.footer { color: var(--text-muted); font-size: 11px; text-align: center; padding: 22px; }
|
||||
|
||||
/* Modal dialogs (Pregen / Backups / Wipe / etc.) */
|
||||
.modal {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal[hidden] { display: none; }
|
||||
.modal-backdrop {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(7, 11, 22, 0.85);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.modal-dialog {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%);
|
||||
border: 1px solid var(--brass-lo);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%; max-width: 520px;
|
||||
max-height: 85vh; overflow-y: auto;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.modal-dialog.danger { border-color: #6a2814; }
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--card-edge);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--brass-hi); font-weight: 600;
|
||||
}
|
||||
.modal-dialog.danger .modal-header h2 { color: #d65a3e; }
|
||||
.modal-close {
|
||||
background: transparent; border: none;
|
||||
color: var(--text-muted); font-size: 22px; line-height: 1;
|
||||
cursor: pointer; padding: 0 4px;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
/* Trigger button list (the "World" card, etc.) */
|
||||
.trigger-list {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.trigger-list button {
|
||||
width: 100%; text-align: left;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.trigger-list .badge {
|
||||
background: var(--bg-deep); border: 1px solid var(--card-edge);
|
||||
color: var(--text-muted);
|
||||
padding: 1px 6px; border-radius: 99px;
|
||||
font-size: 10px; font-weight: 500;
|
||||
}
|
||||
.trigger-list .badge.ok { color: var(--ok); border-color: var(--ok); }
|
||||
.trigger-list .badge.warn { color: var(--brass-hi); border-color: var(--brass-lo); }
|
||||
|
||||
/* Topbar server icon */
|
||||
.topbar-icon {
|
||||
height: 28px; width: 28px;
|
||||
margin-right: 10px;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
.topbar-left {
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
|
||||
/* Login overlay */
|
||||
.login-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(7, 11, 22, 0.92);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.login-overlay[hidden] { display: none; }
|
||||
.login-box {
|
||||
background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%);
|
||||
border: 1px solid var(--brass-lo);
|
||||
border-radius: 8px;
|
||||
padding: 28px 32px;
|
||||
width: 320px;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.login-box h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px; letter-spacing: 0.04em;
|
||||
color: var(--brass-hi); font-weight: 600;
|
||||
}
|
||||
.login-box p {
|
||||
margin: 0 0 16px; color: var(--text-muted); font-size: 13px;
|
||||
}
|
||||
.login-box .input-wrap { margin-bottom: 12px; }
|
||||
.login-box button { width: 100%; }
|
||||
.login-error { color: var(--danger); font-size: 12px; min-height: 14px; padding-top: 8px; }
|
||||
|
||||
/* Account card */
|
||||
.acct-form { font-size: 12px; }
|
||||
.acct-msg { font-size: 12px; min-height: 14px; margin-top: 8px; color: var(--danger); }
|
||||
.acct-msg.ok { color: var(--ok); }
|
||||
|
||||
/* Modpack update card */
|
||||
#updateCard .update-note {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
margin: 10px 0 0; line-height: 1.4;
|
||||
}
|
||||
#updateCard .update-progress { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--card-edge); }
|
||||
#updateCard .update-phase {
|
||||
font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--brass-hi); font-weight: 600; margin-bottom: 6px;
|
||||
}
|
||||
#updateCard .update-status { font-size: 12px; color: var(--text); margin-top: 6px; }
|
||||
#updateCard .update-progress button { margin-top: 10px; }
|
||||
/* Pulsing border highlight when an update is available */
|
||||
#updateCard.has-update {
|
||||
border-color: var(--brass);
|
||||
box-shadow: 0 0 0 1px var(--brass-lo), 0 0 18px rgba(212, 162, 76, 0.18);
|
||||
}
|
||||
|
||||
/* Resources card -- Memory + CPU with progress bars */
|
||||
.res-block { margin-bottom: 14px; }
|
||||
.res-block:last-child { margin-bottom: 0; }
|
||||
.res-label {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.res-label .res-val { color: var(--text); font-family: "SF Mono", Consolas, monospace; }
|
||||
.res-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.res-bar > div {
|
||||
height: 100%; width: 0%;
|
||||
background: linear-gradient(90deg, var(--brass-lo), var(--brass-hi));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.res-sub {
|
||||
display: flex; justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.res-sub strong {
|
||||
color: var(--text); font-family: "SF Mono", Consolas, monospace;
|
||||
font-weight: 500; margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Collapsible cards (h2 click toggles) */
|
||||
.card.collapsible .collapsible-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.card.collapsible .caret {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--brass);
|
||||
font-size: 12px;
|
||||
}
|
||||
.card.collapsible:not(.expanded) .caret { transform: rotate(-90deg); }
|
||||
.card.collapsible:not(.expanded) .card-body { display: none; }
|
||||
.card.collapsible:not(.expanded) h2 { margin: 0; }
|
||||
|
||||
/* Server settings modal */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.settings-grid label {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.settings-grid input, .settings-grid select {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.settings-grid input:focus, .settings-grid select:focus {
|
||||
outline: none; border-color: var(--brass);
|
||||
}
|
||||
.settings-grid label:nth-child(1) { grid-column: 1 / -1; } /* MOTD spans both cols */
|
||||
.settings-checks {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--card-edge);
|
||||
}
|
||||
.settings-checks label { font-size: 12px; }
|
||||
|
||||
/* Whitelist requests */
|
||||
.wl-req-label {
|
||||
font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--brass-hi); font-weight: 600;
|
||||
margin: 6px 0 6px 2px;
|
||||
}
|
||||
#wlRequests li {
|
||||
flex-direction: column; align-items: stretch; gap: 6px;
|
||||
}
|
||||
.wl-req-meta { font-size: 11px; color: var(--text); }
|
||||
.wl-req-msg { font-size: 11px; color: var(--text-muted); font-style: italic; }
|
||||
.wl-req-actions { display: flex; gap: 4px; }
|
||||
.wl-req-actions button { padding: 3px 8px; font-size: 10px; }
|
||||
.card h2 .badge { vertical-align: middle; margin-left: 6px; }
|
||||
|
||||
/* Backups card */
|
||||
.backup-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 8px; padding: 8px 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.backup-meta { flex: 1; min-width: 0; }
|
||||
.backup-name {
|
||||
font-family: "SF Mono", Consolas, monospace; font-size: 11px;
|
||||
color: var(--text); word-break: break-all;
|
||||
}
|
||||
.backup-sub { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
||||
.backup-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.backup-actions button { font-size: 10px; padding: 3px 8px; }
|
||||
|
||||
/* Danger zone card */
|
||||
.danger-card { border-color: #6a2814; }
|
||||
.danger-card .collapsible-toggle .caret { color: var(--danger); }
|
||||
.danger-card h2 { color: #d65a3e; }
|
||||
.danger-note {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
margin: 0 0 12px; line-height: 1.45;
|
||||
}
|
||||
.danger-note code {
|
||||
background: var(--bg-deep); border: 1px solid var(--card-edge);
|
||||
padding: 1px 5px; border-radius: 3px; font-size: 11px;
|
||||
}
|
||||
.danger-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.danger-row input[type="checkbox"] { margin: 0; }
|
||||
.danger-section {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.danger-section-title {
|
||||
font-size: 12px; font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.danger-section .danger-row { margin: 4px 0; }
|
||||
.danger-section code {
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--accent, #d4a24c);
|
||||
}
|
||||
.danger-section input[type="text"] {
|
||||
background: var(--input-bg, #0b1220);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono, monospace);
|
||||
}
|
||||
.danger-section input[type="text"]:disabled { opacity: 0.4; }
|
||||
|
||||
/* Pre-generation status panel */
|
||||
.pg-status {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--card-edge);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pg-status .stat-row { font-size: 12px; margin: 4px 0; }
|
||||
.pg-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--card-edge);
|
||||
border-radius: 3px;
|
||||
margin: 8px 0 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pg-progress-bar > div {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--brass-lo), var(--brass-hi));
|
||||
width: 0%;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.pg-state-running { color: var(--ok); }
|
||||
.pg-state-paused { color: var(--brass-hi); }
|
||||
.pg-state-idle { color: var(--text-muted); }
|
||||
.pg-state-cancelling { color: var(--danger); }
|
||||
Reference in New Issue
Block a user