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);
}
///
/// 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.
///
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 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 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(StringComparer.OrdinalIgnoreCase)
{ "neoforge", "forge", "fabric", "bundled" };
var acronyms = new System.Collections.Generic.HashSet(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(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(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;
}
}