a1331212cb
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.
981 lines
36 KiB
C#
981 lines
36 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://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;
|
|
}
|
|
}
|