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; } }