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,980 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using CmlLib.Core.Auth;
|
||||
using ModpackLauncher.Models;
|
||||
using ModpackLauncher.Services;
|
||||
|
||||
namespace ModpackLauncher;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private enum LauncherState
|
||||
{
|
||||
Checking, // initial / fetching manifest
|
||||
ConfigError, // manifest URL not set or unreachable + nothing local
|
||||
NeedsDownload, // no local pack, manifest reachable
|
||||
NeedsUpdate, // local pack version != manifest version
|
||||
ReadyNotSignedIn,// up-to-date but not signed in
|
||||
Ready // up-to-date and signed in
|
||||
}
|
||||
|
||||
private readonly LauncherConfig _config;
|
||||
private readonly AuthService _auth;
|
||||
private readonly ManifestSyncService _sync;
|
||||
private LauncherSettings _settings;
|
||||
private LaunchService? _launch;
|
||||
private MSession? _session;
|
||||
private Manifest? _remoteManifest;
|
||||
private LauncherState _state = LauncherState.Checking;
|
||||
private bool _busy;
|
||||
private bool _suppressAutoSave;
|
||||
private bool _infoPanelOpen;
|
||||
// Cached "what the play button should show when not busy". Updated by ApplyState,
|
||||
// restored by SetBusy(false). Avoids race between login flow's SetBusy and the
|
||||
// RefreshStateAsync triggered by ApplySession.
|
||||
private string _playButtonLabel = "Play";
|
||||
private bool _playButtonEnabled;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
FileLog.Init();
|
||||
_config = LauncherConfig.Load();
|
||||
_auth = new AuthService(_config.MsalClientId);
|
||||
_sync = new ManifestSyncService();
|
||||
_sync.SetBasicAuth(_config.HttpUsername, _config.HttpPassword);
|
||||
|
||||
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var versionText = version is null ? "" : $" v{version.Major}.{version.Minor}.{version.Build}";
|
||||
Title = $"{_config.PackName} Launcher{versionText}";
|
||||
PackNameText.Text = _config.PackName;
|
||||
TitleText.Text = $"{_config.PackName} Launcher{versionText}";
|
||||
|
||||
_settings = LauncherSettings.Load();
|
||||
|
||||
// Initialise RAM control without triggering auto-save
|
||||
_suppressAutoSave = true;
|
||||
RamBox.Value = _settings.MemoryMB ?? _config.MemoryMB;
|
||||
_suppressAutoSave = false;
|
||||
RamBox.ValueChanged += OnRamValueChanged;
|
||||
|
||||
UpdateInstallDirDisplay();
|
||||
UpdateRamWarning((int)(RamBox.Value ?? _config.MemoryMB));
|
||||
|
||||
var localVersion = _sync.GetLocalPackVersion(GetInstallDir());
|
||||
PackVersionText.Text = localVersion?.Version is { } v
|
||||
? $"Installed: v{v}"
|
||||
: "No pack synced yet";
|
||||
|
||||
// Maximize is disabled on this launcher -- the slide-out animation gets unhappy
|
||||
// when the window can't actually resize. If something (Win+Up, Aero Snap, etc.)
|
||||
// pushes us to Maximized anyway, snap back to Normal.
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == WindowStateProperty && WindowState == WindowState.Maximized)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
}
|
||||
};
|
||||
|
||||
Opened += async (_, _) =>
|
||||
{
|
||||
CheckSystemRequirements();
|
||||
await TrySilentSignInAsync();
|
||||
await RefreshStateAsync(refetchManifest: true);
|
||||
};
|
||||
|
||||
// Auto-refresh manifest when window regains focus (e.g. user alt-tabs back)
|
||||
Activated += async (_, _) =>
|
||||
{
|
||||
if (_busy) return;
|
||||
await RefreshStateAsync(refetchManifest: true);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_busy) return;
|
||||
_remoteManifest = null; // force a fresh fetch
|
||||
await RefreshStateAsync(refetchManifest: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare our running assembly version to the manifest's launcherVersion field.
|
||||
/// If the manifest reports something newer, surface a non-blocking banner that
|
||||
/// links to the public download URL. Doesn't auto-update -- friends decide when.
|
||||
/// </summary>
|
||||
private void CheckLauncherVersion(Manifest manifest)
|
||||
{
|
||||
UpdateBanner.IsVisible = false;
|
||||
if (string.IsNullOrWhiteSpace(manifest.LauncherVersion)) return;
|
||||
|
||||
var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
if (current is null) return;
|
||||
if (!Version.TryParse(manifest.LauncherVersion, out var advertised)) return;
|
||||
if (current >= advertised) return;
|
||||
|
||||
UpdateBannerText.Text = $"A newer launcher (v{advertised}) is available -- you're on v{current.Major}.{current.Minor}.{current.Build}.";
|
||||
UpdateBannerDownloadButton.Tag = manifest.LauncherUrl ?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
|
||||
UpdateBanner.IsVisible = true;
|
||||
AppendLog($"[update] Newer launcher available: v{advertised} (running v{current})");
|
||||
}
|
||||
|
||||
private void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var url = UpdateBannerDownloadButton.Tag as string
|
||||
?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[update] Couldn't open browser: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private const double InfoPanelExtraWidth = 334; // 320 panel + 14 gap
|
||||
|
||||
private void OnInfoClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_infoPanelOpen = !_infoPanelOpen;
|
||||
ApplyInfoPanelState();
|
||||
}
|
||||
|
||||
private void ApplyInfoPanelState()
|
||||
{
|
||||
var currentContainerWidth = InfoPanelContainer.Width;
|
||||
var targetContainerWidth = _infoPanelOpen ? InfoPanelExtraWidth : 0;
|
||||
var deltaWidth = targetContainerWidth - currentContainerWidth;
|
||||
|
||||
AnimateSlideOut(currentContainerWidth, targetContainerWidth, Width, Width + deltaWidth);
|
||||
}
|
||||
|
||||
private DispatcherTimer? _widthAnimTimer;
|
||||
|
||||
private void AnimateSlideOut(double containerStart, double containerEnd,
|
||||
double windowStart, double windowEnd,
|
||||
Action? onComplete = null)
|
||||
{
|
||||
_widthAnimTimer?.Stop();
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
const double durationMs = 220;
|
||||
|
||||
_widthAnimTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(15) };
|
||||
_widthAnimTimer.Tick += (_, _) =>
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
var t = Math.Min(1.0, elapsed / durationMs);
|
||||
var eased = 1 - Math.Pow(1 - t, 3); // ease-out cubic
|
||||
// Animate both in lockstep so the Star column (RootBorder) keeps its width.
|
||||
InfoPanelContainer.Width = containerStart + (containerEnd - containerStart) * eased;
|
||||
Width = windowStart + (windowEnd - windowStart) * eased;
|
||||
if (t >= 1.0)
|
||||
{
|
||||
InfoPanelContainer.Width = containerEnd;
|
||||
Width = windowEnd;
|
||||
_widthAnimTimer?.Stop();
|
||||
_widthAnimTimer = null;
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
};
|
||||
_widthAnimTimer.Start();
|
||||
}
|
||||
|
||||
private void PopulateInfoPanel()
|
||||
{
|
||||
InfoPanelContent.Children.Clear();
|
||||
if (_remoteManifest is null)
|
||||
{
|
||||
InfoPanelContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Pack info will appear once the manifest has been fetched.",
|
||||
Foreground = new SolidColorBrush(Color.Parse("#9F8E72")),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
FontSize = 12
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
AddInfoSection("Pack",
|
||||
("Name", _remoteManifest.Name ?? "(unnamed)"),
|
||||
("Version", _remoteManifest.Version ?? "?"));
|
||||
|
||||
AddInfoSection("Minecraft",
|
||||
("Version", _remoteManifest.Minecraft.Version),
|
||||
("Loader", _remoteManifest.Loader is null
|
||||
? "vanilla"
|
||||
: $"{_remoteManifest.Loader.Type} {_remoteManifest.Loader.Version}"));
|
||||
|
||||
var modFiles = _remoteManifest.Files
|
||||
.Where(f => f.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
AddModListSection($"Mods ({modFiles.Count})", modFiles);
|
||||
|
||||
var others = _remoteManifest.Files
|
||||
.Where(f => !f.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (others.Count > 0)
|
||||
{
|
||||
AddOtherFilesSection($"Other files ({others.Count})", others);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddInfoSection(string title, params (string Label, string Value)[] rows)
|
||||
{
|
||||
var stack = new StackPanel { Spacing = 4 };
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title.ToUpper(),
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.Bold,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#E8B95C")),
|
||||
Margin = new Thickness(0, 0, 0, 4)
|
||||
});
|
||||
foreach (var (label, value) in rows)
|
||||
{
|
||||
var row = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") };
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = label, Margin = new Thickness(0, 0, 8, 0),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#7A8497")),
|
||||
FontSize = 12,
|
||||
MinWidth = 60
|
||||
});
|
||||
var valueBlock = new TextBlock
|
||||
{
|
||||
Text = value,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#E8DFC8")),
|
||||
FontSize = 12,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
Grid.SetColumn(valueBlock, 1);
|
||||
row.Children.Add(valueBlock);
|
||||
stack.Children.Add(row);
|
||||
}
|
||||
InfoPanelContent.Children.Add(stack);
|
||||
}
|
||||
|
||||
private void AddModListSection(string title, System.Collections.Generic.List<ManifestFile> mods)
|
||||
{
|
||||
var stack = new StackPanel { Spacing = 6 };
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title.ToUpper(),
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.Bold,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#E8B95C")),
|
||||
Margin = new Thickness(0, 0, 0, 4)
|
||||
});
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
var (name, version) = ParseModFilename(System.IO.Path.GetFileName(mod.Path));
|
||||
var row = new StackPanel { Spacing = 1 };
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = name,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#E8DFC8")),
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.Medium
|
||||
});
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = version,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#7A8497")),
|
||||
FontSize = 11
|
||||
});
|
||||
stack.Children.Add(row);
|
||||
}
|
||||
InfoPanelContent.Children.Add(stack);
|
||||
}
|
||||
|
||||
private void AddOtherFilesSection(string title, System.Collections.Generic.List<ManifestFile> files)
|
||||
{
|
||||
var stack = new StackPanel { Spacing = 4 };
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title.ToUpper(),
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.Bold,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#E8B95C")),
|
||||
Margin = new Thickness(0, 0, 0, 4)
|
||||
});
|
||||
foreach (var f in files)
|
||||
{
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = f.Path,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#B7C0D6")),
|
||||
FontSize = 11,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
}
|
||||
InfoPanelContent.Children.Add(stack);
|
||||
}
|
||||
|
||||
private static (string Name, string Version) ParseModFilename(string filename)
|
||||
{
|
||||
var stem = System.IO.Path.GetFileNameWithoutExtension(filename);
|
||||
// Split on both '-' and '_' -- Modrinth filenames mix both conventions
|
||||
// (e.g. Terralith_1.21.x_v2.5.8.jar uses underscores).
|
||||
var parts = stem.Split(new[] { '-', '_' });
|
||||
|
||||
// Recognise version segments that start with a digit OR with 'v' followed
|
||||
// by a digit (e.g. "v2.5.8"). Find the smallest index that matches so the
|
||||
// entire trailing version chain is captured.
|
||||
int versionIdx = -1;
|
||||
for (int i = parts.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var p = parts[i];
|
||||
if (p.Length == 0) continue;
|
||||
var c0 = p[0];
|
||||
var startsWithDigit = char.IsDigit(c0);
|
||||
var startsWithVDigit = (c0 == 'v' || c0 == 'V') && p.Length >= 2 && char.IsDigit(p[1]);
|
||||
if (startsWithDigit || startsWithVDigit) versionIdx = i;
|
||||
}
|
||||
if (versionIdx <= 0) return (stem, "");
|
||||
|
||||
var skipWords = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "neoforge", "forge", "fabric", "bundled" };
|
||||
var acronyms = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "ftb", "tfmg", "jei", "rei", "emi", "ae2", "ic2", "kubejs", "rpl", "c2me", "yungs" };
|
||||
|
||||
var nameParts = parts.Take(versionIdx)
|
||||
.Where(s => !skipWords.Contains(s))
|
||||
.Select(s => acronyms.Contains(s) ? s.ToUpper() : (s.Length > 0 ? char.ToUpper(s[0]) + s.Substring(1) : s));
|
||||
var name = string.Join(" ", nameParts);
|
||||
if (string.IsNullOrWhiteSpace(name)) name = stem;
|
||||
var version = string.Join("-", parts.Skip(versionIdx));
|
||||
return (name, version);
|
||||
}
|
||||
|
||||
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
WindowState = WindowState.Minimized;
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CheckSystemRequirements()
|
||||
{
|
||||
// WebView2 Runtime is required for the default sign-in flow. Preinstalled on
|
||||
// most modern Windows but not guaranteed -- surface a helpful message at startup.
|
||||
if (!_auth.HasCustomClientId && !WebView2Check.IsInstalled())
|
||||
{
|
||||
AppendLog("[system] Microsoft Edge WebView2 Runtime not detected.");
|
||||
AppendLog($"[system] Sign-in won't work until it's installed: {WebView2Check.DownloadUrl}");
|
||||
UpdateStatus("WebView2 Runtime missing",
|
||||
$"Sign-in needs Microsoft Edge WebView2 Runtime -- install from {WebView2Check.DownloadUrl}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TrySilentSignInAsync()
|
||||
{
|
||||
var session = await _auth.TryAuthenticateSilentlyAsync();
|
||||
if (session != null)
|
||||
{
|
||||
_session = session;
|
||||
ApplySession(session);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySession(MSession session)
|
||||
{
|
||||
_session = session;
|
||||
UserText.Text = $"Signed in as {session.Username}";
|
||||
LoginButton.IsVisible = false;
|
||||
LogoutButton.IsVisible = true;
|
||||
_ = RefreshStateAsync();
|
||||
_ = RefreshWhitelistStatusAsync();
|
||||
}
|
||||
|
||||
private void ClearSession()
|
||||
{
|
||||
_session = null;
|
||||
UserText.Text = "Not signed in";
|
||||
LoginButton.IsVisible = true;
|
||||
LogoutButton.IsVisible = false;
|
||||
WhitelistStatusText.IsVisible = false;
|
||||
RequestAccessButton.IsVisible = false;
|
||||
_ = RefreshStateAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshWhitelistStatusAsync()
|
||||
{
|
||||
if (_session is null || _remoteManifest is null) return;
|
||||
var panelUrl = _remoteManifest.PanelUrl;
|
||||
if (string.IsNullOrWhiteSpace(panelUrl))
|
||||
{
|
||||
// No panel configured in the manifest -- feature disabled.
|
||||
WhitelistStatusText.IsVisible = false;
|
||||
RequestAccessButton.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
var status = await new WhitelistRequestService().GetStatusAsync(panelUrl, _session.Username ?? "");
|
||||
ApplyWhitelistStatus(status);
|
||||
}
|
||||
|
||||
private void ApplyWhitelistStatus(string status)
|
||||
{
|
||||
// Status values: "pending", "approved", "denied", "unknown" (no record), "" (network error).
|
||||
switch (status)
|
||||
{
|
||||
case "pending":
|
||||
WhitelistStatusText.Text = "Whitelist request pending";
|
||||
WhitelistStatusText.IsVisible = true;
|
||||
RequestAccessButton.IsVisible = false;
|
||||
break;
|
||||
case "approved":
|
||||
// Once approved, the server removes the record; this branch is rare
|
||||
// (status returns "unknown" almost immediately after approve fires).
|
||||
WhitelistStatusText.Text = "Whitelisted ✓";
|
||||
WhitelistStatusText.IsVisible = true;
|
||||
RequestAccessButton.IsVisible = false;
|
||||
break;
|
||||
case "denied":
|
||||
WhitelistStatusText.Text = "Request denied";
|
||||
WhitelistStatusText.IsVisible = true;
|
||||
RequestAccessButton.IsVisible = true; // allow retry
|
||||
RequestAccessButton.Content = "Request again";
|
||||
break;
|
||||
case "":
|
||||
// Network error -- hide both, don't claim anything.
|
||||
WhitelistStatusText.IsVisible = false;
|
||||
RequestAccessButton.IsVisible = false;
|
||||
break;
|
||||
default: // "unknown" -- never requested
|
||||
WhitelistStatusText.IsVisible = false;
|
||||
RequestAccessButton.IsVisible = true;
|
||||
RequestAccessButton.Content = "Request access";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnRequestAccessClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_session is null || _remoteManifest?.PanelUrl is not { } panelUrl) return;
|
||||
RequestAccessButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var resp = await new WhitelistRequestService().SubmitAsync(panelUrl, _session.Username ?? "", null);
|
||||
if (resp.Ok)
|
||||
{
|
||||
AppendLog($"[whitelist] Request sent for {_session.Username}.");
|
||||
ApplyWhitelistStatus("pending");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendLog($"[whitelist] Request failed: {resp.Error ?? "unknown"}");
|
||||
UpdateStatus("Request failed", resp.Error ?? "Couldn't reach server.");
|
||||
}
|
||||
}
|
||||
finally { RequestAccessButton.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private async Task RefreshStateAsync(bool refetchManifest = false)
|
||||
{
|
||||
if (_busy) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_config.ManifestUrl) ||
|
||||
_config.ManifestUrl.Contains("example.com"))
|
||||
{
|
||||
ApplyState(LauncherState.ConfigError, "Setup needed",
|
||||
"Set 'manifestUrl' in launcher-config.json and rebuild.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (refetchManifest || _remoteManifest == null)
|
||||
{
|
||||
ApplyState(LauncherState.Checking, "Checking...", "Looking for updates");
|
||||
try
|
||||
{
|
||||
_remoteManifest = await _sync.FetchManifestOnlyAsync(_config.ManifestUrl);
|
||||
PopulateInfoPanel();
|
||||
CheckLauncherVersion(_remoteManifest);
|
||||
_ = RefreshWhitelistStatusAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[manifest fetch] {ex.GetType().Name}: {ex.Message}");
|
||||
// Offline fallback -- allow play if we have a local pack
|
||||
var fallback = _sync.GetLocalPackVersion(GetInstallDir());
|
||||
if (fallback?.Version != null)
|
||||
{
|
||||
if (_session != null)
|
||||
{
|
||||
ApplyState(LauncherState.Ready, "Play",
|
||||
$"Offline: pack server unreachable, using local v{fallback.Version}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyState(LauncherState.ReadyNotSignedIn, "Play",
|
||||
"Offline mode -- sign in to play");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyState(LauncherState.ConfigError, "Connection error",
|
||||
$"Couldn't reach pack server: {ex.Message}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var local = _sync.GetLocalPackVersion(GetInstallDir());
|
||||
var remote = _remoteManifest!;
|
||||
|
||||
if (local == null)
|
||||
{
|
||||
ApplyState(LauncherState.NeedsDownload, "Download",
|
||||
$"{remote.Name ?? "Pack"} v{remote.Version ?? "?"} ready to download");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(local.Version, remote.Version, StringComparison.Ordinal))
|
||||
{
|
||||
ApplyState(LauncherState.NeedsUpdate, "Update",
|
||||
$"Update available: v{local.Version} → v{remote.Version}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Version matches -- but also verify the actual files are on disk. Catches AV
|
||||
// quarantines, manual deletions, and interrupted downloads.
|
||||
var missing = _sync.FindMissingFiles(remote, GetInstallDir());
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
ApplyState(LauncherState.NeedsUpdate, "Repair",
|
||||
$"Pack files missing ({missing.Count}). Click to redownload.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_session == null)
|
||||
{
|
||||
ApplyState(LauncherState.ReadyNotSignedIn, "Play",
|
||||
$"Up to date (v{local.Version}). Sign in to play.");
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyState(LauncherState.Ready, "Play",
|
||||
$"Up to date (v{local.Version}). Ready to launch.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyState(LauncherState state, string buttonLabel, string subtext)
|
||||
{
|
||||
_state = state;
|
||||
_playButtonLabel = buttonLabel;
|
||||
_playButtonEnabled = state is LauncherState.NeedsDownload
|
||||
or LauncherState.NeedsUpdate
|
||||
or LauncherState.Ready;
|
||||
if (!_busy)
|
||||
{
|
||||
PlayButton.Content = _playButtonLabel;
|
||||
PlayButton.IsEnabled = _playButtonEnabled;
|
||||
}
|
||||
StatusText.Text = state switch
|
||||
{
|
||||
LauncherState.Ready or LauncherState.ReadyNotSignedIn => "Ready",
|
||||
LauncherState.NeedsDownload => "Download required",
|
||||
LauncherState.NeedsUpdate => "Update available",
|
||||
LauncherState.Checking => "Checking...",
|
||||
LauncherState.ConfigError => "Setup needed",
|
||||
_ => "..."
|
||||
};
|
||||
StatusSubtext.Text = subtext;
|
||||
}
|
||||
|
||||
private async void OnLoginClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_busy) return;
|
||||
try
|
||||
{
|
||||
SetBusy(true);
|
||||
UpdateStatus("Signing in...", "A browser window should appear.");
|
||||
var session = await _auth.SignInInteractivelyAsync();
|
||||
ApplySession(session);
|
||||
UpdateStatus("Signed in", $"Welcome, {session.Username}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogException("auth error", ex);
|
||||
UpdateStatus("Sign-in failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false);
|
||||
// ApplySession's fire-and-forget RefreshStateAsync was a no-op because
|
||||
// _busy was still true. Re-run state now that we're idle so the Play
|
||||
// button enables based on the new session + current pack state.
|
||||
await RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void LogException(string label, Exception ex)
|
||||
{
|
||||
AppendLog($"[{label}] {ex.GetType().Name}: {ex.Message}");
|
||||
var inner = ex.InnerException;
|
||||
var depth = 0;
|
||||
while (inner != null && depth < 5)
|
||||
{
|
||||
AppendLog($" ↳ {inner.GetType().Name}: {inner.Message}");
|
||||
inner = inner.InnerException;
|
||||
depth++;
|
||||
}
|
||||
// Reflectively surface any Code / XErr / Redirect / StatusCode properties (XboxAuthException, etc.)
|
||||
foreach (var prop in ex.GetType().GetProperties())
|
||||
{
|
||||
if (prop.Name is "Code" or "XErr" or "ErrorCode" or "StatusCode" or "Redirect" or "Identity" or "Message" or "Source" or "InnerException" or "TargetSite" or "StackTrace" or "Data" or "HelpLink" or "HResult")
|
||||
{
|
||||
if (prop.Name is "Message" or "Source" or "InnerException" or "TargetSite" or "StackTrace" or "Data" or "HelpLink" or "HResult") continue;
|
||||
try
|
||||
{
|
||||
var val = prop.GetValue(ex);
|
||||
if (val != null) AppendLog($" • {prop.Name}: {val}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnLogoutClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await _auth.SignOutAsync();
|
||||
ClearSession();
|
||||
UpdateStatus("Signed out", "Click Sign in to authenticate again.");
|
||||
}
|
||||
|
||||
private async void OnPlayClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_busy) return;
|
||||
|
||||
switch (_state)
|
||||
{
|
||||
case LauncherState.NeedsDownload:
|
||||
case LauncherState.NeedsUpdate:
|
||||
await DoSyncAndInstallAsync();
|
||||
break;
|
||||
case LauncherState.Ready:
|
||||
await DoLaunchAsync();
|
||||
break;
|
||||
// Other states leave the button disabled -- shouldn't reach here.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DoSyncAndInstallAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
SetBusy(true);
|
||||
var installDir = GetInstallDir();
|
||||
Directory.CreateDirectory(installDir);
|
||||
var progress = new Progress<ProgressReport>(OnProgress);
|
||||
|
||||
UpdateStatus("Syncing pack...", "Fetching manifest and downloading mods.");
|
||||
var syncResult = await _sync.SyncAsync(_config.ManifestUrl, installDir, progress);
|
||||
_remoteManifest = syncResult.Manifest;
|
||||
AppendLog($"Sync: {syncResult.Downloaded} downloaded, {syncResult.Removed} removed.");
|
||||
|
||||
PackVersionText.Text = syncResult.Manifest.Version is { } v
|
||||
? $"Installed: v{v}"
|
||||
: "Installed";
|
||||
|
||||
// Pre-populate the multiplayer server list so friends don't have to
|
||||
// hand-type the address. Idempotent -- match-by-IP, won't duplicate.
|
||||
if (syncResult.Manifest.DefaultServer is { } ds && !string.IsNullOrEmpty(ds.Ip))
|
||||
{
|
||||
try
|
||||
{
|
||||
new ServerListService().EnsureServer(installDir, ds.Name, ds.Ip);
|
||||
AppendLog($"Multiplayer list seeded: {ds.Name} ({ds.Ip}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[server-list] Couldn't update servers.dat: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-enable a default shader pack on fresh installs (Iris reads
|
||||
// config/iris.properties at startup). Does nothing if the user has
|
||||
// already chosen a different shader.
|
||||
if (!string.IsNullOrEmpty(syncResult.Manifest.DefaultShader))
|
||||
{
|
||||
try
|
||||
{
|
||||
new IrisConfigService().SetDefaultShader(installDir, syncResult.Manifest.DefaultShader);
|
||||
AppendLog($"Default shader: {syncResult.Manifest.DefaultShader} (only set if no shader was previously chosen).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[iris] Couldn't set default shader: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_launch ??= new LaunchService(installDir);
|
||||
UpdateStatus("Installing Minecraft...",
|
||||
"Downloading client, libraries, assets, and mod loader. First run may take a few minutes.");
|
||||
var versionId = await _launch.InstallVersionAsync(
|
||||
syncResult.Manifest, progress, CancellationToken.None);
|
||||
AppendLog($"Version ready: {versionId}");
|
||||
ResetProgress();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[error] {ex.GetType().Name}: {ex.Message}");
|
||||
UpdateStatus("Install failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false);
|
||||
await RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DoLaunchAsync()
|
||||
{
|
||||
if (_session == null || _remoteManifest == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
SetBusy(true);
|
||||
var progress = new Progress<ProgressReport>(OnProgress);
|
||||
var installDir = GetInstallDir();
|
||||
_launch ??= new LaunchService(installDir);
|
||||
|
||||
// Pre-launch sanity: any manifest files missing? If so, re-sync.
|
||||
var missing = _sync.FindMissingFiles(_remoteManifest, installDir);
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
UpdateStatus("Repairing pack...",
|
||||
$"{missing.Count} file(s) missing -- redownloading before launch.");
|
||||
AppendLog($"Pre-launch check: {missing.Count} files missing, re-syncing.");
|
||||
var syncResult = await _sync.SyncAsync(_config.ManifestUrl, installDir, progress);
|
||||
_remoteManifest = syncResult.Manifest;
|
||||
AppendLog($"Re-sync: {syncResult.Downloaded} downloaded, {syncResult.Removed} removed.");
|
||||
}
|
||||
|
||||
UpdateStatus("Verifying installation...",
|
||||
"Checking Minecraft, mod loader, and libraries.");
|
||||
// InstallVersionAsync is idempotent + always runs the library verifier afterwards,
|
||||
// so this catches any post-install gaps (e.g. CmlLib's bootstraplauncher quirk).
|
||||
var versionId = await _launch.InstallVersionAsync(
|
||||
_remoteManifest, progress, CancellationToken.None);
|
||||
|
||||
var ram = (int)(RamBox.Value ?? _config.MemoryMB);
|
||||
var minRam = ram;
|
||||
var maxRam = ram;
|
||||
|
||||
UpdateStatus("Launching Minecraft...", "");
|
||||
ResetProgress();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _launch.LaunchAsync(versionId, _session!, minRam, maxRam, progress, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
AppendLog($"[launch error] {ex.GetType().Name}: {ex.Message}");
|
||||
UpdateStatus("Launch failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
SetBusy(false);
|
||||
await RefreshStateAsync();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[error] {ex.GetType().Name}: {ex.Message}");
|
||||
UpdateStatus("Launch failed", ex.Message);
|
||||
SetBusy(false);
|
||||
await RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetInstallDir() => _config.GetInstallDir(_settings.InstallDirOverride);
|
||||
|
||||
private void UpdateInstallDirDisplay()
|
||||
{
|
||||
InstallDirText.Text = GetInstallDir();
|
||||
}
|
||||
|
||||
private void UpdateRamWarning(int ramMb)
|
||||
{
|
||||
var totalMb = SystemInfo.TotalPhysicalMemoryMB;
|
||||
var safeMax = SystemInfo.SafeMaxAllocationMB;
|
||||
|
||||
if (totalMb < 12 * 1024)
|
||||
{
|
||||
RamWarningText.Text = $"Only {totalMb / 1024} GB system RAM detected -- pack may not run smoothly (12+ GB recommended)";
|
||||
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#E8B95C"));
|
||||
}
|
||||
else if (ramMb > safeMax)
|
||||
{
|
||||
RamWarningText.Text = $"Above safe limit for {totalMb / 1024} GB system (max {safeMax} MB recommended)";
|
||||
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#DC6E28"));
|
||||
}
|
||||
else if (ramMb < 6144)
|
||||
{
|
||||
RamWarningText.Text = "Below recommended (6 GB+ for Distant Horizons)";
|
||||
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#9F8E72"));
|
||||
}
|
||||
else
|
||||
{
|
||||
RamWarningText.Text = $"OK ({totalMb / 1024} GB system)";
|
||||
RamWarningText.Foreground = new SolidColorBrush(Color.Parse("#7A8497"));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRamValueChanged(object? sender, NumericUpDownValueChangedEventArgs e)
|
||||
{
|
||||
if (_suppressAutoSave) return;
|
||||
var ram = (int)(RamBox.Value ?? _config.MemoryMB);
|
||||
_settings.MemoryMB = ram;
|
||||
_settings.Save();
|
||||
UpdateRamWarning(ram);
|
||||
}
|
||||
|
||||
private async void OnChangeInstallDirClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose install location",
|
||||
AllowMultiple = false
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
var picked = folders[0].TryGetLocalPath();
|
||||
if (string.IsNullOrEmpty(picked)) return;
|
||||
|
||||
_settings.InstallDirOverride = picked;
|
||||
_settings.Save();
|
||||
UpdateInstallDirDisplay();
|
||||
AppendLog($"Install location changed to: {picked}");
|
||||
AppendLog("Pack will need to be re-downloaded at the new location on next Play.");
|
||||
|
||||
_launch = null; // force re-create with new path next time
|
||||
_remoteManifest = null;
|
||||
await RefreshStateAsync(refetchManifest: true);
|
||||
}
|
||||
|
||||
private void OnOpenFolderClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var dir = GetInstallDir();
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = dir,
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLog($"[open folder] {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProgress(ProgressReport report)
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
ApplyProgress(report);
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ApplyProgress(report));
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyProgress(ProgressReport report)
|
||||
{
|
||||
switch (report.Kind)
|
||||
{
|
||||
case ProgressKind.Status:
|
||||
StatusSubtext.Text = report.Message;
|
||||
break;
|
||||
case ProgressKind.Progress:
|
||||
if (report.Percent >= 0) ProgressBar.Value = Math.Clamp(report.Percent, 0, 100);
|
||||
StatusSubtext.Text = report.Message;
|
||||
break;
|
||||
case ProgressKind.Log:
|
||||
AppendLog(report.Message);
|
||||
break;
|
||||
case ProgressKind.Error:
|
||||
AppendLog($"[error] {report.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStatus(string title, string subtitle)
|
||||
{
|
||||
StatusText.Text = title;
|
||||
StatusSubtext.Text = subtitle;
|
||||
}
|
||||
|
||||
private void ResetProgress()
|
||||
{
|
||||
ProgressBar.Value = 0;
|
||||
}
|
||||
|
||||
private void AppendLog(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
LogText.Text += (LogText.Text?.Length > 0 ? "\n" : "") + message;
|
||||
LogScroll.ScrollToEnd();
|
||||
FileLog.Write(message);
|
||||
}
|
||||
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
_busy = busy;
|
||||
if (busy)
|
||||
{
|
||||
PlayButton.IsEnabled = false;
|
||||
PlayButton.Content = "Working...";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Restore from the cached state-derived label (avoids races with concurrent state refreshes).
|
||||
PlayButton.Content = _playButtonLabel;
|
||||
PlayButton.IsEnabled = _playButtonEnabled;
|
||||
}
|
||||
LoginButton.IsEnabled = !busy;
|
||||
LogoutButton.IsEnabled = !busy;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user