Files
brass-and-sigil/launcher/Services/LaunchService.cs
T
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

228 lines
8.7 KiB
C#

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