Files
Matt Sijbers a1331212cb 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.
2026-05-05 00:19:05 +01:00

322 lines
13 KiB
C#

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;
}
}