diff --git a/README.md b/README.md index b5a7f7e..54cba75 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

-

ClassicCounter Launcher

+

ClassicCounter Wauncher

- 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 @@ +Discord \ 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 @@ +Discord + 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 @@ +GitHub 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 @@ +Instagram \ 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 @@ +Instagram + 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 @@ +Counter-Strike \ 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 @@ +Counter-Strike + 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 @@ +Internet Archive \ 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+(?<date>(?: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<GitHubReleaseAsset>? 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<bool> CheckForSelfUpdateAsync() + { + _selfUpdateAvailable = false; + _selfUpdateDownloadUrl = string.Empty; + _selfUpdateVersion = string.Empty; + + try + { + var latestReleaseJson = await Api.GitHub.GetLatestRelease(); + var release = JsonConvert.DeserializeObject<GitHubRelease>(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<GitHubReleaseAsset>(); + 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<double>? 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<string> 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 @@ +<Window + x:Class="Wauncher.Views.SettingsWindow" + 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:vm="using:Wauncher.ViewModels" + Title="Settings" + Width="400" + Height="510" + d:DesignHeight="510" + d:DesignWidth="400" + x:DataType="vm:SettingsWindowViewModel" + SystemDecorations="None" + TransparencyLevelHint="AcrylicBlur" + WindowStartupLocation="CenterScreen" + Background="Transparent" + mc:Ignorable="d"> + + <Window.Styles> + <Style Selector="Button.closeBtn"> + <Setter Property="Padding" Value="0" /> + <Setter Property="BorderThickness" Value="0" /> + <Setter Property="CornerRadius" Value="0" /> + <Setter Property="Background" Value="Transparent" /> + <Setter Property="Transitions"> + <Transitions> + <BrushTransition Property="Background" Duration="0:0:0.15" /> + </Transitions> + </Setter> + <Setter Property="Template"> + <ControlTemplate> + <Border + Width="{TemplateBinding Width}" + Height="{TemplateBinding Height}" + Background="{TemplateBinding Background}"> + <ContentPresenter + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + Content="{TemplateBinding Content}" /> + </Border> + </ControlTemplate> + </Setter> + </Style> + <Style Selector="Button.closeBtn:pointerover"> + <Setter Property="Background" Value="#CCE53935" /> + </Style> + <Style Selector="Button.closeBtn:pressed"> + <Setter Property="Background" Value="#FFB71C1C" /> + </Style> + </Window.Styles> + + <Design.DataContext> + <vm:SettingsWindowViewModel /> + </Design.DataContext> + + <Grid Background="#3A3A3A"> + <Grid.RowDefinitions> + <RowDefinition Height="36" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + + <Grid + Grid.Row="0" + Background="{DynamicResource AppTitleBarBrush}" + PointerPressed="TitleBar_PointerPressed"> + <TextBlock + Margin="16,0,0,0" + VerticalAlignment="Center" + FontSize="11" + FontWeight="Bold" + LetterSpacing="2.5" + Foreground="{DynamicResource AppMutedText}" + Text="SETTINGS" + IsHitTestVisible="False" /> + <Button + HorizontalAlignment="Right" + Classes="closeBtn" + Width="44" + Height="36" + HorizontalContentAlignment="Center" + VerticalContentAlignment="Center" + Click="CloseButton_Click"> + <Path + Width="10" + Height="10" + Data="M0,0 L10,10 M10,0 L0,10" + Stroke="{DynamicResource AppPrimaryText}" + StrokeThickness="1.5" /> + </Button> + </Grid> + + <Grid Grid.Row="1" Background="Transparent"> + <StackPanel Margin="28,24" Spacing="0"> + <TextBlock + Margin="0,0,0,12" + FontSize="11" + FontWeight="Bold" + Foreground="{DynamicResource AppMutedText}" + LetterSpacing="2" + Text="GENERAL" /> + + <Grid Margin="0,0,0,20"> + <StackPanel VerticalAlignment="Center"> + <TextBlock + FontSize="14" + FontWeight="SemiBold" + Foreground="{DynamicResource AppPrimaryText}" + Text="Minimize to System Tray" /> + <TextBlock + FontSize="11" + Foreground="{DynamicResource AppMutedText}" + Text="Keep launcher running in the background" /> + </StackPanel> + <ToggleSwitch + HorizontalAlignment="Right" + VerticalAlignment="Center" + IsChecked="{Binding MinimizeToTray}" + OffContent="" + OnContent="" /> + </Grid> + + <Grid Margin="0,0,0,20"> + <StackPanel VerticalAlignment="Center"> + <TextBlock + FontSize="14" + FontWeight="SemiBold" + Foreground="{DynamicResource AppPrimaryText}" + Text="Skip Updates" /> + <TextBlock + FontSize="11" + Foreground="{DynamicResource AppMutedText}" + Text="Allow launching without patching first" /> + </StackPanel> + <ToggleSwitch + HorizontalAlignment="Right" + VerticalAlignment="Center" + IsChecked="{Binding SkipUpdates}" + OffContent="" + OnContent="" /> + </Grid> + + <Rectangle + Height="1" + Margin="0,4,0,20" + Fill="{DynamicResource AppDivider2}" /> + + <TextBlock + Margin="0,0,0,12" + FontSize="11" + FontWeight="Bold" + Foreground="{DynamicResource AppMutedText}" + LetterSpacing="2" + Text="INTEGRATIONS" /> + + <Grid Margin="0,0,0,20"> + <StackPanel VerticalAlignment="Center"> + <TextBlock + FontSize="14" + FontWeight="SemiBold" + Foreground="{DynamicResource AppPrimaryText}" + Text="Discord RPC" /> + <TextBlock + FontSize="11" + Foreground="{DynamicResource AppMutedText}" + Text="Show game activity in Discord" /> + </StackPanel> + <ToggleSwitch + HorizontalAlignment="Right" + VerticalAlignment="Center" + IsChecked="{Binding DiscordRpc}" + OffContent="" + OnContent="" /> + </Grid> + + <Rectangle + Height="1" + Margin="0,4,0,20" + Fill="{DynamicResource AppDivider2}" /> + + <TextBlock + Margin="0,0,0,12" + FontSize="11" + FontWeight="Bold" + Foreground="{DynamicResource AppMutedText}" + LetterSpacing="2" + Text="LAUNCH OPTIONS" /> + + <TextBox + Padding="12,10" + Background="{DynamicResource AppInputBg}" + BorderThickness="0" + CaretBrush="White" + CornerRadius="6" + FontSize="13" + Foreground="{DynamicResource AppPrimaryText}" + Watermark="e.g. -high -novid +fps_max 300" + Text="{Binding LaunchOptions, UpdateSourceTrigger=PropertyChanged}" /> + + </StackPanel> + </Grid> + </Grid> +</Window> + 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 @@ <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <BuiltInComInteropSupport>true</BuiltInComInteropSupport> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ApplicationManifest>app.manifest</ApplicationManifest> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> + <NoWarn>NU1701</NoWarn> - <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> <PublishSingleFile>true</PublishSingleFile> + <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <SelfContained>false</SelfContained> - <Version>3.0.0</Version> + <Version>3.0.1</Version> <AssemblyVersion>$(Version)</AssemblyVersion> <FileVersion>$(Version)</FileVersion> <AssemblyName>wauncher</AssemblyName> @@ -25,7 +27,8 @@ <ItemGroup> <Folder Include="Models\" /> - <AvaloniaResource Include="Assets\**" /> + <!-- Embed all assets including carousel images --> + <AvaloniaResource Include="Assets\**" Exclude="Assets\background.mp4;Assets\carousel_*.png" /> </ItemGroup> <ItemGroup> @@ -44,10 +47,20 @@ <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> </PackageReference> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> + <PackageReference Include="CSGSI" Version="1.3.1" /> + <PackageReference Include="DiscordRichPresence" Version="1.2.1.24" /> + <PackageReference Include="Downloader" Version="3.2.1" /> + <PackageReference Include="Gameloop.Vdf" Version="0.6.2" /> + <PackageReference Include="Refit" Version="8.0.0" /> + <PackageReference Include="Refit.Newtonsoft.Json" Version="8.0.0" /> + <PackageReference Include="SharpCompress" Version="0.47.0" /> + <PackageReference Include="Spectre.Console" Version="0.49.1" /> <PackageReference Include="Svg.Controls.Skia.Avalonia" Version="11.3.0.4" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\Launcher\Launcher.csproj" /> + <EmbeddedResource Include="steam_api.dll"> + <LogicalName>steam_api.dll</LogicalName> + </EmbeddedResource> + <EmbeddedResource Include="steam_api64.dll"> + <LogicalName>steam_api64.dll</LogicalName> + </EmbeddedResource> </ItemGroup> </Project> 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