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,227 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CmlLib.Core;
|
||||
using CmlLib.Core.Auth;
|
||||
using CmlLib.Core.Installers;
|
||||
using CmlLib.Core.ProcessBuilder;
|
||||
using CmlLib.Core.Installer.Forge;
|
||||
using CmlLib.Core.Installer.NeoForge;
|
||||
using ModpackLauncher.Models;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
public sealed class LaunchService
|
||||
{
|
||||
private readonly MinecraftLauncher _launcher;
|
||||
private readonly ForgeInstaller _forgeInstaller;
|
||||
private readonly NeoForgeInstaller _neoForgeInstaller;
|
||||
|
||||
private readonly string _installDir;
|
||||
|
||||
public LaunchService(string installDir)
|
||||
{
|
||||
_installDir = installDir;
|
||||
var path = new MinecraftPath(installDir);
|
||||
_launcher = new MinecraftLauncher(path);
|
||||
_forgeInstaller = new ForgeInstaller(_launcher);
|
||||
_neoForgeInstaller = new NeoForgeInstaller(_launcher);
|
||||
}
|
||||
|
||||
public MinecraftLauncher Launcher => _launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Belt-and-braces check after the loader installer runs: parse the version JSON
|
||||
/// and download any libraries that are listed but missing on disk. Works around
|
||||
/// a known CmlLib quirk where libraries with @jar suffix in the Maven coordinate
|
||||
/// (e.g. "cpw.mods:bootstraplauncher:2.0.2@jar" used by NeoForge 21.1.x) get
|
||||
/// skipped by the installer's library downloader.
|
||||
/// </summary>
|
||||
private async Task VerifyVersionLibrariesAsync(string versionId,
|
||||
IProgress<ProgressReport> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var versionJsonPath = Path.Combine(_installDir, "versions", versionId, $"{versionId}.json");
|
||||
if (!File.Exists(versionJsonPath)) return;
|
||||
|
||||
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(versionJsonPath, ct).ConfigureAwait(false));
|
||||
if (!doc.RootElement.TryGetProperty("libraries", out var libsArr)) return;
|
||||
|
||||
var libsDir = Path.Combine(_installDir, "libraries");
|
||||
var missing = new System.Collections.Generic.List<(string Path, string Url, string? Sha1)>();
|
||||
|
||||
foreach (var lib in libsArr.EnumerateArray())
|
||||
{
|
||||
if (!lib.TryGetProperty("downloads", out var dls)) continue;
|
||||
if (!dls.TryGetProperty("artifact", out var art)) continue;
|
||||
if (!art.TryGetProperty("path", out var pPath) || !art.TryGetProperty("url", out var pUrl)) continue;
|
||||
|
||||
var relPath = pPath.GetString();
|
||||
var url = pUrl.GetString();
|
||||
var sha1 = art.TryGetProperty("sha1", out var pSha1) ? pSha1.GetString() : null;
|
||||
if (string.IsNullOrEmpty(relPath) || string.IsNullOrEmpty(url)) continue;
|
||||
|
||||
var fullPath = Path.Combine(libsDir, relPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
missing.Add((fullPath, url, sha1));
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.Count == 0) return;
|
||||
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
$"Verifying loader libraries: {missing.Count} missing, fetching..."));
|
||||
|
||||
using var http = new HttpClient();
|
||||
http.Timeout = TimeSpan.FromMinutes(5);
|
||||
for (int i = 0; i < missing.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (path, url, _) = missing[i];
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Log,
|
||||
$" Library {i + 1}/{missing.Count}: {Path.GetFileName(path)}"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var bytes = await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
|
||||
await File.WriteAllBytesAsync(path, bytes, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
"Loader libraries verified."));
|
||||
}
|
||||
|
||||
public async Task<string> InstallVersionAsync(
|
||||
Manifest manifest,
|
||||
IProgress<ProgressReport> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EventHandler<InstallerProgressChangedEventArgs> fileProgressHandler = (_, args) =>
|
||||
{
|
||||
var pct = args.TotalTasks > 0
|
||||
? (args.ProgressedTasks * 100.0 / args.TotalTasks)
|
||||
: -1;
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Progress,
|
||||
$"{args.EventType}: {args.Name ?? ""}",
|
||||
Current: args.ProgressedTasks,
|
||||
Total: args.TotalTasks,
|
||||
Percent: pct
|
||||
));
|
||||
};
|
||||
|
||||
EventHandler<ByteProgress> byteProgressHandler = (_, args) =>
|
||||
{
|
||||
if (args.TotalBytes <= 0) return;
|
||||
var pct = args.ToRatio() * 100.0;
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Progress,
|
||||
$"{args.ProgressedBytes:N0} / {args.TotalBytes:N0} bytes",
|
||||
Current: (int)Math.Min(args.ProgressedBytes, int.MaxValue),
|
||||
Total: (int)Math.Min(args.TotalBytes, int.MaxValue),
|
||||
Percent: pct
|
||||
));
|
||||
};
|
||||
|
||||
_launcher.FileProgressChanged += fileProgressHandler;
|
||||
_launcher.ByteProgressChanged += byteProgressHandler;
|
||||
|
||||
try
|
||||
{
|
||||
var mcVersion = manifest.Minecraft.Version;
|
||||
var loader = manifest.Loader;
|
||||
|
||||
progress.Report(new ProgressReport(ProgressKind.Status, $"Installing Minecraft {mcVersion}..."));
|
||||
await _launcher.InstallAsync(mcVersion, ct).AsTask().ConfigureAwait(false);
|
||||
|
||||
if (loader == null || string.IsNullOrEmpty(loader.Type) ||
|
||||
loader.Type.Equals("vanilla", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return mcVersion;
|
||||
}
|
||||
|
||||
if (loader.Type.Equals("forge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
$"Installing Forge {loader.Version} for {mcVersion}..."
|
||||
));
|
||||
var fid = await _forgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false);
|
||||
await VerifyVersionLibrariesAsync(fid, progress, ct).ConfigureAwait(false);
|
||||
return fid;
|
||||
}
|
||||
|
||||
if (loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
$"Installing NeoForge {loader.Version} for {mcVersion}..."
|
||||
));
|
||||
var nid = await _neoForgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false);
|
||||
await VerifyVersionLibrariesAsync(nid, progress, ct).ConfigureAwait(false);
|
||||
return nid;
|
||||
}
|
||||
|
||||
throw new NotSupportedException(
|
||||
$"Loader '{loader.Type}' is not yet supported. Use 'forge', 'neoforge', or 'vanilla'."
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_launcher.FileProgressChanged -= fileProgressHandler;
|
||||
_launcher.ByteProgressChanged -= byteProgressHandler;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> LaunchAsync(
|
||||
string versionId,
|
||||
MSession session,
|
||||
int minMemoryMB,
|
||||
int maxMemoryMB,
|
||||
IProgress<ProgressReport> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var option = new MLaunchOption
|
||||
{
|
||||
Session = session,
|
||||
MaximumRamMb = maxMemoryMB,
|
||||
MinimumRamMb = minMemoryMB,
|
||||
// Generational ZGC: low-pause concurrent collector -- recommended by
|
||||
// Distant Horizons (and broadly better than the default G1 for modded MC).
|
||||
// Requires Java 21+ which CmlLib auto-installs for MC 1.21.1.
|
||||
ExtraJvmArguments = new[]
|
||||
{
|
||||
new MArgument("-XX:+UseZGC"),
|
||||
new MArgument("-XX:+ZGenerational"),
|
||||
}
|
||||
};
|
||||
|
||||
progress.Report(new ProgressReport(ProgressKind.Status, "Building launch process..."));
|
||||
var process = await _launcher.BuildProcessAsync(versionId, option).ConfigureAwait(false);
|
||||
|
||||
var wrapper = new ProcessWrapper(process);
|
||||
wrapper.OutputReceived += (_, log) =>
|
||||
{
|
||||
progress.Report(new ProgressReport(ProgressKind.Log, log));
|
||||
};
|
||||
|
||||
wrapper.StartWithEvents();
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
$"Minecraft launched (PID {process.Id})"
|
||||
));
|
||||
|
||||
var exitCode = await wrapper.WaitForExitTaskAsync().ConfigureAwait(false);
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
$"Game exited (code {exitCode})"
|
||||
));
|
||||
return exitCode;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user