diff --git a/Wauncher/App.axaml b/Wauncher/App.axaml
index 7ef911c..7bdfefa 100644
--- a/Wauncher/App.axaml
+++ b/Wauncher/App.axaml
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- #CC3A3A3A
- #3A3A3A
- #4CAF50
- White
- White
- #88FFFFFF
- #55FFFFFF
- #CCFFFFFF
- #AAFFFFFF
- #22FFFFFF
- #33FFFFFF
- #33FFFFFF
- #22FFFFFF
- #11FFFFFF
- #44FFFFFF
- #99FFFFFF
- #33FFFFFF
- #22FFFFFF
- #223A3A3A
- #FF3A3A3A
- White
- #6CB5F5
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #CC3A3A3A
+ #3A3A3A
+ #4CAF50
+ White
+ White
+ #88FFFFFF
+ #55FFFFFF
+ #CCFFFFFF
+ #AAFFFFFF
+ #22FFFFFF
+ #33FFFFFF
+ #33FFFFFF
+ #22FFFFFF
+ #11FFFFFF
+ #44FFFFFF
+ #99FFFFFF
+ #33FFFFFF
+ #22FFFFFF
+ #223A3A3A
+ #FF3A3A3A
+ White
+ #6CB5F5
+
+
+
+
diff --git a/Wauncher/App.axaml.cs b/Wauncher/App.axaml.cs
index 1a15481..403d6a8 100644
--- a/Wauncher/App.axaml.cs
+++ b/Wauncher/App.axaml.cs
@@ -1,246 +1,246 @@
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Data.Core.Plugins;
-using Avalonia.Markup.Xaml;
-using Avalonia.Media.Imaging;
-using Avalonia.Platform;
-using Avalonia.Threading;
-using CommunityToolkit.Mvvm.Input;
-using Wauncher.Utils;
-using System.Diagnostics;
-using Wauncher.ViewModels;
-using Wauncher.Views;
-
-namespace Wauncher
-{
- public partial class App : Application
- {
- private NativeMenuItem? _discordRpcMenuItem = null;
-
- public override void Initialize()
- {
- AvaloniaXamlLoader.Load(this);
- ProtocolManager.RegisterURIHandler();
- // Initialize memory management
- MemoryManager.CleanupMemory();
-
- // Subscribe to theme changes
- AppearanceWindowViewModel.ColorThemeChanged += OnColorThemeChanged;
- }
-
- private void OnColorThemeChanged(object? sender, Wauncher.ViewModels.ColorTheme theme)
- {
- // Marshal to the UI thread, then apply to Application.Resources
- Dispatcher.UIThread.Post(() => ApplyThemeToResources(theme));
- }
-
- ///
- /// Applies a ColorTheme to the live Application.Resources so all
- /// DynamicResource-bound UI updates immediately. Must run on UI thread.
- ///
- public void ApplyThemeToResources(Wauncher.ViewModels.ColorTheme theme)
- {
- try
- {
- // Update main background (used by all panels and server selector)
- var brush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(theme.BgMain));
- Resources["AppMainBackground"] = brush;
-
- // Update accent green (launch button, active tab border, etc.)
- var accentBrush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(theme.AccentGreen));
- Resources["AppAccentGreen"] = accentBrush;
-
- // Make slider knobs + filled track follow the accent color
- Resources["SliderThumbBackground"] = accentBrush;
- Resources["SliderThumbBackgroundPointerOver"] = accentBrush;
- Resources["SliderThumbBackgroundPressed"] = accentBrush;
- Resources["SliderTrackValueFill"] = accentBrush;
- Resources["SliderTrackValueFillPointerOver"] = accentBrush;
- Resources["SliderTrackValueFillPressed"] = accentBrush;
- Resources["SliderTrackValueFillDisabled"] = accentBrush;
-
- // Make ToggleSwitch "on" state follow the accent color
- Resources["ToggleSwitchFillOn"] = accentBrush;
- Resources["ToggleSwitchFillOnPointerOver"] = accentBrush;
- Resources["ToggleSwitchFillOnPressed"] = accentBrush;
- Resources["ToggleSwitchStrokeOn"] = accentBrush;
- Resources["ToggleSwitchStrokeOnPointerOver"] = accentBrush;
- Resources["ToggleSwitchStrokeOnPressed"] = accentBrush;
-
- // Make TextBox focus border + selection highlight follow the accent color
- Resources["TextControlBorderBrushFocused"] = accentBrush;
- Resources["TextControlSelectionHighlightColor"] = accentBrush;
-
- // Update primary text color (all text derives from this)
- brush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(theme.TextPrimary));
- Resources["AppTextColor"] = brush;
- Resources["AppPrimaryText"] = brush;
-
- // Update secondary text colors (derived from primary but with transparency)
- var textColor = Avalonia.Media.Color.Parse(theme.TextPrimary);
- Resources["AppMutedText"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.53 };
- Resources["AppSectionLabel"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.33 };
- Resources["AppBodyText"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.8 };
- Resources["AppBulletText"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.67 };
- }
- catch (Exception ex)
- {
- ErrorLogger.LogError("App.ApplyThemeToResources", ex, "Failed to apply color theme");
- }
- }
-
- ///
- /// Loads the saved color theme from wauncher_settings and applies it at startup.
- ///
- private void ApplySavedThemeAtStartup()
- {
- try
- {
- var settings = SettingsWindowViewModel.LoadGlobal();
- var theme = settings.LoadColorTheme();
- if (theme != null)
- ApplyThemeToResources(theme);
- }
- catch (Exception ex)
- {
- ErrorLogger.LogError("App.ApplySavedThemeAtStartup", ex, "Failed to load saved color theme");
- }
- }
-
- public override async void OnFrameworkInitializationCompleted()
- {
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
- {
- DisableAvaloniaDataAnnotationValidation();
-
- try
- {
- if (!Steam.IsInstalled())
- {
- ConsoleManager.ShowError(
- "Steam is required to use Wauncher.\n\nPlease install Steam and relaunch.");
- desktop.Shutdown();
- return;
- }
-
- if (!IsSteamRunning())
- {
- ConsoleManager.ShowError(
- "Steam must be open before using Wauncher.\n\nPlease open Steam, then relaunch Wauncher.");
- desktop.Shutdown();
- return;
- }
-
- if (Game.IsRunning())
- {
- ConsoleManager.ShowError(
- "ClassicCounter is already running.\n\nPlease close the game before opening Wauncher again.");
- desktop.Shutdown();
- return;
- }
-
- bool hasRecentSteamUser = await Steam.GetRecentLoggedInSteamID(false);
- if (!hasRecentSteamUser)
- {
- ConsoleManager.ShowError(
- "Steam is open, but no logged-in Steam account was detected.\n\nPlease sign in to Steam and relaunch Wauncher.");
- desktop.Shutdown();
- return;
- }
- }
- catch (Exception ex)
- {
- ErrorLogger.LogError("App.OnFrameworkInitializationCompleted", ex, "Application startup validation failed");
- ConsoleManager.ShowError($"Startup error: {ex.Message}");
- desktop.Shutdown();
- return;
- }
-
- // Apply the saved color theme before showing the window so
- // custom colors persist across sessions without a flash of defaults.
- ApplySavedThemeAtStartup();
-
- desktop.MainWindow = new MainWindow();
-
- // Initialize Discord in background
- _ = Task.Run(() =>
- {
- try
- {
- if (DependencyChecks.IsDiscordInstalled())
- Discord.Init();
- }
- catch
- {
- // Discord integration is optional.
- }
- });
-
- }
-
- base.OnFrameworkInitializationCompleted();
- }
-
- private static bool IsSteamRunning()
- {
- try
- {
- return Process.GetProcessesByName("steam").Length > 0;
- }
- catch (Exception ex)
- {
- ErrorLogger.LogError("App.IsSteamRunning", ex, "Failed to check if Steam is running");
- return false;
- }
- }
-
-
-
- public void DiscordRpc_Click(object? sender, EventArgs e)
- {
- var settings = SettingsWindowViewModel.LoadGlobal();
- settings.DiscordRpc = !settings.DiscordRpc; // auto-saves via OnDiscordRpcChanged
- ApplyDiscordRpc(settings.DiscordRpc);
- }
-
- private void ApplyDiscordRpc(bool enabled)
- {
- if (!DependencyChecks.IsDiscordInstalled())
- {
- if (_discordRpcMenuItem != null)
- _discordRpcMenuItem.Header = "Discord RPC (Discord not installed)";
- return;
- }
-
- if (enabled)
- {
- Discord.SetDetails("In Main Menu");
- Discord.SetState(null);
- Discord.Update();
- }
- else
- {
- Discord.Deinitialize();
- }
-
- if (_discordRpcMenuItem != null)
- _discordRpcMenuItem.Header = enabled ? "Discord RPC ON" : "Discord RPC OFF";
- }
-
- public void ExitApplication_Click(object? sender, EventArgs e)
- {
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
- d.TryShutdown();
- }
-
- private void DisableAvaloniaDataAnnotationValidation()
- {
- var toRemove = BindingPlugins.DataValidators
- .OfType().ToArray();
- foreach (var plugin in toRemove)
- BindingPlugins.DataValidators.Remove(plugin);
- }
- }
-}
-
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core.Plugins;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.Input;
+using Wauncher.Utils;
+using System.Diagnostics;
+using Wauncher.ViewModels;
+using Wauncher.Views;
+
+namespace Wauncher
+{
+ public partial class App : Application
+ {
+ private NativeMenuItem? _discordRpcMenuItem = null;
+
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ ProtocolManager.RegisterURIHandler();
+ // Initialize memory management
+ MemoryManager.CleanupMemory();
+
+ // Subscribe to theme changes
+ AppearanceWindowViewModel.ColorThemeChanged += OnColorThemeChanged;
+ }
+
+ private void OnColorThemeChanged(object? sender, Wauncher.ViewModels.ColorTheme theme)
+ {
+ // Marshal to the UI thread, then apply to Application.Resources
+ Dispatcher.UIThread.Post(() => ApplyThemeToResources(theme));
+ }
+
+ ///
+ /// Applies a ColorTheme to the live Application.Resources so all
+ /// DynamicResource-bound UI updates immediately. Must run on UI thread.
+ ///
+ public void ApplyThemeToResources(Wauncher.ViewModels.ColorTheme theme)
+ {
+ try
+ {
+ // Update main background (used by all panels and server selector)
+ var brush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(theme.BgMain));
+ Resources["AppMainBackground"] = brush;
+
+ // Update accent green (launch button, active tab border, etc.)
+ var accentBrush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(theme.AccentGreen));
+ Resources["AppAccentGreen"] = accentBrush;
+
+ // Make slider knobs + filled track follow the accent color
+ Resources["SliderThumbBackground"] = accentBrush;
+ Resources["SliderThumbBackgroundPointerOver"] = accentBrush;
+ Resources["SliderThumbBackgroundPressed"] = accentBrush;
+ Resources["SliderTrackValueFill"] = accentBrush;
+ Resources["SliderTrackValueFillPointerOver"] = accentBrush;
+ Resources["SliderTrackValueFillPressed"] = accentBrush;
+ Resources["SliderTrackValueFillDisabled"] = accentBrush;
+
+ // Make ToggleSwitch "on" state follow the accent color
+ Resources["ToggleSwitchFillOn"] = accentBrush;
+ Resources["ToggleSwitchFillOnPointerOver"] = accentBrush;
+ Resources["ToggleSwitchFillOnPressed"] = accentBrush;
+ Resources["ToggleSwitchStrokeOn"] = accentBrush;
+ Resources["ToggleSwitchStrokeOnPointerOver"] = accentBrush;
+ Resources["ToggleSwitchStrokeOnPressed"] = accentBrush;
+
+ // Make TextBox focus border + selection highlight follow the accent color
+ Resources["TextControlBorderBrushFocused"] = accentBrush;
+ Resources["TextControlSelectionHighlightColor"] = accentBrush;
+
+ // Update primary text color (all text derives from this)
+ brush = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(theme.TextPrimary));
+ Resources["AppTextColor"] = brush;
+ Resources["AppPrimaryText"] = brush;
+
+ // Update secondary text colors (derived from primary but with transparency)
+ var textColor = Avalonia.Media.Color.Parse(theme.TextPrimary);
+ Resources["AppMutedText"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.53 };
+ Resources["AppSectionLabel"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.33 };
+ Resources["AppBodyText"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.8 };
+ Resources["AppBulletText"] = new Avalonia.Media.SolidColorBrush(textColor) { Opacity = 0.67 };
+ }
+ catch (Exception ex)
+ {
+ ErrorLogger.LogError("App.ApplyThemeToResources", ex, "Failed to apply color theme");
+ }
+ }
+
+ ///
+ /// Loads the saved color theme from wauncher_settings and applies it at startup.
+ ///
+ private void ApplySavedThemeAtStartup()
+ {
+ try
+ {
+ var settings = SettingsWindowViewModel.LoadGlobal();
+ var theme = settings.LoadColorTheme();
+ if (theme != null)
+ ApplyThemeToResources(theme);
+ }
+ catch (Exception ex)
+ {
+ ErrorLogger.LogError("App.ApplySavedThemeAtStartup", ex, "Failed to load saved color theme");
+ }
+ }
+
+ public override async void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ DisableAvaloniaDataAnnotationValidation();
+
+ try
+ {
+ if (!Steam.IsInstalled())
+ {
+ ConsoleManager.ShowError(
+ "Steam is required to use Wauncher.\n\nPlease install Steam and relaunch.");
+ desktop.Shutdown();
+ return;
+ }
+
+ if (!IsSteamRunning())
+ {
+ ConsoleManager.ShowError(
+ "Steam must be open before using Wauncher.\n\nPlease open Steam, then relaunch Wauncher.");
+ desktop.Shutdown();
+ return;
+ }
+
+ if (Game.IsRunning())
+ {
+ ConsoleManager.ShowError(
+ "ClassicCounter is already running.\n\nPlease close the game before opening Wauncher again.");
+ desktop.Shutdown();
+ return;
+ }
+
+ bool hasRecentSteamUser = await Steam.GetRecentLoggedInSteamID(false);
+ if (!hasRecentSteamUser)
+ {
+ ConsoleManager.ShowError(
+ "Steam is open, but no logged-in Steam account was detected.\n\nPlease sign in to Steam and relaunch Wauncher.");
+ desktop.Shutdown();
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorLogger.LogError("App.OnFrameworkInitializationCompleted", ex, "Application startup validation failed");
+ ConsoleManager.ShowError($"Startup error: {ex.Message}");
+ desktop.Shutdown();
+ return;
+ }
+
+ // Apply the saved color theme before showing the window so
+ // custom colors persist across sessions without a flash of defaults.
+ ApplySavedThemeAtStartup();
+
+ desktop.MainWindow = new MainWindow();
+
+ // Initialize Discord in background
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ if (DependencyChecks.IsDiscordInstalled())
+ Discord.Init();
+ }
+ catch
+ {
+ // Discord integration is optional.
+ }
+ });
+
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private static bool IsSteamRunning()
+ {
+ try
+ {
+ return Process.GetProcessesByName("steam").Length > 0;
+ }
+ catch (Exception ex)
+ {
+ ErrorLogger.LogError("App.IsSteamRunning", ex, "Failed to check if Steam is running");
+ return false;
+ }
+ }
+
+
+
+ public void DiscordRpc_Click(object? sender, EventArgs e)
+ {
+ var settings = SettingsWindowViewModel.LoadGlobal();
+ settings.DiscordRpc = !settings.DiscordRpc; // auto-saves via OnDiscordRpcChanged
+ ApplyDiscordRpc(settings.DiscordRpc);
+ }
+
+ private void ApplyDiscordRpc(bool enabled)
+ {
+ if (!DependencyChecks.IsDiscordInstalled())
+ {
+ if (_discordRpcMenuItem != null)
+ _discordRpcMenuItem.Header = "Discord RPC (Discord not installed)";
+ return;
+ }
+
+ if (enabled)
+ {
+ Discord.SetDetails("In Main Menu");
+ Discord.SetState(null);
+ Discord.Update();
+ }
+ else
+ {
+ Discord.Deinitialize();
+ }
+
+ if (_discordRpcMenuItem != null)
+ _discordRpcMenuItem.Header = enabled ? "Discord RPC ON" : "Discord RPC OFF";
+ }
+
+ public void ExitApplication_Click(object? sender, EventArgs e)
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
+ d.TryShutdown();
+ }
+
+ private void DisableAvaloniaDataAnnotationValidation()
+ {
+ var toRemove = BindingPlugins.DataValidators
+ .OfType().ToArray();
+ foreach (var plugin in toRemove)
+ BindingPlugins.DataValidators.Remove(plugin);
+ }
+ }
+}
+
diff --git a/Wauncher/Assets/social_discord.svg b/Wauncher/Assets/social_discord.svg
index c5d1e6b..4e2134d 100644
--- a/Wauncher/Assets/social_discord.svg
+++ b/Wauncher/Assets/social_discord.svg
@@ -1,2 +1,2 @@
-
-
+
+
diff --git a/Wauncher/Assets/social_instagram.svg b/Wauncher/Assets/social_instagram.svg
index d3e39b5..4c052fb 100644
--- a/Wauncher/Assets/social_instagram.svg
+++ b/Wauncher/Assets/social_instagram.svg
@@ -1,2 +1,2 @@
-
-
+
+
diff --git a/Wauncher/Assets/social_inventory.svg b/Wauncher/Assets/social_inventory.svg
index 55087a8..78cb9d1 100644
--- a/Wauncher/Assets/social_inventory.svg
+++ b/Wauncher/Assets/social_inventory.svg
@@ -1,2 +1,2 @@
-
-
+
+
diff --git a/Wauncher/Assets/social_world.svg b/Wauncher/Assets/social_world.svg
index 531de40..ad8a441 100644
--- a/Wauncher/Assets/social_world.svg
+++ b/Wauncher/Assets/social_world.svg
@@ -1,24 +1,24 @@
-
-
-
+
+
+
diff --git a/Wauncher/Utils/Dependency.cs b/Wauncher/Utils/Dependency.cs
index b7d356d..07d7b08 100644
--- a/Wauncher/Utils/Dependency.cs
+++ b/Wauncher/Utils/Dependency.cs
@@ -1,126 +1,126 @@
-using Microsoft.Win32;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using Spectre.Console;
-using System.Diagnostics;
-
-namespace Wauncher.Utils
-{
- public class Dependency // to everyone seeing this: I am sorry, I think I am doing my best copying the rest of the code :innocent:
- {
- [JsonProperty(PropertyName = "name")]
- public required string Name { get; set; }
-
- [JsonProperty(PropertyName = "download_url")]
- public string? URL { get; set; }
-
- [JsonProperty(PropertyName = "path")]
- public required string Path { get; set; }
-
- [JsonProperty(PropertyName = "registry")]
- public required List RegistryList { get; set; }
-
- public class Registry
- {
- [JsonProperty(PropertyName = "path")]
- public required string Path { get; set; }
-
- [JsonProperty(PropertyName = "key")]
- public required string Key { get; set; }
-
- [JsonProperty(PropertyName = "value")]
- public required string Value { get; set; }
- }
- }
-
- public class Dependencies(bool success, List localDependencies, List remoteDependencies)
- {
- public bool Success = success;
- public List LocalDependencies = localDependencies;
- public List RemoteDependencies = remoteDependencies;
- }
-
- public static class DependencyManager
- {
- private static Process? _process;
- public static string directory = Directory.GetCurrentDirectory();
-
- public async static Task> Get()
- {
- List dependencies = new List();
-
- if (Debug.Enabled())
- Terminal.Debug("Getting list of dependencies.");
- try
- {
- string responseString = await Api.GitHub.GetDependencies();
-
- JObject responseJson = JObject.Parse(responseString);
-
- if (responseJson["files"] != null)
- dependencies = responseJson["files"]!.ToObject()!.ToList();
- }
- catch
- {
- if (Debug.Enabled())
- Terminal.Debug("Couldn't get list of dependencies.");
- }
- return dependencies;
- }
-
- public static bool IsInstalled(StatusContext ctx, Dependency dependency)
- {
- Dependency.Registry registry = dependency.RegistryList.First();
- using (RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
- {
- using (RegistryKey? key = hklm.OpenSubKey($@"{registry.Path}"))
- {
- string? keyValue = key?.GetValue(registry.Key) as string;
- if (keyValue != registry.Value)
- {
- Terminal.Warning($"{dependency.Name} is installed already!");
- return true;
- }
- else
- return false;
- }
- }
- }
-
- public async static Task Install(StatusContext ctx, Dependencies dependencies)
- {
- _process = new Process();
- bool success = false;
-
- List allDependencies = new List(
- dependencies.LocalDependencies.Count +
- dependencies.RemoteDependencies.Count);
- allDependencies.AddRange(dependencies.LocalDependencies);
- allDependencies.AddRange(dependencies.RemoteDependencies);
- foreach (Dependency dependency in allDependencies)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Executing dependency installer: {dependency.Name}");
- _process.StartInfo.FileName = $"{directory}{dependency.Path}";
- _process.StartInfo.UseShellExecute = true;
- _process.StartInfo.Verb = "runas";
- try
- {
- _process.Start();
- await _process.WaitForExitAsync();
- if (Debug.Enabled())
- Terminal.Debug($"Dependency installer {dependency.Name} has exited with status code {_process.ExitCode}");
- success = true;
- }
- catch
- {
- if (Debug.Enabled())
- Terminal.Debug($"Couldn't execute setup for dependency: {dependency.Name}");
- success = false;
- }
- }
- return success;
- }
- }
-}
-
+using Microsoft.Win32;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Spectre.Console;
+using System.Diagnostics;
+
+namespace Wauncher.Utils
+{
+ public class Dependency // to everyone seeing this: I am sorry, I think I am doing my best copying the rest of the code :innocent:
+ {
+ [JsonProperty(PropertyName = "name")]
+ public required string Name { get; set; }
+
+ [JsonProperty(PropertyName = "download_url")]
+ public string? URL { get; set; }
+
+ [JsonProperty(PropertyName = "path")]
+ public required string Path { get; set; }
+
+ [JsonProperty(PropertyName = "registry")]
+ public required List RegistryList { get; set; }
+
+ public class Registry
+ {
+ [JsonProperty(PropertyName = "path")]
+ public required string Path { get; set; }
+
+ [JsonProperty(PropertyName = "key")]
+ public required string Key { get; set; }
+
+ [JsonProperty(PropertyName = "value")]
+ public required string Value { get; set; }
+ }
+ }
+
+ public class Dependencies(bool success, List localDependencies, List remoteDependencies)
+ {
+ public bool Success = success;
+ public List LocalDependencies = localDependencies;
+ public List RemoteDependencies = remoteDependencies;
+ }
+
+ public static class DependencyManager
+ {
+ private static Process? _process;
+ public static string directory = Directory.GetCurrentDirectory();
+
+ public async static Task> Get()
+ {
+ List dependencies = new List();
+
+ if (Debug.Enabled())
+ Terminal.Debug("Getting list of dependencies.");
+ try
+ {
+ string responseString = await Api.GitHub.GetDependencies();
+
+ JObject responseJson = JObject.Parse(responseString);
+
+ if (responseJson["files"] != null)
+ dependencies = responseJson["files"]!.ToObject()!.ToList();
+ }
+ catch
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Couldn't get list of dependencies.");
+ }
+ return dependencies;
+ }
+
+ public static bool IsInstalled(StatusContext ctx, Dependency dependency)
+ {
+ Dependency.Registry registry = dependency.RegistryList.First();
+ using (RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
+ {
+ using (RegistryKey? key = hklm.OpenSubKey($@"{registry.Path}"))
+ {
+ string? keyValue = key?.GetValue(registry.Key) as string;
+ if (keyValue != registry.Value)
+ {
+ Terminal.Warning($"{dependency.Name} is installed already!");
+ return true;
+ }
+ else
+ return false;
+ }
+ }
+ }
+
+ public async static Task Install(StatusContext ctx, Dependencies dependencies)
+ {
+ _process = new Process();
+ bool success = false;
+
+ List allDependencies = new List(
+ dependencies.LocalDependencies.Count +
+ dependencies.RemoteDependencies.Count);
+ allDependencies.AddRange(dependencies.LocalDependencies);
+ allDependencies.AddRange(dependencies.RemoteDependencies);
+ foreach (Dependency dependency in allDependencies)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Executing dependency installer: {dependency.Name}");
+ _process.StartInfo.FileName = $"{directory}{dependency.Path}";
+ _process.StartInfo.UseShellExecute = true;
+ _process.StartInfo.Verb = "runas";
+ try
+ {
+ _process.Start();
+ await _process.WaitForExitAsync();
+ if (Debug.Enabled())
+ Terminal.Debug($"Dependency installer {dependency.Name} has exited with status code {_process.ExitCode}");
+ success = true;
+ }
+ catch
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Couldn't execute setup for dependency: {dependency.Name}");
+ success = false;
+ }
+ }
+ return success;
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/Discord.cs b/Wauncher/Utils/Discord.cs
index fa91b5e..bd89147 100644
--- a/Wauncher/Utils/Discord.cs
+++ b/Wauncher/Utils/Discord.cs
@@ -1,86 +1,86 @@
-using DiscordRPC;
-using DiscordRPC.Logging;
-using DiscordRPC.Message;
-using System;
-
-namespace Wauncher.Utils
-{
- public static class Discord
- {
- private static readonly string _appId = "1133457462024994947";
- private static DiscordRpcClient _client = new DiscordRpcClient(_appId);
- private static RichPresence _presence = new RichPresence();
- public static string? CurrentUserId { get; private set; }
- public static string? CurrentUserAvatar { get; private set; }
- public static string? CurrentUserUsername { get; private set; }
-
- public static void Init()
- {
- _client.OnReady += OnReady;
-
- _client.Logger = new ConsoleLogger()
- {
- Level = Debug.Enabled() ? LogLevel.Warning : LogLevel.None
- };
-
- if (!_client.Initialize())
- return;
-
- SetDetails("In Wauncher");
- SetTimestamp(DateTime.UtcNow);
- SetLargeArtwork("icon");
-
- Update();
- }
-
- public static void Deinitialize()
- {
- if (!_client.IsDisposed)
- {
- // SetPresence(null) clears the presence from Discord immediately.
- // Do NOT call Deinitialize/Dispose here — that prevents ClearPresence
- // from flushing, so the presence stays visible in Discord.
- _client.SetPresence(null);
- }
- }
-
- public static void Update() => _client.SetPresence(_presence);
-
- public static void SetDetails(string? details) => _presence.Details = details;
- public static void SetState(string? state) => _presence.State = state;
-
- public static void SetTimestamp(DateTime? time)
- {
- if (_presence.Timestamps == null) _presence.Timestamps = new();
- _presence.Timestamps.Start = time;
- }
-
- public static void SetLargeArtwork(string? key)
- {
- if (_presence.Assets == null) _presence.Assets = new();
- _presence.Assets.LargeImageKey = key;
- }
-
- public static void SetSmallArtwork(string? key)
- {
- if (_presence.Assets == null) _presence.Assets = new();
- _presence.Assets.SmallImageKey = key;
- }
-
- private static void OnReady(object sender, ReadyMessage e)
- {
- CurrentUserId = e.User.ID.ToString();
- CurrentUserAvatar = e.User.GetAvatarURL(User.AvatarFormat.PNG);
- CurrentUserUsername = e.User.Username;
- OnAvatarUpdate?.Invoke(CurrentUserAvatar);
- OnUsernameUpdate?.Invoke(CurrentUserUsername);
-
- if (Debug.Enabled())
- Terminal.Debug($"Discord RPC: User is ready => @{e.User.Username} ({e.User.ID})");
- }
-
- public static event Action? OnAvatarUpdate;
- public static event Action? OnUsernameUpdate;
- }
-}
-
+using DiscordRPC;
+using DiscordRPC.Logging;
+using DiscordRPC.Message;
+using System;
+
+namespace Wauncher.Utils
+{
+ public static class Discord
+ {
+ private static readonly string _appId = "1133457462024994947";
+ private static DiscordRpcClient _client = new DiscordRpcClient(_appId);
+ private static RichPresence _presence = new RichPresence();
+ public static string? CurrentUserId { get; private set; }
+ public static string? CurrentUserAvatar { get; private set; }
+ public static string? CurrentUserUsername { get; private set; }
+
+ public static void Init()
+ {
+ _client.OnReady += OnReady;
+
+ _client.Logger = new ConsoleLogger()
+ {
+ Level = Debug.Enabled() ? LogLevel.Warning : LogLevel.None
+ };
+
+ if (!_client.Initialize())
+ return;
+
+ SetDetails("In Wauncher");
+ SetTimestamp(DateTime.UtcNow);
+ SetLargeArtwork("icon");
+
+ Update();
+ }
+
+ public static void Deinitialize()
+ {
+ if (!_client.IsDisposed)
+ {
+ // SetPresence(null) clears the presence from Discord immediately.
+ // Do NOT call Deinitialize/Dispose here — that prevents ClearPresence
+ // from flushing, so the presence stays visible in Discord.
+ _client.SetPresence(null);
+ }
+ }
+
+ public static void Update() => _client.SetPresence(_presence);
+
+ public static void SetDetails(string? details) => _presence.Details = details;
+ public static void SetState(string? state) => _presence.State = state;
+
+ public static void SetTimestamp(DateTime? time)
+ {
+ if (_presence.Timestamps == null) _presence.Timestamps = new();
+ _presence.Timestamps.Start = time;
+ }
+
+ public static void SetLargeArtwork(string? key)
+ {
+ if (_presence.Assets == null) _presence.Assets = new();
+ _presence.Assets.LargeImageKey = key;
+ }
+
+ public static void SetSmallArtwork(string? key)
+ {
+ if (_presence.Assets == null) _presence.Assets = new();
+ _presence.Assets.SmallImageKey = key;
+ }
+
+ private static void OnReady(object sender, ReadyMessage e)
+ {
+ CurrentUserId = e.User.ID.ToString();
+ CurrentUserAvatar = e.User.GetAvatarURL(User.AvatarFormat.PNG);
+ CurrentUserUsername = e.User.Username;
+ OnAvatarUpdate?.Invoke(CurrentUserAvatar);
+ OnUsernameUpdate?.Invoke(CurrentUserUsername);
+
+ if (Debug.Enabled())
+ Terminal.Debug($"Discord RPC: User is ready => @{e.User.Username} ({e.User.ID})");
+ }
+
+ public static event Action? OnAvatarUpdate;
+ public static event Action? OnUsernameUpdate;
+ }
+}
+
diff --git a/Wauncher/Utils/Download.cs b/Wauncher/Utils/Download.cs
index 3295c13..0f90d7d 100644
--- a/Wauncher/Utils/Download.cs
+++ b/Wauncher/Utils/Download.cs
@@ -1,823 +1,823 @@
-using Downloader;
-using Refit;
-using SharpCompress.Archives;
-using SharpCompress.Archives.SevenZip;
-using SharpCompress.Common;
-using SharpCompress.Readers;
-using Spectre.Console;
-using System.Diagnostics;
-
-namespace Wauncher.Utils
-{
- public static class DownloadManager
- {
- private static string WauncherDirectory =>
- Path.GetDirectoryName(Services.GetExePath()) ?? Directory.GetCurrentDirectory();
-
- private static readonly DownloadConfiguration _settings = new()
- {
- ChunkCount = 8,
- ParallelDownload = true
- };
- private static readonly DownloadConfiguration _fullGameSettings = new()
- {
- ChunkCount = 1,
- ParallelDownload = false
- };
- // Shared only for DownloadUpdater / DownloadDependencies (console-launcher, always sequential)
- private static DownloadService _downloader = new DownloadService(_settings);
-
- public static async Task DownloadUpdater(string path)
- {
- await _downloader.DownloadFileTaskAsync(
- $"https://github.com/ClassicCounter/updater/releases/download/updater/updater.exe",
- path
- );
- }
-
- public static async Task DownloadDependencies(StatusContext ctx, List dependencies)
- {
- List local = new List();
- List remote = new List();
- Dependencies? _dependencies;
- foreach (var dependency in dependencies)
- {
- if (!DependencyManager.IsInstalled(ctx, dependency))
- {
- if (dependency.URL != null)
- {
- string path = WauncherDirectory + dependency.Path;
- if (File.Exists(path))
- File.Delete(path);
- if (Debug.Enabled())
- Terminal.Debug($"Downloading {dependency.Name}");
- await _downloader.DownloadFileTaskAsync(
- $"{dependency.URL}",
- $"{WauncherDirectory}{dependency.Path}");
- remote.Add(dependency);
- }
- else
- {
- local.Add(dependency);
- }
- }
- }
- _dependencies = new Dependencies(false, local, remote);
- return _dependencies;
- }
-
- public static async Task DownloadPatch(
- Patch patch,
- bool validateAll = false,
- Action? onProgress = null,
- Action? onExtract = null,
- Action? onExtractProgress = null)
- {
- string originalFileName = patch.File.EndsWith(".7z") ? patch.File[..^3] : patch.File;
- string downloadPath = Path.Combine(WauncherDirectory, patch.File);
-
- if (Debug.Enabled())
- Terminal.Debug($"Starting download of: {patch.File}");
-
- if (patch.File.EndsWith(".7z") && File.Exists(downloadPath))
- {
- try
- {
- if (Debug.Enabled())
- Terminal.Debug($"Found existing .7z file, trying to delete: {downloadPath}");
- File.Delete(downloadPath);
- }
- catch (Exception ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Failed to delete existing .7z file: {ex.Message}");
- }
- }
-
- string baseUrl = "https://patch.classiccounter.cc";
-
- // Use a fresh DownloadService per call so concurrent or back-to-back downloads
- // never share state on the same instance.
- using var downloader = new DownloadService(_settings);
- if (onProgress != null)
- downloader.DownloadProgressChanged += (sender, e) => onProgress(e);
-
- await downloader.DownloadFileTaskAsync(
- $"{baseUrl}/{patch.File}",
- Path.Combine(WauncherDirectory, patch.File)
- );
-
- if (patch.File.EndsWith(".7z"))
- {
- if (Debug.Enabled())
- Terminal.Debug($"Download complete, starting extraction of: {patch.File}");
- onExtract?.Invoke();
- string extractPath = Path.Combine(WauncherDirectory, originalFileName);
- await Extract7z(downloadPath, extractPath, onExtractProgress);
- }
- }
-
- public static async Task HandlePatches(Patches patches, StatusContext ctx, bool isGameFiles, int startingProgress = 0)
- {
- string fileType = isGameFiles ? "game file" : "patch";
- string fileTypePlural = isGameFiles ? "game files" : "patches";
-
- var allFiles = patches.Missing.Concat(patches.Outdated).ToList();
- int totalFiles = allFiles.Count;
- int completedFiles = startingProgress;
- int failedFiles = 0;
-
- // status update
- Action updateStatus = (progress, filename) =>
- {
- var speed = progress.BytesPerSecondSpeed / (1024.0 * 1024.0);
- var progressText = $"{((float)completedFiles / totalFiles * 100):F1}% ({completedFiles}/{totalFiles})";
- var status = filename.EndsWith(".7z") && progress.ProgressPercentage >= 100 ? "Extracting" : "Downloading new";
- ctx.Status = _statusFormatter.FormatStatus(status, fileTypePlural, progress.ProgressPercentage, speed, completedFiles, totalFiles);
- };
-
- foreach (var patch in allFiles)
- {
- try
- {
- await DownloadPatch(patch, isGameFiles, progress => updateStatus(progress, patch.File));
- completedFiles++;
- }
- catch
- {
- failedFiles++;
- Terminal.Warning($"Couldn't process {fileType}: {patch.File}, possibly due to missing permissions.");
- }
- }
-
- if (failedFiles > 0)
- Terminal.Warning($"Couldn't download {failedFiles} {(failedFiles == 1 ? fileType : fileTypePlural)}!");
- }
-
- public static async Task DownloadFullGame(StatusContext ctx)
- {
- try
- {
- await Steam.GetRecentLoggedInSteamID();
- if (string.IsNullOrEmpty(Steam.recentSteamID2))
- {
- Terminal.Error("Steam does not seem to be installed. Please make sure that you have Steam installed.");
- Terminal.Error("Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- return;
- }
-
- var gameFiles = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
-
- if (gameFiles?.Files == null || gameFiles.Files.Count == 0)
- {
- Terminal.Error("No game files returned from the API. You may not be whitelisted.");
- Terminal.Error("Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- return;
- }
-
- int totalFiles = gameFiles.Files.Count;
- int completedFiles = 0;
- List failedFiles = new List();
-
- foreach (var file in gameFiles.Files)
- {
- string filePath = Path.Combine(WauncherDirectory, file.File);
- bool needsDownload = true;
-
- if (File.Exists(filePath))
- {
- string fileHash = CalculateMD5(filePath);
- if (fileHash.Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
- {
- needsDownload = false;
- completedFiles++;
- continue;
- }
- }
-
- if (needsDownload)
- {
- try
- {
- EventHandler progressHandler = (sender, e) =>
- {
- var speed = e.BytesPerSecondSpeed / (1024.0 * 1024.0);
- var progressText = $"{((float)completedFiles / totalFiles * 100):F1}% ({completedFiles}/{totalFiles})";
- ctx.Status = _statusFormatter.FormatStatus("Downloading", file.File, e.ProgressPercentage, speed, completedFiles, totalFiles);
- };
- _downloader.DownloadProgressChanged += progressHandler;
-
- try
- {
- await _downloader.DownloadFileTaskAsync(file.Link, filePath);
-
- string downloadedHash = CalculateMD5(filePath);
- if (!downloadedHash.Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
- {
- failedFiles.Add(file.File);
- Terminal.Error($"Hash mismatch for {file.File}");
- continue;
- }
-
- completedFiles++;
- }
- finally
- {
- _downloader.DownloadProgressChanged -= progressHandler;
- }
- }
- catch (Exception ex)
- {
- failedFiles.Add(file.File);
- Terminal.Error($"Failed to download {file.File}: {ex.Message}");
- }
- }
- }
-
- if (failedFiles.Count == 0)
- {
- ctx.Status = "Extracting game files... Please do not close the launcher.";
- await ExtractSplitArchive(gameFiles.Files.Select(f => f.File).ToList());
- Terminal.Success("Game files downloaded and extracted successfully!");
- }
- else
- {
- Terminal.Error($"Failed to download {failedFiles.Count} files. Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- }
- }
- catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
- {
- Terminal.Error("You are not whitelisted on ClassicCounter! (https://classiccounter.cc/whitelist)");
- Terminal.Error("If you are whitelisted, check if you have Steam installed & you're logged into the whitelisted account.");
- Terminal.Error("If you're still facing issues, use one of our other download links to download the game.");
- Terminal.Warning("Closing launcher in 10 seconds...");
- await Task.Delay(10000);
- Environment.Exit(1);
- }
- catch (ApiException ex)
- {
- Terminal.Error($"Failed to get game files from API: {ex.Message}");
- Terminal.Error("Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- }
- catch (Exception ex)
- {
- Terminal.Error($"An error occurred: {ex.Message}");
- Terminal.Error("Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- }
- }
- ///
- /// Downloads and installs the full game from ClassicCounter's CDN.
- /// Designed for use from a GUI — takes progress/status callbacks instead of a StatusContext.
- /// Throws on error so the caller can handle it.
- ///
- public static async Task InstallFullGame(
- Action? onProgress, // (filename, speed, totalPercent)
- Action? onStatus,
- Action? onExtractProgress = null)
- {
- await Steam.GetRecentLoggedInSteamID();
- if (string.IsNullOrEmpty(Steam.recentSteamID2))
- throw new Exception("Steam does not appear to be installed or you are not logged in.");
-
- onStatus?.Invoke("Fetching game files...");
- FullGameDownloadResponse gameFiles;
- try
- {
- gameFiles = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
- }
- catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
- {
- throw new Exception("Not whitelisted. Visit classiccounter.cc/whitelist");
- }
- catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
- {
- throw new Exception("Wrong Steam account or not logged in");
- }
- catch (ApiException ex) when ((int)ex.StatusCode >= 500)
- {
- throw new Exception("Download server is down. Try again soon");
- }
- catch (ApiException)
- {
- throw new Exception("Couldn't fetch game files. Try again soon");
- }
- catch (HttpRequestException)
- {
- throw new Exception("No internet or server unreachable");
- }
-
- if (gameFiles?.Files == null || gameFiles.Files.Count == 0)
- throw new Exception("No game files returned. You may not be whitelisted.\nVisit classiccounter.cc/whitelist to request access.");
-
- int total = gameFiles.Files.Count;
- int completed = 0;
-
- foreach (var file in gameFiles.Files)
- {
- string filePath = Path.Combine(WauncherDirectory, file.File);
-
- if (File.Exists(filePath) &&
- CalculateMD5(filePath).Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
- {
- completed++;
- onProgress?.Invoke(file.File, "", (double)completed / total * 100.0);
- continue;
- }
-
- try
- {
- if (File.Exists(filePath))
- File.Delete(filePath);
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to delete existing file {filePath}: {ex.Message}");
- }
-
- using var downloader = new DownloadService(_fullGameSettings);
- downloader.DownloadProgressChanged += (s, e) =>
- onProgress?.Invoke(
- file.File,
- $"{e.BytesPerSecondSpeed / 1024.0 / 1024.0:F1} MB/s",
- (completed + e.ProgressPercentage / 100.0) / total * 100.0);
-
- await downloader.DownloadFileTaskAsync(file.Link, filePath);
-
- string downloadedHash = CalculateMD5(filePath);
- if (!downloadedHash.Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
- {
- try
- {
- File.Delete(filePath);
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to delete corrupted file {filePath}: {ex.Message}");
- }
-
- throw new Exception($"Downloaded file failed verification: {file.File}");
- }
-
- completed++;
- }
-
- onStatus?.Invoke("Verifying downloaded archives...");
- await VerifySplitArchive(gameFiles.Files.Select(f => f.File).ToList());
-
- onStatus?.Invoke("Extracting game files... This may take a few minutes.");
- await ExtractSplitArchive(gameFiles.Files.Select(f => f.File).ToList(), onExtractProgress);
- }
-
- private static string CalculateMD5(string filename)
- {
- using (var md5 = System.Security.Cryptography.MD5.Create())
- using (var stream = File.OpenRead(filename))
- {
- byte[] hash = md5.ComputeHash(stream);
- return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
- }
- }
-
- private static readonly DownloadStatus _statusFormatter = new DownloadStatus();
- public static async Task ExtractSplitArchive(List files, Action? onProgress = null)
- {
- if (files == null || files.Count == 0)
- {
- throw new ArgumentException("No files provided for extraction");
- }
-
- files.Sort();
-
- if (Debug.Enabled())
- {
- Terminal.Debug("Starting extraction of split archive:");
- foreach (var file in files)
- {
- Terminal.Debug($"Found part: {file}");
- }
- }
-
- string firstFile = Path.Combine(WauncherDirectory, files[0]);
- string extractPath = WauncherDirectory;
- string tempExtractPath = Path.Combine(extractPath, "ClassicCounter_temp");
-
- try
- {
- Directory.CreateDirectory(tempExtractPath);
-
- await Download7za();
-
- string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
- if (launcherDir == null)
- throw new InvalidOperationException("Could not determine launcher directory");
-
- string exePath = Path.Combine(launcherDir, "7za.exe");
-
- if (Debug.Enabled())
- Terminal.Debug("Starting 7za extraction to temp directory...");
-
- using (var process = new Process())
- {
- process.StartInfo = new ProcessStartInfo
- {
- FileName = exePath,
- Arguments = $"x \"{firstFile}\" -o\"{tempExtractPath}\" -y -bsp1",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
-
- process.Start();
-
- // Drain stderr so it never blocks the process
- _ = Task.Run(async () => { try { await process.StandardError.ReadToEndAsync(); } catch { } });
-
- // Parse percentage progress from 7za stdout (-bsp1 sends it there)
- _ = Task.Run(async () =>
- {
- try
- {
- var buf = new char[512];
- var acc = new System.Text.StringBuilder();
- while (true)
- {
- int n = await process.StandardOutput.ReadAsync(buf, 0, buf.Length);
- if (n == 0) break;
- acc.Append(buf, 0, n);
- string text = acc.ToString();
- int pctIdx;
- double lastPct = -1;
- while ((pctIdx = text.IndexOf('%')) >= 0)
- {
- int numStart = pctIdx - 1;
- while (numStart > 0 && (char.IsDigit(text[numStart - 1]) || text[numStart - 1] == ' '))
- numStart--;
- if (double.TryParse(text[numStart..pctIdx].Trim(), out double p))
- lastPct = p;
- text = text[(pctIdx + 1)..];
- }
- if (lastPct >= 0)
- onProgress?.Invoke(lastPct);
- acc.Clear();
- acc.Append(text);
- }
- }
- catch { }
- });
-
- await process.WaitForExitAsync();
-
- if (process.ExitCode != 0)
- throw new Exception($"7za extraction failed with exit code: {process.ExitCode}");
- }
-
- onProgress?.Invoke(100.0);
-
- string classicCounterPath = Path.Combine(tempExtractPath, "ClassicCounter");
- if (Directory.Exists(classicCounterPath))
- {
- if (Debug.Enabled())
- Terminal.Debug("Moving contents from ClassicCounter folder to root directory...");
- await Task.Run(() => MoveExtractedClassicCounterFiles(classicCounterPath, extractPath));
- }
- else
- {
- throw new DirectoryNotFoundException("ClassicCounter folder not found in extracted contents");
- }
-
- try
- {
- Directory.Delete(tempExtractPath, true);
- if (Debug.Enabled())
- Terminal.Debug("Deleted temporary extraction directory");
-
- foreach (string file in files)
- {
- string filePath = Path.Combine(WauncherDirectory, file);
- if (File.Exists(filePath))
- File.Delete(filePath);
- if (Debug.Enabled())
- Terminal.Debug($"Deleted archive part: {file}");
- }
-
- Delete7zaExecutable();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to cleanup some temporary files: {ex.Message}");
- }
-
- if (Debug.Enabled())
- Terminal.Debug("Extraction and file movement completed successfully!");
- }
- catch (Exception ex)
- {
- Terminal.Error($"Extraction failed: {ex.Message}");
- if (Debug.Enabled())
- Terminal.Debug($"Stack trace: {ex.StackTrace}");
-
- try
- {
- if (Directory.Exists(tempExtractPath))
- Directory.Delete(tempExtractPath, true);
- }
- catch (Exception cleanupEx)
- {
- Terminal.Warning($"Failed to cleanup temporary directory {tempExtractPath}: {cleanupEx.Message}");
- }
-
- CleanupSplitArchiveFiles(files);
- Delete7zaExecutable();
-
- throw;
- }
- }
-
- private static async Task VerifySplitArchive(List files)
- {
- if (files == null || files.Count == 0)
- throw new ArgumentException("No files provided for archive verification");
-
- string firstFile = Path.Combine(WauncherDirectory, files[0]);
- await Download7za();
-
- string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
- if (launcherDir == null)
- throw new InvalidOperationException("Could not determine launcher directory");
-
- string exePath = Path.Combine(launcherDir, "7za.exe");
-
- using var process = new Process();
- process.StartInfo = new ProcessStartInfo
- {
- FileName = exePath,
- Arguments = $"t \"{firstFile}\"",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
-
- process.Start();
- string stdOut = await process.StandardOutput.ReadToEndAsync();
- string stdErr = await process.StandardError.ReadToEndAsync();
- await process.WaitForExitAsync();
-
- if (process.ExitCode != 0)
- {
- CleanupSplitArchiveFiles(files);
- Delete7zaExecutable();
-
- string details = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr;
- if (details.Contains("Data Error", StringComparison.OrdinalIgnoreCase))
- throw new Exception("Downloaded archives were corrupted. Please try install again.");
-
- throw new Exception($"Archive verification failed (7za exit code: {process.ExitCode})");
- }
- }
-
- private static async Task Extract7z(string archivePath, string outputPath, Action? onProgress = null)
- {
- try
- {
- if (!File.Exists(archivePath))
- {
- if (Debug.Enabled())
- Terminal.Debug($"Archive file not found: {archivePath}");
- return;
- }
-
- await ExtractArchiveToDirectory(archivePath, Path.GetDirectoryName(outputPath)!, onProgress);
-
- try
- {
- File.Delete(archivePath);
- if (Debug.Enabled())
- Terminal.Debug($"Deleted archive file: {archivePath}");
- }
- catch (Exception ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Failed to delete archive file: {ex.Message}");
- }
- }
- catch (Exception ex)
- {
- Terminal.Error($"Extraction failed: {ex.Message}\nStack trace: {ex.StackTrace}");
- throw;
- }
- }
-
- private static void MoveExtractedClassicCounterFiles(string classicCounterPath, string extractPath)
- {
- foreach (string dirPath in Directory.GetDirectories(classicCounterPath, "*", SearchOption.AllDirectories))
- {
- string newDirPath = dirPath.Replace(classicCounterPath, extractPath);
- Directory.CreateDirectory(newDirPath);
- }
-
- foreach (string filePath in Directory.GetFiles(classicCounterPath, "*.*", SearchOption.AllDirectories))
- {
- string newFilePath = filePath.Replace(classicCounterPath, extractPath);
-
- string fileName = Path.GetFileName(filePath);
- if (fileName.Equals("launcher.exe", StringComparison.OrdinalIgnoreCase) ||
- fileName.Equals("wauncher.exe", StringComparison.OrdinalIgnoreCase))
- {
- if (Debug.Enabled())
- Terminal.Debug($"Skipping {fileName}");
- continue;
- }
-
- try
- {
- if (File.Exists(newFilePath))
- {
- File.Delete(newFilePath);
- }
- File.Move(filePath, newFilePath);
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to move file {filePath}: {ex.Message}");
- }
- }
- }
-
- private static async Task Download7za()
- {
- string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
- if (launcherDir == null)
- throw new InvalidOperationException("Could not determine launcher directory");
-
- string exePath = Path.Combine(launcherDir, "7za.exe");
- if (File.Exists(exePath))
- return;
-
- string[] fallbackUrls =
- {
- "https://fastdl.classiccounter.cc/7za.exe",
- "https://ollumcc.github.io/7za.exe"
- };
-
- Exception? lastError = null;
- foreach (var url in fallbackUrls)
- {
- try
- {
- await _downloader.DownloadFileTaskAsync(url, exePath);
- if (File.Exists(exePath))
- return;
- }
- catch (Exception ex)
- {
- lastError = ex;
- }
- }
-
- throw new Exception($"Couldn't download 7za.exe{(lastError != null ? $": {lastError.Message}" : string.Empty)}");
- }
-
- private static void Delete7zaExecutable()
- {
- try
- {
- string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
- if (string.IsNullOrWhiteSpace(launcherDir))
- return;
-
- string exePath = Path.Combine(launcherDir, "7za.exe");
- if (!File.Exists(exePath))
- return;
-
- File.Delete(exePath);
-
- if (Debug.Enabled())
- Terminal.Debug("Deleted 7za.exe");
- }
- catch (Exception ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Failed to delete 7za.exe: {ex.Message}");
- }
- }
-
- private static async Task ExtractArchiveToDirectory(string archivePath, string outputDirectory, Action? onProgress = null)
- {
- await Task.Run(() =>
- {
- using var archive = ArchiveFactory.OpenArchive(new FileInfo(archivePath), new ReaderOptions());
- var entries = archive.Entries.Where(entry => !entry.IsDirectory).ToArray();
- int totalEntries = entries.Length > 0 ? entries.Length : 1;
- int completedEntries = 0;
-
- onProgress?.Invoke(0);
-
- foreach (var entry in entries)
- {
- entry.WriteToDirectory(outputDirectory, new ExtractionOptions
- {
- ExtractFullPath = true,
- Overwrite = true
- });
-
- completedEntries++;
- onProgress?.Invoke((double)completedEntries / totalEntries * 100.0);
- }
- });
- }
-
-
- private static async Task ExtractSplitArchiveToDirectory(IEnumerable archiveParts, string outputDirectory, Action? onProgress = null)
- {
- await Task.Run(() =>
- {
- var parts = archiveParts
- .Select(part => new FileInfo(Path.Combine(WauncherDirectory, part)))
- .ToArray();
-
- using var archive = SevenZipArchive.OpenArchive(parts, new ReaderOptions());
- var entries = archive.Entries.Where(entry => !entry.IsDirectory).ToArray();
- int totalEntries = entries.Length > 0 ? entries.Length : 1;
- int completedEntries = 0;
-
- onProgress?.Invoke(0);
-
- foreach (var entry in entries)
- {
- entry.WriteToDirectory(outputDirectory, new ExtractionOptions
- {
- ExtractFullPath = true,
- Overwrite = true
- });
-
- completedEntries++;
- onProgress?.Invoke((double)completedEntries / totalEntries * 100.0);
- }
- });
- }
-
- public static void Cleanup7zFiles()
- {
- try
- {
- string directory = WauncherDirectory;
- var files = Directory.GetFiles(directory, "*.7z", SearchOption.AllDirectories)
- .Concat(Directory.GetFiles(directory, "*.7z.*", SearchOption.AllDirectories))
- .Distinct(StringComparer.OrdinalIgnoreCase);
-
- foreach (string file in files)
- {
- try
- {
- File.Delete(file);
- if (Debug.Enabled())
- Terminal.Debug($"Deleted .7z file: {file}");
- }
- catch (Exception ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Failed to delete .7z file {file}: {ex.Message}");
- }
- }
- }
- catch (Exception ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Failed to perform cleanup: {ex.Message}");
- }
- }
-
- private static void CleanupSplitArchiveFiles(IEnumerable files)
- {
- foreach (string file in files)
- {
- try
- {
- string filePath = Path.Combine(WauncherDirectory, file);
- if (File.Exists(filePath))
- File.Delete(filePath);
- }
- catch (Exception ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Failed to delete archive part {file}: {ex.Message}");
- }
- }
- }
- }
-}
-
-
-
+using Downloader;
+using Refit;
+using SharpCompress.Archives;
+using SharpCompress.Archives.SevenZip;
+using SharpCompress.Common;
+using SharpCompress.Readers;
+using Spectre.Console;
+using System.Diagnostics;
+
+namespace Wauncher.Utils
+{
+ public static class DownloadManager
+ {
+ private static string WauncherDirectory =>
+ Path.GetDirectoryName(Services.GetExePath()) ?? Directory.GetCurrentDirectory();
+
+ private static readonly DownloadConfiguration _settings = new()
+ {
+ ChunkCount = 8,
+ ParallelDownload = true
+ };
+ private static readonly DownloadConfiguration _fullGameSettings = new()
+ {
+ ChunkCount = 1,
+ ParallelDownload = false
+ };
+ // Shared only for DownloadUpdater / DownloadDependencies (console-launcher, always sequential)
+ private static DownloadService _downloader = new DownloadService(_settings);
+
+ public static async Task DownloadUpdater(string path)
+ {
+ await _downloader.DownloadFileTaskAsync(
+ $"https://github.com/ClassicCounter/updater/releases/download/updater/updater.exe",
+ path
+ );
+ }
+
+ public static async Task DownloadDependencies(StatusContext ctx, List dependencies)
+ {
+ List local = new List();
+ List remote = new List();
+ Dependencies? _dependencies;
+ foreach (var dependency in dependencies)
+ {
+ if (!DependencyManager.IsInstalled(ctx, dependency))
+ {
+ if (dependency.URL != null)
+ {
+ string path = WauncherDirectory + dependency.Path;
+ if (File.Exists(path))
+ File.Delete(path);
+ if (Debug.Enabled())
+ Terminal.Debug($"Downloading {dependency.Name}");
+ await _downloader.DownloadFileTaskAsync(
+ $"{dependency.URL}",
+ $"{WauncherDirectory}{dependency.Path}");
+ remote.Add(dependency);
+ }
+ else
+ {
+ local.Add(dependency);
+ }
+ }
+ }
+ _dependencies = new Dependencies(false, local, remote);
+ return _dependencies;
+ }
+
+ public static async Task DownloadPatch(
+ Patch patch,
+ bool validateAll = false,
+ Action? onProgress = null,
+ Action? onExtract = null,
+ Action? onExtractProgress = null)
+ {
+ string originalFileName = patch.File.EndsWith(".7z") ? patch.File[..^3] : patch.File;
+ string downloadPath = Path.Combine(WauncherDirectory, patch.File);
+
+ if (Debug.Enabled())
+ Terminal.Debug($"Starting download of: {patch.File}");
+
+ if (patch.File.EndsWith(".7z") && File.Exists(downloadPath))
+ {
+ try
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Found existing .7z file, trying to delete: {downloadPath}");
+ File.Delete(downloadPath);
+ }
+ catch (Exception ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Failed to delete existing .7z file: {ex.Message}");
+ }
+ }
+
+ string baseUrl = "https://patch.classiccounter.cc";
+
+ // Use a fresh DownloadService per call so concurrent or back-to-back downloads
+ // never share state on the same instance.
+ using var downloader = new DownloadService(_settings);
+ if (onProgress != null)
+ downloader.DownloadProgressChanged += (sender, e) => onProgress(e);
+
+ await downloader.DownloadFileTaskAsync(
+ $"{baseUrl}/{patch.File}",
+ Path.Combine(WauncherDirectory, patch.File)
+ );
+
+ if (patch.File.EndsWith(".7z"))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Download complete, starting extraction of: {patch.File}");
+ onExtract?.Invoke();
+ string extractPath = Path.Combine(WauncherDirectory, originalFileName);
+ await Extract7z(downloadPath, extractPath, onExtractProgress);
+ }
+ }
+
+ public static async Task HandlePatches(Patches patches, StatusContext ctx, bool isGameFiles, int startingProgress = 0)
+ {
+ string fileType = isGameFiles ? "game file" : "patch";
+ string fileTypePlural = isGameFiles ? "game files" : "patches";
+
+ var allFiles = patches.Missing.Concat(patches.Outdated).ToList();
+ int totalFiles = allFiles.Count;
+ int completedFiles = startingProgress;
+ int failedFiles = 0;
+
+ // status update
+ Action updateStatus = (progress, filename) =>
+ {
+ var speed = progress.BytesPerSecondSpeed / (1024.0 * 1024.0);
+ var progressText = $"{((float)completedFiles / totalFiles * 100):F1}% ({completedFiles}/{totalFiles})";
+ var status = filename.EndsWith(".7z") && progress.ProgressPercentage >= 100 ? "Extracting" : "Downloading new";
+ ctx.Status = _statusFormatter.FormatStatus(status, fileTypePlural, progress.ProgressPercentage, speed, completedFiles, totalFiles);
+ };
+
+ foreach (var patch in allFiles)
+ {
+ try
+ {
+ await DownloadPatch(patch, isGameFiles, progress => updateStatus(progress, patch.File));
+ completedFiles++;
+ }
+ catch
+ {
+ failedFiles++;
+ Terminal.Warning($"Couldn't process {fileType}: {patch.File}, possibly due to missing permissions.");
+ }
+ }
+
+ if (failedFiles > 0)
+ Terminal.Warning($"Couldn't download {failedFiles} {(failedFiles == 1 ? fileType : fileTypePlural)}!");
+ }
+
+ public static async Task DownloadFullGame(StatusContext ctx)
+ {
+ try
+ {
+ await Steam.GetRecentLoggedInSteamID();
+ if (string.IsNullOrEmpty(Steam.recentSteamID2))
+ {
+ Terminal.Error("Steam does not seem to be installed. Please make sure that you have Steam installed.");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ return;
+ }
+
+ var gameFiles = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
+
+ if (gameFiles?.Files == null || gameFiles.Files.Count == 0)
+ {
+ Terminal.Error("No game files returned from the API. You may not be whitelisted.");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ return;
+ }
+
+ int totalFiles = gameFiles.Files.Count;
+ int completedFiles = 0;
+ List failedFiles = new List();
+
+ foreach (var file in gameFiles.Files)
+ {
+ string filePath = Path.Combine(WauncherDirectory, file.File);
+ bool needsDownload = true;
+
+ if (File.Exists(filePath))
+ {
+ string fileHash = CalculateMD5(filePath);
+ if (fileHash.Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ needsDownload = false;
+ completedFiles++;
+ continue;
+ }
+ }
+
+ if (needsDownload)
+ {
+ try
+ {
+ EventHandler progressHandler = (sender, e) =>
+ {
+ var speed = e.BytesPerSecondSpeed / (1024.0 * 1024.0);
+ var progressText = $"{((float)completedFiles / totalFiles * 100):F1}% ({completedFiles}/{totalFiles})";
+ ctx.Status = _statusFormatter.FormatStatus("Downloading", file.File, e.ProgressPercentage, speed, completedFiles, totalFiles);
+ };
+ _downloader.DownloadProgressChanged += progressHandler;
+
+ try
+ {
+ await _downloader.DownloadFileTaskAsync(file.Link, filePath);
+
+ string downloadedHash = CalculateMD5(filePath);
+ if (!downloadedHash.Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ failedFiles.Add(file.File);
+ Terminal.Error($"Hash mismatch for {file.File}");
+ continue;
+ }
+
+ completedFiles++;
+ }
+ finally
+ {
+ _downloader.DownloadProgressChanged -= progressHandler;
+ }
+ }
+ catch (Exception ex)
+ {
+ failedFiles.Add(file.File);
+ Terminal.Error($"Failed to download {file.File}: {ex.Message}");
+ }
+ }
+ }
+
+ if (failedFiles.Count == 0)
+ {
+ ctx.Status = "Extracting game files... Please do not close the launcher.";
+ await ExtractSplitArchive(gameFiles.Files.Select(f => f.File).ToList());
+ Terminal.Success("Game files downloaded and extracted successfully!");
+ }
+ else
+ {
+ Terminal.Error($"Failed to download {failedFiles.Count} files. Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ }
+ }
+ catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
+ {
+ Terminal.Error("You are not whitelisted on ClassicCounter! (https://classiccounter.cc/whitelist)");
+ Terminal.Error("If you are whitelisted, check if you have Steam installed & you're logged into the whitelisted account.");
+ Terminal.Error("If you're still facing issues, use one of our other download links to download the game.");
+ Terminal.Warning("Closing launcher in 10 seconds...");
+ await Task.Delay(10000);
+ Environment.Exit(1);
+ }
+ catch (ApiException ex)
+ {
+ Terminal.Error($"Failed to get game files from API: {ex.Message}");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ }
+ catch (Exception ex)
+ {
+ Terminal.Error($"An error occurred: {ex.Message}");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ }
+ }
+ ///
+ /// Downloads and installs the full game from ClassicCounter's CDN.
+ /// Designed for use from a GUI — takes progress/status callbacks instead of a StatusContext.
+ /// Throws on error so the caller can handle it.
+ ///
+ public static async Task InstallFullGame(
+ Action? onProgress, // (filename, speed, totalPercent)
+ Action? onStatus,
+ Action? onExtractProgress = null)
+ {
+ await Steam.GetRecentLoggedInSteamID();
+ if (string.IsNullOrEmpty(Steam.recentSteamID2))
+ throw new Exception("Steam does not appear to be installed or you are not logged in.");
+
+ onStatus?.Invoke("Fetching game files...");
+ FullGameDownloadResponse gameFiles;
+ try
+ {
+ gameFiles = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
+ }
+ catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
+ {
+ throw new Exception("Not whitelisted. Visit classiccounter.cc/whitelist");
+ }
+ catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
+ {
+ throw new Exception("Wrong Steam account or not logged in");
+ }
+ catch (ApiException ex) when ((int)ex.StatusCode >= 500)
+ {
+ throw new Exception("Download server is down. Try again soon");
+ }
+ catch (ApiException)
+ {
+ throw new Exception("Couldn't fetch game files. Try again soon");
+ }
+ catch (HttpRequestException)
+ {
+ throw new Exception("No internet or server unreachable");
+ }
+
+ if (gameFiles?.Files == null || gameFiles.Files.Count == 0)
+ throw new Exception("No game files returned. You may not be whitelisted.\nVisit classiccounter.cc/whitelist to request access.");
+
+ int total = gameFiles.Files.Count;
+ int completed = 0;
+
+ foreach (var file in gameFiles.Files)
+ {
+ string filePath = Path.Combine(WauncherDirectory, file.File);
+
+ if (File.Exists(filePath) &&
+ CalculateMD5(filePath).Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ completed++;
+ onProgress?.Invoke(file.File, "", (double)completed / total * 100.0);
+ continue;
+ }
+
+ try
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to delete existing file {filePath}: {ex.Message}");
+ }
+
+ using var downloader = new DownloadService(_fullGameSettings);
+ downloader.DownloadProgressChanged += (s, e) =>
+ onProgress?.Invoke(
+ file.File,
+ $"{e.BytesPerSecondSpeed / 1024.0 / 1024.0:F1} MB/s",
+ (completed + e.ProgressPercentage / 100.0) / total * 100.0);
+
+ await downloader.DownloadFileTaskAsync(file.Link, filePath);
+
+ string downloadedHash = CalculateMD5(filePath);
+ if (!downloadedHash.Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ File.Delete(filePath);
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to delete corrupted file {filePath}: {ex.Message}");
+ }
+
+ throw new Exception($"Downloaded file failed verification: {file.File}");
+ }
+
+ completed++;
+ }
+
+ onStatus?.Invoke("Verifying downloaded archives...");
+ await VerifySplitArchive(gameFiles.Files.Select(f => f.File).ToList());
+
+ onStatus?.Invoke("Extracting game files... This may take a few minutes.");
+ await ExtractSplitArchive(gameFiles.Files.Select(f => f.File).ToList(), onExtractProgress);
+ }
+
+ private static string CalculateMD5(string filename)
+ {
+ using (var md5 = System.Security.Cryptography.MD5.Create())
+ using (var stream = File.OpenRead(filename))
+ {
+ byte[] hash = md5.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+ }
+
+ private static readonly DownloadStatus _statusFormatter = new DownloadStatus();
+ public static async Task ExtractSplitArchive(List files, Action? onProgress = null)
+ {
+ if (files == null || files.Count == 0)
+ {
+ throw new ArgumentException("No files provided for extraction");
+ }
+
+ files.Sort();
+
+ if (Debug.Enabled())
+ {
+ Terminal.Debug("Starting extraction of split archive:");
+ foreach (var file in files)
+ {
+ Terminal.Debug($"Found part: {file}");
+ }
+ }
+
+ string firstFile = Path.Combine(WauncherDirectory, files[0]);
+ string extractPath = WauncherDirectory;
+ string tempExtractPath = Path.Combine(extractPath, "ClassicCounter_temp");
+
+ try
+ {
+ Directory.CreateDirectory(tempExtractPath);
+
+ await Download7za();
+
+ string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
+ if (launcherDir == null)
+ throw new InvalidOperationException("Could not determine launcher directory");
+
+ string exePath = Path.Combine(launcherDir, "7za.exe");
+
+ if (Debug.Enabled())
+ Terminal.Debug("Starting 7za extraction to temp directory...");
+
+ using (var process = new Process())
+ {
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = exePath,
+ Arguments = $"x \"{firstFile}\" -o\"{tempExtractPath}\" -y -bsp1",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ process.Start();
+
+ // Drain stderr so it never blocks the process
+ _ = Task.Run(async () => { try { await process.StandardError.ReadToEndAsync(); } catch { } });
+
+ // Parse percentage progress from 7za stdout (-bsp1 sends it there)
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var buf = new char[512];
+ var acc = new System.Text.StringBuilder();
+ while (true)
+ {
+ int n = await process.StandardOutput.ReadAsync(buf, 0, buf.Length);
+ if (n == 0) break;
+ acc.Append(buf, 0, n);
+ string text = acc.ToString();
+ int pctIdx;
+ double lastPct = -1;
+ while ((pctIdx = text.IndexOf('%')) >= 0)
+ {
+ int numStart = pctIdx - 1;
+ while (numStart > 0 && (char.IsDigit(text[numStart - 1]) || text[numStart - 1] == ' '))
+ numStart--;
+ if (double.TryParse(text[numStart..pctIdx].Trim(), out double p))
+ lastPct = p;
+ text = text[(pctIdx + 1)..];
+ }
+ if (lastPct >= 0)
+ onProgress?.Invoke(lastPct);
+ acc.Clear();
+ acc.Append(text);
+ }
+ }
+ catch { }
+ });
+
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ throw new Exception($"7za extraction failed with exit code: {process.ExitCode}");
+ }
+
+ onProgress?.Invoke(100.0);
+
+ string classicCounterPath = Path.Combine(tempExtractPath, "ClassicCounter");
+ if (Directory.Exists(classicCounterPath))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Moving contents from ClassicCounter folder to root directory...");
+ await Task.Run(() => MoveExtractedClassicCounterFiles(classicCounterPath, extractPath));
+ }
+ else
+ {
+ throw new DirectoryNotFoundException("ClassicCounter folder not found in extracted contents");
+ }
+
+ try
+ {
+ Directory.Delete(tempExtractPath, true);
+ if (Debug.Enabled())
+ Terminal.Debug("Deleted temporary extraction directory");
+
+ foreach (string file in files)
+ {
+ string filePath = Path.Combine(WauncherDirectory, file);
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ if (Debug.Enabled())
+ Terminal.Debug($"Deleted archive part: {file}");
+ }
+
+ Delete7zaExecutable();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to cleanup some temporary files: {ex.Message}");
+ }
+
+ if (Debug.Enabled())
+ Terminal.Debug("Extraction and file movement completed successfully!");
+ }
+ catch (Exception ex)
+ {
+ Terminal.Error($"Extraction failed: {ex.Message}");
+ if (Debug.Enabled())
+ Terminal.Debug($"Stack trace: {ex.StackTrace}");
+
+ try
+ {
+ if (Directory.Exists(tempExtractPath))
+ Directory.Delete(tempExtractPath, true);
+ }
+ catch (Exception cleanupEx)
+ {
+ Terminal.Warning($"Failed to cleanup temporary directory {tempExtractPath}: {cleanupEx.Message}");
+ }
+
+ CleanupSplitArchiveFiles(files);
+ Delete7zaExecutable();
+
+ throw;
+ }
+ }
+
+ private static async Task VerifySplitArchive(List files)
+ {
+ if (files == null || files.Count == 0)
+ throw new ArgumentException("No files provided for archive verification");
+
+ string firstFile = Path.Combine(WauncherDirectory, files[0]);
+ await Download7za();
+
+ string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
+ if (launcherDir == null)
+ throw new InvalidOperationException("Could not determine launcher directory");
+
+ string exePath = Path.Combine(launcherDir, "7za.exe");
+
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = exePath,
+ Arguments = $"t \"{firstFile}\"",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ process.Start();
+ string stdOut = await process.StandardOutput.ReadToEndAsync();
+ string stdErr = await process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ CleanupSplitArchiveFiles(files);
+ Delete7zaExecutable();
+
+ string details = string.IsNullOrWhiteSpace(stdErr) ? stdOut : stdErr;
+ if (details.Contains("Data Error", StringComparison.OrdinalIgnoreCase))
+ throw new Exception("Downloaded archives were corrupted. Please try install again.");
+
+ throw new Exception($"Archive verification failed (7za exit code: {process.ExitCode})");
+ }
+ }
+
+ private static async Task Extract7z(string archivePath, string outputPath, Action? onProgress = null)
+ {
+ try
+ {
+ if (!File.Exists(archivePath))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Archive file not found: {archivePath}");
+ return;
+ }
+
+ await ExtractArchiveToDirectory(archivePath, Path.GetDirectoryName(outputPath)!, onProgress);
+
+ try
+ {
+ File.Delete(archivePath);
+ if (Debug.Enabled())
+ Terminal.Debug($"Deleted archive file: {archivePath}");
+ }
+ catch (Exception ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Failed to delete archive file: {ex.Message}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Terminal.Error($"Extraction failed: {ex.Message}\nStack trace: {ex.StackTrace}");
+ throw;
+ }
+ }
+
+ private static void MoveExtractedClassicCounterFiles(string classicCounterPath, string extractPath)
+ {
+ foreach (string dirPath in Directory.GetDirectories(classicCounterPath, "*", SearchOption.AllDirectories))
+ {
+ string newDirPath = dirPath.Replace(classicCounterPath, extractPath);
+ Directory.CreateDirectory(newDirPath);
+ }
+
+ foreach (string filePath in Directory.GetFiles(classicCounterPath, "*.*", SearchOption.AllDirectories))
+ {
+ string newFilePath = filePath.Replace(classicCounterPath, extractPath);
+
+ string fileName = Path.GetFileName(filePath);
+ if (fileName.Equals("launcher.exe", StringComparison.OrdinalIgnoreCase) ||
+ fileName.Equals("wauncher.exe", StringComparison.OrdinalIgnoreCase))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Skipping {fileName}");
+ continue;
+ }
+
+ try
+ {
+ if (File.Exists(newFilePath))
+ {
+ File.Delete(newFilePath);
+ }
+ File.Move(filePath, newFilePath);
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to move file {filePath}: {ex.Message}");
+ }
+ }
+ }
+
+ private static async Task Download7za()
+ {
+ string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
+ if (launcherDir == null)
+ throw new InvalidOperationException("Could not determine launcher directory");
+
+ string exePath = Path.Combine(launcherDir, "7za.exe");
+ if (File.Exists(exePath))
+ return;
+
+ string[] fallbackUrls =
+ {
+ "https://fastdl.classiccounter.cc/7za.exe",
+ "https://ollumcc.github.io/7za.exe"
+ };
+
+ Exception? lastError = null;
+ foreach (var url in fallbackUrls)
+ {
+ try
+ {
+ await _downloader.DownloadFileTaskAsync(url, exePath);
+ if (File.Exists(exePath))
+ return;
+ }
+ catch (Exception ex)
+ {
+ lastError = ex;
+ }
+ }
+
+ throw new Exception($"Couldn't download 7za.exe{(lastError != null ? $": {lastError.Message}" : string.Empty)}");
+ }
+
+ private static void Delete7zaExecutable()
+ {
+ try
+ {
+ string? launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
+ if (string.IsNullOrWhiteSpace(launcherDir))
+ return;
+
+ string exePath = Path.Combine(launcherDir, "7za.exe");
+ if (!File.Exists(exePath))
+ return;
+
+ File.Delete(exePath);
+
+ if (Debug.Enabled())
+ Terminal.Debug("Deleted 7za.exe");
+ }
+ catch (Exception ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Failed to delete 7za.exe: {ex.Message}");
+ }
+ }
+
+ private static async Task ExtractArchiveToDirectory(string archivePath, string outputDirectory, Action? onProgress = null)
+ {
+ await Task.Run(() =>
+ {
+ using var archive = ArchiveFactory.OpenArchive(new FileInfo(archivePath), new ReaderOptions());
+ var entries = archive.Entries.Where(entry => !entry.IsDirectory).ToArray();
+ int totalEntries = entries.Length > 0 ? entries.Length : 1;
+ int completedEntries = 0;
+
+ onProgress?.Invoke(0);
+
+ foreach (var entry in entries)
+ {
+ entry.WriteToDirectory(outputDirectory, new ExtractionOptions
+ {
+ ExtractFullPath = true,
+ Overwrite = true
+ });
+
+ completedEntries++;
+ onProgress?.Invoke((double)completedEntries / totalEntries * 100.0);
+ }
+ });
+ }
+
+
+ private static async Task ExtractSplitArchiveToDirectory(IEnumerable archiveParts, string outputDirectory, Action? onProgress = null)
+ {
+ await Task.Run(() =>
+ {
+ var parts = archiveParts
+ .Select(part => new FileInfo(Path.Combine(WauncherDirectory, part)))
+ .ToArray();
+
+ using var archive = SevenZipArchive.OpenArchive(parts, new ReaderOptions());
+ var entries = archive.Entries.Where(entry => !entry.IsDirectory).ToArray();
+ int totalEntries = entries.Length > 0 ? entries.Length : 1;
+ int completedEntries = 0;
+
+ onProgress?.Invoke(0);
+
+ foreach (var entry in entries)
+ {
+ entry.WriteToDirectory(outputDirectory, new ExtractionOptions
+ {
+ ExtractFullPath = true,
+ Overwrite = true
+ });
+
+ completedEntries++;
+ onProgress?.Invoke((double)completedEntries / totalEntries * 100.0);
+ }
+ });
+ }
+
+ public static void Cleanup7zFiles()
+ {
+ try
+ {
+ string directory = WauncherDirectory;
+ var files = Directory.GetFiles(directory, "*.7z", SearchOption.AllDirectories)
+ .Concat(Directory.GetFiles(directory, "*.7z.*", SearchOption.AllDirectories))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ foreach (string file in files)
+ {
+ try
+ {
+ File.Delete(file);
+ if (Debug.Enabled())
+ Terminal.Debug($"Deleted .7z file: {file}");
+ }
+ catch (Exception ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Failed to delete .7z file {file}: {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Failed to perform cleanup: {ex.Message}");
+ }
+ }
+
+ private static void CleanupSplitArchiveFiles(IEnumerable files)
+ {
+ foreach (string file in files)
+ {
+ try
+ {
+ string filePath = Path.Combine(WauncherDirectory, file);
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ catch (Exception ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Failed to delete archive part {file}: {ex.Message}");
+ }
+ }
+ }
+ }
+}
+
+
+
diff --git a/Wauncher/Utils/FriendsCache.cs b/Wauncher/Utils/FriendsCache.cs
index 90c6385..a611dd0 100644
--- a/Wauncher/Utils/FriendsCache.cs
+++ b/Wauncher/Utils/FriendsCache.cs
@@ -1,83 +1,83 @@
-using System.Text.Json;
-using Wauncher.Utils;
-
-namespace Wauncher.Utils
-{
- public static class FriendsCache
- {
- private static readonly string _cacheDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "ClassicCounter",
- "Wauncher",
- "cache");
-
- private static readonly string _cacheFile = Path.Combine(_cacheDir, "friends_cache.json");
-
- private sealed class CachedFriend
- {
- public string Username { get; set; } = string.Empty;
- public string AvatarUrl { get; set; } = string.Empty;
- public string Status { get; set; } = "Offline";
- }
-
- private sealed class CacheEnvelope
- {
- public Dictionary> BySteamId { get; set; } = new();
- }
-
- public static async Task SaveAsync(string steamId, IEnumerable friends)
- {
- if (string.IsNullOrWhiteSpace(steamId))
- return;
-
- var envelope = LoadEnvelope();
- envelope.BySteamId[steamId] = friends.Select(f => new CachedFriend
- {
- Username = f.Username ?? string.Empty,
- AvatarUrl = f.AvatarUrl ?? string.Empty,
- Status = string.IsNullOrWhiteSpace(f.Status) ? "Offline" : f.Status
- }).ToList();
-
- Directory.CreateDirectory(_cacheDir);
- var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
- await File.WriteAllTextAsync(_cacheFile, json);
- }
-
- public static List Load(string steamId)
- {
- if (string.IsNullOrWhiteSpace(steamId))
- return new List();
-
- var envelope = LoadEnvelope();
- if (!envelope.BySteamId.TryGetValue(steamId, out var cached) || cached == null)
- return new List();
-
- return cached.Select(c => new FriendInfo
- {
- Username = c.Username,
- AvatarUrl = c.AvatarUrl,
- Status = string.IsNullOrWhiteSpace(c.Status) ? "Offline" : c.Status
- }).ToList();
- }
-
- private static CacheEnvelope LoadEnvelope()
- {
- try
- {
- if (!File.Exists(_cacheFile))
- return new CacheEnvelope();
-
- var json = File.ReadAllText(_cacheFile);
- if (string.IsNullOrWhiteSpace(json))
- return new CacheEnvelope();
-
- return JsonSerializer.Deserialize(json) ?? new CacheEnvelope();
- }
- catch
- {
- return new CacheEnvelope();
- }
- }
- }
-}
-
+using System.Text.Json;
+using Wauncher.Utils;
+
+namespace Wauncher.Utils
+{
+ public static class FriendsCache
+ {
+ private static readonly string _cacheDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "cache");
+
+ private static readonly string _cacheFile = Path.Combine(_cacheDir, "friends_cache.json");
+
+ private sealed class CachedFriend
+ {
+ public string Username { get; set; } = string.Empty;
+ public string AvatarUrl { get; set; } = string.Empty;
+ public string Status { get; set; } = "Offline";
+ }
+
+ private sealed class CacheEnvelope
+ {
+ public Dictionary> BySteamId { get; set; } = new();
+ }
+
+ public static async Task SaveAsync(string steamId, IEnumerable friends)
+ {
+ if (string.IsNullOrWhiteSpace(steamId))
+ return;
+
+ var envelope = LoadEnvelope();
+ envelope.BySteamId[steamId] = friends.Select(f => new CachedFriend
+ {
+ Username = f.Username ?? string.Empty,
+ AvatarUrl = f.AvatarUrl ?? string.Empty,
+ Status = string.IsNullOrWhiteSpace(f.Status) ? "Offline" : f.Status
+ }).ToList();
+
+ Directory.CreateDirectory(_cacheDir);
+ var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(_cacheFile, json);
+ }
+
+ public static List Load(string steamId)
+ {
+ if (string.IsNullOrWhiteSpace(steamId))
+ return new List();
+
+ var envelope = LoadEnvelope();
+ if (!envelope.BySteamId.TryGetValue(steamId, out var cached) || cached == null)
+ return new List();
+
+ return cached.Select(c => new FriendInfo
+ {
+ Username = c.Username,
+ AvatarUrl = c.AvatarUrl,
+ Status = string.IsNullOrWhiteSpace(c.Status) ? "Offline" : c.Status
+ }).ToList();
+ }
+
+ private static CacheEnvelope LoadEnvelope()
+ {
+ try
+ {
+ if (!File.Exists(_cacheFile))
+ return new CacheEnvelope();
+
+ var json = File.ReadAllText(_cacheFile);
+ if (string.IsNullOrWhiteSpace(json))
+ return new CacheEnvelope();
+
+ return JsonSerializer.Deserialize(json) ?? new CacheEnvelope();
+ }
+ catch
+ {
+ return new CacheEnvelope();
+ }
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/Steam.cs b/Wauncher/Utils/Steam.cs
index e7b654f..01f47b3 100644
--- a/Wauncher/Utils/Steam.cs
+++ b/Wauncher/Utils/Steam.cs
@@ -1,181 +1,181 @@
-using Microsoft.Win32;
-using Gameloop.Vdf;
-using System.Reflection;
-using System.Runtime.InteropServices;
-using System.Text.Unicode;
-using Microsoft.CSharp.RuntimeBinder;
-
-namespace Wauncher.Utils
-{
- public class Steam
- {
- public static string? recentSteamID64 { get; private set; }
- public static string? recentSteamID2 { get; private set; }
-
- private static string? steamPath { get; set; }
-
- private static string? GetSteamInstallPath()
- {
- // If was already found return it right away.
- if (steamPath != null)
- return steamPath;
-
- // Try finding it registry.
- using (RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
- {
- using (RegistryKey? key = hklm.OpenSubKey(@"SOFTWARE\Wow6432Node\Valve\Steam") ?? hklm.OpenSubKey(@"SOFTWARE\Valve\Steam"))
- {
- steamPath = key?.GetValue("InstallPath") as string;
- if (steamPath != null)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Steam folder found at {steamPath}");
- return steamPath;
- }
- }
- }
-
- // If registry didn't work, try natively.
- return steamPath = SteamNative.GetSteamInstallPath();
- }
-
- public static bool IsInstalled()
- {
- try
- {
- var path = GetSteamInstallPath();
- return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path);
- }
- catch (Exception ex)
- {
- ErrorLogger.LogError("Steam.IsInstalled", ex, "Failed to check if Steam is installed");
- return false;
- }
- }
-
- public static async Task GetRecentLoggedInSteamID()
- {
- await GetRecentLoggedInSteamID(true);
- }
-
- public static async Task GetRecentLoggedInSteamID(bool exitOnMissing)
- {
- recentSteamID64 = null;
- recentSteamID2 = null;
-
- steamPath = GetSteamInstallPath();
- if (string.IsNullOrEmpty(steamPath) || !Directory.Exists(steamPath))
- {
- if (!exitOnMissing)
- return false;
-
- Terminal.Error("Your Steam install couldn't be found.");
- Terminal.Error("Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- return false;
- }
-
- var loginUsersPath = Path.Combine(steamPath, "config", "loginusers.vdf");
- if (!File.Exists(loginUsersPath))
- {
- if (Debug.Enabled())
- Terminal.Debug("loginusers.vdf not found, trying Steamworks API fallback...");
-
- if (SteamNative.GetSteamID2() == null)
- SteamNative.GetSteamInstallPath();
- recentSteamID2 = SteamNative.GetSteamID2();
- recentSteamID64 = SteamNative.GetSteamID64();
-
- if (Debug.Enabled() && !string.IsNullOrEmpty(recentSteamID64))
- {
- Terminal.Debug($"Steamworks fallback succeeded - SteamID64: {recentSteamID64}");
- Terminal.Debug($"Steamworks fallback succeeded - SteamID2: {recentSteamID2}");
- }
-
- if (!exitOnMissing)
- return !string.IsNullOrEmpty(recentSteamID2);
-
- if (string.IsNullOrEmpty(recentSteamID2))
- {
- Terminal.Error("Steam login data couldn't be found and Steamworks fallback failed.");
- Terminal.Error("Closing launcher in 5 seconds...");
- await Task.Delay(5000);
- Environment.Exit(1);
- }
- return !string.IsNullOrEmpty(recentSteamID2);
- }
-
- dynamic loginUsers = VdfConvert.Deserialize(File.ReadAllText(loginUsersPath));
- string? fallbackSteamId64 = null;
-
- foreach (var user in loginUsers.Value)
- {
- string? steamId64 = null;
-
- try
- {
- steamId64 = Convert.ToString(user.Key);
- if (string.IsNullOrWhiteSpace(fallbackSteamId64) && !string.IsNullOrWhiteSpace(steamId64))
- fallbackSteamId64 = steamId64;
-
- var mostRecent = Convert.ToString(user.Value?.MostRecent?.Value);
- if (mostRecent == "1" && !string.IsNullOrWhiteSpace(steamId64))
- {
- recentSteamID64 = steamId64;
- recentSteamID2 = ConvertToSteamID2(steamId64);
- break;
- }
- }
- catch (RuntimeBinderException ex)
- {
- if (Debug.Enabled())
- Terminal.Debug($"Skipping malformed Steam loginusers entry for {steamId64 ?? "unknown user"}.");
- ErrorLogger.LogError("Steam.GetRecentLoggedInSteamID", ex, $"Malformed Steam loginusers entry for {steamId64 ?? "unknown user"}");
- }
- }
-
- if (string.IsNullOrWhiteSpace(recentSteamID64) && !string.IsNullOrWhiteSpace(fallbackSteamId64))
- {
- recentSteamID64 = fallbackSteamId64;
- recentSteamID2 = ConvertToSteamID2(fallbackSteamId64);
- }
-
- // If VDF method failed, try Steamworks API as fallback
- if (string.IsNullOrWhiteSpace(recentSteamID64))
- {
- if (Debug.Enabled())
- Terminal.Debug("VDF method failed, trying Steamworks API fallback...");
-
- if (SteamNative.GetSteamID2() == null)
- SteamNative.GetSteamInstallPath();
- recentSteamID2 = SteamNative.GetSteamID2();
- recentSteamID64 = SteamNative.GetSteamID64();
-
- if (Debug.Enabled() && !string.IsNullOrEmpty(recentSteamID64))
- {
- Terminal.Debug($"Steamworks fallback succeeded - SteamID64: {recentSteamID64}");
- Terminal.Debug($"Steamworks fallback succeeded - SteamID2: {recentSteamID2}");
- }
- }
- if (Debug.Enabled() && !string.IsNullOrEmpty(recentSteamID64))
- {
- Terminal.Debug($"Most recent Steam account (SteamID64): {recentSteamID64}");
- Terminal.Debug($"Most recent Steam account (SteamID2): {recentSteamID2}");
- }
-
- return !string.IsNullOrEmpty(recentSteamID2);
- }
-
- private static string ConvertToSteamID2(string steamID64)
- {
- ulong id64 = ulong.Parse(steamID64);
- ulong constValue = 76561197960265728;
- ulong accountID = id64 - constValue;
- ulong y = accountID % 2;
- ulong z = accountID / 2;
- return $"STEAM_1:{y}:{z}";
- }
- }
-}
-
+using Microsoft.Win32;
+using Gameloop.Vdf;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Unicode;
+using Microsoft.CSharp.RuntimeBinder;
+
+namespace Wauncher.Utils
+{
+ public class Steam
+ {
+ public static string? recentSteamID64 { get; private set; }
+ public static string? recentSteamID2 { get; private set; }
+
+ private static string? steamPath { get; set; }
+
+ private static string? GetSteamInstallPath()
+ {
+ // If was already found return it right away.
+ if (steamPath != null)
+ return steamPath;
+
+ // Try finding it registry.
+ using (RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
+ {
+ using (RegistryKey? key = hklm.OpenSubKey(@"SOFTWARE\Wow6432Node\Valve\Steam") ?? hklm.OpenSubKey(@"SOFTWARE\Valve\Steam"))
+ {
+ steamPath = key?.GetValue("InstallPath") as string;
+ if (steamPath != null)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Steam folder found at {steamPath}");
+ return steamPath;
+ }
+ }
+ }
+
+ // If registry didn't work, try natively.
+ return steamPath = SteamNative.GetSteamInstallPath();
+ }
+
+ public static bool IsInstalled()
+ {
+ try
+ {
+ var path = GetSteamInstallPath();
+ return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path);
+ }
+ catch (Exception ex)
+ {
+ ErrorLogger.LogError("Steam.IsInstalled", ex, "Failed to check if Steam is installed");
+ return false;
+ }
+ }
+
+ public static async Task GetRecentLoggedInSteamID()
+ {
+ await GetRecentLoggedInSteamID(true);
+ }
+
+ public static async Task GetRecentLoggedInSteamID(bool exitOnMissing)
+ {
+ recentSteamID64 = null;
+ recentSteamID2 = null;
+
+ steamPath = GetSteamInstallPath();
+ if (string.IsNullOrEmpty(steamPath) || !Directory.Exists(steamPath))
+ {
+ if (!exitOnMissing)
+ return false;
+
+ Terminal.Error("Your Steam install couldn't be found.");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ return false;
+ }
+
+ var loginUsersPath = Path.Combine(steamPath, "config", "loginusers.vdf");
+ if (!File.Exists(loginUsersPath))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("loginusers.vdf not found, trying Steamworks API fallback...");
+
+ if (SteamNative.GetSteamID2() == null)
+ SteamNative.GetSteamInstallPath();
+ recentSteamID2 = SteamNative.GetSteamID2();
+ recentSteamID64 = SteamNative.GetSteamID64();
+
+ if (Debug.Enabled() && !string.IsNullOrEmpty(recentSteamID64))
+ {
+ Terminal.Debug($"Steamworks fallback succeeded - SteamID64: {recentSteamID64}");
+ Terminal.Debug($"Steamworks fallback succeeded - SteamID2: {recentSteamID2}");
+ }
+
+ if (!exitOnMissing)
+ return !string.IsNullOrEmpty(recentSteamID2);
+
+ if (string.IsNullOrEmpty(recentSteamID2))
+ {
+ Terminal.Error("Steam login data couldn't be found and Steamworks fallback failed.");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ }
+ return !string.IsNullOrEmpty(recentSteamID2);
+ }
+
+ dynamic loginUsers = VdfConvert.Deserialize(File.ReadAllText(loginUsersPath));
+ string? fallbackSteamId64 = null;
+
+ foreach (var user in loginUsers.Value)
+ {
+ string? steamId64 = null;
+
+ try
+ {
+ steamId64 = Convert.ToString(user.Key);
+ if (string.IsNullOrWhiteSpace(fallbackSteamId64) && !string.IsNullOrWhiteSpace(steamId64))
+ fallbackSteamId64 = steamId64;
+
+ var mostRecent = Convert.ToString(user.Value?.MostRecent?.Value);
+ if (mostRecent == "1" && !string.IsNullOrWhiteSpace(steamId64))
+ {
+ recentSteamID64 = steamId64;
+ recentSteamID2 = ConvertToSteamID2(steamId64);
+ break;
+ }
+ }
+ catch (RuntimeBinderException ex)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Skipping malformed Steam loginusers entry for {steamId64 ?? "unknown user"}.");
+ ErrorLogger.LogError("Steam.GetRecentLoggedInSteamID", ex, $"Malformed Steam loginusers entry for {steamId64 ?? "unknown user"}");
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(recentSteamID64) && !string.IsNullOrWhiteSpace(fallbackSteamId64))
+ {
+ recentSteamID64 = fallbackSteamId64;
+ recentSteamID2 = ConvertToSteamID2(fallbackSteamId64);
+ }
+
+ // If VDF method failed, try Steamworks API as fallback
+ if (string.IsNullOrWhiteSpace(recentSteamID64))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("VDF method failed, trying Steamworks API fallback...");
+
+ if (SteamNative.GetSteamID2() == null)
+ SteamNative.GetSteamInstallPath();
+ recentSteamID2 = SteamNative.GetSteamID2();
+ recentSteamID64 = SteamNative.GetSteamID64();
+
+ if (Debug.Enabled() && !string.IsNullOrEmpty(recentSteamID64))
+ {
+ Terminal.Debug($"Steamworks fallback succeeded - SteamID64: {recentSteamID64}");
+ Terminal.Debug($"Steamworks fallback succeeded - SteamID2: {recentSteamID2}");
+ }
+ }
+ if (Debug.Enabled() && !string.IsNullOrEmpty(recentSteamID64))
+ {
+ Terminal.Debug($"Most recent Steam account (SteamID64): {recentSteamID64}");
+ Terminal.Debug($"Most recent Steam account (SteamID2): {recentSteamID2}");
+ }
+
+ return !string.IsNullOrEmpty(recentSteamID2);
+ }
+
+ private static string ConvertToSteamID2(string steamID64)
+ {
+ ulong id64 = ulong.Parse(steamID64);
+ ulong constValue = 76561197960265728;
+ ulong accountID = id64 - constValue;
+ ulong y = accountID % 2;
+ ulong z = accountID / 2;
+ return $"STEAM_1:{y}:{z}";
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/Terminal.cs b/Wauncher/Utils/Terminal.cs
index e9b610a..f1c330d 100644
--- a/Wauncher/Utils/Terminal.cs
+++ b/Wauncher/Utils/Terminal.cs
@@ -1,60 +1,60 @@
-using Spectre.Console;
-using System.Reflection;
-
-namespace Wauncher.Utils
-{
- public static class Terminal
- {
- private static string _prefix = "[orange1]Classic[/][blue]Counter[/]";
- private static string _grey = "grey82";
- private static string _seperator = "[grey50]|[/]";
- private static readonly string _steamHappy = LoadSteamHappy();
-
- private static string LoadSteamHappy()
- {
- try
- {
- using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Wauncher.Assets.steamhappy.txt");
- if (stream == null)
- return string.Empty;
-
- using var reader = new StreamReader(stream);
- return reader.ReadToEnd();
- }
- catch
- {
- return string.Empty;
- }
- }
-
- public static void Init()
- {
- AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]Wauncher maintained by [/][purple4_1]koolych[/][{_grey}][/]");
- AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]Coded by [/][lightcoral]heapy[/][{_grey}][/]");
- AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]https://github.com/ClassicCounter [/]");
- AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]Version: {Version.Current}[/]");
- }
-
- public static void Print(object? message)
- => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
-
- public static void Success(object? message)
- => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [green1]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
-
- public static void Warning(object? message)
- => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [yellow]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
-
- public static void Error(object? message)
- => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [red]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
-
- public static void Debug(object? message)
- => AnsiConsole.MarkupLine($"[purple]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
-
- public static void SteamHappy() =>
- AnsiConsole.Write(_steamHappy);
-
- private static string Date()
- => $"[{_grey}]{DateTime.Now.ToString("HH:mm:ss")}[/]";
- }
-}
-
+using Spectre.Console;
+using System.Reflection;
+
+namespace Wauncher.Utils
+{
+ public static class Terminal
+ {
+ private static string _prefix = "[orange1]Classic[/][blue]Counter[/]";
+ private static string _grey = "grey82";
+ private static string _seperator = "[grey50]|[/]";
+ private static readonly string _steamHappy = LoadSteamHappy();
+
+ private static string LoadSteamHappy()
+ {
+ try
+ {
+ using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Wauncher.Assets.steamhappy.txt");
+ if (stream == null)
+ return string.Empty;
+
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ public static void Init()
+ {
+ AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]Wauncher maintained by [/][purple4_1]koolych[/][{_grey}][/]");
+ AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]Coded by [/][lightcoral]heapy[/][{_grey}][/]");
+ AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]https://github.com/ClassicCounter [/]");
+ AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]Version: {Version.Current}[/]");
+ }
+
+ public static void Print(object? message)
+ => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [{_grey}]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
+
+ public static void Success(object? message)
+ => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [green1]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
+
+ public static void Warning(object? message)
+ => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [yellow]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
+
+ public static void Error(object? message)
+ => AnsiConsole.MarkupLine($"{_prefix} {_seperator} [red]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
+
+ public static void Debug(object? message)
+ => AnsiConsole.MarkupLine($"[purple]{Markup.Escape(message?.ToString() ?? string.Empty)}[/]");
+
+ public static void SteamHappy() =>
+ AnsiConsole.Write(_steamHappy);
+
+ private static string Date()
+ => $"[{_grey}]{DateTime.Now.ToString("HH:mm:ss")}[/]";
+ }
+}
+
diff --git a/Wauncher/Utils/Version.cs b/Wauncher/Utils/Version.cs
index 663ef3e..4d56357 100644
--- a/Wauncher/Utils/Version.cs
+++ b/Wauncher/Utils/Version.cs
@@ -1,38 +1,38 @@
-using Newtonsoft.Json.Linq;
-using System.Reflection;
-
-namespace Wauncher.Utils
-{
- public static class Version
- {
- public static string Current =>
- Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
-
- public async static Task GetLatestVersion()
- {
- if (Debug.Enabled())
- Terminal.Debug("Getting latest version.");
-
- try
- {
- string responseString = await Api.GitHub.GetLatestRelease();
- JObject responseJson = JObject.Parse(responseString);
-
- if (responseJson["tag_name"] == null)
- throw new Exception("\"tag_name\" doesn't exist in response.");
-
- var tag = ((string?)responseJson["tag_name"] ?? Current).Trim();
- if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase))
- tag = tag[1..];
- return string.IsNullOrWhiteSpace(tag) ? Current : tag;
- }
- catch
- {
- if (Debug.Enabled())
- Terminal.Debug("Couldn't get latest version.");
- }
-
- return Current;
- }
- }
-}
+using Newtonsoft.Json.Linq;
+using System.Reflection;
+
+namespace Wauncher.Utils
+{
+ public static class Version
+ {
+ public static string Current =>
+ Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
+
+ public async static Task GetLatestVersion()
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Getting latest version.");
+
+ try
+ {
+ string responseString = await Api.GitHub.GetLatestRelease();
+ JObject responseJson = JObject.Parse(responseString);
+
+ if (responseJson["tag_name"] == null)
+ throw new Exception("\"tag_name\" doesn't exist in response.");
+
+ var tag = ((string?)responseJson["tag_name"] ?? Current).Trim();
+ if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ tag = tag[1..];
+ return string.IsNullOrWhiteSpace(tag) ? Current : tag;
+ }
+ catch
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Couldn't get latest version.");
+ }
+
+ return Current;
+ }
+ }
+}
diff --git a/Wauncher/ViewModels/MainWindowViewModel.cs b/Wauncher/ViewModels/MainWindowViewModel.cs
index d23fe01..d5179a6 100644
--- a/Wauncher/ViewModels/MainWindowViewModel.cs
+++ b/Wauncher/ViewModels/MainWindowViewModel.cs
@@ -1,687 +1,687 @@
-using Avalonia.Threading;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using System.Linq;
-using System.Threading.Tasks;
-using System.ComponentModel;
-using Wauncher.Utils;
-using Wauncher.Services;
-using System.Collections.ObjectModel;
-using System.Net.NetworkInformation;
-using System;
-using System.Threading;
-using System.Text.RegularExpressions;
-using FriendInfo = Wauncher.Utils.FriendInfo;
-
-namespace Wauncher.ViewModels
-{
- public partial class MainWindowViewModel : ViewModelBase
- {
- // Services
- private readonly IDiscordService _discordService;
- private readonly IGameService _gameService;
- private readonly ICarouselService _carouselService;
- private readonly IUpdateService _updateService;
- private readonly IServerService _serverService;
- private readonly IFriendsService _friendsService;
-
- // Observable Properties
- [ObservableProperty]
- private string _gameStatus = "Not Running";
-
- [ObservableProperty]
- private string _protocolManager = "None";
-
- [ObservableProperty]
- private string _profilePicture = "https://avatars.githubusercontent.com/u/75831703?v=4";
-
- [ObservableProperty]
- private string _usernameGreeting = "Hello, username";
-
- [ObservableProperty]
- private string _whitelistDotColor = "Gray";
-
- [ObservableProperty]
- private string _whitelistText = "Unknown";
-
- [ObservableProperty]
- private bool _isDropdownOpen = false;
-
- [ObservableProperty]
- private string _activeRightTab = "Friends";
-
- [ObservableProperty]
- private bool _isOfflineMode = false;
-
- [ObservableProperty]
- private ServerInfo? _selectedServer;
-
- [ObservableProperty]
- private bool _isStatusBannerVisible = false;
-
- [ObservableProperty]
- private double _statusBannerHeight = 0;
-
- private bool _isChaining; // true during the gap between CDN install finishing and patch update starting
- private CancellationTokenSource? _errorDismissCts;
-
- // Computed Properties
- public bool IsFriendsTabActive => ActiveRightTab == "Friends";
- public bool IsPatchNotesTabActive => ActiveRightTab == "PatchNotes";
- public bool IsOnlineMode => !IsOfflineMode;
- public bool IsCheckingOrUpdating => _updateService.IsCheckingUpdates || _updateService.IsUpdating || _updateService.IsInstalling;
- public bool IsUpdatingOrInstalling => _updateService.IsUpdating || _updateService.IsInstalling || _isChaining;
- public bool IsInstallingOrChaining => _updateService.IsInstalling || _isChaining;
- public bool IsUpdatingOrChaining => _updateService.IsUpdating || _isChaining;
- public bool ShowUpdateStatus =>
- IsCheckingOrUpdating ||
- _updateService.UpdateStatusFile.StartsWith("Install error:", StringComparison.OrdinalIgnoreCase) ||
- _updateService.UpdateStatusFile.StartsWith("Error:", StringComparison.OrdinalIgnoreCase);
- public bool IsInstallPending => _updateService.IsNeedingInstall;
- public bool IsUpdatePending => _updateService.IsUpdateAvailable && !_updateService.IsUpdating && !_updateService.IsInstalling;
- public bool IsUpdateError =>
- !string.IsNullOrWhiteSpace(_updateService.UpdateStatusFile) && (
- _updateService.UpdateStatusFile.StartsWith("Install error:", StringComparison.OrdinalIgnoreCase) ||
- _updateService.UpdateStatusFile.StartsWith("Error:", StringComparison.OrdinalIgnoreCase));
-
- public string FriendlyUpdateError
- {
- get
- {
- var text = _updateService.UpdateStatusFile;
- if (text.StartsWith("Install error:", StringComparison.OrdinalIgnoreCase))
- return text["Install error:".Length..].Trim();
- if (text.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
- return text["Error:".Length..].Trim();
- return text;
- }
- }
-
- private void ShowErrorBanner()
- {
- _errorDismissCts?.Cancel();
- _errorDismissCts = new CancellationTokenSource();
-
- Dispatcher.UIThread.Post(() =>
- {
- IsStatusBannerVisible = true;
- StatusBannerHeight = 60;
- });
-
- var cts = _errorDismissCts;
- _ = Task.Run(async () =>
- {
- try
- {
- await Task.Delay(4000, cts.Token);
- await Dispatcher.UIThread.InvokeAsync(async () =>
- {
- StatusBannerHeight = 0;
- await Task.Delay(350);
- IsStatusBannerVisible = false;
- });
- }
- catch (OperationCanceledException) { }
- });
- }
-
- private void ShowStatusBanner()
- {
- Dispatcher.UIThread.Post(() =>
- {
- IsStatusBannerVisible = true;
- StatusBannerHeight = 60;
- });
- }
-
- private void HideStatusBanner()
- {
- _ = Dispatcher.UIThread.InvokeAsync(async () =>
- {
- StatusBannerHeight = 0;
- await Task.Delay(350);
- IsStatusBannerVisible = false;
- });
- }
-
- public bool IsExtracting => _updateService.IsExtracting;
-
- public string LaunchButtonText =>
- _updateService.IsInstalling ?
- (_updateService.IsExtracting ?
- (_updateService.UpdateProgress > 0 ? $"Installing {_updateService.UpdateProgress:F0}%" : "Installing...") :
- _updateService.UpdateIndeterminate ? "Installing..." :
- _updateService.UpdateProgress > 0 ? $"Downloading {_updateService.UpdateProgress:F0}%" :
- "Installing...") :
- _isChaining ? "Updating..." :
- _updateService.IsUpdating ?
- (_updateService.UpdateIndeterminate ? "Updating..." : $"Updating {_updateService.UpdateProgress:F0}%") :
- _updateService.IsNeedingInstall ? "Install Game" :
- _updateService.IsUpdateAvailable ? "Update" :
- "Launch Game";
-
- public double LaunchProgressScale =>
- _updateService.IsUpdating || _updateService.IsInstalling ? _updateService.UpdateProgress / 100.0 :
- _isChaining ? 1.0 :
- 0.0;
-
- public string StatusBannerText =>
- IsUpdateError ? FriendlyUpdateError :
- _updateService.IsCheckingUpdates ? "Checking for updates..." :
- !string.IsNullOrWhiteSpace(_updateService.UpdateStatusFile) &&
- (_updateService.IsInstalling || _updateService.IsUpdating) ? _updateService.UpdateStatusFile :
- _updateService.IsInstalling ? "Installing..." :
- _updateService.IsUpdating ? "Updating..." :
- "";
-
- public string StatusBannerSpeed =>
- IsUpdateError || _updateService.IsCheckingUpdates ? "" : _updateService.UpdateStatusSpeed;
-
- public string StatusBannerColor =>
- IsUpdateError ? "#D32F2F" :
- _updateService.IsCheckingUpdates ? "#FFC107" :
- _updateService.IsInstalling ? "#2196F3" :
- _updateService.IsUpdating ? "#FFC107" :
- "#00000000";
-
- public string SelectedLabel => SelectedServer?.IsNone == false
- ? SelectedServer.Name
- : "Server not selected...";
-
- public bool IsNoServerSelected => SelectedServer == null || SelectedServer.IsNone;
- public bool IsServerSelected => SelectedServer != null && !SelectedServer.IsNone;
-
- // Service Properties (expose to UI)
- public ObservableCollection Servers => _serverService.Servers;
- public ObservableCollection Friends => _friendsService.Friends;
- public bool FriendsShowStatus => _friendsService.FriendsShowStatus;
- public bool ShowNoFriendsState => _friendsService.ShowNoFriendsState;
- public bool ShowGenericFriendsStatus => _friendsService.ShowGenericFriendsStatus;
- public string FriendsStatus => _friendsService.FriendsStatus;
- public IUpdateService UpdateService => _updateService;
-
- // Commands
- [RelayCommand]
- private async Task LaunchGameAsync()
- {
- if (_updateService.IsInstalling || _updateService.IsUpdating || _updateService.IsCheckingUpdates)
- return;
-
- if (_updateService.IsNeedingInstall)
- {
- await InstallGameAsync();
- return;
- }
-
- if (_updateService.IsUpdateAvailable && !SettingsWindowViewModel.LoadGlobal().SkipUpdates)
- {
- await ValidateFilesAsync();
- return;
- }
-
- if (_gameService.IsRunning())
- {
- ConsoleManager.ShowError(
- "ClassicCounter is already running.\n\nPlease close the game before joining a server from Wauncher.");
- return;
- }
-
- try
- {
- var settings = SettingsWindowViewModel.LoadGlobal();
- var selected = SelectedServer;
-
- // Clear any arguments left over from a previous launch before adding new ones.
- _gameService.ClearAdditionalArguments();
-
- var connectTarget = selected != null && !selected.IsNone && !string.IsNullOrEmpty(selected.IpPort)
- ? selected.IpPort
- : null;
-
- await _gameService.LaunchAsync(connectTarget, settings.LaunchOptions);
-
- GameStatus = "Running";
-
- if (settings.DiscordRpc)
- {
- await _discordService.SetDetailsAsync((selected != null && !selected.IsNone)
- ? $"Playing on {selected.Name}" : "In Main Menu");
- await _discordService.UpdateAsync();
- }
-
- await _gameService.MonitorAsync();
- }
- catch (Exception ex)
- {
- ConsoleManager.ShowError($"Failed to launch game:\n{ex.Message}");
- }
- finally
- {
- GameStatus = "Not Running";
- }
- }
-
- [RelayCommand]
- private async Task CheckForUpdatesAsync()
- {
- await _updateService.CheckForUpdatesAsync();
- }
-
- [RelayCommand]
- private async Task InstallGameAsync()
- {
- bool installed = await _updateService.InstallGameFromCdnAsync();
- if (!installed)
- return;
-
- _isChaining = true;
- OnPropertyChanged(nameof(IsInstallingOrChaining));
- OnPropertyChanged(nameof(IsUpdatingOrChaining));
- OnPropertyChanged(nameof(IsUpdatingOrInstalling));
- OnPropertyChanged(nameof(LaunchButtonText));
- OnPropertyChanged(nameof(LaunchProgressScale));
-
- try
- {
- bool needsUpdate = await _updateService.CheckForUpdatesAsync();
- if (needsUpdate || _updateService.IsUpdateAvailable)
- await _updateService.ValidateGameFilesAsync();
- }
- finally
- {
- _isChaining = false;
- OnPropertyChanged(nameof(IsInstallingOrChaining));
- OnPropertyChanged(nameof(IsUpdatingOrChaining));
- OnPropertyChanged(nameof(IsUpdatingOrInstalling));
- OnPropertyChanged(nameof(LaunchButtonText));
- OnPropertyChanged(nameof(LaunchProgressScale));
- }
- }
-
- [RelayCommand]
- private async Task ValidateFilesAsync()
- {
- // "Verify Game Files" = always re-hash every file from scratch.
- await _updateService.ValidateGameFilesAsync(fullValidate: true);
- }
-
- [RelayCommand]
- private void ToggleServerDropdown()
- {
- if (_serverService.IsOfflineMode)
- {
- IsDropdownOpen = false;
- return;
- }
-
- IsDropdownOpen = !IsDropdownOpen;
- }
-
- [RelayCommand]
- private void SelectServer(ServerInfo? server)
- {
- SelectedServer = server?.IsNone == true ? null : server;
- ProtocolManager = (server == null || server.IsNone) ? "None" : server.Name;
- IsDropdownOpen = false;
- }
-
- [ObservableProperty]
- private bool _isSettingsPanelOpen;
-
- [ObservableProperty]
- private bool _isInfoPanelOpen;
-
- [ObservableProperty]
- private bool _isAppearancePanelOpen;
-
- [RelayCommand]
- private void CloseSettingsPanel() => IsSettingsPanelOpen = false;
-
- [RelayCommand]
- private void CloseInfoPanel() => IsInfoPanelOpen = false;
-
- [RelayCommand]
- private void CloseAppearancePanel() => IsAppearancePanelOpen = false;
-
- partial void OnIsSettingsPanelOpenChanged(bool value)
- {
- if (value)
- {
- IsInfoPanelOpen = false;
- IsDropdownOpen = false;
- IsAppearancePanelOpen = false;
- }
- }
-
- partial void OnIsInfoPanelOpenChanged(bool value)
- {
- if (value)
- {
- IsSettingsPanelOpen = false;
- IsDropdownOpen = false;
- IsAppearancePanelOpen = false;
- }
- }
-
- partial void OnIsDropdownOpenChanged(bool value)
- {
- if (value)
- {
- IsSettingsPanelOpen = false;
- IsInfoPanelOpen = false;
- IsAppearancePanelOpen = false;
- }
- }
-
- partial void OnIsAppearancePanelOpenChanged(bool value)
- {
- if (value)
- {
- IsSettingsPanelOpen = false;
- IsInfoPanelOpen = false;
- IsDropdownOpen = false;
- }
- }
-
- [RelayCommand]
- private void SwitchToFriendsTab()
- {
- ActiveRightTab = "Friends";
- }
-
- [RelayCommand]
- private void SwitchToPatchNotesTab()
- {
- ActiveRightTab = "PatchNotes";
- }
-
- [RelayCommand]
- private void ViewFriendProfile(FriendInfo friend)
- {
- if (friend == null) return;
-
- var profileId = ResolveProfileSteamId(friend.SteamId);
- if (string.IsNullOrWhiteSpace(profileId))
- return;
-
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
- {
- FileName = $"https://eddies.cc/profiles/{profileId}",
- UseShellExecute = true
- });
- }
-
- private static string ResolveProfileSteamId(string? steamId)
- {
- if (string.IsNullOrWhiteSpace(steamId))
- return string.Empty;
-
- var value = steamId.Trim();
- if (ulong.TryParse(value, out _))
- return value;
-
- if (TryConvertSteamId2To64(value, out var steamId64))
- return steamId64.ToString();
-
- return string.Empty;
- }
-
- private static bool TryConvertSteamId2To64(string steamId2, out ulong steamId64)
- {
- steamId64 = 0;
- var match = Regex.Match(steamId2, @"^STEAM_[0-5]:([0-1]):(\d+)$", RegexOptions.IgnoreCase);
- if (!match.Success)
- return false;
-
- if (!ulong.TryParse(match.Groups[1].Value, out var y))
- return false;
- if (!ulong.TryParse(match.Groups[2].Value, out var z))
- return false;
-
- steamId64 = 76561197960265728UL + (z * 2UL) + y;
- return true;
- }
-
- [RelayCommand]
- private async Task JoinFriendServerAsync(FriendInfo friend)
- {
- if (friend == null || string.IsNullOrEmpty(friend.QuickJoinIpPort)) return;
-
- // Find matching server and select it
- var matchingServer = Servers.FirstOrDefault(s =>
- !s.IsNone && string.Equals(s.IpPort, friend.QuickJoinIpPort, StringComparison.OrdinalIgnoreCase));
-
- if (matchingServer != null)
- {
- SelectedServer = matchingServer;
- await LaunchGameAsync();
- }
- }
-
- // Constructor with dependency injection
- public MainWindowViewModel(
- IDiscordService discordService,
- IGameService gameService,
- ICarouselService carouselService,
- IUpdateService updateService,
- IServerService serverService,
- IFriendsService friendsService)
- {
- _discordService = discordService;
- _gameService = gameService;
- _carouselService = carouselService;
- _updateService = updateService;
- _serverService = serverService;
- _friendsService = friendsService;
-
- if (Argument.HasProtocolCommand())
- ProtocolManager = "Ready to Launch!";
-
- // Subscribe to service property changes
- SubscribeToServiceChanges();
-
- // Setup network monitoring
- NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
- UpdateOfflineMode();
-
- // Initialize services after subscriptions are ready so early results reach the UI.
- _ = InitializeServicesAsync();
- }
-
- private async Task InitializeServicesAsync()
- {
- _serverService.Start();
- _friendsService.Start();
-
- try
- {
- await _discordService.InitializeAsync();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to initialize Discord service: {ex.Message}");
- }
-
- try
- {
- await _carouselService.SetupCarouselAsync();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to setup carousel: {ex.Message}");
- }
-
- _ = Task.Run(async () =>
- {
- try
- {
- await _friendsService.LoadSelfProfileAsync();
- Dispatcher.UIThread.Post(SyncSelfProfile);
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to load self profile: {ex.Message}");
- }
- });
-
- _ = Task.Run(async () =>
- {
- try
- {
- await CheckWhitelistStatusAsync();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to check whitelist status: {ex.Message}");
- }
- });
-
- _ = Task.Run(async () =>
- {
- try
- {
- await _serverService.RefreshServersSafeAsync();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to refresh servers: {ex.Message}");
- }
- });
-
- _ = Task.Run(async () =>
- {
- try
- {
- await _friendsService.RefreshFriendsSafeAsync();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to refresh friends: {ex.Message}");
- }
- });
-
- _ = Task.Run(async () =>
- {
- try
- {
- await _updateService.CheckForUpdatesAsync();
- }
- catch (Exception ex)
- {
- Terminal.Warning($"Failed to check for updates: {ex.Message}");
- }
- });
- }
-
- private void SubscribeToServiceChanges()
- {
- // Subscribe to property changes from services to update UI
- if (_updateService is INotifyPropertyChanged updateNotifier)
- {
- updateNotifier.PropertyChanged += (s, e) =>
- {
- OnPropertyChanged(nameof(LaunchButtonText));
- OnPropertyChanged(nameof(StatusBannerText));
- OnPropertyChanged(nameof(StatusBannerSpeed));
- OnPropertyChanged(nameof(StatusBannerColor));
- OnPropertyChanged(nameof(LaunchProgressScale));
- OnPropertyChanged(nameof(IsExtracting));
- OnPropertyChanged(nameof(IsCheckingOrUpdating));
- OnPropertyChanged(nameof(IsUpdatingOrInstalling));
- OnPropertyChanged(nameof(IsInstallingOrChaining));
- OnPropertyChanged(nameof(IsUpdatingOrChaining));
- OnPropertyChanged(nameof(ShowUpdateStatus));
- OnPropertyChanged(nameof(IsInstallPending));
- OnPropertyChanged(nameof(IsUpdatePending));
- OnPropertyChanged(nameof(IsUpdateError));
- OnPropertyChanged(nameof(FriendlyUpdateError));
-
- if (IsUpdateError)
- ShowErrorBanner();
- else if ((IsUpdatingOrInstalling || _updateService.IsCheckingUpdates) && !IsStatusBannerVisible)
- ShowStatusBanner();
- else if (!IsUpdatingOrInstalling && !_updateService.IsCheckingUpdates && !IsUpdateError && IsStatusBannerVisible)
- HideStatusBanner();
- };
- }
-
- if (_friendsService is INotifyPropertyChanged friendsNotifier)
- {
- friendsNotifier.PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == nameof(IFriendsService.FriendsStatus))
- OnPropertyChanged(nameof(FriendsStatus));
- if (e.PropertyName == nameof(IFriendsService.FriendsShowStatus))
- {
- OnPropertyChanged(nameof(FriendsShowStatus));
- OnPropertyChanged(nameof(ShowGenericFriendsStatus));
- }
- if (e.PropertyName == nameof(IFriendsService.ShowNoFriendsState))
- {
- OnPropertyChanged(nameof(ShowNoFriendsState));
- OnPropertyChanged(nameof(ShowGenericFriendsStatus));
- }
- if (e.PropertyName == nameof(IFriendsService.CurrentUserAvatar))
- ProfilePicture = _friendsService.CurrentUserAvatar;
- if (e.PropertyName == nameof(IFriendsService.CurrentUserUsername))
- UsernameGreeting = $"Hello, {_friendsService.CurrentUserUsername}";
- };
- }
- }
-
- private void SyncSelfProfile()
- {
- ProfilePicture = _friendsService.CurrentUserAvatar;
- UsernameGreeting = $"Hello, {_friendsService.CurrentUserUsername}";
- }
-
- private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
- {
- Dispatcher.UIThread.Post(UpdateOfflineMode);
- }
-
- private void UpdateOfflineMode()
- {
- IsOfflineMode = !NetworkInterface.GetIsNetworkAvailable();
- }
-
- private async Task CheckWhitelistStatusAsync()
- {
- try
- {
- bool hasSteam = await Steam.GetRecentLoggedInSteamID(false);
- if (!hasSteam || string.IsNullOrEmpty(Steam.recentSteamID2))
- {
- WhitelistDotColor = "Gray";
- WhitelistText = "Unknown";
- return;
- }
-
- var response = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
- bool whitelisted = response?.Files != null && response.Files.Count > 0;
- WhitelistDotColor = whitelisted ? "#4CAF50" : "#F44336";
- WhitelistText = whitelisted ? "Whitelisted" : "Not Whitelisted";
- }
- catch
- {
- WhitelistDotColor = "Gray";
- WhitelistText = "Unknown";
- }
- }
-
- partial void OnActiveRightTabChanged(string value)
- {
- OnPropertyChanged(nameof(IsFriendsTabActive));
- OnPropertyChanged(nameof(IsPatchNotesTabActive));
- }
-
- partial void OnSelectedServerChanged(ServerInfo? value)
- {
- OnPropertyChanged(nameof(SelectedLabel));
- OnPropertyChanged(nameof(IsNoServerSelected));
- OnPropertyChanged(nameof(IsServerSelected));
- }
-
- partial void OnIsOfflineModeChanged(bool value) => OnPropertyChanged(nameof(IsOnlineMode));
- }
-}
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using System.Linq;
+using System.Threading.Tasks;
+using System.ComponentModel;
+using Wauncher.Utils;
+using Wauncher.Services;
+using System.Collections.ObjectModel;
+using System.Net.NetworkInformation;
+using System;
+using System.Threading;
+using System.Text.RegularExpressions;
+using FriendInfo = Wauncher.Utils.FriendInfo;
+
+namespace Wauncher.ViewModels
+{
+ public partial class MainWindowViewModel : ViewModelBase
+ {
+ // Services
+ private readonly IDiscordService _discordService;
+ private readonly IGameService _gameService;
+ private readonly ICarouselService _carouselService;
+ private readonly IUpdateService _updateService;
+ private readonly IServerService _serverService;
+ private readonly IFriendsService _friendsService;
+
+ // Observable Properties
+ [ObservableProperty]
+ private string _gameStatus = "Not Running";
+
+ [ObservableProperty]
+ private string _protocolManager = "None";
+
+ [ObservableProperty]
+ private string _profilePicture = "https://avatars.githubusercontent.com/u/75831703?v=4";
+
+ [ObservableProperty]
+ private string _usernameGreeting = "Hello, username";
+
+ [ObservableProperty]
+ private string _whitelistDotColor = "Gray";
+
+ [ObservableProperty]
+ private string _whitelistText = "Unknown";
+
+ [ObservableProperty]
+ private bool _isDropdownOpen = false;
+
+ [ObservableProperty]
+ private string _activeRightTab = "Friends";
+
+ [ObservableProperty]
+ private bool _isOfflineMode = false;
+
+ [ObservableProperty]
+ private ServerInfo? _selectedServer;
+
+ [ObservableProperty]
+ private bool _isStatusBannerVisible = false;
+
+ [ObservableProperty]
+ private double _statusBannerHeight = 0;
+
+ private bool _isChaining; // true during the gap between CDN install finishing and patch update starting
+ private CancellationTokenSource? _errorDismissCts;
+
+ // Computed Properties
+ public bool IsFriendsTabActive => ActiveRightTab == "Friends";
+ public bool IsPatchNotesTabActive => ActiveRightTab == "PatchNotes";
+ public bool IsOnlineMode => !IsOfflineMode;
+ public bool IsCheckingOrUpdating => _updateService.IsCheckingUpdates || _updateService.IsUpdating || _updateService.IsInstalling;
+ public bool IsUpdatingOrInstalling => _updateService.IsUpdating || _updateService.IsInstalling || _isChaining;
+ public bool IsInstallingOrChaining => _updateService.IsInstalling || _isChaining;
+ public bool IsUpdatingOrChaining => _updateService.IsUpdating || _isChaining;
+ public bool ShowUpdateStatus =>
+ IsCheckingOrUpdating ||
+ _updateService.UpdateStatusFile.StartsWith("Install error:", StringComparison.OrdinalIgnoreCase) ||
+ _updateService.UpdateStatusFile.StartsWith("Error:", StringComparison.OrdinalIgnoreCase);
+ public bool IsInstallPending => _updateService.IsNeedingInstall;
+ public bool IsUpdatePending => _updateService.IsUpdateAvailable && !_updateService.IsUpdating && !_updateService.IsInstalling;
+ public bool IsUpdateError =>
+ !string.IsNullOrWhiteSpace(_updateService.UpdateStatusFile) && (
+ _updateService.UpdateStatusFile.StartsWith("Install error:", StringComparison.OrdinalIgnoreCase) ||
+ _updateService.UpdateStatusFile.StartsWith("Error:", StringComparison.OrdinalIgnoreCase));
+
+ public string FriendlyUpdateError
+ {
+ get
+ {
+ var text = _updateService.UpdateStatusFile;
+ if (text.StartsWith("Install error:", StringComparison.OrdinalIgnoreCase))
+ return text["Install error:".Length..].Trim();
+ if (text.StartsWith("Error:", StringComparison.OrdinalIgnoreCase))
+ return text["Error:".Length..].Trim();
+ return text;
+ }
+ }
+
+ private void ShowErrorBanner()
+ {
+ _errorDismissCts?.Cancel();
+ _errorDismissCts = new CancellationTokenSource();
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ IsStatusBannerVisible = true;
+ StatusBannerHeight = 60;
+ });
+
+ var cts = _errorDismissCts;
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(4000, cts.Token);
+ await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ StatusBannerHeight = 0;
+ await Task.Delay(350);
+ IsStatusBannerVisible = false;
+ });
+ }
+ catch (OperationCanceledException) { }
+ });
+ }
+
+ private void ShowStatusBanner()
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ IsStatusBannerVisible = true;
+ StatusBannerHeight = 60;
+ });
+ }
+
+ private void HideStatusBanner()
+ {
+ _ = Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ StatusBannerHeight = 0;
+ await Task.Delay(350);
+ IsStatusBannerVisible = false;
+ });
+ }
+
+ public bool IsExtracting => _updateService.IsExtracting;
+
+ public string LaunchButtonText =>
+ _updateService.IsInstalling ?
+ (_updateService.IsExtracting ?
+ (_updateService.UpdateProgress > 0 ? $"Installing {_updateService.UpdateProgress:F0}%" : "Installing...") :
+ _updateService.UpdateIndeterminate ? "Installing..." :
+ _updateService.UpdateProgress > 0 ? $"Downloading {_updateService.UpdateProgress:F0}%" :
+ "Installing...") :
+ _isChaining ? "Updating..." :
+ _updateService.IsUpdating ?
+ (_updateService.UpdateIndeterminate ? "Updating..." : $"Updating {_updateService.UpdateProgress:F0}%") :
+ _updateService.IsNeedingInstall ? "Install Game" :
+ _updateService.IsUpdateAvailable ? "Update" :
+ "Launch Game";
+
+ public double LaunchProgressScale =>
+ _updateService.IsUpdating || _updateService.IsInstalling ? _updateService.UpdateProgress / 100.0 :
+ _isChaining ? 1.0 :
+ 0.0;
+
+ public string StatusBannerText =>
+ IsUpdateError ? FriendlyUpdateError :
+ _updateService.IsCheckingUpdates ? "Checking for updates..." :
+ !string.IsNullOrWhiteSpace(_updateService.UpdateStatusFile) &&
+ (_updateService.IsInstalling || _updateService.IsUpdating) ? _updateService.UpdateStatusFile :
+ _updateService.IsInstalling ? "Installing..." :
+ _updateService.IsUpdating ? "Updating..." :
+ "";
+
+ public string StatusBannerSpeed =>
+ IsUpdateError || _updateService.IsCheckingUpdates ? "" : _updateService.UpdateStatusSpeed;
+
+ public string StatusBannerColor =>
+ IsUpdateError ? "#D32F2F" :
+ _updateService.IsCheckingUpdates ? "#FFC107" :
+ _updateService.IsInstalling ? "#2196F3" :
+ _updateService.IsUpdating ? "#FFC107" :
+ "#00000000";
+
+ public string SelectedLabel => SelectedServer?.IsNone == false
+ ? SelectedServer.Name
+ : "Server not selected...";
+
+ public bool IsNoServerSelected => SelectedServer == null || SelectedServer.IsNone;
+ public bool IsServerSelected => SelectedServer != null && !SelectedServer.IsNone;
+
+ // Service Properties (expose to UI)
+ public ObservableCollection Servers => _serverService.Servers;
+ public ObservableCollection Friends => _friendsService.Friends;
+ public bool FriendsShowStatus => _friendsService.FriendsShowStatus;
+ public bool ShowNoFriendsState => _friendsService.ShowNoFriendsState;
+ public bool ShowGenericFriendsStatus => _friendsService.ShowGenericFriendsStatus;
+ public string FriendsStatus => _friendsService.FriendsStatus;
+ public IUpdateService UpdateService => _updateService;
+
+ // Commands
+ [RelayCommand]
+ private async Task LaunchGameAsync()
+ {
+ if (_updateService.IsInstalling || _updateService.IsUpdating || _updateService.IsCheckingUpdates)
+ return;
+
+ if (_updateService.IsNeedingInstall)
+ {
+ await InstallGameAsync();
+ return;
+ }
+
+ if (_updateService.IsUpdateAvailable && !SettingsWindowViewModel.LoadGlobal().SkipUpdates)
+ {
+ await ValidateFilesAsync();
+ return;
+ }
+
+ if (_gameService.IsRunning())
+ {
+ ConsoleManager.ShowError(
+ "ClassicCounter is already running.\n\nPlease close the game before joining a server from Wauncher.");
+ return;
+ }
+
+ try
+ {
+ var settings = SettingsWindowViewModel.LoadGlobal();
+ var selected = SelectedServer;
+
+ // Clear any arguments left over from a previous launch before adding new ones.
+ _gameService.ClearAdditionalArguments();
+
+ var connectTarget = selected != null && !selected.IsNone && !string.IsNullOrEmpty(selected.IpPort)
+ ? selected.IpPort
+ : null;
+
+ await _gameService.LaunchAsync(connectTarget, settings.LaunchOptions);
+
+ GameStatus = "Running";
+
+ if (settings.DiscordRpc)
+ {
+ await _discordService.SetDetailsAsync((selected != null && !selected.IsNone)
+ ? $"Playing on {selected.Name}" : "In Main Menu");
+ await _discordService.UpdateAsync();
+ }
+
+ await _gameService.MonitorAsync();
+ }
+ catch (Exception ex)
+ {
+ ConsoleManager.ShowError($"Failed to launch game:\n{ex.Message}");
+ }
+ finally
+ {
+ GameStatus = "Not Running";
+ }
+ }
+
+ [RelayCommand]
+ private async Task CheckForUpdatesAsync()
+ {
+ await _updateService.CheckForUpdatesAsync();
+ }
+
+ [RelayCommand]
+ private async Task InstallGameAsync()
+ {
+ bool installed = await _updateService.InstallGameFromCdnAsync();
+ if (!installed)
+ return;
+
+ _isChaining = true;
+ OnPropertyChanged(nameof(IsInstallingOrChaining));
+ OnPropertyChanged(nameof(IsUpdatingOrChaining));
+ OnPropertyChanged(nameof(IsUpdatingOrInstalling));
+ OnPropertyChanged(nameof(LaunchButtonText));
+ OnPropertyChanged(nameof(LaunchProgressScale));
+
+ try
+ {
+ bool needsUpdate = await _updateService.CheckForUpdatesAsync();
+ if (needsUpdate || _updateService.IsUpdateAvailable)
+ await _updateService.ValidateGameFilesAsync();
+ }
+ finally
+ {
+ _isChaining = false;
+ OnPropertyChanged(nameof(IsInstallingOrChaining));
+ OnPropertyChanged(nameof(IsUpdatingOrChaining));
+ OnPropertyChanged(nameof(IsUpdatingOrInstalling));
+ OnPropertyChanged(nameof(LaunchButtonText));
+ OnPropertyChanged(nameof(LaunchProgressScale));
+ }
+ }
+
+ [RelayCommand]
+ private async Task ValidateFilesAsync()
+ {
+ // "Verify Game Files" = always re-hash every file from scratch.
+ await _updateService.ValidateGameFilesAsync(fullValidate: true);
+ }
+
+ [RelayCommand]
+ private void ToggleServerDropdown()
+ {
+ if (_serverService.IsOfflineMode)
+ {
+ IsDropdownOpen = false;
+ return;
+ }
+
+ IsDropdownOpen = !IsDropdownOpen;
+ }
+
+ [RelayCommand]
+ private void SelectServer(ServerInfo? server)
+ {
+ SelectedServer = server?.IsNone == true ? null : server;
+ ProtocolManager = (server == null || server.IsNone) ? "None" : server.Name;
+ IsDropdownOpen = false;
+ }
+
+ [ObservableProperty]
+ private bool _isSettingsPanelOpen;
+
+ [ObservableProperty]
+ private bool _isInfoPanelOpen;
+
+ [ObservableProperty]
+ private bool _isAppearancePanelOpen;
+
+ [RelayCommand]
+ private void CloseSettingsPanel() => IsSettingsPanelOpen = false;
+
+ [RelayCommand]
+ private void CloseInfoPanel() => IsInfoPanelOpen = false;
+
+ [RelayCommand]
+ private void CloseAppearancePanel() => IsAppearancePanelOpen = false;
+
+ partial void OnIsSettingsPanelOpenChanged(bool value)
+ {
+ if (value)
+ {
+ IsInfoPanelOpen = false;
+ IsDropdownOpen = false;
+ IsAppearancePanelOpen = false;
+ }
+ }
+
+ partial void OnIsInfoPanelOpenChanged(bool value)
+ {
+ if (value)
+ {
+ IsSettingsPanelOpen = false;
+ IsDropdownOpen = false;
+ IsAppearancePanelOpen = false;
+ }
+ }
+
+ partial void OnIsDropdownOpenChanged(bool value)
+ {
+ if (value)
+ {
+ IsSettingsPanelOpen = false;
+ IsInfoPanelOpen = false;
+ IsAppearancePanelOpen = false;
+ }
+ }
+
+ partial void OnIsAppearancePanelOpenChanged(bool value)
+ {
+ if (value)
+ {
+ IsSettingsPanelOpen = false;
+ IsInfoPanelOpen = false;
+ IsDropdownOpen = false;
+ }
+ }
+
+ [RelayCommand]
+ private void SwitchToFriendsTab()
+ {
+ ActiveRightTab = "Friends";
+ }
+
+ [RelayCommand]
+ private void SwitchToPatchNotesTab()
+ {
+ ActiveRightTab = "PatchNotes";
+ }
+
+ [RelayCommand]
+ private void ViewFriendProfile(FriendInfo friend)
+ {
+ if (friend == null) return;
+
+ var profileId = ResolveProfileSteamId(friend.SteamId);
+ if (string.IsNullOrWhiteSpace(profileId))
+ return;
+
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = $"https://eddies.cc/profiles/{profileId}",
+ UseShellExecute = true
+ });
+ }
+
+ private static string ResolveProfileSteamId(string? steamId)
+ {
+ if (string.IsNullOrWhiteSpace(steamId))
+ return string.Empty;
+
+ var value = steamId.Trim();
+ if (ulong.TryParse(value, out _))
+ return value;
+
+ if (TryConvertSteamId2To64(value, out var steamId64))
+ return steamId64.ToString();
+
+ return string.Empty;
+ }
+
+ private static bool TryConvertSteamId2To64(string steamId2, out ulong steamId64)
+ {
+ steamId64 = 0;
+ var match = Regex.Match(steamId2, @"^STEAM_[0-5]:([0-1]):(\d+)$", RegexOptions.IgnoreCase);
+ if (!match.Success)
+ return false;
+
+ if (!ulong.TryParse(match.Groups[1].Value, out var y))
+ return false;
+ if (!ulong.TryParse(match.Groups[2].Value, out var z))
+ return false;
+
+ steamId64 = 76561197960265728UL + (z * 2UL) + y;
+ return true;
+ }
+
+ [RelayCommand]
+ private async Task JoinFriendServerAsync(FriendInfo friend)
+ {
+ if (friend == null || string.IsNullOrEmpty(friend.QuickJoinIpPort)) return;
+
+ // Find matching server and select it
+ var matchingServer = Servers.FirstOrDefault(s =>
+ !s.IsNone && string.Equals(s.IpPort, friend.QuickJoinIpPort, StringComparison.OrdinalIgnoreCase));
+
+ if (matchingServer != null)
+ {
+ SelectedServer = matchingServer;
+ await LaunchGameAsync();
+ }
+ }
+
+ // Constructor with dependency injection
+ public MainWindowViewModel(
+ IDiscordService discordService,
+ IGameService gameService,
+ ICarouselService carouselService,
+ IUpdateService updateService,
+ IServerService serverService,
+ IFriendsService friendsService)
+ {
+ _discordService = discordService;
+ _gameService = gameService;
+ _carouselService = carouselService;
+ _updateService = updateService;
+ _serverService = serverService;
+ _friendsService = friendsService;
+
+ if (Argument.HasProtocolCommand())
+ ProtocolManager = "Ready to Launch!";
+
+ // Subscribe to service property changes
+ SubscribeToServiceChanges();
+
+ // Setup network monitoring
+ NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
+ UpdateOfflineMode();
+
+ // Initialize services after subscriptions are ready so early results reach the UI.
+ _ = InitializeServicesAsync();
+ }
+
+ private async Task InitializeServicesAsync()
+ {
+ _serverService.Start();
+ _friendsService.Start();
+
+ try
+ {
+ await _discordService.InitializeAsync();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to initialize Discord service: {ex.Message}");
+ }
+
+ try
+ {
+ await _carouselService.SetupCarouselAsync();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to setup carousel: {ex.Message}");
+ }
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _friendsService.LoadSelfProfileAsync();
+ Dispatcher.UIThread.Post(SyncSelfProfile);
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to load self profile: {ex.Message}");
+ }
+ });
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await CheckWhitelistStatusAsync();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to check whitelist status: {ex.Message}");
+ }
+ });
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _serverService.RefreshServersSafeAsync();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to refresh servers: {ex.Message}");
+ }
+ });
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _friendsService.RefreshFriendsSafeAsync();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to refresh friends: {ex.Message}");
+ }
+ });
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await _updateService.CheckForUpdatesAsync();
+ }
+ catch (Exception ex)
+ {
+ Terminal.Warning($"Failed to check for updates: {ex.Message}");
+ }
+ });
+ }
+
+ private void SubscribeToServiceChanges()
+ {
+ // Subscribe to property changes from services to update UI
+ if (_updateService is INotifyPropertyChanged updateNotifier)
+ {
+ updateNotifier.PropertyChanged += (s, e) =>
+ {
+ OnPropertyChanged(nameof(LaunchButtonText));
+ OnPropertyChanged(nameof(StatusBannerText));
+ OnPropertyChanged(nameof(StatusBannerSpeed));
+ OnPropertyChanged(nameof(StatusBannerColor));
+ OnPropertyChanged(nameof(LaunchProgressScale));
+ OnPropertyChanged(nameof(IsExtracting));
+ OnPropertyChanged(nameof(IsCheckingOrUpdating));
+ OnPropertyChanged(nameof(IsUpdatingOrInstalling));
+ OnPropertyChanged(nameof(IsInstallingOrChaining));
+ OnPropertyChanged(nameof(IsUpdatingOrChaining));
+ OnPropertyChanged(nameof(ShowUpdateStatus));
+ OnPropertyChanged(nameof(IsInstallPending));
+ OnPropertyChanged(nameof(IsUpdatePending));
+ OnPropertyChanged(nameof(IsUpdateError));
+ OnPropertyChanged(nameof(FriendlyUpdateError));
+
+ if (IsUpdateError)
+ ShowErrorBanner();
+ else if ((IsUpdatingOrInstalling || _updateService.IsCheckingUpdates) && !IsStatusBannerVisible)
+ ShowStatusBanner();
+ else if (!IsUpdatingOrInstalling && !_updateService.IsCheckingUpdates && !IsUpdateError && IsStatusBannerVisible)
+ HideStatusBanner();
+ };
+ }
+
+ if (_friendsService is INotifyPropertyChanged friendsNotifier)
+ {
+ friendsNotifier.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(IFriendsService.FriendsStatus))
+ OnPropertyChanged(nameof(FriendsStatus));
+ if (e.PropertyName == nameof(IFriendsService.FriendsShowStatus))
+ {
+ OnPropertyChanged(nameof(FriendsShowStatus));
+ OnPropertyChanged(nameof(ShowGenericFriendsStatus));
+ }
+ if (e.PropertyName == nameof(IFriendsService.ShowNoFriendsState))
+ {
+ OnPropertyChanged(nameof(ShowNoFriendsState));
+ OnPropertyChanged(nameof(ShowGenericFriendsStatus));
+ }
+ if (e.PropertyName == nameof(IFriendsService.CurrentUserAvatar))
+ ProfilePicture = _friendsService.CurrentUserAvatar;
+ if (e.PropertyName == nameof(IFriendsService.CurrentUserUsername))
+ UsernameGreeting = $"Hello, {_friendsService.CurrentUserUsername}";
+ };
+ }
+ }
+
+ private void SyncSelfProfile()
+ {
+ ProfilePicture = _friendsService.CurrentUserAvatar;
+ UsernameGreeting = $"Hello, {_friendsService.CurrentUserUsername}";
+ }
+
+ private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
+ {
+ Dispatcher.UIThread.Post(UpdateOfflineMode);
+ }
+
+ private void UpdateOfflineMode()
+ {
+ IsOfflineMode = !NetworkInterface.GetIsNetworkAvailable();
+ }
+
+ private async Task CheckWhitelistStatusAsync()
+ {
+ try
+ {
+ bool hasSteam = await Steam.GetRecentLoggedInSteamID(false);
+ if (!hasSteam || string.IsNullOrEmpty(Steam.recentSteamID2))
+ {
+ WhitelistDotColor = "Gray";
+ WhitelistText = "Unknown";
+ return;
+ }
+
+ var response = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
+ bool whitelisted = response?.Files != null && response.Files.Count > 0;
+ WhitelistDotColor = whitelisted ? "#4CAF50" : "#F44336";
+ WhitelistText = whitelisted ? "Whitelisted" : "Not Whitelisted";
+ }
+ catch
+ {
+ WhitelistDotColor = "Gray";
+ WhitelistText = "Unknown";
+ }
+ }
+
+ partial void OnActiveRightTabChanged(string value)
+ {
+ OnPropertyChanged(nameof(IsFriendsTabActive));
+ OnPropertyChanged(nameof(IsPatchNotesTabActive));
+ }
+
+ partial void OnSelectedServerChanged(ServerInfo? value)
+ {
+ OnPropertyChanged(nameof(SelectedLabel));
+ OnPropertyChanged(nameof(IsNoServerSelected));
+ OnPropertyChanged(nameof(IsServerSelected));
+ }
+
+ partial void OnIsOfflineModeChanged(bool value) => OnPropertyChanged(nameof(IsOnlineMode));
+ }
+}
diff --git a/Wauncher/Views/Controls/ServerListControl.axaml b/Wauncher/Views/Controls/ServerListControl.axaml
index 3f55fcb..c4adb53 100644
--- a/Wauncher/Views/Controls/ServerListControl.axaml
+++ b/Wauncher/Views/Controls/ServerListControl.axaml
@@ -61,75 +61,76 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Wauncher/Views/MainWindow.axaml b/Wauncher/Views/MainWindow.axaml
index 199a764..2056201 100644
--- a/Wauncher/Views/MainWindow.axaml
+++ b/Wauncher/Views/MainWindow.axaml
@@ -1,639 +1,639 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wauncher/Views/MainWindow.axaml.cs b/Wauncher/Views/MainWindow.axaml.cs
index 3a5b9c0..2a4cfff 100644
--- a/Wauncher/Views/MainWindow.axaml.cs
+++ b/Wauncher/Views/MainWindow.axaml.cs
@@ -1,629 +1,629 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Net.Http;
-using System.Net.NetworkInformation;
-using System.Security.Cryptography;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Avalonia;
-using Avalonia.Animation;
-using Avalonia.Animation.Easings;
-using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Input;
-using Avalonia.Interactivity;
-using Avalonia.Media;
-using Avalonia.Media.Imaging;
-using Avalonia.Threading;
-using System.ComponentModel;
-using System.Diagnostics;
-using SkiaSharp;
-using Wauncher.Services;
-using Wauncher.ViewModels;
-using Wauncher.Utils;
-
-namespace Wauncher.Views
-{
- public partial class MainWindow : Window
- {
- private static readonly HttpClient Http = HttpClientFactory.Shared;
- private static readonly string CarouselCacheDir =
- Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "ClassicCounter",
- "Wauncher",
- "cache",
- "carousel");
-
- private const int CarouselRotationIntervalSeconds = 5;
- private const int CarouselMaxWidth = 1280;
- private const int CarouselMaxHeight = 720;
-
- private ICarouselService? _carouselService;
- private readonly List _zoomCts = new();
-
- private bool _forceClose;
- private bool _gameWasLaunched;
- private bool _isLoaded;
- private volatile int _carouselInitInProgress;
- private Image[] _carouselImages = Array.Empty();
- private List _carouselImageUrls = new();
- private DispatcherTimer? _carouselTimer;
- private int _currentCarouselIndex;
- private int _currentCarouselSlot;
- private volatile int _carouselRotateInProgress;
-
- public MainWindow()
- {
- InitializeComponent();
- SettingsWindowViewModel.DisableCarouselChanged += OnDisableCarouselChanged;
-
- // Initialize services in background to improve startup performance
- _ = Task.Run(() =>
- {
- try
- {
- ServiceContainer.Initialize();
- _carouselService = ServiceContainer.GetService();
-
- var viewModel = new MainWindowViewModel(
- ServiceContainer.GetService(),
- ServiceContainer.GetService(),
- _carouselService,
- ServiceContainer.GetService(),
- ServiceContainer.GetService(),
- ServiceContainer.GetService());
-
- Dispatcher.UIThread.Post(() =>
- {
- DataContext = viewModel;
- viewModel.PropertyChanged += ViewModel_PropertyChanged;
- MemoryManager.StartBackgroundCleanup();
- if (_isLoaded)
- _ = InitializeCarouselAsync();
- });
- }
- catch (Exception ex)
- {
- Dispatcher.UIThread.Post(() =>
- {
- ConsoleManager.ShowError($"Failed to initialize services: {ex.Message}");
- });
- }
- });
-
- Loaded += (_, _) =>
- {
- _isLoaded = true;
- if (_carouselService != null)
- _ = InitializeCarouselAsync();
- _ = PatchNotesControl.LoadPatchNotesAsync();
-
- SettingsPanelControl.CloseRequested += (_, _) =>
- {
- if (DataContext is MainWindowViewModel vm)
- vm.IsSettingsPanelOpen = false;
- };
-
- InfoPanelControl.CloseRequested += (_, _) =>
- {
- if (DataContext is MainWindowViewModel vm)
- vm.IsInfoPanelOpen = false;
- };
-
- var appearancePanelControl = this.FindControl("AppearancePanelControl");
- if (appearancePanelControl != null)
- {
- appearancePanelControl.CloseRequested += () =>
- {
- if (DataContext is MainWindowViewModel vm)
- vm.IsAppearancePanelOpen = false;
- };
- }
- };
-
- Closing += (_, e) =>
- {
- if (_forceClose)
- {
- Dispatcher.UIThread.Post(() => Environment.Exit(0));
- return;
- }
-
- _forceClose = true;
- MemoryManager.StopBackgroundCleanup();
-
- try
- {
- TeardownCarousel();
- }
- catch { }
-
- if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
- {
- Dispatcher.UIThread.Post(() =>
- {
- try
- {
- desktop.Shutdown();
- }
- catch { }
-
- Environment.Exit(0);
- });
- }
- };
-
- Closed += (_, _) => _ = CleanupServicesAsync();
- }
-
- private async Task InitializeCarouselAsync()
- {
- if (Interlocked.Exchange(ref _carouselInitInProgress, 1) == 1)
- return;
-
- try
- {
- for (int attempt = 0; attempt < 20 && _carouselService == null; attempt++)
- await Task.Delay(100);
-
- if (_carouselService == null)
- return;
-
- TeardownCarousel();
-
- var carouselContainer = this.FindControl("CarouselContainer");
- var offlinePanel = this.FindControl("CarouselOfflinePanel");
- var offlineTitle = this.FindControl("CarouselOfflineTitle");
- var offlineSubText = this.FindControl("CarouselOfflineSubText");
- if (carouselContainer == null)
- return;
-
- var settings = SettingsWindowViewModel.LoadGlobal();
- if (settings.DisableCarousel)
- {
- if (offlinePanel != null)
- offlinePanel.IsVisible = true;
-
- if (offlineTitle != null)
- offlineTitle.Text = "Carousel Disabled";
-
- if (offlineSubText != null)
- offlineSubText.Text = "Carousel is turned off in settings.";
-
- return;
- }
-
- bool hasInternet = NetworkInterface.GetIsNetworkAvailable();
- var urls = hasInternet
- ? await _carouselService.LoadCarouselUrlsFromGitHubAsync()
- : null;
-
- if (urls == null || urls.Count == 0)
- {
- if (offlinePanel != null)
- offlinePanel.IsVisible = true;
-
- if (offlineTitle != null)
- offlineTitle.Text = "No internet connection";
-
- if (offlineSubText != null)
- {
- offlineSubText.Text = hasInternet
- ? "Carousel is temporarily unavailable."
- : "Connect to Wi-Fi or Ethernet to load the carousel.";
- }
-
- return;
- }
-
- if (offlinePanel != null)
- offlinePanel.IsVisible = false;
-
- _carouselImageUrls = urls;
- _carouselImages = CreateCarouselImages(2);
- EnsureZoomSlots(_carouselImages.Length);
-
- int overlayIndex = offlinePanel != null ? carouselContainer.Children.IndexOf(offlinePanel) : -1;
- for (int i = 0; i < _carouselImages.Length; i++)
- {
- if (overlayIndex >= 0)
- {
- carouselContainer.Children.Insert(overlayIndex, _carouselImages[i]);
- overlayIndex++;
- }
- else
- {
- carouselContainer.Children.Add(_carouselImages[i]);
- }
- }
-
- _currentCarouselIndex = 0;
- _currentCarouselSlot = 0;
-
- await SetCarouselImageAsync(_carouselImages[_currentCarouselSlot], _carouselImageUrls[_currentCarouselIndex]);
- _carouselImages[_currentCarouselSlot].Opacity = 1.0;
- StartZoomOut(_carouselImages[_currentCarouselSlot], _currentCarouselSlot);
-
- _carouselTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CarouselRotationIntervalSeconds) };
- _carouselTimer.Tick += async (_, _) => await RotateCarouselAsync();
- _carouselTimer.Start();
- }
- catch (Exception ex)
- {
- System.Diagnostics.Debug.WriteLine("Carousel init failed: " + ex.Message);
- }
- finally
- {
- Interlocked.Exchange(ref _carouselInitInProgress, 0);
- }
- }
-
- private async Task CleanupServicesAsync()
- {
- SettingsWindowViewModel.DisableCarouselChanged -= OnDisableCarouselChanged;
- TeardownCarousel();
-
- try
- {
- if (_carouselService != null)
- await _carouselService.TeardownCarouselAsync();
- }
- catch
- {
- }
- }
-
- private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- if (sender is not MainWindowViewModel vm)
- return;
-
- if (e.PropertyName != nameof(MainWindowViewModel.GameStatus))
- return;
-
- if (!_forceClose &&
- string.Equals(vm.GameStatus, "Running", StringComparison.OrdinalIgnoreCase))
- {
- _gameWasLaunched = true;
- Dispatcher.UIThread.Post(() =>
- {
- if (IsVisible)
- Hide();
- });
- MemoryManager.StartBackgroundCleanup();
- return;
- }
-
- if (_gameWasLaunched &&
- string.Equals(vm.GameStatus, "Not Running", StringComparison.OrdinalIgnoreCase))
- {
- Dispatcher.UIThread.Post(() =>
- {
- _forceClose = true;
- Close();
- });
- MemoryManager.StopBackgroundCleanup();
- }
- }
-
- private static Image[] CreateCarouselImages(int count)
- {
- var images = new Image[count];
- for (int i = 0; i < count; i++)
- {
- images[i] = new Image
- {
- Stretch = Stretch.UniformToFill,
- Opacity = 0.0,
- Transitions = new Transitions
- {
- new DoubleTransition
- {
- Property = Visual.OpacityProperty,
- Duration = TimeSpan.FromSeconds(1.5),
- Easing = new CubicEaseInOut()
- }
- }
- };
- }
-
- return images;
- }
-
- private void EnsureZoomSlots(int count)
- {
- while (_zoomCts.Count < count)
- _zoomCts.Add(null);
- }
-
- private async Task RotateCarouselAsync()
- {
- if (_carouselImages.Length < 2 || _carouselImageUrls.Count < 2)
- return;
-
- if (Interlocked.Exchange(ref _carouselRotateInProgress, 1) == 1)
- return;
-
- try
- {
- int nextIndex = (_currentCarouselIndex + 1) % _carouselImageUrls.Count;
- int nextSlot = (_currentCarouselSlot + 1) % _carouselImages.Length;
- int currentSlot = _currentCarouselSlot;
-
- await SetCarouselImageAsync(_carouselImages[nextSlot], _carouselImageUrls[nextIndex]);
-
- _carouselImages[currentSlot].Opacity = 0.0;
- StartZoomOut(_carouselImages[nextSlot], nextSlot);
- _carouselImages[nextSlot].Opacity = 1.0;
-
- _currentCarouselIndex = nextIndex;
- _currentCarouselSlot = nextSlot;
- }
- finally
- {
- Interlocked.Exchange(ref _carouselRotateInProgress, 0);
- }
- }
-
- private void TeardownCarousel()
- {
- _carouselTimer?.Stop();
- _carouselTimer = null;
-
- for (int i = 0; i < _zoomCts.Count; i++)
- StopZoom(i);
-
- foreach (var image in _carouselImages)
- {
- if (image.Source is IDisposable disposable)
- disposable.Dispose();
-
- image.Source = null;
-
- if (image.Parent is Panel panel)
- panel.Children.Remove(image);
- }
-
- _carouselImageUrls.Clear();
- _carouselImages = Array.Empty();
- _currentCarouselIndex = 0;
- _currentCarouselSlot = 0;
- Interlocked.Exchange(ref _carouselRotateInProgress, 0);
- }
-
- private void OnDisableCarouselChanged(bool disabled)
- {
- Dispatcher.UIThread.Post(async () =>
- {
- var offlinePanel = this.FindControl("CarouselOfflinePanel");
- var offlineTitle = this.FindControl("CarouselOfflineTitle");
- var offlineSubText = this.FindControl("CarouselOfflineSubText");
-
- if (disabled)
- {
- TeardownCarousel();
-
- if (offlinePanel != null)
- offlinePanel.IsVisible = true;
-
- if (offlineTitle != null)
- offlineTitle.Text = "Carousel Disabled";
-
- if (offlineSubText != null)
- offlineSubText.Text = "Carousel is turned off in settings.";
-
- return;
- }
-
- if (offlinePanel != null)
- offlinePanel.IsVisible = false;
-
- await InitializeCarouselAsync();
- });
- }
-
- private async Task SetCarouselImageAsync(Image image, string url)
- {
- var nextBitmap = await LoadCarouselBitmapAsync(url);
- if (nextBitmap == null)
- return;
-
- if (image.Source is IDisposable disposable)
- disposable.Dispose();
-
- image.Source = nextBitmap;
- }
-
- private static async Task LoadCarouselBitmapAsync(string url)
- {
- try
- {
- var cachedBytes = await TryGetCachedCarouselBytesAsync(url);
- var bytes = cachedBytes ?? await Http.GetByteArrayAsync(url);
- var resized = cachedBytes ?? TryResizeCarouselBytes(bytes) ?? bytes;
-
- if (cachedBytes == null)
- await TryWriteCarouselCacheAsync(url, resized);
-
- using var ms = new MemoryStream(resized);
- return new Bitmap(ms);
- }
- catch
- {
- return null;
- }
- }
-
- private static async Task TryGetCachedCarouselBytesAsync(string url)
- {
- try
- {
- var path = GetCarouselCachePath(url);
- if (!File.Exists(path))
- return null;
-
- return await File.ReadAllBytesAsync(path);
- }
- catch
- {
- return null;
- }
- }
-
- private static async Task TryWriteCarouselCacheAsync(string url, byte[] bytes)
- {
- try
- {
- Directory.CreateDirectory(CarouselCacheDir);
- var path = GetCarouselCachePath(url);
- var tempPath = path + ".tmp";
- await File.WriteAllBytesAsync(tempPath, bytes);
- File.Move(tempPath, path, overwrite: true);
- }
- catch
- {
- }
- }
-
- private static string GetCarouselCachePath(string url)
- {
- var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url))).ToLowerInvariant();
- return Path.Combine(CarouselCacheDir, $"{hash}.jpg");
- }
-
- private static byte[]? TryResizeCarouselBytes(byte[] bytes)
- {
- try
- {
- using var sourceBitmap = SKBitmap.Decode(bytes);
- if (sourceBitmap == null)
- return null;
-
- if (sourceBitmap.Width <= CarouselMaxWidth &&
- sourceBitmap.Height <= CarouselMaxHeight)
- {
- return null;
- }
-
- var scale = Math.Min(
- (double)CarouselMaxWidth / sourceBitmap.Width,
- (double)CarouselMaxHeight / sourceBitmap.Height);
-
- int targetWidth = Math.Max(1, (int)Math.Round(sourceBitmap.Width * scale));
- int targetHeight = Math.Max(1, (int)Math.Round(sourceBitmap.Height * scale));
-
- using var resizedBitmap = sourceBitmap.Resize(
- new SKImageInfo(targetWidth, targetHeight),
- SKFilterQuality.Medium);
-
- if (resizedBitmap == null)
- return null;
-
- using var image = SKImage.FromBitmap(resizedBitmap);
- using var data = image.Encode(SKEncodedImageFormat.Jpeg, 88);
- return data?.ToArray();
- }
- catch
- {
- return null;
- }
- }
-
- private void StartZoomOut(Image image, int slot)
- {
- StopZoom(slot);
- _zoomCts[slot] = new CancellationTokenSource();
- var cts = _zoomCts[slot]!;
-
- image.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
- var scale = new ScaleTransform(1.15, 1.15);
- image.RenderTransform = scale;
-
- const double startScale = 1.15;
- const double endScale = 1.0;
- const double totalMs = 6000.0;
- var startTime = DateTime.UtcNow;
-
- var zoomTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
- zoomTimer.Tick += (_, _) =>
- {
- if (cts.IsCancellationRequested)
- {
- zoomTimer.Stop();
- return;
- }
-
- var t = Math.Min((DateTime.UtcNow - startTime).TotalMilliseconds / totalMs, 1.0);
- var s = startScale + (endScale - startScale) * t;
- scale.ScaleX = s;
- scale.ScaleY = s;
-
- if (t >= 1.0)
- zoomTimer.Stop();
- };
- zoomTimer.Start();
- }
-
- private void StopZoom(int slot)
- {
- if (slot < 0 || slot >= _zoomCts.Count)
- return;
-
- _zoomCts[slot]?.Cancel();
- _zoomCts[slot] = null;
- }
-
- private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
- {
- if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
- BeginMoveDrag(e);
- }
-
- private void MinimizeButton_Click(object? sender, RoutedEventArgs e)
- {
- WindowState = WindowState.Minimized;
- }
-
- private void CloseButton_Click(object? sender, RoutedEventArgs e)
- {
- ForceQuit();
- }
-
- public void ForceQuit()
- {
- _forceClose = true;
-
- try
- {
- TeardownCarousel();
- }
- catch
- {
- }
-
- MemoryManager.StopBackgroundCleanup();
-
- try
- {
- Close();
- }
- catch
- {
- }
-
- try
- {
- if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
- desktop.Shutdown();
- }
- catch
- {
- }
-
- Environment.Exit(0);
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Net.NetworkInformation;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using System.ComponentModel;
+using System.Diagnostics;
+using SkiaSharp;
+using Wauncher.Services;
+using Wauncher.ViewModels;
+using Wauncher.Utils;
+
+namespace Wauncher.Views
+{
+ public partial class MainWindow : Window
+ {
+ private static readonly HttpClient Http = HttpClientFactory.Shared;
+ private static readonly string CarouselCacheDir =
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "cache",
+ "carousel");
+
+ private const int CarouselRotationIntervalSeconds = 5;
+ private const int CarouselMaxWidth = 1280;
+ private const int CarouselMaxHeight = 720;
+
+ private ICarouselService? _carouselService;
+ private readonly List _zoomCts = new();
+
+ private bool _forceClose;
+ private bool _gameWasLaunched;
+ private bool _isLoaded;
+ private volatile int _carouselInitInProgress;
+ private Image[] _carouselImages = Array.Empty();
+ private List _carouselImageUrls = new();
+ private DispatcherTimer? _carouselTimer;
+ private int _currentCarouselIndex;
+ private int _currentCarouselSlot;
+ private volatile int _carouselRotateInProgress;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ SettingsWindowViewModel.DisableCarouselChanged += OnDisableCarouselChanged;
+
+ // Initialize services in background to improve startup performance
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ ServiceContainer.Initialize();
+ _carouselService = ServiceContainer.GetService();
+
+ var viewModel = new MainWindowViewModel(
+ ServiceContainer.GetService(),
+ ServiceContainer.GetService(),
+ _carouselService,
+ ServiceContainer.GetService(),
+ ServiceContainer.GetService(),
+ ServiceContainer.GetService());
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ DataContext = viewModel;
+ viewModel.PropertyChanged += ViewModel_PropertyChanged;
+ MemoryManager.StartBackgroundCleanup();
+ if (_isLoaded)
+ _ = InitializeCarouselAsync();
+ });
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ ConsoleManager.ShowError($"Failed to initialize services: {ex.Message}");
+ });
+ }
+ });
+
+ Loaded += (_, _) =>
+ {
+ _isLoaded = true;
+ if (_carouselService != null)
+ _ = InitializeCarouselAsync();
+ _ = PatchNotesControl.LoadPatchNotesAsync();
+
+ SettingsPanelControl.CloseRequested += (_, _) =>
+ {
+ if (DataContext is MainWindowViewModel vm)
+ vm.IsSettingsPanelOpen = false;
+ };
+
+ InfoPanelControl.CloseRequested += (_, _) =>
+ {
+ if (DataContext is MainWindowViewModel vm)
+ vm.IsInfoPanelOpen = false;
+ };
+
+ var appearancePanelControl = this.FindControl("AppearancePanelControl");
+ if (appearancePanelControl != null)
+ {
+ appearancePanelControl.CloseRequested += () =>
+ {
+ if (DataContext is MainWindowViewModel vm)
+ vm.IsAppearancePanelOpen = false;
+ };
+ }
+ };
+
+ Closing += (_, e) =>
+ {
+ if (_forceClose)
+ {
+ Dispatcher.UIThread.Post(() => Environment.Exit(0));
+ return;
+ }
+
+ _forceClose = true;
+ MemoryManager.StopBackgroundCleanup();
+
+ try
+ {
+ TeardownCarousel();
+ }
+ catch { }
+
+ if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ try
+ {
+ desktop.Shutdown();
+ }
+ catch { }
+
+ Environment.Exit(0);
+ });
+ }
+ };
+
+ Closed += (_, _) => _ = CleanupServicesAsync();
+ }
+
+ private async Task InitializeCarouselAsync()
+ {
+ if (Interlocked.Exchange(ref _carouselInitInProgress, 1) == 1)
+ return;
+
+ try
+ {
+ for (int attempt = 0; attempt < 20 && _carouselService == null; attempt++)
+ await Task.Delay(100);
+
+ if (_carouselService == null)
+ return;
+
+ TeardownCarousel();
+
+ var carouselContainer = this.FindControl("CarouselContainer");
+ var offlinePanel = this.FindControl("CarouselOfflinePanel");
+ var offlineTitle = this.FindControl("CarouselOfflineTitle");
+ var offlineSubText = this.FindControl("CarouselOfflineSubText");
+ if (carouselContainer == null)
+ return;
+
+ var settings = SettingsWindowViewModel.LoadGlobal();
+ if (settings.DisableCarousel)
+ {
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = true;
+
+ if (offlineTitle != null)
+ offlineTitle.Text = "Carousel Disabled";
+
+ if (offlineSubText != null)
+ offlineSubText.Text = "Carousel is turned off in settings.";
+
+ return;
+ }
+
+ bool hasInternet = NetworkInterface.GetIsNetworkAvailable();
+ var urls = hasInternet
+ ? await _carouselService.LoadCarouselUrlsFromGitHubAsync()
+ : null;
+
+ if (urls == null || urls.Count == 0)
+ {
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = true;
+
+ if (offlineTitle != null)
+ offlineTitle.Text = "No internet connection";
+
+ if (offlineSubText != null)
+ {
+ offlineSubText.Text = hasInternet
+ ? "Carousel is temporarily unavailable."
+ : "Connect to Wi-Fi or Ethernet to load the carousel.";
+ }
+
+ return;
+ }
+
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = false;
+
+ _carouselImageUrls = urls;
+ _carouselImages = CreateCarouselImages(2);
+ EnsureZoomSlots(_carouselImages.Length);
+
+ int overlayIndex = offlinePanel != null ? carouselContainer.Children.IndexOf(offlinePanel) : -1;
+ for (int i = 0; i < _carouselImages.Length; i++)
+ {
+ if (overlayIndex >= 0)
+ {
+ carouselContainer.Children.Insert(overlayIndex, _carouselImages[i]);
+ overlayIndex++;
+ }
+ else
+ {
+ carouselContainer.Children.Add(_carouselImages[i]);
+ }
+ }
+
+ _currentCarouselIndex = 0;
+ _currentCarouselSlot = 0;
+
+ await SetCarouselImageAsync(_carouselImages[_currentCarouselSlot], _carouselImageUrls[_currentCarouselIndex]);
+ _carouselImages[_currentCarouselSlot].Opacity = 1.0;
+ StartZoomOut(_carouselImages[_currentCarouselSlot], _currentCarouselSlot);
+
+ _carouselTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CarouselRotationIntervalSeconds) };
+ _carouselTimer.Tick += async (_, _) => await RotateCarouselAsync();
+ _carouselTimer.Start();
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine("Carousel init failed: " + ex.Message);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _carouselInitInProgress, 0);
+ }
+ }
+
+ private async Task CleanupServicesAsync()
+ {
+ SettingsWindowViewModel.DisableCarouselChanged -= OnDisableCarouselChanged;
+ TeardownCarousel();
+
+ try
+ {
+ if (_carouselService != null)
+ await _carouselService.TeardownCarouselAsync();
+ }
+ catch
+ {
+ }
+ }
+
+ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (sender is not MainWindowViewModel vm)
+ return;
+
+ if (e.PropertyName != nameof(MainWindowViewModel.GameStatus))
+ return;
+
+ if (!_forceClose &&
+ string.Equals(vm.GameStatus, "Running", StringComparison.OrdinalIgnoreCase))
+ {
+ _gameWasLaunched = true;
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (IsVisible)
+ Hide();
+ });
+ MemoryManager.StartBackgroundCleanup();
+ return;
+ }
+
+ if (_gameWasLaunched &&
+ string.Equals(vm.GameStatus, "Not Running", StringComparison.OrdinalIgnoreCase))
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _forceClose = true;
+ Close();
+ });
+ MemoryManager.StopBackgroundCleanup();
+ }
+ }
+
+ private static Image[] CreateCarouselImages(int count)
+ {
+ var images = new Image[count];
+ for (int i = 0; i < count; i++)
+ {
+ images[i] = new Image
+ {
+ Stretch = Stretch.UniformToFill,
+ Opacity = 0.0,
+ Transitions = new Transitions
+ {
+ new DoubleTransition
+ {
+ Property = Visual.OpacityProperty,
+ Duration = TimeSpan.FromSeconds(1.5),
+ Easing = new CubicEaseInOut()
+ }
+ }
+ };
+ }
+
+ return images;
+ }
+
+ private void EnsureZoomSlots(int count)
+ {
+ while (_zoomCts.Count < count)
+ _zoomCts.Add(null);
+ }
+
+ private async Task RotateCarouselAsync()
+ {
+ if (_carouselImages.Length < 2 || _carouselImageUrls.Count < 2)
+ return;
+
+ if (Interlocked.Exchange(ref _carouselRotateInProgress, 1) == 1)
+ return;
+
+ try
+ {
+ int nextIndex = (_currentCarouselIndex + 1) % _carouselImageUrls.Count;
+ int nextSlot = (_currentCarouselSlot + 1) % _carouselImages.Length;
+ int currentSlot = _currentCarouselSlot;
+
+ await SetCarouselImageAsync(_carouselImages[nextSlot], _carouselImageUrls[nextIndex]);
+
+ _carouselImages[currentSlot].Opacity = 0.0;
+ StartZoomOut(_carouselImages[nextSlot], nextSlot);
+ _carouselImages[nextSlot].Opacity = 1.0;
+
+ _currentCarouselIndex = nextIndex;
+ _currentCarouselSlot = nextSlot;
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _carouselRotateInProgress, 0);
+ }
+ }
+
+ private void TeardownCarousel()
+ {
+ _carouselTimer?.Stop();
+ _carouselTimer = null;
+
+ for (int i = 0; i < _zoomCts.Count; i++)
+ StopZoom(i);
+
+ foreach (var image in _carouselImages)
+ {
+ if (image.Source is IDisposable disposable)
+ disposable.Dispose();
+
+ image.Source = null;
+
+ if (image.Parent is Panel panel)
+ panel.Children.Remove(image);
+ }
+
+ _carouselImageUrls.Clear();
+ _carouselImages = Array.Empty();
+ _currentCarouselIndex = 0;
+ _currentCarouselSlot = 0;
+ Interlocked.Exchange(ref _carouselRotateInProgress, 0);
+ }
+
+ private void OnDisableCarouselChanged(bool disabled)
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ var offlinePanel = this.FindControl("CarouselOfflinePanel");
+ var offlineTitle = this.FindControl("CarouselOfflineTitle");
+ var offlineSubText = this.FindControl("CarouselOfflineSubText");
+
+ if (disabled)
+ {
+ TeardownCarousel();
+
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = true;
+
+ if (offlineTitle != null)
+ offlineTitle.Text = "Carousel Disabled";
+
+ if (offlineSubText != null)
+ offlineSubText.Text = "Carousel is turned off in settings.";
+
+ return;
+ }
+
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = false;
+
+ await InitializeCarouselAsync();
+ });
+ }
+
+ private async Task SetCarouselImageAsync(Image image, string url)
+ {
+ var nextBitmap = await LoadCarouselBitmapAsync(url);
+ if (nextBitmap == null)
+ return;
+
+ if (image.Source is IDisposable disposable)
+ disposable.Dispose();
+
+ image.Source = nextBitmap;
+ }
+
+ private static async Task LoadCarouselBitmapAsync(string url)
+ {
+ try
+ {
+ var cachedBytes = await TryGetCachedCarouselBytesAsync(url);
+ var bytes = cachedBytes ?? await Http.GetByteArrayAsync(url);
+ var resized = cachedBytes ?? TryResizeCarouselBytes(bytes) ?? bytes;
+
+ if (cachedBytes == null)
+ await TryWriteCarouselCacheAsync(url, resized);
+
+ using var ms = new MemoryStream(resized);
+ return new Bitmap(ms);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static async Task TryGetCachedCarouselBytesAsync(string url)
+ {
+ try
+ {
+ var path = GetCarouselCachePath(url);
+ if (!File.Exists(path))
+ return null;
+
+ return await File.ReadAllBytesAsync(path);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static async Task TryWriteCarouselCacheAsync(string url, byte[] bytes)
+ {
+ try
+ {
+ Directory.CreateDirectory(CarouselCacheDir);
+ var path = GetCarouselCachePath(url);
+ var tempPath = path + ".tmp";
+ await File.WriteAllBytesAsync(tempPath, bytes);
+ File.Move(tempPath, path, overwrite: true);
+ }
+ catch
+ {
+ }
+ }
+
+ private static string GetCarouselCachePath(string url)
+ {
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url))).ToLowerInvariant();
+ return Path.Combine(CarouselCacheDir, $"{hash}.jpg");
+ }
+
+ private static byte[]? TryResizeCarouselBytes(byte[] bytes)
+ {
+ try
+ {
+ using var sourceBitmap = SKBitmap.Decode(bytes);
+ if (sourceBitmap == null)
+ return null;
+
+ if (sourceBitmap.Width <= CarouselMaxWidth &&
+ sourceBitmap.Height <= CarouselMaxHeight)
+ {
+ return null;
+ }
+
+ var scale = Math.Min(
+ (double)CarouselMaxWidth / sourceBitmap.Width,
+ (double)CarouselMaxHeight / sourceBitmap.Height);
+
+ int targetWidth = Math.Max(1, (int)Math.Round(sourceBitmap.Width * scale));
+ int targetHeight = Math.Max(1, (int)Math.Round(sourceBitmap.Height * scale));
+
+ using var resizedBitmap = sourceBitmap.Resize(
+ new SKImageInfo(targetWidth, targetHeight),
+ SKFilterQuality.Medium);
+
+ if (resizedBitmap == null)
+ return null;
+
+ using var image = SKImage.FromBitmap(resizedBitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Jpeg, 88);
+ return data?.ToArray();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private void StartZoomOut(Image image, int slot)
+ {
+ StopZoom(slot);
+ _zoomCts[slot] = new CancellationTokenSource();
+ var cts = _zoomCts[slot]!;
+
+ image.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ var scale = new ScaleTransform(1.15, 1.15);
+ image.RenderTransform = scale;
+
+ const double startScale = 1.15;
+ const double endScale = 1.0;
+ const double totalMs = 6000.0;
+ var startTime = DateTime.UtcNow;
+
+ var zoomTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
+ zoomTimer.Tick += (_, _) =>
+ {
+ if (cts.IsCancellationRequested)
+ {
+ zoomTimer.Stop();
+ return;
+ }
+
+ var t = Math.Min((DateTime.UtcNow - startTime).TotalMilliseconds / totalMs, 1.0);
+ var s = startScale + (endScale - startScale) * t;
+ scale.ScaleX = s;
+ scale.ScaleY = s;
+
+ if (t >= 1.0)
+ zoomTimer.Stop();
+ };
+ zoomTimer.Start();
+ }
+
+ private void StopZoom(int slot)
+ {
+ if (slot < 0 || slot >= _zoomCts.Count)
+ return;
+
+ _zoomCts[slot]?.Cancel();
+ _zoomCts[slot] = null;
+ }
+
+ private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginMoveDrag(e);
+ }
+
+ private void MinimizeButton_Click(object? sender, RoutedEventArgs e)
+ {
+ WindowState = WindowState.Minimized;
+ }
+
+ private void CloseButton_Click(object? sender, RoutedEventArgs e)
+ {
+ ForceQuit();
+ }
+
+ public void ForceQuit()
+ {
+ _forceClose = true;
+
+ try
+ {
+ TeardownCarousel();
+ }
+ catch
+ {
+ }
+
+ MemoryManager.StopBackgroundCleanup();
+
+ try
+ {
+ Close();
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ desktop.Shutdown();
+ }
+ catch
+ {
+ }
+
+ Environment.Exit(0);
+ }
+ }
+}
diff --git a/Wauncher/Views/SettingsWindow.axaml b/Wauncher/Views/SettingsWindow.axaml
index 175e360..9a676ac 100644
--- a/Wauncher/Views/SettingsWindow.axaml
+++ b/Wauncher/Views/SettingsWindow.axaml
@@ -1,315 +1,315 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wauncher/Wauncher.sln b/Wauncher/Wauncher.sln
index 76aaceb..979c704 100644
--- a/Wauncher/Wauncher.sln
+++ b/Wauncher/Wauncher.sln
@@ -1,24 +1,24 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.5.2.0
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wauncher", "Wauncher.csproj", "{26B503D9-F947-924C-D517-78C86C319A7C}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {26B503D9-F947-924C-D517-78C86C319A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {26B503D9-F947-924C-D517-78C86C319A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {26B503D9-F947-924C-D517-78C86C319A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {26B503D9-F947-924C-D517-78C86C319A7C}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {2B5005F6-85FA-4467-97A0-541E7DFF4DFA}
- EndGlobalSection
-EndGlobal
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.2.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wauncher", "Wauncher.csproj", "{26B503D9-F947-924C-D517-78C86C319A7C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {2B5005F6-85FA-4467-97A0-541E7DFF4DFA}
+ EndGlobalSection
+EndGlobal
diff --git a/Wauncher/patchnotes.md b/Wauncher/patchnotes.md
index 503cf48..07d0569 100644
--- a/Wauncher/patchnotes.md
+++ b/Wauncher/patchnotes.md
@@ -1,153 +1,153 @@
-# Major Update - Game Coordinator Beta
-06/27/2026
-## What's Changed
-- Added **RU | ClassicCounter | PUG | 128 Tick** server (host located in Moscow)
-- Added **Game Coordinator** support
-- Added Spectral Knives (currently unobtainable to the public, may be used for planned future events)
-- Added ClassicCounter logo to in-game pause menu
-- Added ability to view eddies.cc profiles via clicking players on the in-game scoreboard
-- Removed Herobrine
-- Fixed an issue with Weapon Cases not opening correctly
-- Fixed an issue with Sticker Capsules not opening correctly
-- Fixed an issue with Music Kit Boxes not opening correctly
-- Fixed an issue with scraping stickers
-- Fixed Trade Up Contracts not functioning
-- Fixed certain items failing to appear in Trade Up Contracts
-- Fixed all NOVAGANG Collection items' inspect models
-- Fixed all NOVAGANG Music Kit main menu music not playing in main menu
-- Fixed an issue where some weapon models wouldn't render properly in the inventory
-- Fixed main menu News tab failing to display ClassicCounter server list
-- Fixed missing assets from main menu carousel
-- Added new items to main menu carousel
-- Added launcher settings toggle for **Enable In-Game Inventory** (launches cc.exe with game coordinator)
-- Added error message in the case launcher fails to connect to update server
-- Fixed an issue with launcher failing to verify game files
-- Fixed launcher sometimes staying open as a background process after being closed
-- Fixed launcher not showing an "Update" button when detecting a new patch
-- Removed launcher system tray icon behavior
-- Updated appearance of installation and update progress bars in launcher
-
-# Launcher Update 3.2.1
-06/19/2026
-## What's Changed
-- Added "Enable GC (Beta)" in Settings to launch through the game coordinator (cc.exe)
-
-# Launcher Update 3.2.0
-06/02/2026
-## What's Changed
-- Added an Appearance menu to customize the launcher's colors (Background, Accent, and Text)
-- Settings and Info are no longer separate windows; they now open as panels inside the launcher
-- Redesigned the server selector with a cleaner layout
-- Fixed the launcher getting stuck on "Checking for updates…" when the update server is unreachable
-- Fixed auto-updating, so future versions will install automatically (3.2.0 is the last manual download)
-
-# Minor Update - Bug Fixes
-05/17/2026
-## What's Changed
-- Fixed collection drops not functioning as intended
-- Fixed an issue with drop rarity
-- Fixed drop amounts for donors and non-donors
-- Fed the Horse twice
-
-# Update - Map and Drop Pool Changes
-04/02/2026
-## What's Changed
-- End of ClassicCounter anniversary event
-- Disabled sv_party_mode on all servers
-- Reverted drops back to normal amounts
-- Updated map pool for Spring season
-- Updated drop pool
-- Removed souvenir drops (event/holiday exclusive)
-- Fed the Horse
-- Some servers still need to be restarted for map and drop pool changes to apply.
-- Servers will be restarted during inactive hours.
-
-# Launcher Update 3.1.0
-03/22/2026
-## What's Changed
-- Improved full game downloading and extraction reliability
-- You no longer need an older build installed first for downloads to work reliably
-- Fixed the launcher sometimes staying open in the background after closing the game
-- Improved memory usage
-- Improved Steam detection
-- Added the ability to add ClassicCounter to Steam directly from Wauncher
-- Added a `Disable Carousel` setting for lower memory usage
-- Added a `Disable Hardware Acceleration` setting
-- Improved server browser behavior and added scrolling for longer server lists
-- Server information is now pulled from the live ClassicCounter server list repository
-- Various backend and stability improvements
-
-# Hotfix
-03/18/2026
-## What's Changed
-- Fixed context menu when page is not in initial position (is scrolled down)
-- Changed how Newest and Oldest sorting works, and added two new sorting methods based on item ID
-- Revamped trading code, so now traded items are properly tracked which allows us in the future to create an accurate trade history
-- Improved the code for validation of items in a trade offer, should fix some bugs
-- StatTrak kills get now reset down to 0 when they get traded away
-- General updates of a weapon no longer update the timestamp used for sorting in the inventory, most commonly seen with StatTrak weapons by getting kills
-- Changed how deleting items works, they are no longer expunged from the database and just remain hidden
-- Made randomness in multiple places on the website more cryptographically safe
-- Fixed traded-up items having incorrect wear name
-- In-game item announcer now works again
-- Trading is no longer under maintainance and is now again available to the public
-
-# Hotfix
-03/08/2026
-## What's Changed
-- Updated appearance of the "Edit Profile" page on eddies.cc
-- Profiles on eddies.cc are now limited to a maximum level of 6,199
-- Profile vanity URLs on eddies.cc are now limited to a minimum of three characters
-- Image hosts used for eddies.cc profiles are now whitelist only
-- Added an information tab to the "Edit Profile" page on eddies.cc that lists whitelisted image hosts
-- Groups on eddies.cc can now have a custom URL set, directing to the desired Steam group URL instead of the vanity URL that matches the group name
-- Groups on eddies.cc will direct to the vanity URL that matches the group name if no custom group URL is set when editing profile
-
-# Anniversary Update
-03/04/2026
-## What's Changed
-- Donors now permanently get an extra drop at the end of each match, on top of the extra drop during this month's event. We're grateful for your support!
-- NOVAGANG Collection drops have been reverted back to normal rates.
-- The drop pool has been updated to make some less desirable drops less common, some item collections have been disabled, and a few more cases have been added to the drop pool.
-- Previously nonfunctional map de_nuketown has been fixed and added to the map pool.
-- Profile backgrounds on eddies.cc now scale with your display resolution.
-- Profile background holder on eddies.cc has been updated to a lower opacity gradient, making backgrounds more visible.
-- Fixed broken or invalid item IDs in player inventories.
-- Bug fixes and security improvements.
-
-# Hotfix
-02/16/2026
-## What's Changed
-- Fixed NOVAGANG Collection drop names and images not appearing in game (make sure to run the launcher)
-- CZ-75 now works again on team Terrorists
-- Fixes for Shattered Web knives
-- Updated map list for !nominate command
-- Background video on the classiccounter.cc website now works properly again
-- Backend fixes and improvements
-
-# NOVAGANG Collection Update
-02/15/2026
-## What's Changed
-- Added NOVAGANG Sticker Capsule
-- Added Dysocjacja by Exodus1900
-- Added Inure+Idyllic by Exodus1900
-- Added bits and pieces by gv1nn
-- Added FREE GEEK by gv1nn
-- Added Atticus by Ways
-- Added You Lose Forever by Ways
-- Added [x]"TheDevilMayCry!" by prblm
-- Added Veire Dawf by prblm
-- Added sonata░de░lluvia (卸ぬマ)(卸ぬマ) by Trauma
-- Added Vai S.//3 バロック by Trauma
-- Added 760 by Zephrxd
-- Added THE HELLSING CONSPIRACY by Zephrxd
-- Added No Body by Zootzie
-- Added Terminal Z by Zootzie
-- Added H.T.N.G by NOVAGANG
-- Added H.T.N.G. VOL. 2: JUDGEMENT DAY by NOVAGANG
-
-
-
-
-
-
+# Major Update - Game Coordinator Beta
+06/27/2026
+## What's Changed
+- Added **RU | ClassicCounter | PUG | 128 Tick** server (host located in Moscow)
+- Added **Game Coordinator** support
+- Added Spectral Knives (currently unobtainable to the public, may be used for planned future events)
+- Added ClassicCounter logo to in-game pause menu
+- Added ability to view eddies.cc profiles via clicking players on the in-game scoreboard
+- Removed Herobrine
+- Fixed an issue with Weapon Cases not opening correctly
+- Fixed an issue with Sticker Capsules not opening correctly
+- Fixed an issue with Music Kit Boxes not opening correctly
+- Fixed an issue with scraping stickers
+- Fixed Trade Up Contracts not functioning
+- Fixed certain items failing to appear in Trade Up Contracts
+- Fixed all NOVAGANG Collection items' inspect models
+- Fixed all NOVAGANG Music Kit main menu music not playing in main menu
+- Fixed an issue where some weapon models wouldn't render properly in the inventory
+- Fixed main menu News tab failing to display ClassicCounter server list
+- Fixed missing assets from main menu carousel
+- Added new items to main menu carousel
+- Added launcher settings toggle for **Enable In-Game Inventory** (launches cc.exe with game coordinator)
+- Added error message in the case launcher fails to connect to update server
+- Fixed an issue with launcher failing to verify game files
+- Fixed launcher sometimes staying open as a background process after being closed
+- Fixed launcher not showing an "Update" button when detecting a new patch
+- Removed launcher system tray icon behavior
+- Updated appearance of installation and update progress bars in launcher
+
+# Launcher Update 3.2.1
+06/19/2026
+## What's Changed
+- Added "Enable GC (Beta)" in Settings to launch through the game coordinator (cc.exe)
+
+# Launcher Update 3.2.0
+06/02/2026
+## What's Changed
+- Added an Appearance menu to customize the launcher's colors (Background, Accent, and Text)
+- Settings and Info are no longer separate windows; they now open as panels inside the launcher
+- Redesigned the server selector with a cleaner layout
+- Fixed the launcher getting stuck on "Checking for updates…" when the update server is unreachable
+- Fixed auto-updating, so future versions will install automatically (3.2.0 is the last manual download)
+
+# Minor Update - Bug Fixes
+05/17/2026
+## What's Changed
+- Fixed collection drops not functioning as intended
+- Fixed an issue with drop rarity
+- Fixed drop amounts for donors and non-donors
+- Fed the Horse twice
+
+# Update - Map and Drop Pool Changes
+04/02/2026
+## What's Changed
+- End of ClassicCounter anniversary event
+- Disabled sv_party_mode on all servers
+- Reverted drops back to normal amounts
+- Updated map pool for Spring season
+- Updated drop pool
+- Removed souvenir drops (event/holiday exclusive)
+- Fed the Horse
+- Some servers still need to be restarted for map and drop pool changes to apply.
+- Servers will be restarted during inactive hours.
+
+# Launcher Update 3.1.0
+03/22/2026
+## What's Changed
+- Improved full game downloading and extraction reliability
+- You no longer need an older build installed first for downloads to work reliably
+- Fixed the launcher sometimes staying open in the background after closing the game
+- Improved memory usage
+- Improved Steam detection
+- Added the ability to add ClassicCounter to Steam directly from Wauncher
+- Added a `Disable Carousel` setting for lower memory usage
+- Added a `Disable Hardware Acceleration` setting
+- Improved server browser behavior and added scrolling for longer server lists
+- Server information is now pulled from the live ClassicCounter server list repository
+- Various backend and stability improvements
+
+# Hotfix
+03/18/2026
+## What's Changed
+- Fixed context menu when page is not in initial position (is scrolled down)
+- Changed how Newest and Oldest sorting works, and added two new sorting methods based on item ID
+- Revamped trading code, so now traded items are properly tracked which allows us in the future to create an accurate trade history
+- Improved the code for validation of items in a trade offer, should fix some bugs
+- StatTrak kills get now reset down to 0 when they get traded away
+- General updates of a weapon no longer update the timestamp used for sorting in the inventory, most commonly seen with StatTrak weapons by getting kills
+- Changed how deleting items works, they are no longer expunged from the database and just remain hidden
+- Made randomness in multiple places on the website more cryptographically safe
+- Fixed traded-up items having incorrect wear name
+- In-game item announcer now works again
+- Trading is no longer under maintainance and is now again available to the public
+
+# Hotfix
+03/08/2026
+## What's Changed
+- Updated appearance of the "Edit Profile" page on eddies.cc
+- Profiles on eddies.cc are now limited to a maximum level of 6,199
+- Profile vanity URLs on eddies.cc are now limited to a minimum of three characters
+- Image hosts used for eddies.cc profiles are now whitelist only
+- Added an information tab to the "Edit Profile" page on eddies.cc that lists whitelisted image hosts
+- Groups on eddies.cc can now have a custom URL set, directing to the desired Steam group URL instead of the vanity URL that matches the group name
+- Groups on eddies.cc will direct to the vanity URL that matches the group name if no custom group URL is set when editing profile
+
+# Anniversary Update
+03/04/2026
+## What's Changed
+- Donors now permanently get an extra drop at the end of each match, on top of the extra drop during this month's event. We're grateful for your support!
+- NOVAGANG Collection drops have been reverted back to normal rates.
+- The drop pool has been updated to make some less desirable drops less common, some item collections have been disabled, and a few more cases have been added to the drop pool.
+- Previously nonfunctional map de_nuketown has been fixed and added to the map pool.
+- Profile backgrounds on eddies.cc now scale with your display resolution.
+- Profile background holder on eddies.cc has been updated to a lower opacity gradient, making backgrounds more visible.
+- Fixed broken or invalid item IDs in player inventories.
+- Bug fixes and security improvements.
+
+# Hotfix
+02/16/2026
+## What's Changed
+- Fixed NOVAGANG Collection drop names and images not appearing in game (make sure to run the launcher)
+- CZ-75 now works again on team Terrorists
+- Fixes for Shattered Web knives
+- Updated map list for !nominate command
+- Background video on the classiccounter.cc website now works properly again
+- Backend fixes and improvements
+
+# NOVAGANG Collection Update
+02/15/2026
+## What's Changed
+- Added NOVAGANG Sticker Capsule
+- Added Dysocjacja by Exodus1900
+- Added Inure+Idyllic by Exodus1900
+- Added bits and pieces by gv1nn
+- Added FREE GEEK by gv1nn
+- Added Atticus by Ways
+- Added You Lose Forever by Ways
+- Added [x]"TheDevilMayCry!" by prblm
+- Added Veire Dawf by prblm
+- Added sonata░de░lluvia (卸ぬマ)(卸ぬマ) by Trauma
+- Added Vai S.//3 バロック by Trauma
+- Added 760 by Zephrxd
+- Added THE HELLSING CONSPIRACY by Zephrxd
+- Added No Body by Zootzie
+- Added Terminal Z by Zootzie
+- Added H.T.N.G by NOVAGANG
+- Added H.T.N.G. VOL. 2: JUDGEMENT DAY by NOVAGANG
+
+
+
+
+
+