Files
brass-and-sigil/launcher/MainWindow.axaml.cs
T
Matt 8e00526d27 launcher: refresh Microsoft session right before launch
Microsoft access tokens have a ~1 hour TTL. The launcher cached the
MSession once at sign-in and re-used it on every Play click, so leaving
the launcher open for >1 hour and then hitting Play sent stale
credentials to MC -- Mojang's session check on the server then rejected
the join with "Invalid session".

Fix: in DoLaunchAsync, before assembling the MC launch command, call
_auth.TryAuthenticateSilentlyAsync(). XboxAuthNet's silent flow uses
the cached refresh token (~14 day TTL) to mint a fresh access token
transparently. On success ApplySession swaps in the new MSession and
we launch with valid credentials. On failure (refresh token also
stale) the launcher prompts the player to sign in again -- they keep
the launcher open, hit Sign In, no MC restart needed.

No new dependencies, no UI change beyond the brief "Refreshing
session..." status line, no perf cost beyond the ~500ms HTTP roundtrip
to Microsoft each launch.

Bump the launcher version in launcher/ModpackLauncher.csproj before
publishing so old launcher installs see the upgrade banner.
2026-05-23 16:31:17 +00:00

1042 lines
39 KiB
C#

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://bns.sijbers.uk/launcher/BrassAndSigil-Launcher.exe";
UpdateBanner.IsVisible = true;
AppendLog($"[update] Newer launcher available: v{advertised} (running v{current})");
}
private async void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
{
var url = UpdateBannerDownloadButton.Tag as string;
if (string.IsNullOrEmpty(url))
{
AppendLog("[update] No download URL configured in manifest.");
return;
}
// First click: try in-place self-update. On failure (network blip, write-protected
// dir, antivirus blocking), fall back to opening the browser so the user can still
// grab the new exe manually.
UpdateBannerDownloadButton.IsEnabled = false;
var originalLabel = UpdateBannerDownloadButton.Content as string ?? "Install update";
UpdateBannerDownloadButton.Content = "Updating...";
try
{
var svc = new ModpackLauncher.Services.SelfUpdateService();
var progress = new Progress<(long bytes, long? total)>(p =>
{
var mb = p.bytes / 1024.0 / 1024.0;
UpdateBannerDownloadButton.Content = p.total.HasValue
? $"{(double)p.bytes * 100 / p.total.Value:F0}%"
: $"{mb:F1} MB";
});
await svc.DownloadAndInstallAsync(url, progress, AppendLog);
// If the method returns, it means it didn't exit -- that shouldn't normally
// happen, but reset the UI just in case.
UpdateBannerDownloadButton.Content = originalLabel;
UpdateBannerDownloadButton.IsEnabled = true;
}
catch (Exception ex)
{
AppendLog($"[update] Auto-update failed: {ex.Message}");
AppendLog("[update] Opening browser as fallback...");
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
catch (Exception ex2)
{
AppendLog($"[update] Browser fallback also failed: {ex2.Message}");
}
UpdateBannerDownloadButton.Content = originalLabel;
UpdateBannerDownloadButton.IsEnabled = true;
}
}
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);
// ─── Refresh the Microsoft session before launch. ─────────────────
// _session was set at sign-in; the access token inside is short-lived
// (~1 hour). If the launcher's been open longer than that, MC will
// send stale credentials to the server and Mojang's session check
// rejects the join ("Invalid session"). XboxAuthNet's silent flow
// uses the cached refresh token (~14 day TTL) to mint a fresh
// access token transparently.
UpdateStatus("Refreshing session...", "");
var freshSession = await _auth.TryAuthenticateSilentlyAsync();
if (freshSession != null)
{
ApplySession(freshSession);
AppendLog("[auth] Session refreshed before launch.");
}
else
{
// Refresh token also stale -- need full interactive re-login.
// Don't try inline (player would lose context); prompt them to
// hit Sign In again.
AppendLog("[auth] Silent refresh returned no session; sign-in required.");
UpdateStatus("Session expired", "Please sign in again to continue.");
ClearSession();
return;
}
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;
}
}