From 8b60ebcb844ae65e481cb0291600a9711111c7a9 Mon Sep 17 00:00:00 2001 From: Ioannis Date: Wed, 22 Jul 2026 23:22:35 +0100 Subject: [PATCH] ; --- Wauncher/App.axaml | 102 +- Wauncher/App.axaml.cs | 492 ++--- Wauncher/Assets/social_discord.svg | 4 +- Wauncher/Assets/social_instagram.svg | 4 +- Wauncher/Assets/social_inventory.svg | 4 +- Wauncher/Assets/social_world.svg | 48 +- Wauncher/Utils/Dependency.cs | 252 +-- Wauncher/Utils/Discord.cs | 172 +- Wauncher/Utils/Download.cs | 1646 ++++++++--------- Wauncher/Utils/FriendsCache.cs | 166 +- Wauncher/Utils/Steam.cs | 362 ++-- Wauncher/Utils/Terminal.cs | 120 +- Wauncher/Utils/Version.cs | 76 +- Wauncher/ViewModels/MainWindowViewModel.cs | 1374 +++++++------- .../Views/Controls/ServerListControl.axaml | 131 +- Wauncher/Views/MainWindow.axaml | 1278 ++++++------- Wauncher/Views/MainWindow.axaml.cs | 1258 ++++++------- Wauncher/Views/SettingsWindow.axaml | 630 +++---- Wauncher/Wauncher.sln | 48 +- Wauncher/patchnotes.md | 306 +-- 20 files changed, 4237 insertions(+), 4236 deletions(-) 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 @@ -Discord - +Discord + 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 @@ -Instagram - +Instagram + 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 @@ -Counter-Strike - +Counter-Strike + 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +