- Launcher for ClassicCounter with Discord RPC, Auto-Updates and More!
+ Wauncher for ClassicCounter with Discord RPC, a Server List, Friends List, Auto-Updates and More!
- Written in C# using .NET 8.
+ Written in C# using .NET 8 and Avalonia.
@@ -13,29 +13,39 @@
[![MIT License][license-shield]][license-url]
> [!IMPORTANT]
-> .NET Runtime 8 is required to run the launcher. Download it from [**here**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.11-windows-x64-installer).
+> .NET Runtime 8 is required to run the Wauncher. Download it from [**here**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.11-windows-x64-installer).
-## Arguments
-- `--debug-mode` - Enables debug mode, prints additional info.
-- `--disable-rpc` - Disables Discord RPC.
-- `--gc` - Launches the Game with custom Game Coordinator.
-- `--install-dependencies` - Launches setup process for required Game dependencies.
-- `--patch-only` - Will only check for patches, won't open the game.
-- `--skip-updates` - Skips checking for launcher updates.
-- `--skip-validating` - Skips validating patches.
-- `--validate-all` - Validates all game files.
+> [!IMPORTANT]
+> Wauncher is still a work in progress. Some features are unfinished and you may run into bugs or changes between builds.
+
+## Settings
+- `Minimize to System Tray` keeps the launcher running in the background when in-game.
+- `Skip Updates` lets you launch the game even when Wauncher detects available updates.
+- `Discord RPC` controls whether Wauncher updates your Discord presence. In some cases, Discord may still show "Counter-Strike: Global Offensive" even when RPC is disabled.
+- `Launch Options` lets you pass extra game launch flags such as `-high` or `+fps_max 300`.
+- `Verify Game Files` checks your installation and repairs any missing or damaged game files automatically.
-> [!CAUTION]
-> **Using `--skip-updates` or `--skip-validating` is NOT recommended!**
-> **An outdated launcher or patches might cause issues.**
+## Build / Publish
+- Build: `dotnet build Wauncher/Wauncher.csproj -c Release`
+- Publish: `dotnet publish Wauncher/Wauncher.csproj -c Release -r win-x64 --self-contained false`
+- Quick publish script: `publish.bat` (builds + hashes + optional copy target)
## Packages Used
+- [AsyncImageLoader.Avalonia](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia) by [AvaloniaUtils](https://github.com/AvaloniaUtils)
+- [Avalonia](https://github.com/AvaloniaUI/Avalonia) by [AvaloniaUI](https://github.com/AvaloniaUI)
+- [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) by [AvaloniaUI](https://github.com/AvaloniaUI)
+- [Avalonia.Themes.Fluent](https://github.com/AvaloniaUI/Avalonia) by [AvaloniaUI](https://github.com/AvaloniaUI)
+- [Avalonia.Fonts.Inter](https://github.com/AvaloniaUI/Avalonia) by [AvaloniaUI](https://github.com/AvaloniaUI)
+- [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) by [AvaloniaUI](https://github.com/AvaloniaUI)
+- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) by [CommunityToolkit](https://github.com/CommunityToolkit)
- [CSGSI](https://github.com/rakijah/CSGSI) by [rakijah](https://github.com/rakijah)
- [DiscordRichPresence](https://github.com/Lachee/discord-rpc-csharp) by [Lachee](https://github.com/Lachee)
- [Downloader](https://github.com/bezzad/Downloader) by [bezzad](https://github.com/bezzad)
- [Gameloop.Vdf](https://github.com/shravan2x/Gameloop.Vdf) by [shravan2x](https://github.com/shravan2x)
- [Refit](https://github.com/reactiveui/refit) by [ReactiveUI](https://github.com/reactiveui)
+- [Refit.Newtonsoft.Json](https://github.com/reactiveui/refit) by [ReactiveUI](https://github.com/reactiveui)
- [Spectre.Console](https://github.com/spectreconsole/spectre.console) by [Spectre Console](https://github.com/spectreconsole)
+- [Svg.Controls.Skia.Avalonia](https://github.com/wieslawsoltes/Svg.Skia) by [wieslawsoltes](https://github.com/wieslawsoltes)
[downloads-shield]: https://img.shields.io/github/downloads/classiccounter/launcher/total.svg?style=for-the-badge
[downloads-url]: https://github.com/classiccounter/launcher/releases/latest
diff --git a/Wauncher/App.axaml b/Wauncher/App.axaml
index 19649f3..a8ed31b 100644
--- a/Wauncher/App.axaml
+++ b/Wauncher/App.axaml
@@ -1,32 +1,48 @@
-
-
-
-
-
-
+ xmlns:themes="using:Avalonia.Styling"
+ xmlns:fluent="using:Avalonia.Themes.Fluent">
+
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+ #CC3A3A3A
+ 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 ebaafb8..d5c261c 100644
--- a/Wauncher/App.axaml.cs
+++ b/Wauncher/App.axaml.cs
@@ -1,10 +1,13 @@
-using Avalonia;
+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 CommunityToolkit.Mvvm.Input;
-using Launcher.Utils;
using Wauncher.Utils;
+using System.Diagnostics;
using Wauncher.ViewModels;
using Wauncher.Views;
@@ -12,10 +15,12 @@ namespace Wauncher
{
public partial class App : Application
{
+ private TrayIcon? _trayIcon = null;
+ private NativeMenuItem? _discordRpcMenuItem = null;
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
- Discord.Init();
ProtocolManager.RegisterURIHandler();
}
@@ -23,53 +28,180 @@ public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- // Avoid duplicate validations from both Avalonia and the CommunityToolkit.
- // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
- DisableAvaloniaDataAnnotationValidation();
+ DisableAvaloniaDataAnnotationValidation();
+
+ if (!Steam.IsInstalled())
+ {
+ ConsoleManager.ShowError(
+ "Steam is required to use Wauncher.\n\nPlease install Steam and relaunch.");
+ Environment.Exit(1);
+ return;
+ }
+
+ if (!IsSteamRunning())
+ {
+ ConsoleManager.ShowError(
+ "Steam must be open before using Wauncher.\n\nPlease open Steam, then relaunch Wauncher.");
+ Environment.Exit(2);
+ return;
+ }
+
+ if (Game.IsRunning())
+ {
+ ConsoleManager.ShowError(
+ "ClassicCounter is already running.\n\nPlease close the game before opening Wauncher again.");
+ Environment.Exit(3);
+ return;
+ }
+
+ bool hasRecentSteamUser = Steam.GetRecentLoggedInSteamID(false).GetAwaiter().GetResult();
+ if (!hasRecentSteamUser)
+ {
+ ConsoleManager.ShowError(
+ "Steam is open, but no logged-in Steam account was detected.\n\nPlease sign in to Steam and relaunch Wauncher.");
+ Environment.Exit(4);
+ return;
+ }
+
+ // Always init so Discord username/avatar callbacks fire for the greeting.
+ // Presence is only pushed via Update() when RPC is enabled.
+ try
+ {
+ if (DependencyChecks.IsDiscordInstalled())
+ Discord.Init();
+ }
+ catch
+ {
+ // Discord integration is optional.
+ }
+
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
+ desktop.Exit += (_, _) => _trayIcon?.Dispose();
}
+ SetupTrayIcon();
base.OnFrameworkInitializationCompleted();
}
- private void DisableAvaloniaDataAnnotationValidation()
+ private static bool IsSteamRunning()
{
- // Get an array of plugins to remove
- var dataValidationPluginsToRemove =
- BindingPlugins.DataValidators.OfType().ToArray();
-
- // remove each entry found
- foreach (var plugin in dataValidationPluginsToRemove)
+ try
{
- BindingPlugins.DataValidators.Remove(plugin);
+ return Process.GetProcessesByName("steam").Length > 0;
+ }
+ catch
+ {
+ return false;
}
}
+ private void SetupTrayIcon()
+ {
+ var settings = SettingsWindowViewModel.LoadGlobal();
- [RelayCommand]
- public void TrayIconClicked()
+ _discordRpcMenuItem = new NativeMenuItem
+ {
+ Header = settings.DiscordRpc ? "Discord RPC ON" : "Discord RPC OFF"
+ };
+ _discordRpcMenuItem.Click += DiscordRpc_Click;
+
+ var exitItem = new NativeMenuItem { Header = "Exit" };
+ exitItem.Click += (_, _) =>
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
+ {
+ if (d.MainWindow is Views.MainWindow mw)
+ mw.ForceQuit();
+ d.TryShutdown();
+ }
+ };
+
+ var menu = new NativeMenu();
+ menu.Items.Add(_discordRpcMenuItem);
+ menu.Items.Add(new NativeMenuItemSeparator());
+ menu.Items.Add(exitItem);
+
+ _trayIcon = new TrayIcon
+ {
+ ToolTipText = "ClassicCounter",
+ Menu = menu,
+ };
+
+ try
+ {
+ var uri = new Uri("avares://Wauncher/Assets/Wauncher.ico");
+ using var stream = AssetLoader.Open(uri);
+ _trayIcon.Icon = new WindowIcon(stream);
+ }
+ catch { }
+
+ _trayIcon.Clicked += (_, _) => ShowMainWindow();
+
+ // Live sync
+ SettingsWindowViewModel.DiscordRpcChanged += enabled => ApplyDiscordRpc(enabled);
+ }
+
+ private void ShowMainWindow()
{
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow != null)
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
+ && desktop.MainWindow != null)
{
desktop.MainWindow.Show();
+ desktop.MainWindow.WindowState = Avalonia.Controls.WindowState.Normal;
desktop.MainWindow.Activate();
}
}
- public void ExitApplication_Click(object? sender, System.EventArgs e)
+ 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)
{
- switch (ApplicationLifetime)
+ if (!DependencyChecks.IsDiscordInstalled())
{
- case IClassicDesktopStyleApplicationLifetime desktopLifetime:
- desktopLifetime.TryShutdown();
- break;
- case IControlledApplicationLifetime controlledLifetime:
- controlledLifetime.Shutdown();
- break;
+ 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";
+ }
+
+ [RelayCommand]
+ public void TrayIconClicked() => ShowMainWindow();
+
+ 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);
}
}
-}
\ No newline at end of file
+}
+
diff --git a/Wauncher/Assets/Styles.axaml b/Wauncher/Assets/Styles.axaml
index c0d156b..e0b55c4 100644
--- a/Wauncher/Assets/Styles.axaml
+++ b/Wauncher/Assets/Styles.axaml
@@ -48,13 +48,25 @@
+
-
+
-
+
+
+
+
+
diff --git a/Wauncher/Assets/logo.png b/Wauncher/Assets/logo.png
new file mode 100644
index 0000000..ede32aa
Binary files /dev/null and b/Wauncher/Assets/logo.png differ
diff --git a/Wauncher/Assets/server.png b/Wauncher/Assets/server.png
new file mode 100644
index 0000000..812a017
Binary files /dev/null and b/Wauncher/Assets/server.png differ
diff --git a/Wauncher/Assets/settings.png b/Wauncher/Assets/settings.png
new file mode 100644
index 0000000..8c58c7f
Binary files /dev/null and b/Wauncher/Assets/settings.png differ
diff --git a/Wauncher/Assets/social_discord.png b/Wauncher/Assets/social_discord.png
new file mode 100644
index 0000000..be45274
--- /dev/null
+++ b/Wauncher/Assets/social_discord.png
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Wauncher/Assets/social_discord.svg b/Wauncher/Assets/social_discord.svg
new file mode 100644
index 0000000..c5d1e6b
--- /dev/null
+++ b/Wauncher/Assets/social_discord.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/Wauncher/Assets/social_github.svg b/Wauncher/Assets/social_github.svg
new file mode 100644
index 0000000..14edd1e
--- /dev/null
+++ b/Wauncher/Assets/social_github.svg
@@ -0,0 +1 @@
+
diff --git a/Wauncher/Assets/social_instagram.png b/Wauncher/Assets/social_instagram.png
new file mode 100644
index 0000000..4aa7e8f
--- /dev/null
+++ b/Wauncher/Assets/social_instagram.png
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Wauncher/Assets/social_instagram.svg b/Wauncher/Assets/social_instagram.svg
new file mode 100644
index 0000000..d3e39b5
--- /dev/null
+++ b/Wauncher/Assets/social_instagram.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/Wauncher/Assets/social_inventory.png b/Wauncher/Assets/social_inventory.png
new file mode 100644
index 0000000..6016ee9
--- /dev/null
+++ b/Wauncher/Assets/social_inventory.png
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Wauncher/Assets/social_inventory.svg b/Wauncher/Assets/social_inventory.svg
new file mode 100644
index 0000000..55087a8
--- /dev/null
+++ b/Wauncher/Assets/social_inventory.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/Wauncher/Assets/social_world.png b/Wauncher/Assets/social_world.png
new file mode 100644
index 0000000..5771dd3
--- /dev/null
+++ b/Wauncher/Assets/social_world.png
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Wauncher/Assets/social_world.svg b/Wauncher/Assets/social_world.svg
new file mode 100644
index 0000000..1ef6907
--- /dev/null
+++ b/Wauncher/Assets/social_world.svg
@@ -0,0 +1,24 @@
+
+
+
diff --git a/Wauncher/CONTRIBUTORS.md b/Wauncher/CONTRIBUTORS.md
new file mode 100644
index 0000000..272983b
--- /dev/null
+++ b/Wauncher/CONTRIBUTORS.md
@@ -0,0 +1,12 @@
+ClassicCounter Wauncher Contributors
+
+Developers & Contributors
+- koolych
+- Ways
+- Grizzle
+- Simpy
+
+Maintainer
+- eddies
+
+Thank you for your contributions!
diff --git a/Wauncher/LICENSE b/Wauncher/LICENSE
index b8e043e..66c3b2d 100644
--- a/Wauncher/LICENSE
+++ b/Wauncher/LICENSE
@@ -1,83 +1,83 @@
-ClassicCounter Community Source License (CCCSL)
-
-Copyright (c) 2026 ClassicCounter Community Developers.
-Maintained by eddies. All Rights Reserved.
-
-1. Definitions
-
-"Software" refers to the Wauncher program, including its source code,
-compiled binaries, documentation, and any associated files distributed
-as part of the ClassicCounter project.
-
-"ClassicCounter Community" refers to the official ClassicCounter
-community, its approved members, and its official servers.
-
-"Authorized Members" refers to individuals who are explicitly approved
-or whitelisted by ClassicCounter staff.
-
-2. Permission Grant
-
-Subject to the restrictions below, Authorized Members of the
-ClassicCounter Community are granted permission to:
-
-- View the source code of the Software
-- Use the Software within the ClassicCounter Community
-- Modify the Software for use within the ClassicCounter Community
-- Create forks or derivative works solely for use within
- ClassicCounter community servers
-
-3. Restrictions
-
-The following actions are strictly prohibited unless explicit written
-permission is granted by the copyright holder or authorized
-ClassicCounter staff:
-
-1. Using the Software outside of the ClassicCounter Community.
-2. Running or deploying the Software on non-ClassicCounter servers.
-3. Redistributing, publishing, or mirroring the Software outside of
- the ClassicCounter Community.
-4. Selling, sublicensing, or commercially exploiting the Software.
-5. Distributing modified versions outside of the ClassicCounter
- Community.
-6. Public hosting of the Software or its source code on external
- repositories, mirrors, or file hosting platforms (including but
- not limited to GitHub, GitLab, Bitbucket, or similar services)
- is prohibited unless the repository is owned or explicitly
- authorized by ClassicCounter staff.
-7. Replication of the Software's functionality, architecture, or
- design for the purpose of creating a competing implementation
- for use outside the ClassicCounter community is prohibited.
-
-Any forks, modifications, or derivative works must remain exclusively
-for use within ClassicCounter community servers.
-
-4. Contributions
-
-By submitting code, patches, or modifications to the ClassicCounter
-project, you grant ClassicCounter and its maintainers a perpetual,
-worldwide, non-exclusive, royalty-free license to use, modify,
-and distribute your contributions as part of the ClassicCounter
-project under the terms of this license.
-
-Contributors retain ownership of their individual contributions.
-
-5. Termination
-
-Any violation of this license automatically terminates the permissions
-granted under this license. Upon termination, all copies of the
-Software must be destroyed and use of the Software must cease
-immediately.
-
-6. No Warranty
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
-BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM
-THE USE OF THE SOFTWARE.
-
-7. Jurisdiction
-
-This license shall be governed by applicable copyright laws and
-international copyright treaties.
+ClassicCounter Community Source License (CCCSL)
+
+Copyright (c) 2026 ClassicCounter Community Developers.
+Maintained by eddies. All Rights Reserved.
+
+1. Definitions
+
+"Software" refers to the Wauncher program, including its source code,
+compiled binaries, documentation, and any associated files distributed
+as part of the ClassicCounter project.
+
+"ClassicCounter Community" refers to the official ClassicCounter
+community, its approved members, and its official servers.
+
+"Authorized Members" refers to individuals who are explicitly approved
+or whitelisted by ClassicCounter staff.
+
+2. Permission Grant
+
+Subject to the restrictions below, Authorized Members of the
+ClassicCounter Community are granted permission to:
+
+- View the source code of the Software
+- Use the Software within the ClassicCounter Community
+- Modify the Software for use within the ClassicCounter Community
+- Create forks or derivative works solely for use within
+ ClassicCounter community servers
+
+3. Restrictions
+
+The following actions are strictly prohibited unless explicit written
+permission is granted by the copyright holder or authorized
+ClassicCounter staff:
+
+1. Using the Software outside of the ClassicCounter Community.
+2. Running or deploying the Software on non-ClassicCounter servers.
+3. Redistributing, publishing, or mirroring the Software outside of
+ the ClassicCounter Community.
+4. Selling, sublicensing, or commercially exploiting the Software.
+5. Distributing modified versions outside of the ClassicCounter
+ Community.
+6. Public hosting of the Software or its source code on external
+ repositories, mirrors, or file hosting platforms (including but
+ not limited to GitHub, GitLab, Bitbucket, or similar services)
+ is prohibited unless the repository is owned or explicitly
+ authorized by ClassicCounter staff.
+7. Replication of the Software's functionality, architecture, or
+ design for the purpose of creating a competing implementation
+ for use outside the ClassicCounter community is prohibited.
+
+Any forks, modifications, or derivative works must remain exclusively
+for use within ClassicCounter community servers.
+
+4. Contributions
+
+By submitting code, patches, or modifications to the ClassicCounter
+project, you grant ClassicCounter and its maintainers a perpetual,
+worldwide, non-exclusive, royalty-free license to use, modify,
+and distribute your contributions as part of the ClassicCounter
+project under the terms of this license.
+
+Contributors retain ownership of their individual contributions.
+
+5. Termination
+
+Any violation of this license automatically terminates the permissions
+granted under this license. Upon termination, all copies of the
+Software must be destroyed and use of the Software must cease
+immediately.
+
+6. No Warranty
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM
+THE USE OF THE SOFTWARE.
+
+7. Jurisdiction
+
+This license shall be governed by applicable copyright laws and
+international copyright treaties.
diff --git a/Wauncher/Program.cs b/Wauncher/Program.cs
index b17b8c2..1e757c4 100644
--- a/Wauncher/Program.cs
+++ b/Wauncher/Program.cs
@@ -5,7 +5,7 @@ namespace Wauncher
{
internal sealed class Program
{
- public static EventWaitHandle ProgramStarted;
+ public static EventWaitHandle? ProgramStarted;
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
@@ -13,41 +13,70 @@ internal sealed class Program
[STAThread]
public static void Main(string[] args)
{
- if (OnStartup(args) == false)
+ try
{
- Environment.Exit(0);
- return;
- }
+ var exeDirectory = Path.GetDirectoryName(Services.GetExePath());
+ if (!string.IsNullOrWhiteSpace(exeDirectory) && Directory.Exists(exeDirectory))
+ Directory.SetCurrentDirectory(exeDirectory);
+
+ if (OnStartup(args) == false)
+ {
+ Environment.Exit(0);
+ return;
+ }
- BuildAvaloniaApp()
- .StartWithClassicDesktopLifetime(args);
+ BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+ }
+ catch (Exception ex)
+ {
+ try
+ {
+ var logPath = Path.Combine(Path.GetDirectoryName(System.Environment.ProcessPath) ?? ".", "wauncher_error.log");
+ File.WriteAllText(logPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n{ex}");
+ }
+ catch { }
+ throw;
+ }
}
// Reference (COPYPASTA)
// https://github.com/2dust/v2rayN/blob/d9843dc77502454b1ec48cec6244e115f1abd082/v2rayN/v2rayN.Desktop/Program.cs#L25-L52
private static bool OnStartup(string[]? Args)
{
-
- if (Services.IsWindows())
+ try
{
- var exePathKey = Services.GetMd5(Services.GetExePath());
- var rebootas = (Args ?? []).Any(t => t == "rebootas");
- ProgramStarted = new EventWaitHandle(false, EventResetMode.AutoReset, exePathKey, out var bCreatedNew);
- if (!rebootas && !bCreatedNew)
+ if (Services.IsWindows())
+ {
+ var exePathKey = Services.GetMd5(Services.GetExePath());
+ var rebootas = (Args ?? []).Any(t => t == "rebootas");
+ ProgramStarted = new EventWaitHandle(false, EventResetMode.AutoReset, exePathKey, out var bCreatedNew);
+ if (!rebootas && !bCreatedNew)
+ {
+ ProgramStarted?.Set();
+ return false;
+ }
+ }
+ else
{
- ProgramStarted.Set();
- return false;
+ _ = new Mutex(true, "Wauncher", out var bOnlyOneInstance);
+ if (!bOnlyOneInstance)
+ {
+ return false;
+ }
}
+ return true;
}
- else
+ catch (Exception ex)
{
- _ = new Mutex(true, "Wauncher", out var bOnlyOneInstance);
- if (!bOnlyOneInstance)
+ try
{
- return false;
+ var logPath = Path.Combine(Path.GetDirectoryName(System.Environment.ProcessPath) ?? ".", "wauncher_startup_error.log");
+ File.WriteAllText(logPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\nOnStartup Error:\n{ex}");
}
+ catch { }
+ return true; // Allow app to continue anyway
}
- return true;
}
// Avalonia configuration, don't remove; also used by visual designer.
diff --git a/Wauncher/README.md b/Wauncher/README.md
new file mode 100644
index 0000000..ab066e7
--- /dev/null
+++ b/Wauncher/README.md
@@ -0,0 +1,2 @@
+This project is source-available for the ClassicCounter community only.
+It is NOT open source and may not be used outside of official ClassicCounter servers.
diff --git a/Wauncher/Utils/Api.cs b/Wauncher/Utils/Api.cs
new file mode 100644
index 0000000..7943655
--- /dev/null
+++ b/Wauncher/Utils/Api.cs
@@ -0,0 +1,254 @@
+using Refit;
+using Newtonsoft.Json;
+
+namespace Wauncher.Utils
+{
+ public class FullGameDownload
+ {
+ public required string File { get; set; }
+ public required string Link { get; set; }
+ public required string Hash { get; set; }
+ }
+
+ public class FullGameDownloadResponse
+ {
+ public List? Files { get; set; }
+ public string? Error { get; set; }
+ }
+
+ public interface IGitHub
+ {
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/repos/ClassicCounter/launcher/releases/latest")]
+ Task GetLatestRelease();
+
+ [Headers("User-Agent: ClassicCounter Wauncher",
+ "Accept: application/vnd.github.raw+json")]
+ [Get("/repos/ClassicCounter/launcher/contents/dependencies.json")]
+ Task GetDependencies();
+
+ [Headers("User-Agent: ClassicCounter Wauncher",
+ "Accept: application/vnd.github.raw+json")]
+ [Get("/repos/ClassicCounter/launcher/contents/carousel.json")]
+ Task GetCarouselManifest();
+
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/repos/ClassicCounter/launcher/contents/Wauncher/Assets")]
+ Task GetCarouselAssetsWauncher();
+
+ [Headers("User-Agent: ClassicCounter Wauncher",
+ "Accept: application/vnd.github.raw+json")]
+ [Get("/repos/ClassicCounter/launcher/contents/Wauncher/patchnotes.md")]
+ Task GetPatchNotesWauncher();
+ }
+
+ public class FriendInfo
+ {
+ [JsonProperty("steamid")]
+ public string SteamId { get; set; } = "";
+
+ [JsonProperty("steamid2")]
+ public string? SteamId2
+ {
+ set
+ {
+ if (!string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(SteamId))
+ SteamId = value;
+ }
+ }
+
+ [JsonProperty("username")]
+ public string Username { get; set; } = "";
+
+ [JsonProperty("avatar_url")]
+ public string AvatarUrl { get; set; } = "";
+
+ [JsonProperty("avatar")]
+ public string? Avatar
+ {
+ set
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ AvatarUrl = value;
+ }
+ }
+
+ [JsonProperty("custom_username")]
+ public string? CustomUsername
+ {
+ set
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ Username = value;
+ }
+ }
+
+ [JsonProperty("custom_avatar")]
+ public string? CustomAvatar
+ {
+ set
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ AvatarUrl = value;
+ }
+ }
+
+ [JsonProperty("status")]
+ public string Status { get; set; } = "Offline";
+
+ [JsonIgnore]
+ public string QuickJoinIpPort { get; set; } = "";
+
+ [JsonIgnore]
+ public string QuickJoinServerName { get; set; } = "";
+
+ [JsonIgnore]
+ public bool CanQuickJoin => !string.IsNullOrWhiteSpace(QuickJoinIpPort);
+
+ public string DotColor => IsOffline ? "#888888" : "#4CAF50";
+ public bool IsOffline => string.Equals(Status, "Offline", StringComparison.OrdinalIgnoreCase);
+ public double AvatarOpacity => IsOffline ? 0.35 : 1.0;
+ public string StatusText
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(Status))
+ return "Offline";
+
+ const string inGamePrefix = "In Game - ";
+ return Status.StartsWith(inGamePrefix, StringComparison.OrdinalIgnoreCase)
+ ? Status[inGamePrefix.Length..].Trim()
+ : Status;
+ }
+ }
+ public string StatusColor => IsOffline ? "#666666" : "#999999";
+ }
+
+ public class FriendsResponse
+ {
+ public List? Friends { get; set; }
+ }
+
+ public interface IEddies
+ {
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/friendsapi.php")]
+ Task GetFriends([AliasAs("steamid64")] string steamId64);
+
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/friendsapi.php")]
+ Task GetFriendsBySteamId2([AliasAs("steamid2")] string steamId2);
+
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/selfinfo.php")]
+ Task GetSelfInfo([AliasAs("steamid64")] string steamId64);
+ }
+
+ public interface IClassicCounter
+ {
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/patch/get")]
+ Task GetPatches();
+
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/game/get")]
+ Task GetFullGameValidate();
+
+ [Headers("User-Agent: ClassicCounter Wauncher")]
+ [Get("/game/full")]
+ Task GetFullGameDownload([Query] string steam_id);
+ }
+
+ public static class Api
+ {
+ private static RefitSettings _settings = new RefitSettings(new NewtonsoftJsonContentSerializer());
+ public static IGitHub GitHub = RestService.For("https://api.github.com", _settings);
+ public static IClassicCounter ClassicCounter = RestService.For("https://classiccounter.cc/api", _settings);
+ public static IEddies Eddies = RestService.For("https://eddies.cc/api", _settings);
+
+ public static List ParseFriendsPayload(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return new List();
+
+ try
+ {
+ var wrapped = JsonConvert.DeserializeObject(json);
+ if (wrapped?.Friends != null && wrapped.Friends.Count > 0)
+ return NormalizeFriends(wrapped.Friends);
+ }
+ catch
+ {
+ // Fall through to array parse.
+ }
+
+ try
+ {
+ var flat = JsonConvert.DeserializeObject>(json);
+ if (flat != null)
+ return NormalizeFriends(flat);
+ }
+ catch
+ {
+ // Ignore and return empty.
+ }
+
+ return new List();
+ }
+
+ public static FriendInfo? ParseSelfInfoPayload(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ var parsed = JsonConvert.DeserializeObject(json);
+ if (parsed == null)
+ return null;
+
+ return NormalizeFriends(new[] { parsed }).FirstOrDefault();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static List NormalizeFriends(IEnumerable friends)
+ {
+ var normalized = new List();
+ foreach (var f in friends)
+ {
+ var username = string.IsNullOrWhiteSpace(f.Username) ? "Unknown" : f.Username;
+ var status = NormalizeStatus(f.Status);
+
+ normalized.Add(new FriendInfo
+ {
+ SteamId = f.SteamId ?? string.Empty,
+ Username = username,
+ AvatarUrl = f.AvatarUrl ?? string.Empty,
+ Status = status
+ });
+ }
+
+ return normalized;
+ }
+
+ private static string NormalizeStatus(string? status)
+ {
+ if (string.IsNullOrWhiteSpace(status))
+ return "Offline";
+
+ var trimmed = status.Trim();
+ if (string.Equals(trimmed, "Offline", StringComparison.OrdinalIgnoreCase))
+ return "Offline";
+
+ if (string.Equals(trimmed, "Online", StringComparison.OrdinalIgnoreCase))
+ return "Online";
+
+ return trimmed;
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/Argument.cs b/Wauncher/Utils/Argument.cs
new file mode 100644
index 0000000..38c0f8f
--- /dev/null
+++ b/Wauncher/Utils/Argument.cs
@@ -0,0 +1,81 @@
+namespace Wauncher.Utils
+{
+ public static class Argument
+ {
+ private static readonly List _additionalArguments = new();
+ private static bool _protocolConnectConsumed;
+
+ public static void AddArgument(string argument)
+ {
+ if (!_additionalArguments.Any(a => string.Equals(a, argument, StringComparison.OrdinalIgnoreCase)))
+ _additionalArguments.Add(argument);
+ }
+
+ public static void ClearAdditionalArguments()
+ {
+ _additionalArguments.Clear();
+ }
+
+ public static bool HasProtocolCommand() =>
+ Environment.GetCommandLineArgs().Any(arg =>
+ arg.StartsWith("cc://", StringComparison.OrdinalIgnoreCase));
+
+ public static string? GetProtocolConnectTarget()
+ {
+ foreach (string arg in Environment.GetCommandLineArgs())
+ {
+ if (!arg.StartsWith("cc://", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ string protocolArgument = arg.Replace("cc://", "", StringComparison.OrdinalIgnoreCase);
+ string[] protocolArguments = protocolArgument.Split('/');
+ if (protocolArguments.Length < 2)
+ continue;
+
+ if (!string.Equals(protocolArguments[0], "connect", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var target = Uri.UnescapeDataString(protocolArguments[1]).Trim();
+ return string.IsNullOrWhiteSpace(target) ? null : target;
+ }
+
+ return null;
+ }
+
+ public static void ConsumeProtocolConnectTarget()
+ {
+ _protocolConnectConsumed = true;
+ }
+
+ public static List GenerateGameArguments()
+ {
+ IEnumerable launcherArguments = Environment.GetCommandLineArgs();
+ List gameArguments = new();
+
+ foreach (string arg in launcherArguments)
+ {
+ if (!arg.StartsWith("cc://", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ string protocolArgument = arg.Replace("cc://", "", StringComparison.OrdinalIgnoreCase);
+ string[] protocolArguments = protocolArgument.Split('/');
+ if (protocolArguments.Length < 2)
+ continue;
+
+ switch (protocolArguments[0])
+ {
+ case "connect":
+ if (_protocolConnectConsumed)
+ break;
+
+ gameArguments.Add("+connect");
+ gameArguments.Add(Uri.UnescapeDataString(protocolArguments[1]));
+ break;
+ }
+ }
+
+ gameArguments.AddRange(_additionalArguments);
+ return gameArguments;
+ }
+ }
+}
diff --git a/Wauncher/Utils/AvatarCache.cs b/Wauncher/Utils/AvatarCache.cs
new file mode 100644
index 0000000..3e03d3d
--- /dev/null
+++ b/Wauncher/Utils/AvatarCache.cs
@@ -0,0 +1,188 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using SkiaSharp;
+
+namespace Wauncher.Utils
+{
+ public static class AvatarCache
+ {
+ private static readonly HttpClient _http = new();
+ private static readonly ConcurrentDictionary _inFlight = new();
+ private static readonly string _cacheDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "cache",
+ "avatars");
+
+ private const int MaxAvatarBytes = 20 * 1024 * 1024; // 20 MB
+ private const int MaxSteamAvatarDimension = 128;
+
+ public static string GetDisplaySource(string? avatarUrl)
+ {
+ if (string.IsNullOrWhiteSpace(avatarUrl))
+ return string.Empty;
+
+ var cachedPath = GetCachePath(avatarUrl);
+ if (File.Exists(cachedPath))
+ return new Uri(cachedPath).AbsoluteUri;
+
+ QueueWarmCache(avatarUrl);
+ return avatarUrl;
+ }
+
+ public static void QueueWarmCache(string? avatarUrl)
+ {
+ if (string.IsNullOrWhiteSpace(avatarUrl))
+ return;
+
+ if (!_inFlight.TryAdd(avatarUrl, 0))
+ return;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await EnsureCachedAsync(avatarUrl);
+ }
+ catch
+ {
+ // Best-effort cache warmup only.
+ }
+ finally
+ {
+ _inFlight.TryRemove(avatarUrl, out _);
+ }
+ });
+ }
+
+ private static async Task EnsureCachedAsync(string avatarUrl)
+ {
+ var cachePath = GetCachePath(avatarUrl);
+ if (File.Exists(cachePath))
+ return;
+
+ Directory.CreateDirectory(_cacheDir);
+
+ using var response = await _http.GetAsync(avatarUrl, HttpCompletionOption.ResponseHeadersRead);
+ response.EnsureSuccessStatusCode();
+
+ var tempPath = cachePath + ".tmp";
+ var bytes = await ReadAvatarBytesAsync(response);
+ var bytesToWrite = TryDownscaleSteamAvatar(avatarUrl, bytes) ?? bytes;
+
+ await File.WriteAllBytesAsync(tempPath, bytesToWrite);
+ File.Move(tempPath, cachePath, overwrite: true);
+ }
+
+ private static async Task ReadAvatarBytesAsync(HttpResponseMessage response)
+ {
+ await using var input = await response.Content.ReadAsStreamAsync();
+ await using var bufferStream = new MemoryStream();
+
+ var buffer = new byte[81920];
+ int read;
+ int total = 0;
+ while ((read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
+ {
+ total += read;
+ if (total > MaxAvatarBytes)
+ throw new InvalidDataException("Avatar exceeds size limit.");
+
+ await bufferStream.WriteAsync(buffer.AsMemory(0, read));
+ }
+
+ return bufferStream.ToArray();
+ }
+
+ private static byte[]? TryDownscaleSteamAvatar(string avatarUrl, byte[] bytes)
+ {
+ if (!ShouldDownscaleAvatar(avatarUrl))
+ return null;
+
+ try
+ {
+ using var sourceBitmap = SKBitmap.Decode(bytes);
+ if (sourceBitmap == null)
+ return null;
+
+ if (sourceBitmap.Width <= MaxSteamAvatarDimension &&
+ sourceBitmap.Height <= MaxSteamAvatarDimension)
+ {
+ return null;
+ }
+
+ var scale = Math.Min(
+ (double)MaxSteamAvatarDimension / sourceBitmap.Width,
+ (double)MaxSteamAvatarDimension / sourceBitmap.Height);
+
+ int targetWidth = Math.Max(1, (int)Math.Round(sourceBitmap.Width * scale));
+ int targetHeight = Math.Max(1, (int)Math.Round(sourceBitmap.Height * scale));
+
+ using var resizedBitmap = sourceBitmap.Resize(
+ new SKImageInfo(targetWidth, targetHeight),
+ SKFilterQuality.Medium);
+
+ if (resizedBitmap == null)
+ return null;
+
+ using var image = SKImage.FromBitmap(resizedBitmap);
+ var format = GetEncodedFormat(avatarUrl);
+ using var data = image.Encode(format, 90);
+ return data?.ToArray();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool ShouldDownscaleAvatar(string avatarUrl)
+ {
+ try
+ {
+ var host = new Uri(avatarUrl).Host;
+ return host.Contains("steamstatic.com", StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static SKEncodedImageFormat GetEncodedFormat(string avatarUrl)
+ {
+ var ext = GetExtensionFromUrl(avatarUrl);
+ return ext switch
+ {
+ ".png" => SKEncodedImageFormat.Png,
+ ".webp" => SKEncodedImageFormat.Webp,
+ _ => SKEncodedImageFormat.Jpeg,
+ };
+ }
+
+ private static string GetCachePath(string avatarUrl)
+ {
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(avatarUrl))).ToLowerInvariant();
+ var ext = GetExtensionFromUrl(avatarUrl);
+ return Path.Combine(_cacheDir, $"{hash}{ext}");
+ }
+
+ private static string GetExtensionFromUrl(string avatarUrl)
+ {
+ try
+ {
+ var uri = new Uri(avatarUrl);
+ var ext = Path.GetExtension(uri.AbsolutePath);
+ if (!string.IsNullOrWhiteSpace(ext) && ext.Length <= 6)
+ return ext.ToLowerInvariant();
+ }
+ catch
+ {
+ // ignore and fall back
+ }
+ return ".img";
+ }
+ }
+}
diff --git a/Wauncher/Utils/Console.cs b/Wauncher/Utils/Console.cs
new file mode 100644
index 0000000..db9a17e
--- /dev/null
+++ b/Wauncher/Utils/Console.cs
@@ -0,0 +1,31 @@
+using System.Runtime.InteropServices;
+
+namespace Wauncher.Utils
+{
+ public static class ConsoleManager
+ {
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetConsoleWindow();
+
+ [DllImport("user32.dll")]
+ private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
+
+ [DllImport("user32.dll", CharSet = CharSet.Auto)]
+ private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
+
+ private const int SW_HIDE = 0;
+ private const int SW_SHOW = 5;
+ private const uint MB_ICONERROR = 0x00000010;
+
+ private static IntPtr ConsoleHandle = GetConsoleWindow();
+
+ public static void HideConsole() => ShowWindow(ConsoleHandle, SW_HIDE);
+ public static void ShowConsole() => ShowWindow(ConsoleHandle, SW_SHOW);
+
+ public static void ShowError(string message)
+ {
+ if (OperatingSystem.IsWindows())
+ MessageBox(IntPtr.Zero, message, "ClassicCounter Error", MB_ICONERROR);
+ }
+ }
+}
diff --git a/Wauncher/Utils/Debug.cs b/Wauncher/Utils/Debug.cs
new file mode 100644
index 0000000..e5dd059
--- /dev/null
+++ b/Wauncher/Utils/Debug.cs
@@ -0,0 +1,7 @@
+namespace Wauncher.Utils
+{
+ public static class Debug
+ {
+ public static bool Enabled() => false;
+ }
+}
diff --git a/Wauncher/Utils/Dependency.cs b/Wauncher/Utils/Dependency.cs
new file mode 100644
index 0000000..f2638d2
--- /dev/null
+++ b/Wauncher/Utils/Dependency.cs
@@ -0,0 +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;
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/DependencyChecks.cs b/Wauncher/Utils/DependencyChecks.cs
new file mode 100644
index 0000000..6e8a930
--- /dev/null
+++ b/Wauncher/Utils/DependencyChecks.cs
@@ -0,0 +1,41 @@
+using Microsoft.Win32;
+
+namespace Wauncher.Utils
+{
+ public static class DependencyChecks
+ {
+ public static bool IsDiscordInstalled()
+ {
+ if (!OperatingSystem.IsWindows())
+ return true;
+
+ if (HasDiscordProtocolCommand(Registry.CurrentUser) || HasDiscordProtocolCommand(Registry.LocalMachine))
+ return true;
+
+ string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+
+ string[] candidates =
+ {
+ Path.Combine(localAppData, "Discord", "Update.exe"),
+ Path.Combine(localAppData, "DiscordCanary", "Update.exe"),
+ Path.Combine(localAppData, "DiscordPTB", "Update.exe"),
+ Path.Combine(programFiles, "Discord", "Update.exe"),
+ Path.Combine(programFilesX86, "Discord", "Update.exe"),
+ };
+
+ return candidates.Any(File.Exists);
+ }
+
+ private static bool HasDiscordProtocolCommand(RegistryKey root)
+ {
+ using var key =
+ root.OpenSubKey(@"Software\Classes\discord\shell\open\command") ??
+ root.OpenSubKey(@"SOFTWARE\Classes\discord\shell\open\command");
+
+ var command = key?.GetValue(string.Empty) as string;
+ return !string.IsNullOrWhiteSpace(command);
+ }
+ }
+}
diff --git a/Wauncher/Utils/Discord.cs b/Wauncher/Utils/Discord.cs
new file mode 100644
index 0000000..a6f162b
--- /dev/null
+++ b/Wauncher/Utils/Discord.cs
@@ -0,0 +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;
+ }
+}
+
diff --git a/Wauncher/Utils/Download.cs b/Wauncher/Utils/Download.cs
new file mode 100644
index 0000000..e6e2f59
--- /dev/null
+++ b/Wauncher/Utils/Download.cs
@@ -0,0 +1,713 @@
+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 readonly DownloadConfiguration _settings = new()
+ {
+ ChunkCount = 8,
+ ParallelDownload = true
+ };
+ // 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 = Directory.GetCurrentDirectory() + dependency.Path;
+ if (File.Exists(path))
+ File.Delete(path);
+ if (Debug.Enabled())
+ Terminal.Debug($"Downloading {dependency.Name}");
+ await _downloader.DownloadFileTaskAsync(
+ $"{dependency.URL}",
+ $"{Directory.GetCurrentDirectory()}{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 = $"{Directory.GetCurrentDirectory()}/{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}",
+ $"{Directory.GetCurrentDirectory()}/{patch.File}"
+ );
+
+ if (patch.File.EndsWith(".7z"))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Download complete, starting extraction of: {patch.File}");
+ onExtract?.Invoke();
+ string extractPath = $"{Directory.GetCurrentDirectory()}/{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 = $"{status} {fileTypePlural}{GetDots().PadRight(3)} [gray]|[/] {progressText} [gray]|[/] {GetProgressBar(progress.ProgressPercentage)} {progress.ProgressPercentage:F1}% [gray]|[/] {speed:F1} MB/s";
+ };
+
+ 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(Directory.GetCurrentDirectory(), 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 = $"Downloading {file.File}{GetDots().PadRight(3)} [gray]|[/] {progressText} [gray]|[/] {GetProgressBar(e.ProgressPercentage)} {e.ProgressPercentage:F1}% [gray]|[/] {speed:F1} MB/s";
+ };
+ _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(Directory.GetCurrentDirectory(), file.File);
+
+ if (File.Exists(filePath) &&
+ CalculateMD5(filePath).Equals(file.Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ completed++;
+ onProgress?.Invoke(file.File, "", (double)completed / total * 100.0);
+ continue;
+ }
+
+ using var downloader = new DownloadService(_settings);
+ 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);
+ completed++;
+ }
+
+ 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();
+ }
+ }
+
+ // meant only for downloading whole game for now
+ // todo maybe make it more modular/allow other functions to use this
+ // FOR DOWNLOAD STATUS
+ public static int dotCount = 0;
+ public static DateTime lastDotUpdate = DateTime.Now;
+ public static string GetDots()
+ {
+ if ((DateTime.Now - lastDotUpdate).TotalMilliseconds > 500)
+ {
+ dotCount = (dotCount + 1) % 4;
+ lastDotUpdate = DateTime.Now;
+ }
+ return "...".Substring(0, dotCount);
+ }
+ public static string GetProgressBar(double percentage)
+ {
+ int blocks = 16;
+ int level = (int)(percentage / (100.0 / (blocks * 3)));
+ string bar = "";
+
+ for (int i = 0; i < blocks; i++)
+ {
+ int blockLevel = Math.Min(3, Math.Max(0, level - (i * 3)));
+ bar += blockLevel switch
+ {
+ 0 => "¦",
+ 1 => "¦",
+ 2 => "¦",
+ 3 => "¦",
+ _ => "¦"
+ };
+ }
+ return bar;
+ }
+ // DOWNLOAD STATUS OVER
+ 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 = files[0];
+ string extractPath = Directory.GetCurrentDirectory();
+ 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",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true
+ };
+
+ process.Start();
+ 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)
+ {
+ File.Delete(file);
+ 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 { }
+
+ Delete7zaExecutable();
+
+ throw;
+ }
+ }
+
+ 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(Directory.GetCurrentDirectory(), 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 = Directory.GetCurrentDirectory();
+ var files = Directory.GetFiles(directory, "*.7z", SearchOption.AllDirectories);
+
+ 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}");
+ }
+ }
+ }
+}
+
+
+
diff --git a/Wauncher/Utils/FriendsCache.cs b/Wauncher/Utils/FriendsCache.cs
new file mode 100644
index 0000000..6d2191a
--- /dev/null
+++ b/Wauncher/Utils/FriendsCache.cs
@@ -0,0 +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();
+ }
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/Game.cs b/Wauncher/Utils/Game.cs
new file mode 100644
index 0000000..6ae3cf6
--- /dev/null
+++ b/Wauncher/Utils/Game.cs
@@ -0,0 +1,201 @@
+using CSGSI;
+using CSGSI.Nodes;
+using System.Diagnostics;
+using System.Net.NetworkInformation;
+
+namespace Wauncher.Utils
+{
+ public static class Game
+ {
+ private static Process? _process;
+ private static GameStateListener? _listener;
+ private static int _port;
+ private static MapNode? _node;
+
+ private static string _map = "main_menu";
+ private static int _scoreCT = 0;
+ private static int _scoreT = 0;
+
+ public static bool IsRunning()
+ {
+ try
+ {
+ return Process.GetProcessesByName("csgo").Length > 0 ||
+ Process.GetProcessesByName("cc").Length > 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public static async Task Launch()
+ {
+ List arguments = Argument.GenerateGameArguments();
+ if (arguments.Count > 0) Terminal.Print($"Arguments: {string.Join(" ", arguments)}");
+
+ var settings = ViewModels.SettingsWindowViewModel.LoadGlobal();
+ string directory = Path.GetDirectoryName(Services.GetExePath()) ?? Directory.GetCurrentDirectory();
+ Terminal.Print($"Directory: {directory}");
+
+ string gameStatePath = Path.Combine(directory, "csgo", "cfg", "gamestate_integration_cc.cfg");
+
+ if (settings.DiscordRpc)
+ {
+ EnsureGameStateListenerStarted();
+
+ try
+ {
+ string gameStateContents = $$"""
+"ClassicCounter"
+{
+ "uri" "http://localhost:{{_port}}"
+ "timeout" "5.0"
+ "auth"
+ {
+ "token" "ClassicCounter {{Version.Current}}"
+ }
+ "data"
+ {
+ "provider" "1"
+ "map" "1"
+ "round" "1"
+ "player_id" "1"
+ "player_weapons" "1"
+ "player_match_stats" "1"
+ "player_state" "1"
+ "allplayers_id" "1"
+ "allplayers_state" "1"
+ "allplayers_match_stats" "1"
+ }
+}
+""";
+ await File.WriteAllTextAsync(gameStatePath, gameStateContents);
+ }
+ catch
+ {
+ Terminal.Error("(!) \"/csgo/cfg/gamestate_integration_cc.cfg\" not found in the current directory!");
+ }
+ }
+ else if (File.Exists(gameStatePath))
+ {
+ File.Delete(gameStatePath);
+ }
+
+ _process = new Process();
+
+ string gameExe = "csgo.exe";
+ _process.StartInfo.FileName = Path.Combine(directory, gameExe);
+ _process.StartInfo.Arguments = string.Join(" ", arguments);
+ _process.StartInfo.WorkingDirectory = directory;
+
+ if (!File.Exists(_process.StartInfo.FileName))
+ {
+ Terminal.Error($"(!) {gameExe} not found in the current directory!");
+ ConsoleManager.ShowError($"{gameExe} not found in the current directory!\n\nPlease make sure the launcher and game files are in the same folder.");
+ return false;
+ }
+
+ return _process.Start();
+ }
+
+ public static async Task Monitor()
+ {
+ if (_process == null) return;
+
+ while (!_process.HasExited)
+ {
+ if (_node != null && _node.Name.Trim().Length != 0)
+ {
+ bool isMainMenu = string.Equals(_node.Name, "main_menu", StringComparison.OrdinalIgnoreCase);
+ if (!isMainMenu)
+ {
+ if (_map != _node.Name)
+ {
+ _map = _node.Name;
+ _scoreCT = _node.TeamCT.Score;
+ _scoreT = _node.TeamT.Score;
+
+ Discord.SetDetails(_map);
+ Discord.SetState($"Score → {_scoreCT}:{_scoreT}");
+ Discord.SetTimestamp(DateTime.UtcNow);
+ Discord.SetLargeArtwork($"https://assets.classiccounter.cc/maps/default/{_map}.jpg");
+ Discord.SetSmallArtwork("icon");
+ Discord.Update();
+ }
+
+ if (_scoreCT != _node.TeamCT.Score || _scoreT != _node.TeamT.Score)
+ {
+ _scoreCT = _node.TeamCT.Score;
+ _scoreT = _node.TeamT.Score;
+
+ Discord.SetState($"Score → {_scoreCT}:{_scoreT}");
+ Discord.Update();
+ }
+ }
+ else
+ {
+ _map = "main_menu";
+ _scoreCT = 0;
+ _scoreT = 0;
+ }
+ }
+ else if (_map != "main_menu")
+ {
+ _map = "main_menu";
+ _scoreCT = 0;
+ _scoreT = 0;
+
+ Discord.SetDetails("In Main Menu");
+ Discord.SetState(null);
+ Discord.SetTimestamp(DateTime.UtcNow);
+ Discord.SetLargeArtwork("icon");
+ Discord.SetSmallArtwork(null);
+ Discord.Update();
+ }
+
+ await Task.Delay(2000);
+ }
+
+ _process = null;
+ _node = null;
+ _map = "main_menu";
+ _scoreCT = 0;
+ _scoreT = 0;
+ }
+
+ private static void EnsureGameStateListenerStarted()
+ {
+ if (_listener != null)
+ return;
+
+ _port = GeneratePort();
+
+ var listener = new GameStateListener($"http://localhost:{_port}/");
+ listener.NewGameState += OnNewGameState;
+
+ if (!listener.Start())
+ {
+ listener.NewGameState -= OnNewGameState;
+ throw new InvalidOperationException("Couldn't start Wauncher's local game state listener.");
+ }
+
+ _listener = listener;
+ }
+
+ private static int GeneratePort()
+ {
+ int port = new Random().Next(1024, 65536);
+
+ IPGlobalProperties properties = IPGlobalProperties.GetIPGlobalProperties();
+ while (properties.GetActiveTcpConnections().Any(x => x.LocalEndPoint.Port == port))
+ {
+ port = new Random().Next(1024, 65536);
+ }
+
+ return port;
+ }
+
+ public static void OnNewGameState(GameState gs) => _node = gs.Map;
+ }
+}
diff --git a/Wauncher/Utils/Patch.cs b/Wauncher/Utils/Patch.cs
new file mode 100644
index 0000000..76d8175
--- /dev/null
+++ b/Wauncher/Utils/Patch.cs
@@ -0,0 +1,240 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+
+namespace Wauncher.Utils
+{
+ public class Patch
+ {
+ [JsonProperty(PropertyName = "file")]
+ public required string File { get; set; }
+
+ [JsonProperty(PropertyName = "hash")]
+ public required string Hash { get; set; }
+ };
+
+ public class Patches(bool success, List missing, List outdated)
+ {
+ public bool Success = success;
+ public List Missing = missing;
+ public List Outdated = outdated;
+ }
+
+ public static class PatchManager
+ {
+ private static string GetOriginalFileName(string fileName)
+ {
+ return fileName.EndsWith(".7z") ? fileName[..^3] : fileName;
+ }
+
+ private static async Task> GetPatches(bool validateAll = false)
+ {
+ List patches = new List();
+
+ try
+ {
+ string responseString = await Api.ClassicCounter.GetPatches();
+
+ JObject responseJson = JObject.Parse(responseString);
+
+ if (responseJson["files"] != null)
+ patches = responseJson["files"]!.ToObject()!.ToList();
+ }
+ catch
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Couldn't get {(validateAll ? "full game" : "patch")} API data.");
+ }
+
+ return patches;
+ }
+
+ private static async Task GetHash(string filePath)
+ {
+ using var md5 = MD5.Create();
+ await using var stream = File.OpenRead(filePath);
+ byte[] hash = await Task.Run(() => md5.ComputeHash(stream));
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+
+ public static async Task ValidatePatches(bool validateAll = false, bool deleteOutdatedFiles = true)
+ {
+ List patches = await GetPatches(validateAll);
+ List missing = new();
+ List outdated = new();
+ Patch? dirPatch = null;
+
+ // first only check pak_dat.vpk
+ var pakDatPatch = patches.FirstOrDefault(p => p.File == "csgo/pak_dat.vpk");
+ bool skipValidation = false;
+
+ if (pakDatPatch != null && !validateAll)
+ {
+ string pakDatPath = $"{Directory.GetCurrentDirectory()}/csgo/pak_dat.vpk";
+
+ if (Debug.Enabled())
+ Terminal.Debug("Checking csgo/pak_dat.vpk first...");
+
+ if (File.Exists(pakDatPath))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Checking hash for: csgo/pak_dat.vpk");
+
+ string pakDatHash = await GetHash(pakDatPath);
+ if (pakDatHash == pakDatPatch.Hash)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("csgo/pak_dat.vpk is up to date - skipping other file checks");
+ skipValidation = true;
+ return new Patches(true, missing, outdated);
+ }
+ else
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("csgo/pak_dat.vpk is outdated - will check all files");
+ if (deleteOutdatedFiles)
+ File.Delete(pakDatPath);
+ }
+ }
+ else
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Missing: csgo/pak_dat.vpk - will check all files");
+ }
+ }
+
+ if (!skipValidation)
+ {
+ // find pak01_dir.vpk from patch api
+ dirPatch = patches.FirstOrDefault(p => p.File.Contains("pak01_dir.vpk"));
+ bool needPak01Update = false;
+
+ if (dirPatch != null)
+ {
+ string dirPath = $"{Directory.GetCurrentDirectory()}/csgo/pak01_dir.vpk";
+
+ if (Debug.Enabled())
+ Terminal.Debug("Checking csgo/pak01_dir.vpk first...");
+
+ if (File.Exists(dirPath))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Checking hash for: csgo/pak01_dir.vpk");
+
+ string dirHash = await GetHash(dirPath);
+ if (dirHash != dirPatch.Hash)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("csgo/pak01_dir.vpk is outdated!");
+
+ if (deleteOutdatedFiles)
+ File.Delete(dirPath);
+ outdated.Add(dirPatch);
+ needPak01Update = true;
+ }
+ else if (!validateAll)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("csgo/pak01_dir.vpk is up to date - will skip pak01 files");
+ }
+ else
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("csgo/pak01_dir.vpk is up to date - checking all files anyway due to full validation mode");
+ }
+ }
+ else
+ {
+ if (Debug.Enabled())
+ Terminal.Debug("Missing: csgo/pak01_dir.vpk!");
+
+ missing.Add(dirPatch);
+ needPak01Update = true;
+ }
+
+ if (!needPak01Update)
+ {
+ patches.Remove(dirPatch);
+ }
+ }
+
+ var concurrentMissing = new ConcurrentBag();
+ var concurrentOutdated = new ConcurrentBag();
+
+ var parallelOptions = new ParallelOptions
+ {
+ MaxDegreeOfParallelism = 4
+ };
+
+ await Parallel.ForEachAsync(patches, parallelOptions, async (patch, cancellationToken) =>
+ {
+ string originalFileName = GetOriginalFileName(patch.File);
+
+ // skip dir file (we already checked it)
+ if (originalFileName.Contains("pak01_dir.vpk"))
+ return;
+
+ // are you a pak01 file?
+ bool isPak01File = originalFileName.Contains("pak01_");
+ string path = Path.Combine(Directory.GetCurrentDirectory(), originalFileName);
+
+ if (isPak01File && !needPak01Update && !validateAll)
+ {
+ if (!File.Exists(path))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Missing: {originalFileName}");
+
+ concurrentMissing.Add(patch);
+ return;
+ }
+
+ if (Debug.Enabled())
+ Terminal.Debug($"Skipping hash check for: {originalFileName} (pak01_dir.vpk up to date)");
+
+ return;
+ }
+
+ if (!File.Exists(path))
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Missing: {originalFileName}");
+
+ concurrentMissing.Add(patch);
+ return;
+ }
+
+ if (Debug.Enabled())
+ Terminal.Debug($"Checking hash for: {originalFileName}{(isPak01File && validateAll ? " (full validation)" : "")}");
+
+ string hash = await GetHash(path);
+ if (hash != patch.Hash)
+ {
+ if (Debug.Enabled())
+ Terminal.Debug($"Outdated: {originalFileName}");
+
+ if (deleteOutdatedFiles)
+ File.Delete(path);
+ concurrentOutdated.Add(patch);
+ }
+ });
+
+ missing.AddRange(concurrentMissing);
+ outdated.AddRange(concurrentOutdated);
+
+ // if pak01_dir.vpk needs update, move it to end of lists
+ if (needPak01Update && dirPatch != null)
+ {
+ if (outdated.Remove(dirPatch))
+ outdated.Add(dirPatch);
+ if (missing.Remove(dirPatch))
+ missing.Add(dirPatch);
+ }
+ }
+
+ return new Patches(patches.Count > 0, missing, outdated);
+ }
+ }
+}
+
diff --git a/Wauncher/Utils/ProtocolManager.cs b/Wauncher/Utils/ProtocolManager.cs
index b81f5ea..efad942 100644
--- a/Wauncher/Utils/ProtocolManager.cs
+++ b/Wauncher/Utils/ProtocolManager.cs
@@ -5,12 +5,15 @@ namespace Wauncher.Utils
public class ProtocolManager
{
public static void RegisterURIHandler()
- {
- var appCurrentLocation = Path.Combine(new FileInfo(System.Environment.ProcessPath).Directory.FullName, "wauncher.exe");
+ {
+ var appCurrentLocation = Services.GetExePath();
+ if (string.IsNullOrWhiteSpace(appCurrentLocation))
+ return;
+
EnsureKeyExists(Registry.CurrentUser, "Software/Classes/cc", "ClassicCounter");
SetValue(Registry.CurrentUser, "Software/Classes/cc", "URL Protocol", string.Empty);
EnsureKeyExists(Registry.CurrentUser, "Software/Classes/cc/DefaultIcon", $"{appCurrentLocation},1");
- EnsureKeyExists(Registry.CurrentUser, "Software/Classes/cc/shell/open/command", $"\"{appCurrentLocation}\" --protocol-command \"%1\"");
+ EnsureKeyExists(Registry.CurrentUser, "Software/Classes/cc/shell/open/command", $"\"{appCurrentLocation}\" \"%1\"");
}
private static void SetValue(RegistryKey rootKey, string keys, string valueName, string value)
@@ -19,7 +22,7 @@ private static void SetValue(RegistryKey rootKey, string keys, string valueName,
key.SetValue(valueName, value);
}
- private static RegistryKey EnsureKeyExists(RegistryKey rootKey, string keys, string defaultValue = null)
+ private static RegistryKey EnsureKeyExists(RegistryKey rootKey, string keys, string? defaultValue = null)
{
if (rootKey == null)
{
diff --git a/Wauncher/Utils/ServerQuery.cs b/Wauncher/Utils/ServerQuery.cs
new file mode 100644
index 0000000..112f1e1
--- /dev/null
+++ b/Wauncher/Utils/ServerQuery.cs
@@ -0,0 +1,139 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Collections.Concurrent;
+
+namespace Wauncher.Utils
+{
+ public class ServerQueryResult
+ {
+ public bool Online { get; set; }
+ public int Players { get; set; }
+ public int MaxPlayers { get; set; }
+ public string Map { get; set; } = "";
+ }
+
+ public static class ServerQuery
+ {
+ private sealed class CachedHostEntry
+ {
+ public IPAddress[] Addresses { get; init; } = Array.Empty();
+ public DateTime ExpiresAtUtc { get; init; }
+ }
+
+ private static readonly byte[] A2S_INFO_REQUEST =
+ {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0x54,
+ 0x53, 0x6F, 0x75, 0x72, 0x63, 0x65, 0x20, 0x45, 0x6E, 0x67,
+ 0x69, 0x6E, 0x65, 0x20, 0x51, 0x75, 0x65, 0x72, 0x79, 0x00
+ };
+ private static readonly ConcurrentDictionary _dnsCache = new();
+ private static readonly TimeSpan DnsCacheDuration = TimeSpan.FromMinutes(5);
+
+ public static async Task QueryAsync(string ipPort, int timeoutMs = 2000)
+ {
+ var result = new ServerQueryResult();
+ try
+ {
+ var parts = ipPort.Split(':');
+ string host = parts[0];
+ int port = int.Parse(parts[1]);
+
+ var addresses = await GetHostAddressesCachedAsync(host);
+ if (addresses.Length == 0) return result;
+
+ var endpoint = new IPEndPoint(addresses[0], port);
+
+ using var udp = new UdpClient();
+
+ await udp.SendAsync(A2S_INFO_REQUEST, A2S_INFO_REQUEST.Length, endpoint);
+
+ var cts = new CancellationTokenSource(timeoutMs);
+ var recv = await udp.ReceiveAsync(cts.Token);
+ byte[] data = recv.Buffer;
+
+ // Some servers respond with a challenge packet (0x41) before sending the real info.
+ // Re-send the request with the 4-byte challenge appended and wait for the real reply.
+ if (data.Length >= 9 && data[4] == 0x41)
+ {
+ var challengeRequest = new byte[A2S_INFO_REQUEST.Length + 4];
+ Buffer.BlockCopy(A2S_INFO_REQUEST, 0, challengeRequest, 0, A2S_INFO_REQUEST.Length);
+ Buffer.BlockCopy(data, 5, challengeRequest, A2S_INFO_REQUEST.Length, 4);
+
+ cts = new CancellationTokenSource(timeoutMs);
+ await udp.SendAsync(challengeRequest, challengeRequest.Length, endpoint);
+ recv = await udp.ReceiveAsync(cts.Token);
+ data = recv.Buffer;
+ }
+
+ // A2S_INFO response: 4×0xFF + 0x49 header, then null-terminated strings:
+ // [0] Server name [1] Map [2] Folder [3] Game then 2-byte AppID
+ // then Players, MaxPlayers, ...
+ if (data.Length < 6 || data[4] != 0x49) return result;
+
+ int pos = 5;
+
+ // Read each null-terminated string
+ string ReadString()
+ {
+ int start = pos;
+ while (pos < data.Length && data[pos] != 0x00) pos++;
+ var s = Encoding.UTF8.GetString(data, start, pos - start);
+ pos++; // skip null terminator
+ return s;
+ }
+
+ ReadString(); // [0] Server name — skip
+ result.Map = ReadString(); // [1] Map name — keep
+ ReadString(); // [2] Folder — skip
+ ReadString(); // [3] Game — skip
+
+ pos += 2; // AppID (2 bytes)
+
+ if (pos + 2 > data.Length) return result;
+
+ result.Players = data[pos];
+ result.MaxPlayers = data[pos + 1];
+ result.Online = true;
+ }
+ catch { /* timeout or unreachable = offline */ }
+
+ return result;
+ }
+
+ public static async Task RefreshServers(IEnumerable servers)
+ {
+ var tasks = servers
+ .Where(s => !s.IsNone)
+ .Select(async s =>
+ {
+ var r = await QueryAsync(s.IpPort);
+ s.IsOnline = r.Online;
+ s.Players = r.Players;
+ s.MaxPlayers = r.MaxPlayers;
+ s.Map = r.Map;
+ // Each setter fires its own targeted PropertyChanged notifications.
+ });
+
+ await Task.WhenAll(tasks);
+ }
+
+ private static async Task GetHostAddressesCachedAsync(string host)
+ {
+ if (_dnsCache.TryGetValue(host, out var cached) &&
+ cached.ExpiresAtUtc > DateTime.UtcNow &&
+ cached.Addresses.Length > 0)
+ {
+ return cached.Addresses;
+ }
+
+ var addresses = await Dns.GetHostAddressesAsync(host);
+ _dnsCache[host] = new CachedHostEntry
+ {
+ Addresses = addresses,
+ ExpiresAtUtc = DateTime.UtcNow.Add(DnsCacheDuration)
+ };
+ return addresses;
+ }
+ }
+}
diff --git a/Wauncher/Utils/Steam.cs b/Wauncher/Utils/Steam.cs
new file mode 100644
index 0000000..0f301e4
--- /dev/null
+++ b/Wauncher/Utils/Steam.cs
@@ -0,0 +1,113 @@
+using Microsoft.Win32;
+using Gameloop.Vdf;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Unicode;
+
+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()
+ {
+ var path = GetSteamInstallPath();
+ return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path);
+ }
+
+ 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 (!exitOnMissing)
+ return false;
+
+ Terminal.Error("Steam login data couldn't be found.");
+ Terminal.Error("Closing launcher in 5 seconds...");
+ await Task.Delay(5000);
+ Environment.Exit(1);
+ return false;
+ }
+
+ dynamic loginUsers = VdfConvert.Deserialize(File.ReadAllText(loginUsersPath));
+ foreach (var user in loginUsers.Value)
+ {
+ var mostRecent = user.Value.MostRecent.Value;
+ if (mostRecent == "1")
+ {
+ recentSteamID64 = user.Key;
+ recentSteamID2 = ConvertToSteamID2(user.Key);
+ }
+ }
+ 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/SteamNative.cs b/Wauncher/Utils/SteamNative.cs
new file mode 100644
index 0000000..e0367be
--- /dev/null
+++ b/Wauncher/Utils/SteamNative.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Wauncher.Utils
+{
+ public class SteamNative
+ {
+ // 64-bit steam_api calls
+ [DllImport("platform/steam_api64", EntryPoint = "SteamAPI_InitFlat", CallingConvention = CallingConvention.Cdecl)]
+ private unsafe static extern int SteamAPI64_InitFlat(byte* steamErrMsg);
+ [DllImport("platform/steam_api64", EntryPoint = "SteamAPI_GetSteamInstallPath", CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr SteamAPI64_GetSteamInstallPath();
+ [DllImport("platform/steam_api64", EntryPoint = "SteamAPI_Shutdown", CallingConvention = CallingConvention.Cdecl)]
+ private static extern void SteamAPI64_Shutdown();
+
+ // 32-bit steam_api calls
+ [DllImport("platform/steam_api", EntryPoint = "SteamAPI_InitFlat", CallingConvention = CallingConvention.Cdecl)]
+ private unsafe static extern int SteamAPI_InitFlat(byte* steamErrMsg);
+ [DllImport("platform/steam_api", EntryPoint = "SteamAPI_GetSteamInstallPath", CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr SteamAPI_GetSteamInstallPath();
+ [DllImport("platform/steam_api", EntryPoint = "SteamAPI_Shutdown", CallingConvention = CallingConvention.Cdecl)]
+ private static extern void SteamAPI_Shutdown();
+
+ private static string? _steamPath = null;
+
+ public static string? GetSteamInstallPath()
+ {
+ // If was already found return it right away.
+ if (_steamPath != null)
+ return _steamPath;
+
+ // If it wasn't found before by registry, try using Steamworks.
+ // (Steamworks.NET doesn't give access to native methods)
+ string steamDll = Environment.Is64BitProcess ? "steam_api64.dll" : "steam_api.dll";
+ using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(steamDll)
+ ?? throw new Exception($"{steamDll} wasn't found in the binary!");
+
+ // If the needed steam_api(64).dll doesn't exist in the folder, unpack it from the binary.
+ var outputPath = Path.Combine(AppContext.BaseDirectory, "platform", steamDll);
+ if (!File.Exists(outputPath))
+ {
+ Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "platform"));
+ using (var file = File.Create(outputPath))
+ stream.CopyTo(file);
+ }
+
+ // Make sure the steam_appid.txt exists, because if it doesn't steam throws an error.
+ if (!File.Exists("steam_appid.txt"))
+ File.WriteAllText("steam_appid.txt", "730");
+
+ unsafe
+ {
+ byte* errMsg = stackalloc byte[1024];
+ int result = Environment.Is64BitProcess ? SteamAPI64_InitFlat(errMsg) : SteamAPI_InitFlat(errMsg);
+ if (result > 0)
+ {
+ ConsoleManager.ShowError($"Steamworks couldn't initialize to find Steam. (Error ({result}) {Marshal.PtrToStringUTF8((IntPtr)errMsg)})");
+ return null;
+ }
+ _steamPath = Marshal.PtrToStringUTF8(Environment.Is64BitProcess ? SteamAPI64_GetSteamInstallPath() : SteamAPI_GetSteamInstallPath());
+ if (Environment.Is64BitProcess) SteamAPI64_Shutdown(); else SteamAPI_Shutdown();
+ }
+
+ return _steamPath;
+ }
+ }
+}
diff --git a/Wauncher/Utils/Terminal.cs b/Wauncher/Utils/Terminal.cs
new file mode 100644
index 0000000..ff23061
--- /dev/null
+++ b/Wauncher/Utils/Terminal.cs
@@ -0,0 +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")}[/]";
+ }
+}
+
diff --git a/Wauncher/Utils/Version.cs b/Wauncher/Utils/Version.cs
new file mode 100644
index 0000000..1c6ac2b
--- /dev/null
+++ b/Wauncher/Utils/Version.cs
@@ -0,0 +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;
+ }
+ }
+}
diff --git a/Wauncher/ViewModels/InfoWindowViewModel.cs b/Wauncher/ViewModels/InfoWindowViewModel.cs
index be05980..1c77c7e 100644
--- a/Wauncher/ViewModels/InfoWindowViewModel.cs
+++ b/Wauncher/ViewModels/InfoWindowViewModel.cs
@@ -2,11 +2,12 @@
using CommunityToolkit.Mvvm.Input;
using System.Diagnostics;
using System.Runtime.InteropServices;
-
namespace Wauncher.ViewModels
{
public partial class InfoWindowViewModel : ViewModelBase
{
+ public string DisplayVersion => $"Version {Wauncher.Utils.Version.Current}";
+
[RelayCommand]
private void OpenUrl(string? url)
{
@@ -32,4 +33,4 @@ private void OpenUrl(string? url)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/Wauncher/ViewModels/MainWindowViewModel.cs b/Wauncher/ViewModels/MainWindowViewModel.cs
index e64c68b..4a2a88e 100644
--- a/Wauncher/ViewModels/MainWindowViewModel.cs
+++ b/Wauncher/ViewModels/MainWindowViewModel.cs
@@ -1,45 +1,612 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
-using Launcher.Utils;
+using Wauncher.Utils;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Net.NetworkInformation;
+using System.Text.RegularExpressions;
+using System.Text;
+using System.Threading;
+using FriendInfo = Wauncher.Utils.FriendInfo;
namespace Wauncher.ViewModels
{
+ public class ServerInfo : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public string Name { get; set; } = "";
+ public string IpPort { get; set; } = "";
+
+ private int _players;
+ private int _maxPlayers;
+ private bool _isOnline;
+ private string _map = "";
+
+ public int Players
+ {
+ get => _players;
+ set
+ {
+ if (_players == value) return;
+ _players = value;
+ Notify(nameof(Players), nameof(PlayerCount));
+ }
+ }
+
+ public int MaxPlayers
+ {
+ get => _maxPlayers;
+ set
+ {
+ if (_maxPlayers == value) return;
+ _maxPlayers = value;
+ Notify(nameof(MaxPlayers), nameof(PlayerCount));
+ }
+ }
+
+ public bool IsOnline
+ {
+ get => _isOnline;
+ set
+ {
+ if (_isOnline == value) return;
+ _isOnline = value;
+ Notify(nameof(IsOnline), nameof(DotColor));
+ }
+ }
+
+ public string Map
+ {
+ get => _map;
+ set
+ {
+ if (_map == value) return;
+ _map = value;
+ Notify(nameof(Map), nameof(MapDisplay));
+ }
+ }
+
+ public bool IsNone => string.IsNullOrEmpty(IpPort);
+
+ public string PlayerCount => IsNone ? "" : $"{Players}/{MaxPlayers}";
+ public string DotColor => IsNone ? "Transparent" : (IsOnline ? "#4CAF50" : "#F44336");
+ public string NameColor => IsNone ? "#66FFFFFF" : "White";
+ public string MapDisplay => (!IsNone && !string.IsNullOrEmpty(Map)) ? Map : "";
+
+ private void Notify(params string[] names)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ foreach (var name in names)
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ });
+ }
+ }
+
public partial class MainWindowViewModel : ViewModelBase
{
- public string GameStatus { get; private set; } = "Game Status: ";
-
- public string ProtocolManager { get; private set; } = "Selected server: ";
-
+ [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";
-
- public string WhitelistStatus { get; set; } = "Gray";
-
+
+ [ObservableProperty]
+ private string _whitelistDotColor = "Gray";
+
+ [ObservableProperty]
+ private string _whitelistText = "Unknown";
+
+ [ObservableProperty]
+ private bool _isDropdownOpen = false;
+
+ [ObservableProperty]
+ private string _activeRightTab = "Friends";
+
+ public bool IsFriendsTabActive => ActiveRightTab == "Friends";
+ public bool IsPatchNotesTabActive => ActiveRightTab == "PatchNotes";
+
+ partial void OnActiveRightTabChanged(string value)
+ {
+ OnPropertyChanged(nameof(IsFriendsTabActive));
+ OnPropertyChanged(nameof(IsPatchNotesTabActive));
+ }
+
+ [ObservableProperty]
+ private bool _isOfflineMode = false;
+
+ public bool IsOnlineMode => !IsOfflineMode;
+ partial void OnIsOfflineModeChanged(bool value) => OnPropertyChanged(nameof(IsOnlineMode));
+
+ [ObservableProperty]
+ private bool _isUpdating = false;
+
+ [ObservableProperty]
+ private bool _isInstalling = false;
+
+ [ObservableProperty]
+ private bool _isNeedingInstall = false;
+
+ [ObservableProperty]
+ private bool _isCheckingUpdates = false;
+
+ public bool IsCheckingOrUpdating => IsCheckingUpdates || IsUpdating || IsInstalling;
+ public bool IsUpdatingOrInstalling => IsUpdating || IsInstalling;
+
+ partial void OnIsCheckingUpdatesChanged(bool value) => OnPropertyChanged(nameof(IsCheckingOrUpdating));
+ partial void OnIsInstallingChanged(bool value)
+ {
+ OnPropertyChanged(nameof(LaunchButtonText));
+ OnPropertyChanged(nameof(IsCheckingOrUpdating));
+ OnPropertyChanged(nameof(IsUpdatingOrInstalling));
+ }
+ partial void OnIsNeedingInstallChanged(bool value) => OnPropertyChanged(nameof(LaunchButtonText));
+
+ [ObservableProperty]
+ private string _updateStatus = "";
+
+ [ObservableProperty]
+ private string _updateStatusFile = "";
+
+ [ObservableProperty]
+ private string _updateStatusSpeed = "";
+
+ [ObservableProperty]
+ private double _updateProgress = 0;
+
+ [ObservableProperty]
+ private bool _updateIndeterminate = false;
+
+ [ObservableProperty]
+ private bool _updateAvailable = false;
+
+ public string LaunchButtonText =>
+ IsInstalling ? "Installing Game..." :
+ IsUpdating ? "Updating..." :
+ IsNeedingInstall ? "Install Game" :
+ UpdateAvailable ? "Update" :
+ "Launch Game";
+
+ partial void OnIsUpdatingChanged(bool value)
+ {
+ OnPropertyChanged(nameof(LaunchButtonText));
+ OnPropertyChanged(nameof(IsCheckingOrUpdating));
+ OnPropertyChanged(nameof(IsUpdatingOrInstalling));
+ }
+ partial void OnUpdateAvailableChanged(bool value) => OnPropertyChanged(nameof(LaunchButtonText));
+
+ [ObservableProperty]
+ private ServerInfo? _selectedServer;
+
+ // What the SELECTED SERVER label shows
+ 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;
+
+ public ObservableCollection Servers { get; } = new()
+ {
+ // ── None (clears selection) ──────────────────────────────────────
+ new ServerInfo { Name = "None", IpPort = "", IsOnline = false },
+
+ // ── Real servers ─────────────────────────────────────────────────
+ new ServerInfo { Name = "NA | PUG | 64 Tick", IpPort = "na.classiccounter.cc:27015", Players = 0, MaxPlayers = 10, IsOnline = true },
+ new ServerInfo { Name = "NA | PUG-2 | 64 Tick", IpPort = "na.classiccounter.cc:27016", Players = 0, MaxPlayers = 10, IsOnline = true },
+ new ServerInfo { Name = "EU | PUG | 64 Tick", IpPort = "eu.classiccounter.cc:27016", Players = 0, MaxPlayers = 10, IsOnline = true },
+ new ServerInfo { Name = "EU | PUG | 128 Tick", IpPort = "eu.classiccounter.cc:27015", Players = 0, MaxPlayers = 10, IsOnline = true },
+ new ServerInfo { Name = "EU | PUG-2 | 128 Tick",IpPort = "eu.classiccounter.cc:27022", Players = 0, MaxPlayers = 10, IsOnline = true },
+ };
+
+ partial void OnSelectedServerChanged(ServerInfo? value)
+ {
+ // Update the label shown in the trigger button
+ ProtocolManager = (value == null || value.IsNone) ? "None" : value.Name;
+ OnPropertyChanged(nameof(SelectedLabel));
+ OnPropertyChanged(nameof(IsNoServerSelected));
+ OnPropertyChanged(nameof(IsServerSelected));
+ }
+
public MainWindowViewModel()
{
- if (Argument.Exists("--protocol-command"))
+ if (Argument.HasProtocolCommand())
+ ProtocolManager = "Ready to Launch!";
+
+ _ = LoadSelfProfileAsync();
+
+ CheckWhitelistStatus();
+ UpdateOfflineMode();
+ NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
+
+ // Query servers immediately, then refresh every 30 seconds
+ _ = RefreshServersSafeAsync();
+ _serverRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
+ _serverRefreshTimer.Tick += async (_, _) => await RefreshServersSafeAsync();
+ _serverRefreshTimer.Start();
+
+ // Query friends immediately, then refresh every 30 seconds
+ _ = RefreshFriendsSafeAsync();
+ _friendsTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
+ _friendsTimer.Tick += async (_, _) => await RefreshFriendsSafeAsync();
+ _friendsTimer.Start();
+ }
+
+ private DispatcherTimer? _serverRefreshTimer;
+ private int _serverRefreshInProgress;
+
+ // ── Friends ───────────────────────────────────────────────────────────────
+ public ObservableCollection Friends { get; } = new();
+
+ [ObservableProperty] private bool _friendsShowStatus = true;
+ [ObservableProperty] private string _friendsStatus = "Loading...";
+ [ObservableProperty] private bool _showNoFriendsState = false;
+ public bool ShowGenericFriendsStatus => FriendsShowStatus && !ShowNoFriendsState;
+
+ partial void OnFriendsShowStatusChanged(bool value) => OnPropertyChanged(nameof(ShowGenericFriendsStatus));
+ partial void OnShowNoFriendsStateChanged(bool value) => OnPropertyChanged(nameof(ShowGenericFriendsStatus));
+
+ private DispatcherTimer? _friendsTimer;
+ private int _friendsRefreshInProgress;
+ private string _lastRenderedFriendsSignature = string.Empty;
+ private string _lastKnownSteamId2 = string.Empty;
+
+ private async Task LoadSelfProfileAsync()
+ {
+ try
+ {
+ bool hasSteam = await Steam.GetRecentLoggedInSteamID(false);
+ if (!hasSteam || string.IsNullOrWhiteSpace(Steam.recentSteamID64))
+ return;
+
+ var rawSelfJson = await Api.Eddies.GetSelfInfo(Steam.recentSteamID64);
+ var self = Api.ParseSelfInfoPayload(rawSelfJson);
+ if (self == null)
+ return;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (!string.IsNullOrWhiteSpace(self.AvatarUrl))
+ ProfilePicture = AvatarCache.GetDisplaySource(self.AvatarUrl);
+
+ if (!string.IsNullOrWhiteSpace(self.Username))
+ UsernameGreeting = $"Hello, {self.Username}";
+ });
+ }
+ catch
+ {
+ // Best-effort profile load; keep defaults on failure.
+ }
+ }
+
+ private async Task RefreshServersAsync()
+ {
+ if (IsOfflineMode)
+ {
+ foreach (var s in Servers.Where(s => !s.IsNone))
+ {
+ s.IsOnline = false;
+ s.Players = 0;
+ s.MaxPlayers = 0;
+ s.Map = "";
+ }
+ return;
+ }
+
+ await ServerQuery.RefreshServers(Servers.Where(s => !s.IsNone));
+
+ // Re-order by player count descending; None always stays at index 0
+ var sorted = Servers.Where(s => !s.IsNone)
+ .OrderByDescending(s => s.Players)
+ .ToList();
+ int insertAt = 1;
+ foreach (var server in sorted)
{
- ProtocolManager = ProtocolManager + "Ready to Launch!";
+ int from = Servers.IndexOf(server);
+ if (from != insertAt)
+ Servers.Move(from, insertAt);
+ insertAt++;
}
+ }
+
+ private async Task RefreshServersSafeAsync()
+ {
+ if (Interlocked.Exchange(ref _serverRefreshInProgress, 1) == 1)
+ return;
- Discord.OnAvatarUpdate += (avatarUrl) =>
+ try
{
- if (!string.IsNullOrEmpty(avatarUrl))
+ await RefreshServersAsync();
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _serverRefreshInProgress, 0);
+ }
+ }
+
+ private async Task RefreshFriendsAsync()
+ {
+ try
+ {
+ if (IsOfflineMode)
+ {
+ var steamIdForCache = !string.IsNullOrWhiteSpace(_lastKnownSteamId2)
+ ? _lastKnownSteamId2
+ : (Steam.recentSteamID2 ?? string.Empty);
+
+ if (TryShowCachedFriends(steamIdForCache, forceOfflineStatus: true))
+ return;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ Friends.Clear();
+ _lastRenderedFriendsSignature = string.Empty;
+ ShowNoFriendsState = false;
+ FriendsStatus = "Offline mode";
+ FriendsShowStatus = true;
+ });
+ return;
+ }
+
+ bool hasSteam = await Steam.GetRecentLoggedInSteamID(false);
+ string steamId = Steam.recentSteamID2 ?? string.Empty;
+ if (!string.IsNullOrWhiteSpace(steamId))
+ _lastKnownSteamId2 = steamId;
+
+ if (!hasSteam)
{
- Dispatcher.UIThread.Post(() => ProfilePicture = avatarUrl);
+ Dispatcher.UIThread.Post(() =>
+ {
+ ShowNoFriendsState = false;
+ FriendsStatus = "Steam is not installed.";
+ FriendsShowStatus = true;
+ });
+ return;
}
- };
- Discord.OnUsernameUpdate += (username) =>
+ if (string.IsNullOrEmpty(Steam.recentSteamID2))
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ ShowNoFriendsState = false;
+ FriendsStatus = "Sign in to Steam to see friends.";
+ FriendsShowStatus = true;
+ });
+ return;
+ }
+
+ string rawFriendsJson;
+ try
+ {
+ rawFriendsJson = await Api.Eddies.GetFriends(Steam.recentSteamID64 ?? string.Empty);
+ }
+ catch
+ {
+ rawFriendsJson = await Api.Eddies.GetFriendsBySteamId2(Steam.recentSteamID2 ?? string.Empty);
+ }
+ var apiFriends = Api.ParseFriendsPayload(rawFriendsJson)
+ .OrderBy(f => f.Status == "Offline" ? 1 : 0)
+ .ToList();
+
+ await FriendsCache.SaveAsync(steamId, apiFriends);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ var sorted = apiFriends;
+
+ ApplyQuickJoinMetadata(sorted);
+
+ foreach (var f in sorted)
+ f.AvatarUrl = AvatarCache.GetDisplaySource(f.AvatarUrl);
+
+ var signature = BuildFriendsSignature(sorted);
+ if (!string.Equals(signature, _lastRenderedFriendsSignature, StringComparison.Ordinal))
+ {
+ Friends.Clear();
+ foreach (var f in sorted)
+ Friends.Add(f);
+ _lastRenderedFriendsSignature = signature;
+ }
+
+ FriendsShowStatus = Friends.Count == 0;
+ ShowNoFriendsState = Friends.Count == 0;
+ FriendsStatus = Friends.Count == 0 ? "No friends found." : "";
+ });
+ }
+ catch
{
- if (!string.IsNullOrEmpty(username))
+ if (TryShowCachedFriends(Steam.recentSteamID2 ?? string.Empty, forceOfflineStatus: true))
+ return;
+
+ Dispatcher.UIThread.Post(() =>
{
- Dispatcher.UIThread.Post(() => UsernameGreeting = $"Hello, {username}");
+ Friends.Clear();
+ _lastRenderedFriendsSignature = string.Empty;
+ ShowNoFriendsState = false;
+ FriendsStatus = IsOfflineMode ? "Offline mode" : "Couldn't load friends right now.";
+ FriendsShowStatus = true;
+ });
+ }
+ }
+
+ private bool TryShowCachedFriends(string steamId, bool forceOfflineStatus)
+ {
+ var cached = FriendsCache.Load(steamId);
+ if (cached.Count == 0)
+ return false;
+
+ var sorted = cached
+ .OrderBy(f => f.Status == "Offline" ? 1 : 0)
+ .ToList();
+
+ if (forceOfflineStatus)
+ {
+ foreach (var f in sorted)
+ f.Status = "Offline";
+ }
+
+ ApplyQuickJoinMetadata(sorted);
+
+ foreach (var f in sorted)
+ f.AvatarUrl = AvatarCache.GetDisplaySource(f.AvatarUrl);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ var signature = BuildFriendsSignature(sorted);
+ if (!string.Equals(signature, _lastRenderedFriendsSignature, StringComparison.Ordinal))
+ {
+ Friends.Clear();
+ foreach (var f in sorted)
+ Friends.Add(f);
+ _lastRenderedFriendsSignature = signature;
}
- };
+
+ FriendsShowStatus = false;
+ ShowNoFriendsState = false;
+ FriendsStatus = "";
+ });
+
+ return true;
+ }
+
+ private static string BuildFriendsSignature(IEnumerable friends)
+ {
+ var sb = new StringBuilder();
+ foreach (var f in friends)
+ {
+ sb.Append(f.Username ?? string.Empty)
+ .Append('\u001f')
+ .Append(f.AvatarUrl ?? string.Empty)
+ .Append('\u001f')
+ .Append(f.Status ?? "Offline")
+ .Append('\u001e');
+ }
+ return sb.ToString();
+ }
+
+ private void ApplyQuickJoinMetadata(IEnumerable friends)
+ {
+ foreach (var friend in friends)
+ {
+ friend.QuickJoinIpPort = string.Empty;
+ friend.QuickJoinServerName = string.Empty;
+
+ var serverName = ExtractServerNameFromStatus(friend.Status);
+ if (string.IsNullOrWhiteSpace(serverName))
+ continue;
+
+ var matches = Servers
+ .Where(s => !s.IsNone)
+ .Where(s => string.Equals(s.Name, serverName, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (matches.Count == 1)
+ {
+ friend.QuickJoinIpPort = matches[0].IpPort;
+ friend.QuickJoinServerName = matches[0].Name;
+ }
+ }
+ }
+
+ private static string ExtractServerNameFromStatus(string? status)
+ {
+ if (string.IsNullOrWhiteSpace(status))
+ return string.Empty;
+
+ var match = Regex.Match(
+ status,
+ @"^In Game - (?.+?) \(\d+/\d+\)$",
+ RegexOptions.IgnoreCase);
+
+ return match.Success
+ ? match.Groups["name"].Value.Trim()
+ : string.Empty;
+ }
+
+ private async Task RefreshFriendsSafeAsync()
+ {
+ if (Interlocked.Exchange(ref _friendsRefreshInProgress, 1) == 1)
+ return;
+
+ try
+ {
+ await RefreshFriendsAsync();
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _friendsRefreshInProgress, 0);
+ }
+ }
+
+
+
+
+
+ private void CheckWhitelistStatus()
+ {
+ Task.Run(async () =>
+ {
+ try
+ {
+ bool hasSteam = await Steam.GetRecentLoggedInSteamID(false);
+ if (!hasSteam)
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ WhitelistDotColor = "Gray";
+ WhitelistText = "Unknown";
+ });
+ return;
+ }
+
+ if (string.IsNullOrEmpty(Steam.recentSteamID2))
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ WhitelistDotColor = "Gray";
+ WhitelistText = "Unknown";
+ });
+ return;
+ }
+
+ var response = await Api.ClassicCounter.GetFullGameDownload(Steam.recentSteamID2);
+ bool whitelisted = response?.Files != null && response.Files.Count > 0;
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ WhitelistDotColor = whitelisted ? "#4CAF50" : "#F44336";
+ WhitelistText = whitelisted ? "Whitelisted" : "Not Whitelisted";
+ });
+ }
+ catch
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ WhitelistDotColor = "Gray";
+ WhitelistText = "Unknown";
+ });
+ }
+ });
+ }
+
+ private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
+ {
+ Dispatcher.UIThread.Post(UpdateOfflineMode);
+ }
+
+ private void UpdateOfflineMode()
+ {
+ IsOfflineMode = !NetworkInterface.GetIsNetworkAvailable();
}
}
}
+
+
diff --git a/Wauncher/ViewModels/PatchNoteItem.cs b/Wauncher/ViewModels/PatchNoteItem.cs
new file mode 100644
index 0000000..9641dfd
--- /dev/null
+++ b/Wauncher/ViewModels/PatchNoteItem.cs
@@ -0,0 +1,11 @@
+namespace Wauncher.ViewModels
+{
+ public class PatchNoteItem
+ {
+ public string Text { get; set; } = string.Empty;
+ public bool IsMajorHeader { get; set; }
+ public bool IsDateHeader { get; set; }
+ public bool IsHeader { get; set; }
+ public bool IsBullet { get; set; }
+ }
+}
diff --git a/Wauncher/ViewModels/SettingsWindowViewModel.cs b/Wauncher/ViewModels/SettingsWindowViewModel.cs
new file mode 100644
index 0000000..f415548
--- /dev/null
+++ b/Wauncher/ViewModels/SettingsWindowViewModel.cs
@@ -0,0 +1,103 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Wauncher.ViewModels
+{
+ public partial class SettingsWindowViewModel : ViewModelBase
+ {
+ // ── Static events — fire whenever these settings change on ANY instance ──
+ public static event Action? DiscordRpcChanged;
+
+ [ObservableProperty]
+ private bool _minimizeToTray = true;
+
+ [ObservableProperty]
+ private bool _discordRpc = true;
+
+ [ObservableProperty]
+ private bool _skipUpdates = false;
+
+ [ObservableProperty]
+ private string _launchOptions = string.Empty;
+
+ public SettingsWindowViewModel()
+ {
+ Load();
+ }
+
+ partial void OnMinimizeToTrayChanged(bool value) => Save();
+ partial void OnSkipUpdatesChanged(bool value) => Save();
+ partial void OnLaunchOptionsChanged(string value) => Save();
+
+ partial void OnDiscordRpcChanged(bool value)
+ {
+ Save();
+ DiscordRpcChanged?.Invoke(value);
+ }
+
+ private void Load()
+ {
+ try
+ {
+ string path = SettingsPath();
+ if (!File.Exists(path)) { Save(); return; }
+
+ foreach (var line in File.ReadAllLines(path))
+ {
+ // Split only on the first "=" so values like "+set key=value" are preserved.
+ int eq = line.IndexOf('=');
+ if (eq <= 0) continue;
+
+ var key = line[..eq].Trim();
+ var value = line[(eq + 1)..];
+
+ switch (key)
+ {
+ case "MinimizeToTray": MinimizeToTray = value.Trim() == "true"; break;
+ case "DiscordRpc": DiscordRpc = value.Trim() == "true"; break;
+ case "SkipUpdates": SkipUpdates = value.Trim() == "true"; break;
+ case "LaunchOptions": LaunchOptions = value; break;
+ }
+ }
+ }
+ catch { }
+ }
+
+ public void Save()
+ {
+ try
+ {
+ File.WriteAllLines(SettingsPath(), new[]
+ {
+ $"MinimizeToTray={MinimizeToTray.ToString().ToLower()}",
+ $"DiscordRpc={DiscordRpc.ToString().ToLower()}",
+ $"SkipUpdates={SkipUpdates.ToString().ToLower()}",
+ $"LaunchOptions={LaunchOptions}",
+ });
+ }
+ catch { }
+ }
+
+ public static SettingsWindowViewModel LoadGlobal() => new();
+
+ public static string SettingsPath()
+ {
+ var configDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "config");
+ var newPath = Path.Combine(configDir, "wauncher_settings.cfg");
+
+ try
+ {
+ Directory.CreateDirectory(configDir);
+ }
+ catch
+ {
+ // Fall back to returning the new path even if folder creation fails.
+ }
+
+ return newPath;
+ }
+ }
+}
diff --git a/Wauncher/Views/InfoWindow.axaml b/Wauncher/Views/InfoWindow.axaml
index d43fb24..7112602 100644
--- a/Wauncher/Views/InfoWindow.axaml
+++ b/Wauncher/Views/InfoWindow.axaml
@@ -1,93 +1,274 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- Thank You for playing ClassicCounter and using Wauncher.
- Special thanks to h4rmy, heapy and eddies for maintaining this project.
-
- Built using:
-
- and
-
-
-
- by
-
-
-
- Suggest via
-
- or
-
-
- Something is not working? Read
-
-
-
-
-
-
-
+ x:Class="Wauncher.InfoWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:svg="clr-namespace:Avalonia.Svg.Controls;assembly=Avalonia.Svg.Skia"
+ xmlns:vm="using:Wauncher.ViewModels"
+ Title="Info"
+ Width="560"
+ Height="420"
+ d:DesignHeight="420"
+ d:DesignWidth="560"
+ x:DataType="vm:InfoWindowViewModel"
+ SystemDecorations="None"
+ WindowStartupLocation="CenterOwner"
+ TransparencyLevelHint="AcrylicBlur"
+ Background="Transparent"
+ mc:Ignorable="d">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wauncher/Views/InfoWindow.axaml.cs b/Wauncher/Views/InfoWindow.axaml.cs
index 1d6a8a7..ff84242 100644
--- a/Wauncher/Views/InfoWindow.axaml.cs
+++ b/Wauncher/Views/InfoWindow.axaml.cs
@@ -1,4 +1,6 @@
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Input;
using Wauncher.ViewModels;
namespace Wauncher;
@@ -10,4 +12,15 @@ public InfoWindow()
InitializeComponent();
DataContext = new InfoWindowViewModel();
}
-}
\ No newline at end of file
+
+ private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginMoveDrag(e);
+ }
+
+ private void CloseButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ Close();
+ }
+}
diff --git a/Wauncher/Views/MainWindow.axaml b/Wauncher/Views/MainWindow.axaml
index a9e454f..66f9428 100644
--- a/Wauncher/Views/MainWindow.axaml
+++ b/Wauncher/Views/MainWindow.axaml
@@ -1,77 +1,786 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wauncher/Views/MainWindow.axaml.cs b/Wauncher/Views/MainWindow.axaml.cs
index d5a312f..8d0911e 100644
--- a/Wauncher/Views/MainWindow.axaml.cs
+++ b/Wauncher/Views/MainWindow.axaml.cs
@@ -1,35 +1,1904 @@
-using Avalonia.Controls;
-using Launcher.Utils;
-
-namespace Wauncher.Views
-{
- public partial class MainWindow : Window
- {
- private InfoWindow? _infoWindow = null;
- public MainWindow()
+using System.IO;
+using System.Net.Http;
+using System.Linq;
+using System.Net.NetworkInformation;
+using System.Runtime.InteropServices;
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text;
+using System.Text.RegularExpressions;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using SkiaSharp;
+using Wauncher.Utils;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Wauncher.ViewModels;
+using Wauncher.Views;
+
+namespace Wauncher.Views
+{
+ public partial class MainWindow : Window
+ {
+ private InfoWindow? _infoWindow = null;
+ private SettingsWindow? _settingsWindow = null;
+ private SettingsWindowViewModel _settings;
+ private int _launchInProgress;
+ private int _updateInProgress;
+ private int _installInProgress;
+
+ private bool _dropdownOpen = false;
+
+ private const double HeightClosed = 720;
+ private const double HeightOpen = 720;
+
+ // ── Image carousel (center content area) ──────────────────────────────────
+ private Image[] _carouselImages = Array.Empty();
+ private List _carouselImageUrls = new();
+ private DispatcherTimer? _carouselTimer;
+ private int _currentCarouselIndex = 0;
+ private int _currentCarouselSlot = 0;
+ private int _carouselRotateInProgress = 0;
+ private const int CarouselRotationIntervalSeconds = 5;
+ private const int CarouselMaxWidth = 1280;
+ private const int CarouselMaxHeight = 720;
+ private readonly List _zoomCts = new();
+ private static string WauncherDirectory =>
+ Path.GetDirectoryName(Environment.ProcessPath ?? string.Empty) ?? Directory.GetCurrentDirectory();
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ _settings = SettingsWindowViewModel.LoadGlobal();
+
+ this.Loaded += (_, _) =>
+ {
+ LaunchUpdateButton.IsEnabled = true;
+ };
+
+ this.Opened += (_, _) =>
+ {
+ _ = SetupCarouselAsync();
+ _ = StartupAsync();
+ _ = LoadPatchNotesAsync();
+
+ if (DataContext is MainWindowViewModel vm2)
+ vm2.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(MainWindowViewModel.IsUpdating) ||
+ e.PropertyName == nameof(MainWindowViewModel.IsInstalling))
+ SetLaunchGlow(vm2.IsUpdating || vm2.IsInstalling);
+ };
+ };
+
+ // Window minimize always goes to taskbar; tray hide only happens on game launch.
+
+ this.Closing += (s, e) =>
+ {
+ if (_forceClose) return;
+ _settings = SettingsWindowViewModel.LoadGlobal();
+ if (_settings.MinimizeToTray && IsGameRunning())
+ {
+ e.Cancel = true;
+ Hide();
+ }
+ };
+
+ // Ensure carousel timer is stopped whenever the window closes,
+ // regardless of which code path triggered it.
+ this.Closed += (_, _) => TeardownCarousel();
+ }
+
+ // ── Image carousel (center content area) ──────────────────────────────────
+ private static readonly HttpClient _http = new();
+ private static string PatchNotesCachePath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "cache",
+ "patchnotes.md");
+ private static string CarouselCacheDir =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "cache",
+ "carousel");
+
+ private async Task SetupCarouselAsync()
+ {
+ try
+ {
+ TeardownCarousel();
+
+ var carouselContainer = this.FindControl("CarouselContainer");
+ var offlinePanel = this.FindControl("CarouselOfflinePanel");
+ var offlineSubText = this.FindControl("CarouselOfflineSubText");
+ if (carouselContainer == null)
+ return;
+
+ bool hasInternet = NetworkInterface.GetIsNetworkAvailable();
+ var urls = hasInternet
+ ? await LoadCarouselUrlsFromGitHubAsync()
+ : null;
+
+ if (urls == null || urls.Count == 0)
+ {
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = true;
+ if (offlineSubText != null)
+ {
+ offlineSubText.Text = hasInternet
+ ? "Carousel is temporarily unavailable."
+ : "Connect to Wi-Fi or Ethernet to load the carousel.";
+ }
+ return;
+ }
+
+ if (offlinePanel != null)
+ offlinePanel.IsVisible = false;
+
+ _carouselImageUrls = urls;
+ _carouselImages = CreateCarouselImages(2);
+ EnsureZoomSlots(_carouselImages.Length);
+
+ foreach (var existingImage in carouselContainer.Children.OfType().ToList())
+ carouselContainer.Children.Remove(existingImage);
+
+ int overlayIndex = offlinePanel != null ? carouselContainer.Children.IndexOf(offlinePanel) : -1;
+ for (int i = 0; i < _carouselImages.Length; i++)
+ {
+ if (overlayIndex >= 0)
+ {
+ carouselContainer.Children.Insert(overlayIndex, _carouselImages[i]);
+ overlayIndex++;
+ }
+ else
+ {
+ carouselContainer.Children.Add(_carouselImages[i]);
+ }
+ }
+
+ _currentCarouselIndex = 0;
+ _currentCarouselSlot = 0;
+ await SetCarouselImageAsync(_carouselImages[_currentCarouselSlot], _carouselImageUrls[_currentCarouselIndex]);
+ _carouselImages[_currentCarouselSlot].Opacity = 1.0;
+ StartZoomOut(_carouselImages[_currentCarouselSlot], _currentCarouselSlot);
+
+ _carouselTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CarouselRotationIntervalSeconds) };
+ _carouselTimer.Tick += async (_, _) => await RotateCarouselAsync();
+ _carouselTimer.Start();
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine("Carousel: " + ex.Message);
+ }
+ }
+
+ private async Task?> LoadCarouselUrlsFromGitHubAsync()
+ {
+ try
+ {
+ var json = await Api.GitHub.GetCarouselAssetsWauncher();
+ var assets = JsonConvert.DeserializeObject>(json);
+ if (assets == null || assets.Count == 0)
+ return null;
+
+ var urls = assets
+ .Where(a => string.Equals(a.Type, "file", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !string.IsNullOrWhiteSpace(a.Name) && a.Name.StartsWith("carousel_", StringComparison.OrdinalIgnoreCase))
+ .Where(a => a.Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
+ a.Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
+ a.Name.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
+ a.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !string.IsNullOrWhiteSpace(a.DownloadUrl))
+ .OrderBy(a => GetCarouselSortIndex(a.Name))
+ .ThenBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(a => a.DownloadUrl!)
+ .ToList();
+
+ if (urls.Count == 0)
+ return null;
+
+ return urls;
+ }
+ catch { return null; }
+ }
+
+ private static int GetCarouselSortIndex(string name)
+ {
+ var match = Regex.Match(name, @"^carousel_(\d+)", RegexOptions.IgnoreCase);
+ if (match.Success && int.TryParse(match.Groups[1].Value, out var index))
+ return index;
+ return int.MaxValue;
+ }
+
+ private sealed class GitHubAssetEntry
+ {
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonProperty("type")]
+ public string Type { get; set; } = string.Empty;
+
+ [JsonProperty("download_url")]
+ public string? DownloadUrl { get; set; }
+ }
+
+ private static Image[] CreateCarouselImages(int count)
+ {
+ var images = new Image[count];
+ for (int i = 0; i < count; i++)
+ {
+ images[i] = new Image
+ {
+ Stretch = Stretch.UniformToFill,
+ Opacity = 0.0,
+ Transitions = new Transitions
+ {
+ new DoubleTransition
+ {
+ Property = Visual.OpacityProperty,
+ Duration = TimeSpan.FromSeconds(1.5),
+ Easing = new CubicEaseInOut()
+ }
+ }
+ };
+ }
+
+ return images;
+ }
+
+ private void EnsureZoomSlots(int count)
+ {
+ while (_zoomCts.Count < count)
+ _zoomCts.Add(null);
+ }
+
+ private async Task RotateCarouselAsync()
+ {
+ if (_carouselImages.Length < 2 || _carouselImageUrls.Count < 2)
+ return;
+
+ if (Interlocked.Exchange(ref _carouselRotateInProgress, 1) == 1)
+ return;
+
+ try
+ {
+ int nextIndex = (_currentCarouselIndex + 1) % _carouselImageUrls.Count;
+ int nextSlot = (_currentCarouselSlot + 1) % _carouselImages.Length;
+ int currentSlot = _currentCarouselSlot;
+
+ await SetCarouselImageAsync(_carouselImages[nextSlot], _carouselImageUrls[nextIndex]);
+
+ _carouselImages[currentSlot].Opacity = 0.0;
+ StartZoomOut(_carouselImages[nextSlot], nextSlot);
+ _carouselImages[nextSlot].Opacity = 1.0;
+
+ _currentCarouselIndex = nextIndex;
+ _currentCarouselSlot = nextSlot;
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _carouselRotateInProgress, 0);
+ }
+ }
+
+ private void TeardownCarousel()
+ {
+ _carouselTimer?.Stop();
+ _carouselTimer = null;
+ for (int i = 0; i < _zoomCts.Count; i++) StopZoom(i);
+ foreach (var image in _carouselImages)
+ {
+ if (image.Source is IDisposable disposable)
+ disposable.Dispose();
+
+ image.Source = null;
+ }
+ _carouselImageUrls.Clear();
+ _carouselImages = Array.Empty();
+ _currentCarouselIndex = 0;
+ _currentCarouselSlot = 0;
+ Interlocked.Exchange(ref _carouselRotateInProgress, 0);
+ }
+
+ private async Task SetCarouselImageAsync(Image image, string url)
+ {
+ var nextBitmap = await LoadCarouselBitmapAsync(url);
+ if (nextBitmap == null)
+ return;
+
+ if (image.Source is IDisposable disposable)
+ disposable.Dispose();
+
+ image.Source = nextBitmap;
+ }
+
+ private static async Task LoadCarouselBitmapAsync(string url)
+ {
+ try
+ {
+ var cachedBytes = await TryGetCachedCarouselBytesAsync(url);
+ var bytes = cachedBytes ?? await _http.GetByteArrayAsync(url);
+ var resized = cachedBytes ?? TryResizeCarouselBytes(bytes) ?? bytes;
+
+ if (cachedBytes == null)
+ await TryWriteCarouselCacheAsync(url, resized);
+
+ using var ms = new MemoryStream(resized);
+ return new Bitmap(ms);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static async Task TryGetCachedCarouselBytesAsync(string url)
+ {
+ try
+ {
+ var path = GetCarouselCachePath(url);
+ if (!File.Exists(path))
+ return null;
+
+ return await File.ReadAllBytesAsync(path);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static async Task TryWriteCarouselCacheAsync(string url, byte[] bytes)
+ {
+ try
+ {
+ Directory.CreateDirectory(CarouselCacheDir);
+ var path = GetCarouselCachePath(url);
+ var tempPath = path + ".tmp";
+ await File.WriteAllBytesAsync(tempPath, bytes);
+ File.Move(tempPath, path, overwrite: true);
+ }
+ catch
+ {
+ // Best-effort cache only.
+ }
+ }
+
+ private static string GetCarouselCachePath(string url)
+ {
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url))).ToLowerInvariant();
+ return Path.Combine(CarouselCacheDir, $"{hash}.jpg");
+ }
+
+ private static byte[]? TryResizeCarouselBytes(byte[] bytes)
+ {
+ try
+ {
+ using var sourceBitmap = SKBitmap.Decode(bytes);
+ if (sourceBitmap == null)
+ return null;
+
+ if (sourceBitmap.Width <= CarouselMaxWidth &&
+ sourceBitmap.Height <= CarouselMaxHeight)
+ {
+ return null;
+ }
+
+ var scale = Math.Min(
+ (double)CarouselMaxWidth / sourceBitmap.Width,
+ (double)CarouselMaxHeight / sourceBitmap.Height);
+
+ int targetWidth = Math.Max(1, (int)Math.Round(sourceBitmap.Width * scale));
+ int targetHeight = Math.Max(1, (int)Math.Round(sourceBitmap.Height * scale));
+
+ using var resizedBitmap = sourceBitmap.Resize(
+ new SKImageInfo(targetWidth, targetHeight),
+ SKFilterQuality.Medium);
+
+ if (resizedBitmap == null)
+ return null;
+
+ using var image = SKImage.FromBitmap(resizedBitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Jpeg, 88);
+ return data?.ToArray();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private void StartZoomOut(Image img, int slot)
+ {
+ StopZoom(slot);
+ _zoomCts[slot] = new System.Threading.CancellationTokenSource();
+ var cts = _zoomCts[slot]!;
+
+ img.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
+ var scale = new ScaleTransform(1.15, 1.15);
+ img.RenderTransform = scale;
+
+ const double startScale = 1.15;
+ const double endScale = 1.0;
+ var totalMs = 6000.0;
+ var startTime = DateTime.UtcNow;
+
+ var zoomTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
+ zoomTimer.Tick += (_, _) =>
+ {
+ if (cts.IsCancellationRequested) { zoomTimer.Stop(); return; }
+ var t = Math.Min((DateTime.UtcNow - startTime).TotalMilliseconds / totalMs, 1.0);
+ var s = startScale + (endScale - startScale) * t;
+ scale.ScaleX = s;
+ scale.ScaleY = s;
+ if (t >= 1.0) zoomTimer.Stop();
+ };
+ zoomTimer.Start();
+ }
+
+ private void StopZoom(int slot)
+ {
+ if (slot < 0 || slot >= _zoomCts.Count)
+ return;
+
+ _zoomCts[slot]?.Cancel();
+ _zoomCts[slot] = null;
+ }
+
+ // ── Server dropdown ───────────────────────────────────────────
+ private void ToggleServerDropdown(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vmOffline && vmOffline.IsOfflineMode)
+ {
+ CloseDropdown();
+ return;
+ }
+
+ _dropdownOpen = !_dropdownOpen;
+
+ if (DataContext is MainWindowViewModel vm)
+ vm.IsDropdownOpen = _dropdownOpen;
+
+ if (_dropdownOpen)
+ {
+ ServerListPanel.MaxHeight = 270;
+ }
+ else
+ {
+ ServerListPanel.MaxHeight = 0;
+ }
+ }
+
+ private void ServerItem_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is ServerInfo server &&
+ DataContext is MainWindowViewModel vm)
+ {
+ vm.SelectedServer = server.IsNone ? null : server;
+ }
+ CloseDropdown();
+ }
+
+ private void CloseDropdown()
+ {
+ _dropdownOpen = false;
+ if (DataContext is MainWindowViewModel vm)
+ vm.IsDropdownOpen = false;
+
+ ServerListPanel.MaxHeight = 0;
+ }
+
+ // ── Game launch ───────────────────────────────────────────
+ private void LaunchUpdate_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var vm = DataContext as MainWindowViewModel;
+ if (vm == null) return;
+ _settings = SettingsWindowViewModel.LoadGlobal();
+
+ if (vm.IsCheckingUpdates || vm.IsUpdating || vm.IsInstalling || Volatile.Read(ref _launchInProgress) == 1)
+ return;
+ else if (vm.IsNeedingInstall)
+ _ = InstallGameFromCdnAsync();
+ else if (_settings.SkipUpdates)
+ _ = LaunchGameAsync();
+ else if (vm.UpdateAvailable)
+ {
+ if (_selfUpdateAvailable)
+ _ = Button_SelfUpdateAsync();
+ else
+ Button_Update(sender, e);
+ }
+ else
+ _ = LaunchGameAsync();
+ }
+
+ private async Task LaunchGameAsync()
{
- InitializeComponent();
+ if (Interlocked.Exchange(ref _launchInProgress, 1) == 1)
+ return;
+
+ var vm = DataContext as MainWindowViewModel;
+ try
+ {
+ _settings = SettingsWindowViewModel.LoadGlobal();
+ var selected = vm?.SelectedServer;
+
+ if (selected != null && !selected.IsNone && !string.IsNullOrEmpty(selected.IpPort) && Game.IsRunning())
+ {
+ ConsoleManager.ShowError(
+ "ClassicCounter is already running.\n\nPlease close the game before joining a server from Wauncher.");
+ return;
+ }
+
+ if (vm != null) vm.GameStatus = "Running";
+
+ // Clear any arguments left over from a previous launch before adding new ones.
+ Argument.ClearAdditionalArguments();
+
+ Argument.AddArgument("-novid");
+ if (!string.IsNullOrWhiteSpace(_settings.LaunchOptions))
+ {
+ foreach (var arg in ParseLaunchOptions(_settings.LaunchOptions))
+ Argument.AddArgument(arg);
+ }
+
+ if (selected != null && !selected.IsNone && !string.IsNullOrEmpty(selected.IpPort))
+ {
+ Argument.AddArgument("+connect");
+ Argument.AddArgument(selected.IpPort);
+ }
+
+ var launched = await Game.Launch();
+ if (!launched)
+ return;
+
+ if (_settings.MinimizeToTray) Hide();
+
+ if (_settings.DiscordRpc)
+ {
+ Discord.SetDetails((selected != null && !selected.IsNone)
+ ? $"Playing on {selected.Name}" : "In Main Menu");
+ Discord.Update();
+ }
+
+ await Game.Monitor();
+ }
+ catch (Exception ex)
+ {
+ ConsoleManager.ShowError($"Failed to launch game:\n{ex.Message}");
+ }
+ finally
+ {
+ if (vm != null) vm.GameStatus = "Not Running";
+
+ if (!_forceClose && _settings.MinimizeToTray && !IsVisible)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ Show();
+ WindowState = WindowState.Normal;
+ Activate();
+ });
+ }
+
+ Interlocked.Exchange(ref _launchInProgress, 0);
+ }
+ }
+
+ // ── Window chrome ───────────────────────────────────────────
+ private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginMoveDrag(e);
}
- private async void Button_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ private void MinimizeButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
- await Game.Launch();
- Discord.SetDetails("In Main Menu");
- Discord.Update();
- await Game.Monitor();
+ WindowState = WindowState.Minimized;
}
- private void Button_Info(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+
+ private bool _forceClose = false;
+
+ private void CloseButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ _forceClose = true;
+ TeardownCarousel();
+ Close();
+ }
+
+ public void ForceQuit()
+ {
+ _forceClose = true;
+ TeardownCarousel();
+ Close();
+ }
+
+ private bool IsGameRunning()
+ {
+ return DataContext is MainWindowViewModel vm &&
+ string.Equals(vm.GameStatus, "Running", StringComparison.Ordinal);
+ }
+
+ private void OpenGameFolder_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var dir = WauncherDirectory;
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = dir,
+ UseShellExecute = true
+ });
+ }
+
+ private void VerifyGameFiles_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm)
+ return;
+
+ if (vm.IsCheckingUpdates || vm.IsUpdating || vm.IsInstalling || vm.IsNeedingInstall)
+ return;
+
+ _forceValidateAllOnce = true;
+ _cachedPatches = null;
+ Button_Update(sender, e);
+ }
+
+ // ── Update ─────────────────────────────────────────────────────
+ private CancellationTokenSource? _updateCts;
+ private Patches? _cachedPatches;
+ private bool _selfUpdateAvailable;
+ private string _selfUpdateDownloadUrl = string.Empty;
+ private string _selfUpdateVersion = string.Empty;
+ private int _autoSelfUpdateTriggered;
+ private int _protocolLaunchTriggered;
+ private bool _forceValidateAllOnce;
+
+ ///
+ /// Called on window open. If csgo.exe is missing, triggers a full CDN install.
+ /// Otherwise runs the normal patch update check.
+ ///
+ private async Task StartupAsync()
{
- if (_infoWindow == null)
+ // Yield to let Avalonia finish its initial layout/styling pass
+ // (Loaded sets the button disabled/gray; we need that to settle before overriding)
+ await Task.Delay(50);
+
+ LaunchUpdateButton.IsEnabled = true;
+
+ string csgoExe = Path.Combine(WauncherDirectory, "csgo.exe");
+ if (DataContext is not MainWindowViewModel vm)
+ return;
+
+ if (!File.Exists(csgoExe))
{
- _infoWindow = new InfoWindow();
- _infoWindow.Closed += (s, e) => _infoWindow = null;
- _infoWindow.Show(this);
+ vm.IsNeedingInstall = true;
+ var blue = new SolidColorBrush(Color.Parse("#2196F3"));
+ LaunchUpdateButton.Background = blue;
+ ArrowButton.Background = blue;
+ LaunchButtonGlow.BoxShadow = BoxShadows.Parse("0 0 18 2 #552196F3");
+ LaunchUpdateButton.IsEnabled = true;
+ return;
+ }
+
+ if (await TryHandleProtocolLaunchAsync(vm))
+ return;
+
+ if (vm?.IsOfflineMode == true)
+ {
+ vm.IsNeedingInstall = false;
+ vm.UpdateAvailable = false;
+ var green = new SolidColorBrush(Color.Parse("#4CAF50"));
+ LaunchUpdateButton.Background = green;
+ ArrowButton.Background = green;
+ LaunchButtonGlow.BoxShadow = BoxShadows.Parse("0 0 18 2 #554CAF50");
+ LaunchUpdateButton.IsEnabled = true;
+ return;
+ }
+
+ _settings = SettingsWindowViewModel.LoadGlobal();
+ if (_settings.SkipUpdates)
+ {
+ vm!.IsNeedingInstall = false;
+ vm.UpdateAvailable = false;
+ vm.IsCheckingUpdates = false;
+ var green = new SolidColorBrush(Color.Parse("#4CAF50"));
+ LaunchUpdateButton.Background = green;
+ ArrowButton.Background = green;
+ LaunchButtonGlow.BoxShadow = BoxShadows.Parse("0 0 18 2 #554CAF50");
+ LaunchUpdateButton.IsEnabled = true;
+ return;
+ }
+
+ await CheckForUpdatesAsync();
+ }
+
+ private async Task TryHandleProtocolLaunchAsync(MainWindowViewModel vm)
+ {
+ var protocolTarget = Argument.GetProtocolConnectTarget();
+ if (string.IsNullOrWhiteSpace(protocolTarget))
+ return false;
+
+ if (Interlocked.Exchange(ref _protocolLaunchTriggered, 1) == 1)
+ return true;
+
+ try
+ {
+ var matchedServer = vm.Servers.FirstOrDefault(s =>
+ !s.IsNone &&
+ string.Equals(s.IpPort, protocolTarget, StringComparison.OrdinalIgnoreCase));
+
+ if (matchedServer != null)
+ {
+ vm.SelectedServer = matchedServer;
+ Argument.ConsumeProtocolConnectTarget();
+ }
+
+ await LaunchGameAsync();
+ return true;
}
- else
+ catch
{
- _infoWindow.Activate();
+ Interlocked.Exchange(ref _protocolLaunchTriggered, 0);
+ throw;
}
}
- }
-}
\ No newline at end of file
+
+ private async Task InstallGameFromCdnAsync()
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ if (Interlocked.Exchange(ref _installInProgress, 1) == 1)
+ return;
+
+ bool installSucceeded = false;
+ vm.IsNeedingInstall = false;
+ vm.IsInstalling = true;
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = true;
+ vm.UpdateStatusFile = "Connecting...";
+ vm.UpdateStatusSpeed = "";
+
+ try
+ {
+ await DownloadManager.InstallFullGame(
+ onProgress: (file, speed, percent) =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateStatusFile = $"Installing {ShortFileName(file)} {percent:F0}%";
+ vm.UpdateStatusSpeed = string.IsNullOrWhiteSpace(speed) ? "" : speed;
+ vm.UpdateProgress = percent;
+ vm.UpdateIndeterminate = false;
+ });
+ },
+ onStatus: status =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ bool isExtracting = status.Contains("Extracting", StringComparison.OrdinalIgnoreCase);
+ vm.UpdateStatusFile = status;
+ vm.UpdateStatusSpeed = isExtracting ? "Large installs can take a few minutes." : "";
+ vm.UpdateIndeterminate = isExtracting;
+ if (isExtracting)
+ vm.UpdateProgress = 0;
+ });
+ },
+ onExtractProgress: extractPercent =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (extractPercent >= 100)
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = "Finalizing extracted files...";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = 100;
+ }
+ });
+ });
+
+ // Immediately apply any post-install patches so first-time installs
+ // end in a launch-ready state without requiring a second manual update.
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = true;
+ vm.UpdateStatusFile = "Checking post-install patches...";
+ vm.UpdateStatusSpeed = "";
+ });
+
+ var patches = await Task.Run(() => PatchManager.ValidatePatches());
+ var allPatches = patches.Missing.Concat(patches.Outdated).ToList();
+ if (allPatches.Count > 0)
+ {
+ int totalFiles = allPatches.Count;
+ int completed = 0;
+
+ foreach (var patch in allPatches)
+ {
+ var extractWatch = new System.Diagnostics.Stopwatch();
+ await DownloadManager.DownloadPatch(
+ patch,
+ onProgress: (p) =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = $"Installing {ShortFileName(patch.File)} {p.ProgressPercentage:F0}%";
+ vm.UpdateStatusSpeed = FormatDownloadSpeedAndEta(p);
+ vm.UpdateProgress = ((completed + p.ProgressPercentage / 100.0) / totalFiles) * 100.0;
+ });
+ },
+ onExtract: () =>
+ {
+ extractWatch.Restart();
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = $"Extracting {ShortFileName(patch.File)}... 0%";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = ((double)completed / totalFiles) * 100.0;
+ });
+ },
+ onExtractProgress: extractPercent =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = $"Extracting {ShortFileName(patch.File)}... {extractPercent:F0}%";
+ vm.UpdateStatusSpeed = FormatExtractEta(extractWatch, extractPercent);
+ vm.UpdateProgress = ((completed + extractPercent / 100.0) / totalFiles) * 100.0;
+ });
+ });
+
+ completed++;
+ vm.UpdateProgress = (double)completed / totalFiles * 100.0;
+ }
+ }
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateStatusFile = "Game installed and fully updated!";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateIndeterminate = false;
+ vm.UpdateProgress = 100;
+ });
+ installSucceeded = true;
+ await Task.Delay(1500);
+ }
+ catch (Exception ex)
+ {
+ vm.UpdateStatusFile = $"Install error: {ex.Message}";
+ vm.UpdateStatusSpeed = "";
+ await Task.Delay(4000);
+ }
+ finally
+ {
+ vm.IsInstalling = false;
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = "";
+ vm.UpdateStatusSpeed = "";
+
+ _cachedPatches = null;
+
+ if (!installSucceeded && !File.Exists(Path.Combine(WauncherDirectory, "csgo.exe")))
+ {
+ vm.IsNeedingInstall = true;
+ var blue = new SolidColorBrush(Color.Parse("#2196F3"));
+ LaunchUpdateButton.Background = blue;
+ ArrowButton.Background = blue;
+ LaunchButtonGlow.BoxShadow = BoxShadows.Parse("0 0 18 2 #552196F3");
+ }
+ else
+ {
+ try
+ {
+ await CheckForUpdatesAsync();
+ }
+ catch { }
+ }
+
+ LaunchUpdateButton.IsEnabled = true;
+ Interlocked.Exchange(ref _installInProgress, 0);
+ }
+ }
+
+ private async Task LoadPatchNotesAsync()
+ {
+ try
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ PatchNotesVersion.IsVisible = false;
+ });
+
+ if (DataContext is MainWindowViewModel vm && vm.IsOfflineMode)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var cachedItems = LoadCachedPatchNotes();
+ if (cachedItems.Count > 0)
+ {
+ PatchNotesList.ItemsSource = cachedItems;
+ }
+ else
+ {
+ PatchNotesList.ItemsSource = new List();
+ }
+
+ PatchNotesVersion.IsVisible = false;
+ PatchNotesScroll.Offset = new Vector(0, 0);
+ });
+ return;
+ }
+
+ var md = await Api.GitHub.GetPatchNotesWauncher();
+ var items = ParsePatchNotes(md);
+ SavePatchNotesCache(md);
+ Dispatcher.UIThread.Post(() =>
+ {
+ PatchNotesVersion.IsVisible = false;
+ PatchNotesList.ItemsSource = items;
+ PatchNotesScroll.Offset = new Vector(0, 0);
+ });
+ }
+ catch
+ {
+ var items = LoadCachedPatchNotes();
+ if (items.Count == 0)
+ {
+ items = BuildFallbackPatchNotes();
+ }
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ PatchNotesVersion.IsVisible = false;
+ PatchNotesList.ItemsSource = items;
+ PatchNotesScroll.Offset = new Vector(0, 0);
+ });
+ }
+ }
+
+ private static void SavePatchNotesCache(string markdown)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(PatchNotesCachePath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(PatchNotesCachePath, markdown);
+ }
+ catch
+ {
+ // Caching is best-effort; keep patch notes functional if disk write fails.
+ }
+ }
+
+ private static List LoadCachedPatchNotes()
+ {
+ try
+ {
+ if (!File.Exists(PatchNotesCachePath))
+ {
+ return new List();
+ }
+
+ var markdown = File.ReadAllText(PatchNotesCachePath);
+ return ParsePatchNotes(markdown);
+ }
+ catch
+ {
+ return new List();
+ }
+ }
+
+ private static List BuildFallbackPatchNotes()
+ {
+ return new List
+ {
+ new() { Text = "Anniversary Update", IsMajorHeader = true },
+ new() { Text = "March 12, 2026", IsDateHeader = true },
+ new() { Text = "What''s Changed", IsHeader = true },
+ new() { Text = "Donors now permanently get an extra drop at the end of each match.", IsBullet = true },
+ new() { Text = "NOVAGANG Collection drops have been reverted back to normal rates.", IsBullet = true },
+ new() { Text = "Bug fixes and security improvements.", IsBullet = true },
+ };
+ }
+
+ private static List ParsePatchNotes(string markdown)
+ {
+ var items = new List();
+ bool lastWasMajorHeader = false;
+
+ foreach (var raw in markdown.Split('\n'))
+ {
+ var line = raw.TrimEnd();
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ line = line.Trim();
+ line = line.Replace("**", "").Replace("__", "");
+ line = Regex.Replace(line, @"\[(.*?)\]\((.*?)\)", "$1");
+ line = Regex.Replace(line, @"`([^`]*)`", "$1");
+
+ if (line.StartsWith("# "))
+ {
+ var headerText = line.TrimStart('#', ' ');
+ var (title, dateText) = SplitPatchTitleAndDate(headerText);
+
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = title,
+ IsMajorHeader = true
+ });
+
+ if (!string.IsNullOrWhiteSpace(dateText))
+ {
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = dateText,
+ IsDateHeader = true
+ });
+ }
+
+ lastWasMajorHeader = true;
+ }
+ else if (lastWasMajorHeader && TryParsePatchDateLine(line, out var parsedDate))
+ {
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = parsedDate,
+ IsDateHeader = true
+ });
+ lastWasMajorHeader = false;
+ }
+ else if (line.StartsWith("## ") || line.StartsWith("### "))
+ {
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = line.TrimStart('#', ' '),
+ IsHeader = true
+ });
+ lastWasMajorHeader = false;
+ }
+ else if (line.StartsWith("* ") || line.StartsWith("- ") || line.StartsWith("• "))
+ {
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = line.Substring(2).Trim(),
+ IsBullet = true
+ });
+ lastWasMajorHeader = false;
+ }
+ else if (Regex.IsMatch(line, @"^\d+\.\s+"))
+ {
+ var bulletText = Regex.Replace(line, @"^\d+\.\s+", string.Empty).Trim();
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = bulletText,
+ IsBullet = true
+ });
+ lastWasMajorHeader = false;
+ }
+ else if (line.StartsWith("**") && line.EndsWith("**"))
+ {
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = line.Trim('*', ' '),
+ IsHeader = true
+ });
+ lastWasMajorHeader = false;
+ }
+ else
+ {
+ items.Add(new ViewModels.PatchNoteItem
+ {
+ Text = line.TrimStart('#', '*', '-', ' '),
+ IsBullet = true
+ });
+ lastWasMajorHeader = false;
+ }
+ }
+
+ return items;
+ }
+
+ private static (string Title, string DateText) SplitPatchTitleAndDate(string headerText)
+ {
+ var match = Regex.Match(
+ headerText,
+ @"^(?.+?)\s+(?:-|–|—)\s+(?(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},\s+\d{4})$",
+ RegexOptions.IgnoreCase);
+
+ if (!match.Success)
+ return (headerText, string.Empty);
+
+ return (match.Groups["title"].Value.Trim(), match.Groups["date"].Value.Trim());
+ }
+
+ private static bool TryParsePatchDateLine(string line, out string dateText)
+ {
+ dateText = string.Empty;
+ var trimmed = line.Trim();
+
+ if (trimmed.StartsWith("Date:", StringComparison.OrdinalIgnoreCase))
+ {
+ dateText = trimmed[5..].Trim();
+ return !string.IsNullOrWhiteSpace(dateText);
+ }
+
+ if (Regex.IsMatch(trimmed, @"^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},\s+\d{4}$", RegexOptions.IgnoreCase))
+ {
+ dateText = trimmed;
+ return true;
+ }
+
+ if (Regex.IsMatch(trimmed, @"^\d{1,2}/\d{1,2}/\d{4}$", RegexOptions.IgnoreCase))
+ {
+ dateText = trimmed;
+ return true;
+ }
+
+ return false;
+ }
+
+ private sealed class GitHubRelease
+ {
+ [JsonProperty("tag_name")]
+ public string? TagName { get; set; }
+
+ [JsonProperty("assets")]
+ public List? Assets { get; set; }
+ }
+
+ private sealed class GitHubReleaseAsset
+ {
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonProperty("browser_download_url")]
+ public string DownloadUrl { get; set; } = string.Empty;
+ }
+
+ private static string NormalizeVersionToken(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ return "0.0.0";
+
+ var cleaned = version.Trim();
+ if (cleaned.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ cleaned = cleaned[1..];
+
+ cleaned = Regex.Replace(cleaned, @"[^0-9\.]", string.Empty);
+ return string.IsNullOrWhiteSpace(cleaned) ? "0.0.0" : cleaned;
+ }
+
+ private static bool TryParseVersion(string value, out global::System.Version parsed)
+ {
+ if (global::System.Version.TryParse(value, out parsed!))
+ return true;
+
+ var tokens = value.Split('.', StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length == 0)
+ {
+ parsed = new global::System.Version(0, 0, 0);
+ return false;
+ }
+
+ while (tokens.Length < 3)
+ tokens = tokens.Append("0").ToArray();
+
+ return global::System.Version.TryParse(string.Join('.', tokens.Take(4)), out parsed!);
+ }
+
+ private async Task CheckForSelfUpdateAsync()
+ {
+ _selfUpdateAvailable = false;
+ _selfUpdateDownloadUrl = string.Empty;
+ _selfUpdateVersion = string.Empty;
+
+ try
+ {
+ var latestReleaseJson = await Api.GitHub.GetLatestRelease();
+ var release = JsonConvert.DeserializeObject(latestReleaseJson);
+ if (release == null)
+ return false;
+
+ var currentVersion = NormalizeVersionToken(Wauncher.Utils.Version.Current);
+ var latestVersion = NormalizeVersionToken(release.TagName);
+ if (!TryParseVersion(currentVersion, out var current) || !TryParseVersion(latestVersion, out var latest))
+ return false;
+
+ if (latest <= current)
+ return false;
+
+ var assets = release.Assets ?? new List();
+ var preferred = assets.FirstOrDefault(a =>
+ !string.IsNullOrWhiteSpace(a.DownloadUrl) &&
+ string.Equals(a.Name, "wauncher.exe", StringComparison.OrdinalIgnoreCase));
+
+ preferred ??= assets.FirstOrDefault(a =>
+ !string.IsNullOrWhiteSpace(a.DownloadUrl) &&
+ a.Name.Contains("wauncher", StringComparison.OrdinalIgnoreCase) &&
+ a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
+
+ if (preferred == null)
+ return false;
+
+ _selfUpdateAvailable = true;
+ _selfUpdateDownloadUrl = preferred.DownloadUrl;
+ _selfUpdateVersion = latestVersion;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private async Task DownloadFileWithProgressAsync(string url, string destination, Action? onProgress, CancellationToken token)
+ {
+ using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
+ response.EnsureSuccessStatusCode();
+
+ var totalBytes = response.Content.Headers.ContentLength;
+ await using var input = await response.Content.ReadAsStreamAsync(token);
+ await using var output = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
+
+ var buffer = new byte[81920];
+ long received = 0;
+ while (true)
+ {
+ int read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), token);
+ if (read == 0)
+ break;
+
+ await output.WriteAsync(buffer.AsMemory(0, read), token);
+ received += read;
+ if (totalBytes.HasValue && totalBytes.Value > 0)
+ {
+ onProgress?.Invoke((double)received / totalBytes.Value * 100.0);
+ }
+ }
+
+ onProgress?.Invoke(100.0);
+ }
+
+ private static string BuildSelfUpdateScript(string stagedExePath, string currentExePath)
+ {
+ return
+$@"@echo off
+setlocal
+set ""SRC={stagedExePath}""
+set ""DST={currentExePath}""
+
+for /L %%i in (1,1,60) do (
+ copy /Y ""%SRC%"" ""%DST%"" >nul 2>nul && goto copied
+ timeout /t 1 /nobreak >nul
+)
+
+exit /b 1
+
+:copied
+start """" ""%DST%""
+del /Q ""%SRC%"" >nul 2>nul
+del /Q ""%~f0"" >nul 2>nul
+exit /b 0
+";
+ }
+
+ private async Task Button_SelfUpdateAsync()
+ {
+ var vm = DataContext as MainWindowViewModel;
+ if (vm == null)
+ return;
+
+ if (Interlocked.Exchange(ref _updateInProgress, 1) == 1)
+ return;
+
+ _updateCts?.Dispose();
+ _updateCts = new CancellationTokenSource();
+ var token = _updateCts.Token;
+
+ vm.IsUpdating = true;
+ vm.UpdateAvailable = false;
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = true;
+ vm.UpdateStatus = "";
+ vm.UpdateStatusFile = "Downloading Wauncher update...";
+ vm.UpdateStatusSpeed = "";
+
+ try
+ {
+ if (string.IsNullOrWhiteSpace(_selfUpdateDownloadUrl))
+ throw new Exception("No self-update package URL found.");
+
+ var updatesDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClassicCounter",
+ "Wauncher",
+ "self-update");
+ Directory.CreateDirectory(updatesDir);
+
+ var safeVersion = Regex.Replace(_selfUpdateVersion, @"[^0-9A-Za-z\.\-_]", string.Empty);
+ if (string.IsNullOrWhiteSpace(safeVersion))
+ safeVersion = "latest";
+
+ var stagedExePath = Path.Combine(updatesDir, $"wauncher_{safeVersion}.exe");
+ await DownloadFileWithProgressAsync(_selfUpdateDownloadUrl, stagedExePath, percent =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateProgress = percent;
+ vm.UpdateStatusFile = $"Downloading Wauncher update... {percent:F0}%";
+ });
+ }, token);
+
+ var currentExePath = Services.GetExePath();
+ if (string.IsNullOrWhiteSpace(currentExePath))
+ throw new Exception("Could not locate current Wauncher executable.");
+
+ var scriptPath = Path.Combine(updatesDir, "apply_wauncher_update.cmd");
+ var script = BuildSelfUpdateScript(stagedExePath, currentExePath);
+ File.WriteAllText(scriptPath, script, Encoding.ASCII);
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = $"/c \"{scriptPath}\"",
+ WorkingDirectory = updatesDir,
+ CreateNoWindow = true,
+ UseShellExecute = false,
+ });
+
+ vm.UpdateStatusFile = "Restarting Wauncher to apply update...";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = 100;
+ await Task.Delay(500, token);
+ ForceQuit();
+ }
+ catch (OperationCanceledException)
+ {
+ vm.UpdateStatusFile = "Update cancelled.";
+ vm.UpdateStatusSpeed = "";
+ await Task.Delay(800);
+ }
+ catch (Exception ex)
+ {
+ vm.UpdateStatusFile = $"Self-update failed: {ex.Message}";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateIndeterminate = false;
+ await Task.Delay(2500);
+ }
+ finally
+ {
+ if (!_forceClose)
+ {
+ vm.IsUpdating = false;
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatus = "";
+ vm.UpdateStatusFile = "";
+ vm.UpdateStatusSpeed = "";
+ _updateCts?.Dispose();
+ _updateCts = null;
+
+ try
+ {
+ await CheckForUpdatesAsync();
+ }
+ catch
+ {
+ // keep UI responsive even if refresh fails
+ }
+ }
+
+ Interlocked.Exchange(ref _updateInProgress, 0);
+ }
+ }
+
+ private async Task CheckForUpdatesAsync()
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ _settings = SettingsWindowViewModel.LoadGlobal();
+
+ if (vm.IsOfflineMode)
+ {
+ _selfUpdateAvailable = false;
+ _selfUpdateDownloadUrl = string.Empty;
+ _selfUpdateVersion = string.Empty;
+ _cachedPatches = null;
+ vm.UpdateAvailable = false;
+ vm.IsCheckingUpdates = false;
+ var launchColor = new SolidColorBrush(Color.Parse("#4CAF50"));
+ LaunchUpdateButton.Background = launchColor;
+ ArrowButton.Background = launchColor;
+ LaunchUpdateButton.IsEnabled = true;
+ return;
+ }
+
+ vm.IsCheckingUpdates = true;
+ LaunchUpdateButton.Background = new SolidColorBrush(Color.Parse("#555555"));
+ ArrowButton.Background = new SolidColorBrush(Color.Parse("#555555"));
+ LaunchUpdateButton.IsEnabled = false;
+ try
+ {
+ bool hasSelfUpdate = await CheckForSelfUpdateAsync();
+ if (hasSelfUpdate)
+ {
+ _cachedPatches = null;
+ vm.UpdateAvailable = true;
+ var selfUpdateColor = new SolidColorBrush(Color.Parse("#FFC107"));
+ LaunchUpdateButton.Background = selfUpdateColor;
+ ArrowButton.Background = selfUpdateColor;
+
+ if (Interlocked.Exchange(ref _autoSelfUpdateTriggered, 1) == 0)
+ {
+ _ = Button_SelfUpdateAsync();
+ }
+
+ return;
+ }
+
+ if (_settings.SkipUpdates)
+ {
+ _cachedPatches = null;
+ vm.UpdateAvailable = false;
+ var launchColor = new SolidColorBrush(Color.Parse("#4CAF50"));
+ LaunchUpdateButton.Background = launchColor;
+ ArrowButton.Background = launchColor;
+ return;
+ }
+
+ var patches = await Task.Run(() => PatchManager.ValidatePatches(deleteOutdatedFiles: false));
+ bool hasUpdates = patches.Missing.Count > 0 || patches.Outdated.Count > 0;
+
+ // Cache the result so Button_Update can consume it without re-validating.
+ _cachedPatches = patches;
+ _selfUpdateAvailable = false;
+ _selfUpdateDownloadUrl = string.Empty;
+ _selfUpdateVersion = string.Empty;
+ vm.UpdateAvailable = hasUpdates;
+ var buttonColor = new SolidColorBrush(
+ Color.Parse(hasUpdates ? "#FFC107" : "#4CAF50"));
+ LaunchUpdateButton.Background = buttonColor;
+ ArrowButton.Background = buttonColor;
+ }
+ catch
+ {
+ var defaultColor = new SolidColorBrush(Color.Parse("#4CAF50"));
+ LaunchUpdateButton.Background = defaultColor;
+ ArrowButton.Background = defaultColor;
+ }
+ finally
+ {
+ vm.IsCheckingUpdates = false;
+ LaunchUpdateButton.IsEnabled = true;
+ }
+ }
+
+ private async void Button_Update(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var vm = DataContext as MainWindowViewModel;
+ if (vm == null) return;
+
+ if (Interlocked.Exchange(ref _updateInProgress, 1) == 1)
+ return;
+
+ _updateCts?.Dispose();
+ _updateCts = new CancellationTokenSource();
+ var token = _updateCts.Token;
+ vm.IsUpdating = true;
+ vm.UpdateAvailable = false;
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatus = "";
+
+ try
+ {
+ // Use the result already computed by CheckForUpdatesAsync when available,
+ // to avoid a redundant full validation on every update click.
+ bool validateAll = _forceValidateAllOnce;
+ _forceValidateAllOnce = false;
+ bool usingCachedPatches = _cachedPatches != null;
+
+ if (!usingCachedPatches)
+ {
+ vm.UpdateIndeterminate = true;
+ vm.UpdateStatusFile = validateAll
+ ? "Verifying all game files..."
+ : "Checking game files...";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = 0;
+ }
+
+ var patches = _cachedPatches ?? await Task.Run(() => PatchManager.ValidatePatches(validateAll: validateAll), token);
+ _cachedPatches = null; // consumed — force fresh check next time
+ if (token.IsCancellationRequested) return;
+
+ bool hasPatches = patches.Missing.Count > 0 || patches.Outdated.Count > 0;
+ if (!hasPatches)
+ {
+ vm.UpdateStatusFile = "Game is up to date!";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = 100;
+ await Task.Delay(2000);
+ return;
+ }
+
+ var allPatches = patches.Missing.Concat(patches.Outdated).ToList();
+ int totalFiles = allPatches.Count;
+ int completed = 0;
+
+ foreach (var patch in allPatches)
+ {
+ if (token.IsCancellationRequested) break;
+
+ var extractWatch = new System.Diagnostics.Stopwatch();
+ await DownloadManager.DownloadPatch(
+ patch,
+ onProgress: (p) =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = $"Installing {ShortFileName(patch.File)} {p.ProgressPercentage:F0}%";
+ vm.UpdateStatusSpeed = FormatDownloadSpeedAndEta(p);
+ vm.UpdateProgress = ((completed + p.ProgressPercentage / 100.0) / totalFiles) * 100.0;
+ });
+ },
+ onExtract: () =>
+ {
+ extractWatch.Restart();
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = $"Extracting {ShortFileName(patch.File)}... 0%";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = ((double)completed / totalFiles) * 100.0;
+ });
+ },
+ onExtractProgress: extractPercent =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatusFile = $"Extracting {ShortFileName(patch.File)}... {extractPercent:F0}%";
+ vm.UpdateStatusSpeed = FormatExtractEta(extractWatch, extractPercent);
+ vm.UpdateProgress = ((completed + extractPercent / 100.0) / totalFiles) * 100.0;
+ });
+ });
+
+ completed++;
+ vm.UpdateProgress = (double)completed / totalFiles * 100.0;
+ }
+
+ if (!token.IsCancellationRequested)
+ {
+ vm.UpdateStatusFile = "Update complete!";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateProgress = 100;
+ await Task.Delay(2000);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ vm.UpdateStatusFile = "Update cancelled.";
+ vm.UpdateStatusSpeed = "";
+ await Task.Delay(800);
+ }
+ catch (Exception ex)
+ {
+ vm.UpdateStatusFile = $"Error: {ex.Message}";
+ vm.UpdateStatusSpeed = "";
+ vm.UpdateIndeterminate = false;
+ await Task.Delay(3000);
+ }
+ finally
+ {
+ vm.IsUpdating = false;
+ vm.UpdateProgress = 0;
+ vm.UpdateIndeterminate = false;
+ vm.UpdateStatus = "";
+ vm.UpdateStatusFile = "";
+ vm.UpdateStatusSpeed = "";
+ _cachedPatches = null;
+ _updateCts?.Dispose();
+ _updateCts = null;
+
+ try
+ {
+ await CheckForUpdatesAsync();
+ }
+ catch { }
+
+ Interlocked.Exchange(ref _updateInProgress, 0);
+ }
+ }
+
+ private void Button_CancelUpdate(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ _updateCts?.Cancel();
+ }
+
+ // ── Settings / Info windows ────────────────────────────────────────
+ private void FriendsTab_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm) vm.ActiveRightTab = "Friends";
+ }
+
+ private void PatchNotesTab_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm) vm.ActiveRightTab = "PatchNotes";
+ PatchNotesScroll.Offset = new Vector(0, 0);
+ }
+
+ private void ViewFriendProfile_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { Tag: FriendInfo friend })
+ return;
+
+ var profileId = ResolveProfileSteamId(friend.SteamId);
+ if (string.IsNullOrWhiteSpace(profileId))
+ return;
+
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = $"https://eddies.cc/profiles/{profileId}",
+ UseShellExecute = true
+ });
+ }
+ catch
+ {
+ // Best-effort open.
+ }
+ }
+
+ private async void JoinFriendServer_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { Tag: FriendInfo friend })
+ return;
+
+ if (DataContext is not MainWindowViewModel vm)
+ return;
+
+ if (string.IsNullOrWhiteSpace(friend.QuickJoinIpPort))
+ return;
+
+ var matchedServer = vm.Servers.FirstOrDefault(s =>
+ !s.IsNone &&
+ string.Equals(s.IpPort, friend.QuickJoinIpPort, StringComparison.OrdinalIgnoreCase));
+
+ if (matchedServer == null)
+ {
+ Wauncher.Utils.ConsoleManager.ShowError("Couldn't match that friend to a server in your server list.");
+ return;
+ }
+
+ vm.SelectedServer = matchedServer;
+ await LaunchGameAsync();
+ }
+
+ 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;
+ }
+
+ private void Button_Settings(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (_settingsWindow == null)
+ {
+ var skipUpdatesBeforeOpen = _settings.SkipUpdates;
+ _settingsWindow = new SettingsWindow();
+ _settingsWindow.Closed += (s, e) =>
+ {
+ _settingsWindow = null;
+ _settings = SettingsWindowViewModel.LoadGlobal();
+ if (skipUpdatesBeforeOpen != _settings.SkipUpdates)
+ _ = CheckForUpdatesAsync();
+ };
+ _settingsWindow.Show(this);
+ }
+ else _settingsWindow.Activate();
+ }
+
+ private void Button_Info(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (_infoWindow == null)
+ {
+ _infoWindow = new InfoWindow();
+ _infoWindow.Closed += (s, e) => _infoWindow = null;
+ _infoWindow.Show(this);
+ }
+ else _infoWindow.Activate();
+ }
+
+ // ── Launch button glow + color ────────────────────────────────────────────
+ private void SetLaunchGlow(bool updating)
+ {
+ var brush = new SolidColorBrush(Color.Parse(updating ? "#FFC107" : "#4CAF50"));
+ LaunchUpdateButton.Background = brush;
+ ArrowButton.Background = brush;
+ LaunchButtonGlow.BoxShadow = updating
+ ? BoxShadows.Parse("0 0 18 2 #55FFC107")
+ : BoxShadows.Parse("0 0 18 2 #554CAF50");
+ }
+
+ private static string ShortFileName(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return path;
+
+ var normalized = path.Replace('\\', '/');
+ if (normalized.Length <= 42)
+ return normalized;
+
+ var fileName = Path.GetFileName(normalized);
+ if (fileName.Length <= 30)
+ return fileName;
+
+ return fileName[..27] + "...";
+ }
+
+ private static string FormatDownloadSpeedAndEta(object progressArgs)
+ {
+ double speedBytes = 0;
+ if (TryGetDoubleProperty(progressArgs, "AverageBytesPerSecondSpeed", out var avg) && avg > 0)
+ speedBytes = avg;
+ else if (TryGetDoubleProperty(progressArgs, "BytesPerSecondSpeed", out var cur) && cur > 0)
+ speedBytes = cur;
+
+ var speedText = speedBytes > 0
+ ? $"{speedBytes / 1024.0 / 1024.0:F1} MB/s"
+ : "";
+
+ if (speedBytes <= 0 ||
+ !TryGetLongProperty(progressArgs, "TotalBytesToReceive", out var totalBytes) ||
+ !TryGetLongProperty(progressArgs, "ReceivedBytesSize", out var receivedBytes) ||
+ totalBytes <= 0 || receivedBytes < 0 || receivedBytes >= totalBytes)
+ {
+ return speedText;
+ }
+
+ var remainingBytes = totalBytes - receivedBytes;
+ var eta = TimeSpan.FromSeconds(remainingBytes / speedBytes);
+ var etaText = $"ETA {FormatEta(eta)}";
+
+ return string.IsNullOrEmpty(speedText) ? etaText : $"{speedText} • {etaText}";
+ }
+
+ private static string FormatExtractEta(System.Diagnostics.Stopwatch watch, double percent)
+ {
+ if (watch == null || !watch.IsRunning || percent <= 1.0)
+ return "";
+
+ var elapsed = watch.Elapsed.TotalSeconds;
+ var total = elapsed / (percent / 100.0);
+ var remaining = Math.Max(0, total - elapsed);
+ return $"ETA {FormatEta(TimeSpan.FromSeconds(remaining))}";
+ }
+
+ private static string FormatEta(TimeSpan eta)
+ {
+ if (eta.TotalHours >= 1)
+ return eta.ToString(@"hh\:mm\:ss");
+ return eta.ToString(@"mm\:ss");
+ }
+
+ private static bool TryGetDoubleProperty(object obj, string propertyName, out double value)
+ {
+ value = 0;
+ var prop = obj.GetType().GetProperty(propertyName);
+ if (prop == null) return false;
+ var raw = prop.GetValue(obj);
+ if (raw == null) return false;
+ try
+ {
+ value = Convert.ToDouble(raw);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryGetLongProperty(object obj, string propertyName, out long value)
+ {
+ value = 0;
+ var prop = obj.GetType().GetProperty(propertyName);
+ if (prop == null) return false;
+ var raw = prop.GetValue(obj);
+ if (raw == null) return false;
+ try
+ {
+ value = Convert.ToInt64(raw);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ // Minimal parser for launch options that supports quoted values.
+ private static IEnumerable ParseLaunchOptions(string options)
+ {
+ if (string.IsNullOrWhiteSpace(options))
+ yield break;
+
+ var current = new StringBuilder();
+ bool inQuotes = false;
+
+ foreach (var ch in options)
+ {
+ if (ch == '"')
+ {
+ inQuotes = !inQuotes;
+ continue;
+ }
+
+ if (char.IsWhiteSpace(ch) && !inQuotes)
+ {
+ if (current.Length > 0)
+ {
+ yield return current.ToString();
+ current.Clear();
+ }
+ continue;
+ }
+
+ current.Append(ch);
+ }
+
+ if (current.Length > 0)
+ yield return current.ToString();
+ }
+
+ }
+}
+
+
+
diff --git a/Wauncher/Views/SettingsWindow.axaml b/Wauncher/Views/SettingsWindow.axaml
new file mode 100644
index 0000000..0a67744
--- /dev/null
+++ b/Wauncher/Views/SettingsWindow.axaml
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wauncher/Views/SettingsWindow.axaml.cs b/Wauncher/Views/SettingsWindow.axaml.cs
new file mode 100644
index 0000000..c5efae8
--- /dev/null
+++ b/Wauncher/Views/SettingsWindow.axaml.cs
@@ -0,0 +1,36 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Wauncher.ViewModels;
+
+namespace Wauncher.Views
+{
+ public partial class SettingsWindow : Window
+ {
+ public SettingsWindow()
+ {
+ InitializeComponent();
+ DataContext = new SettingsWindowViewModel();
+
+ SettingsWindowViewModel.DiscordRpcChanged += OnDiscordRpcChangedExternally;
+ this.Closed += (_, _) => SettingsWindowViewModel.DiscordRpcChanged -= OnDiscordRpcChangedExternally;
+ }
+
+ private void OnDiscordRpcChangedExternally(bool enabled)
+ {
+ if (DataContext is SettingsWindowViewModel vm && vm.DiscordRpc != enabled)
+ vm.DiscordRpc = enabled;
+ }
+
+ private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginMoveDrag(e);
+ }
+
+ private void CloseButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ Close();
+ }
+ }
+}
diff --git a/Wauncher/Wauncher.csproj b/Wauncher/Wauncher.csproj
index 27333d1..989cc0e 100644
--- a/Wauncher/Wauncher.csproj
+++ b/Wauncher/Wauncher.csproj
@@ -7,13 +7,15 @@
enableenabletrue
+ trueapp.manifesttrue
+ NU1701
- truetrue
+ truefalse
- 3.0.0
+ 3.0.1$(Version)$(Version)wauncher
@@ -25,7 +27,8 @@
-
+
+
@@ -44,10 +47,20 @@
All
+
+
+
+
+
+
+
+
-
-
-
-
+
+ steam_api.dll
+
+
+ steam_api64.dll
+
diff --git a/Wauncher/Wauncher.sln b/Wauncher/Wauncher.sln
new file mode 100644
index 0000000..76aaceb
--- /dev/null
+++ b/Wauncher/Wauncher.sln
@@ -0,0 +1,24 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.2.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wauncher", "Wauncher.csproj", "{26B503D9-F947-924C-D517-78C86C319A7C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {26B503D9-F947-924C-D517-78C86C319A7C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {2B5005F6-85FA-4467-97A0-541E7DFF4DFA}
+ EndGlobalSection
+EndGlobal
diff --git a/Wauncher/steam_api.dll b/Wauncher/steam_api.dll
new file mode 100644
index 0000000..b7ae797
Binary files /dev/null and b/Wauncher/steam_api.dll differ
diff --git a/Wauncher/steam_api64.dll b/Wauncher/steam_api64.dll
new file mode 100644
index 0000000..f0a4154
Binary files /dev/null and b/Wauncher/steam_api64.dll differ
diff --git a/publish.bat b/publish.bat
index e3789b1..13147c7 100644
--- a/publish.bat
+++ b/publish.bat
@@ -1,15 +1,36 @@
@echo off
for /f %%a in ('echo prompt $E^| cmd') do set "ESC=%%a"
+setlocal
+
+set "publishDir=Wauncher\bin\Release\net8.0-windows7.0\win-x64\publish"
+
echo =============================
-echo %ESC%[42mBuilding...%ESC%[0m
-dotnet publish Launcher -c Release
-dotnet publish Wauncher -c Release
+echo %ESC%[42mBuilding Wauncher...%ESC%[0m
+dotnet publish Wauncher\Wauncher.csproj -c Release -r win-x64 --self-contained false
+if errorlevel 1 (
+ echo Publish failed.
+ exit /b 1
+)
+
echo =============================
-echo %ESC%[41mHashing...%ESC%[0m
-certutil -hashfile "Launcher\bin\Release\net8.0-windows7.0\win-x64\publish\launcher.exe" MD5
-certutil -hashfile "Wauncher\bin\Release\net8.0-windows7.0\win-x64\publish\wauncher.exe" MD5
+echo %ESC%[41mHashing wauncher.exe...%ESC%[0m
+certutil -hashfile "%publishDir%\wauncher.exe" MD5
+
echo =============================
-echo %ESC%[1;43mCopying...%ESC%[0m
-set /p "destination=Copying destination (in quotations): "
-xcopy "Wauncher\bin\Release\net8.0-windows7.0\win-x64\publish\" %destination% /e /y
-timeout /t 5
\ No newline at end of file
+echo %ESC%[1;43mCopying Wauncher publish output...%ESC%[0m
+set "defaultDest=C:\Games\ClassicCounter"
+set /p "destination=Destination folder (Enter for default: C:\Games\ClassicCounter): "
+if "%destination%"=="" set "destination=%defaultDest%"
+
+if not exist "%destination%" (
+ mkdir "%destination%"
+)
+
+robocopy "%publishDir%" "%destination%" /e /r:1 /w:1 /xf *.pdb >nul
+if errorlevel 8 (
+ echo Copy failed.
+ exit /b 1
+)
+
+echo Copied to: %destination% (without .pdb files)
+timeout /t 3 >nul