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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user