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,95 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CmlLib.Core.Auth;
|
||||
using CmlLib.Core.Auth.Microsoft;
|
||||
using XboxAuthNet.Game.Msal;
|
||||
using XboxAuthNet.Game.Msal.OAuth;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft auth wrapper. Two modes:
|
||||
/// 1. Custom Azure AD client ID (msalClientId set) -> MSAL flow. Requires Microsoft
|
||||
/// to have approved the app for Minecraft API access.
|
||||
/// 2. No custom client ID (default) -> CmlLib's BuildDefault() which uses the
|
||||
/// WebView2-driven Microsoft Live OAuth flow with the Xbox Live SDK client ID.
|
||||
/// Doesn't require an Azure registration; works out of the box on any Win10/11
|
||||
/// machine that has the WebView2 Runtime installed (preinstalled since 2021).
|
||||
/// </summary>
|
||||
public sealed class AuthService
|
||||
{
|
||||
private readonly string _clientId;
|
||||
|
||||
public AuthService(string clientId)
|
||||
{
|
||||
_clientId = clientId;
|
||||
}
|
||||
|
||||
/// <summary>True when the user has provided their own Azure App Registration ID.</summary>
|
||||
public bool HasCustomClientId => !string.IsNullOrWhiteSpace(_clientId)
|
||||
&& _clientId != "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
/// <summary>Auth is always available now (BuildDefault provides a fallback).</summary>
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public async Task<MSession> AuthenticateAsync()
|
||||
{
|
||||
var loginHandler = await BuildLoginHandlerAsync();
|
||||
try
|
||||
{
|
||||
return await loginHandler.AuthenticateSilently();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await loginHandler.AuthenticateInteractively();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MSession> SignInInteractivelyAsync()
|
||||
{
|
||||
var loginHandler = await BuildLoginHandlerAsync();
|
||||
return await loginHandler.AuthenticateInteractively();
|
||||
}
|
||||
|
||||
public async Task<MSession?> TryAuthenticateSilentlyAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var loginHandler = await BuildLoginHandlerAsync();
|
||||
return await loginHandler.AuthenticateSilently();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SignOutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var loginHandler = await BuildLoginHandlerAsync();
|
||||
await loginHandler.Signout();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JELoginHandler> BuildLoginHandlerAsync()
|
||||
{
|
||||
if (HasCustomClientId)
|
||||
{
|
||||
// Custom Azure AD MSAL flow -- requires the app to be approved by Microsoft.
|
||||
var app = await MsalClientHelper.BuildApplicationWithCache(_clientId);
|
||||
return new JELoginHandlerBuilder()
|
||||
.WithOAuthProvider(new MsalCodeFlowProvider(app))
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Default path: WebView2 + Xbox Live SDK community client ID. No Azure registration.
|
||||
// Note: requires WebView2 Runtime on Windows (preinstalled on Win10/11 since 2021).
|
||||
return JELoginHandlerBuilder.BuildDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
public static class FileLog
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static string LogPath { get; } = BuildPath();
|
||||
|
||||
private static string BuildPath()
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"BrassAndSigil"
|
||||
);
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, "launcher.log");
|
||||
}
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Truncate per launch so we always have the most recent run.
|
||||
File.WriteAllText(LogPath,
|
||||
$"=== ModpackLauncher launched {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void Write(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(LogPath,
|
||||
$"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-seeds Iris's <c>config/iris.properties</c> with a default shader pack
|
||||
/// for fresh installs, so friends launch the game and the recommended shader
|
||||
/// is already on rather than them having to dig through Video Settings.
|
||||
///
|
||||
/// Respects user choice: if the file already exists with a non-empty
|
||||
/// <c>shaderPack=...</c> entry, we leave it alone -- only fresh installs (or
|
||||
/// installs where Iris has never been opened) get the default.
|
||||
/// </summary>
|
||||
public sealed class IrisConfigService
|
||||
{
|
||||
public void SetDefaultShader(string gameDir, string shaderPackFilename)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(shaderPackFilename)) return;
|
||||
var configDir = Path.Combine(gameDir, "config");
|
||||
Directory.CreateDirectory(configDir);
|
||||
var path = Path.Combine(configDir, "iris.properties");
|
||||
|
||||
var lines = File.Exists(path) ? File.ReadAllLines(path).ToList() : new List<string>();
|
||||
|
||||
// If a shaderPack is already chosen and non-empty (the user picked
|
||||
// something), respect it and bail.
|
||||
var existingShader = lines
|
||||
.Select(l => l.TrimStart())
|
||||
.Where(l => l.Length > 0 && l[0] != '#')
|
||||
.Select(l => { var i = l.IndexOf('='); return i < 0 ? null : new { Key = l.Substring(0, i).Trim(), Value = l.Substring(i + 1).Trim() }; })
|
||||
.Where(p => p != null && p.Key.Equals("shaderPack", System.StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p!.Value)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(existingShader)) return;
|
||||
|
||||
// No shader set -- write our defaults. Update existing keys in-place if
|
||||
// they exist (e.g. shaderPack="" placeholder), append otherwise.
|
||||
var defaults = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "shaderPack", shaderPackFilename },
|
||||
{ "enableShaders", "true" },
|
||||
};
|
||||
var seen = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var trimmed = lines[i].TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] == '#') continue;
|
||||
var idx = trimmed.IndexOf('=');
|
||||
if (idx < 0) continue;
|
||||
var key = trimmed.Substring(0, idx).Trim();
|
||||
if (defaults.TryGetValue(key, out var val))
|
||||
{
|
||||
lines[i] = $"{key}={val}";
|
||||
seen.Add(key);
|
||||
}
|
||||
}
|
||||
foreach (var (k, v) in defaults)
|
||||
{
|
||||
if (!seen.Contains(k)) lines.Add($"{k}={v}");
|
||||
}
|
||||
File.WriteAllLines(path, lines);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ModpackLauncher.Models;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
public sealed class ManifestSyncService
|
||||
{
|
||||
private const string PackVersionFile = "pack-version.json";
|
||||
|
||||
private static readonly string[] ManagedRoots =
|
||||
{
|
||||
"mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs"
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public ManifestSyncService(HttpClient? http = null)
|
||||
{
|
||||
_http = http ?? new HttpClient();
|
||||
_http.Timeout = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>Configure HTTP Basic auth for all subsequent requests. Pass null to clear.</summary>
|
||||
public void SetBasicAuth(string? username, string? password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(username))
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = null;
|
||||
return;
|
||||
}
|
||||
var raw = $"{username}:{password ?? ""}";
|
||||
var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
|
||||
_http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", b64);
|
||||
}
|
||||
|
||||
public sealed record SyncResult(Manifest Manifest, int Downloaded, int Removed);
|
||||
|
||||
/// <summary>Just fetch the manifest JSON without doing the full file sync. Used for "is an update available?" checks on startup.</summary>
|
||||
public async Task<Manifest> FetchManifestOnlyAsync(string manifestUrl, CancellationToken ct = default)
|
||||
{
|
||||
var json = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<Manifest>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
if (manifest == null) throw new InvalidOperationException("Manifest is empty or invalid.");
|
||||
manifest.Files ??= new System.Collections.Generic.List<ManifestFile>();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast check (no hashing): which files listed in the manifest are missing on disk.
|
||||
/// Used as a pre-launch sanity pass to catch AV quarantines / interrupted installs.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<ManifestFile> FindMissingFiles(Manifest manifest, string installDir)
|
||||
{
|
||||
var missing = new System.Collections.Generic.List<ManifestFile>();
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var dest = Path.Combine(installDir, file.Path);
|
||||
if (!File.Exists(dest)) missing.Add(file);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
public PackVersionRecord? GetLocalPackVersion(string installDir)
|
||||
{
|
||||
var path = Path.Combine(installDir, PackVersionFile);
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PackVersionRecord>(File.ReadAllText(path));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncAsync(
|
||||
string manifestUrl,
|
||||
string installDir,
|
||||
IProgress<ProgressReport> progress,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
progress.Report(new ProgressReport(ProgressKind.Status, "Fetching manifest..."));
|
||||
|
||||
var manifestJson = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<Manifest>(manifestJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
}) ?? throw new InvalidOperationException("Manifest is empty or invalid.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Minecraft.Version))
|
||||
{
|
||||
throw new InvalidOperationException("Manifest is missing minecraft.version.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(installDir);
|
||||
|
||||
var local = GetLocalPackVersion(installDir);
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
$"Pack: {manifest.Name ?? "modpack"} v{manifest.Version ?? "?"} (local: {local?.Version ?? "none"})"
|
||||
));
|
||||
|
||||
var wantedPaths = new HashSet<string>(
|
||||
manifest.Files.Select(f => NormalizePath(f.Path)),
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
// Remove managed files no longer in manifest
|
||||
var toRemove = new List<string>();
|
||||
foreach (var root in ManagedRoots)
|
||||
{
|
||||
var rootDir = Path.Combine(installDir, root);
|
||||
if (!Directory.Exists(rootDir)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = NormalizePath(Path.GetRelativePath(installDir, file));
|
||||
if (!wantedPaths.Contains(rel))
|
||||
{
|
||||
toRemove.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in toRemove)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Log,
|
||||
$"Removed: {Path.GetRelativePath(installDir, file)}"
|
||||
));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Log,
|
||||
$"Could not remove {file}: {ex.Message}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine what to download
|
||||
var toDownload = new List<ManifestFile>();
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var dest = Path.Combine(installDir, file.Path);
|
||||
if (!File.Exists(dest))
|
||||
{
|
||||
toDownload.Add(file);
|
||||
continue;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(file.Sha1))
|
||||
{
|
||||
var actual = await ComputeSha1Async(dest, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
toDownload.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Status,
|
||||
toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} file(s)..."
|
||||
));
|
||||
|
||||
for (int i = 0; i < toDownload.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var file = toDownload[i];
|
||||
var pct = toDownload.Count == 0 ? 100 : (i * 100.0 / toDownload.Count);
|
||||
progress.Report(new ProgressReport(
|
||||
ProgressKind.Progress,
|
||||
$"Downloading {file.Path}",
|
||||
Current: i + 1,
|
||||
Total: toDownload.Count,
|
||||
Percent: pct
|
||||
));
|
||||
var dest = Path.Combine(installDir, file.Path);
|
||||
await DownloadFileAsync(file.Url, dest, file.Sha1, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var record = new PackVersionRecord
|
||||
{
|
||||
Name = manifest.Name,
|
||||
Version = manifest.Version,
|
||||
SyncedAt = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(installDir, PackVersionFile),
|
||||
JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }),
|
||||
ct
|
||||
).ConfigureAwait(false);
|
||||
|
||||
progress.Report(new ProgressReport(ProgressKind.Status, "Sync complete."));
|
||||
return new SyncResult(manifest, toDownload.Count, toRemove.Count);
|
||||
}
|
||||
|
||||
private async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
|
||||
var tmp = destPath + ".part";
|
||||
|
||||
using (var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var src = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
await using var dst = File.Create(tmp);
|
||||
await src.CopyToAsync(dst, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(expectedSha1))
|
||||
{
|
||||
var actual = await ComputeSha1Async(tmp, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(tmp);
|
||||
throw new InvalidOperationException(
|
||||
$"Hash mismatch for {Path.GetFileName(destPath)} (expected {expectedSha1}, got {actual})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(destPath)) File.Delete(destPath);
|
||||
File.Move(tmp, destPath);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha1Async(string path, CancellationToken ct)
|
||||
{
|
||||
using var sha = SHA1.Create();
|
||||
await using var stream = File.OpenRead(path);
|
||||
var bytes = await sha.ComputeHashAsync(stream, ct).ConfigureAwait(false);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizePath(string p) => p.Replace('\\', '/').TrimStart('/');
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
public enum ProgressKind
|
||||
{
|
||||
Status,
|
||||
Progress,
|
||||
Log,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record ProgressReport(
|
||||
ProgressKind Kind,
|
||||
string Message,
|
||||
int Current = 0,
|
||||
int Total = 0,
|
||||
double Percent = -1
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using fNbt;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-populates Minecraft's multiplayer server list (`servers.dat`) so the
|
||||
/// modpack's server is one click away on first launch.
|
||||
///
|
||||
/// servers.dat is uncompressed NBT (unlike level.dat which is gzipped). Schema:
|
||||
/// compound {
|
||||
/// servers : list[compound] {
|
||||
/// name : string
|
||||
/// ip : string
|
||||
/// acceptTextures : byte (optional, 1 = enabled)
|
||||
/// hidden : byte (optional, 0 = visible)
|
||||
/// icon : string (optional, base64 PNG)
|
||||
/// }
|
||||
/// }
|
||||
/// </summary>
|
||||
public sealed class ServerListService
|
||||
{
|
||||
/// <summary>
|
||||
/// Add or update an entry. Match-by-IP: if an entry with the same IP exists,
|
||||
/// update its name; otherwise prepend a new entry so the friend's first
|
||||
/// glance at multiplayer shows our server at the top.
|
||||
/// </summary>
|
||||
public void EnsureServer(string gameDir, string name, string ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(ip)) return;
|
||||
Directory.CreateDirectory(gameDir);
|
||||
var path = Path.Combine(gameDir, "servers.dat");
|
||||
|
||||
NbtCompound root;
|
||||
NbtList servers;
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var nbt = new NbtFile();
|
||||
nbt.LoadFromFile(path, NbtCompression.None, _ => true);
|
||||
root = nbt.RootTag;
|
||||
if (root.TryGet("servers", out NbtList? existingList) && existingList is not null)
|
||||
{
|
||||
servers = existingList;
|
||||
}
|
||||
else
|
||||
{
|
||||
servers = new NbtList("servers", NbtTagType.Compound);
|
||||
root.Add(servers);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Existing file unreadable -- start fresh rather than crashing the install.
|
||||
root = new NbtCompound("");
|
||||
servers = new NbtList("servers", NbtTagType.Compound);
|
||||
root.Add(servers);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
root = new NbtCompound("");
|
||||
servers = new NbtList("servers", NbtTagType.Compound);
|
||||
root.Add(servers);
|
||||
}
|
||||
|
||||
// Match-by-IP. If found, update the display name; otherwise prepend.
|
||||
for (int i = 0; i < servers.Count; i++)
|
||||
{
|
||||
if (servers[i] is not NbtCompound entry) continue;
|
||||
if (entry.TryGet("ip", out NbtString? ipTag) && ipTag?.Value == ip)
|
||||
{
|
||||
entry["name"] = new NbtString("name", name);
|
||||
Save(root, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var newEntry = new NbtCompound
|
||||
{
|
||||
new NbtString("name", name),
|
||||
new NbtString("ip", ip),
|
||||
// acceptTextures = 1 lets the server send its resource pack without prompting
|
||||
// (the player still gets the prompt; this just allows the option). Default
|
||||
// value matches what vanilla MC writes when you click "Done" in the UI.
|
||||
new NbtByte("acceptTextures", 1),
|
||||
};
|
||||
servers.Insert(0, newEntry);
|
||||
Save(root, path);
|
||||
}
|
||||
|
||||
private static void Save(NbtCompound root, string path)
|
||||
{
|
||||
var file = new NbtFile(root);
|
||||
file.SaveToFile(path, NbtCompression.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
public static class SystemInfo
|
||||
{
|
||||
private const long DefaultFallbackKB = 8L * 1024 * 1024; // assume 8 GB if detection fails
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetPhysicallyInstalledSystemMemory(out long memoryInKilobytes);
|
||||
|
||||
/// <summary>Total physically installed system RAM in megabytes.</summary>
|
||||
public static int TotalPhysicalMemoryMB
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
if (GetPhysicallyInstalledSystemMemory(out var kb))
|
||||
return (int)(kb / 1024);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Read MemTotal from /proc/meminfo
|
||||
foreach (var line in File.ReadAllLines("/proc/meminfo"))
|
||||
{
|
||||
if (line.StartsWith("MemTotal:", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && long.TryParse(parts[1], out var memKb))
|
||||
return (int)(memKb / 1024);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
// GC heap-size hint; not perfect but a reasonable fallback for macOS.
|
||||
var gcInfo = GC.GetGCMemoryInfo();
|
||||
if (gcInfo.TotalAvailableMemoryBytes > 0)
|
||||
return (int)(gcInfo.TotalAvailableMemoryBytes / (1024 * 1024));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to default
|
||||
}
|
||||
return (int)(DefaultFallbackKB / 1024);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Recommended max user-allocatable RAM (leaves headroom for OS + other apps).</summary>
|
||||
public static int SafeMaxAllocationMB
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = TotalPhysicalMemoryMB;
|
||||
// Leave at least 4 GB for OS + browser + Discord + everything else.
|
||||
var headroom = total >= 32 * 1024 ? 6 * 1024 : 4 * 1024;
|
||||
return Math.Max(2048, total - headroom);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether Microsoft Edge WebView2 Runtime is installed.
|
||||
/// Required by the Xbox Live SDK + WebView2 sign-in flow used by the launcher when
|
||||
/// no custom Azure client ID is configured. Preinstalled on Windows 10/11 since
|
||||
/// 2021 (came with Edge), but not guaranteed on older / cleaned Windows installs.
|
||||
/// </summary>
|
||||
public static class WebView2Check
|
||||
{
|
||||
private const string ClientGuid = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; // Microsoft Edge WebView2 Runtime
|
||||
|
||||
public const string DownloadUrl = "https://developer.microsoft.com/microsoft-edge/webview2/";
|
||||
|
||||
public static bool IsInstalled()
|
||||
{
|
||||
// The runtime registers in one of three places depending on machine vs. per-user install.
|
||||
return GetVersion(RegistryHive.LocalMachine, $@"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v1 && v1 != "0.0.0.0"
|
||||
|| GetVersion(RegistryHive.LocalMachine, $@"SOFTWARE\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v2 && v2 != "0.0.0.0"
|
||||
|| GetVersion(RegistryHive.CurrentUser, $@"Software\Microsoft\EdgeUpdate\Clients\{ClientGuid}") is { } v3 && v3 != "0.0.0.0";
|
||||
}
|
||||
|
||||
private static string? GetVersion(RegistryHive hive, string keyPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var baseKey = RegistryKey.OpenBaseKey(hive, RegistryView.Default);
|
||||
using var key = baseKey.OpenSubKey(keyPath);
|
||||
return key?.GetValue("pv") as string;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ModpackLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Friend-side wrapper for the brass-sigil-server's public whitelist endpoints.
|
||||
/// Exists so the launcher can:
|
||||
/// - Send a "please add me" request without the friend needing to share their
|
||||
/// MC username with the admin out-of-band.
|
||||
/// - Poll status afterwards so the launcher UI reflects pending/approved/denied.
|
||||
/// </summary>
|
||||
public sealed class WhitelistRequestService
|
||||
{
|
||||
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||
|
||||
public sealed class StatusResponse
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok { get; set; }
|
||||
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RequestResponse
|
||||
{
|
||||
[JsonPropertyName("ok")] public bool Ok { get; set; }
|
||||
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||
[JsonPropertyName("error")] public string? Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns "pending" / "approved" / "denied" / "unknown" / "" (network error).
|
||||
/// </summary>
|
||||
public async Task<string> GetStatusAsync(string panelUrl, string username)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/status?username={Uri.EscapeDataString(username)}";
|
||||
var resp = await _http.GetFromJsonAsync<StatusResponse>(url);
|
||||
return resp?.Status ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RequestResponse> SubmitAsync(string panelUrl, string username, string? message = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{panelUrl.TrimEnd('/')}/api/whitelist/request";
|
||||
var resp = await _http.PostAsJsonAsync(url, new { username, message });
|
||||
var body = await resp.Content.ReadFromJsonAsync<RequestResponse>();
|
||||
if (body is null) return new RequestResponse { Ok = false, Error = "Empty response." };
|
||||
if (!resp.IsSuccessStatusCode && string.IsNullOrEmpty(body.Error))
|
||||
body.Error = $"HTTP {(int)resp.StatusCode}";
|
||||
return body;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new RequestResponse { Ok = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user