a1331212cb
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.
322 lines
13 KiB
C#
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;
|
|
}
|
|
}
|