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.
228 lines
8.7 KiB
C#
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;
|
|
}
|
|
}
|