├── MacAppBuilding ├── .gitignore ├── dmg_background.png ├── AppTemplate │ ├── AppIcon.icns │ ├── launch_wrapper │ ├── WinterspringLauncherTerminal │ └── Resources │ │ └── Info.plist ├── build_dmg.sh └── build_app.sh ├── GitVersion.yml ├── WinterspringLauncher ├── Assets │ ├── translations │ │ └── en.json │ ├── icons │ │ ├── language-icons │ │ │ ├── source.txt │ │ │ ├── chinese.png │ │ │ └── english.png │ │ ├── reset.png │ │ ├── folder.png │ │ ├── github.png │ │ ├── settings.png │ │ ├── winterspring-launcher-icon.ico │ │ └── winterspring-launcher-icon.png │ ├── fonts │ │ └── RobotoMono-Regular.ttf │ └── Resources.axaml ├── 7z.dll ├── App.axaml ├── LocaleDefaults.cs ├── App.axaml.cs ├── Utils │ ├── UnixApi.cs │ ├── UtilHelper.cs │ ├── DirectoryCopy.cs │ ├── HashHelper.cs │ ├── SimpleFileDownloader.cs │ ├── GitHubApi.cs │ ├── BinaryPatchHandler.cs │ ├── ProgressiveFileDownloader.cs │ └── ArchiveCompression.cs ├── app.manifest ├── Views │ ├── NewVersionAvailableDialog.axaml.cs │ ├── MainWindow.axaml.cs │ ├── NewVersionAvailableDialog.axaml │ └── MainWindow.axaml ├── UiElements │ └── HyperlinkSpan.cs ├── LauncherLogic.OpenGameFolder.cs ├── ProgramStartup.cs ├── LauncherVersion.cs ├── LauncherUpdateHandler.cs ├── WinterspringLauncher.csproj ├── ViewModels │ └── MainWindowViewModel.cs ├── LauncherLogic.cs ├── LauncherActions.cs ├── LauncherConfig.cs └── LauncherLogic.StartGame.cs ├── winterspring-launcher-icon.png ├── global.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── Build_Launcher.yml ├── WinterspringLauncher.sln.DotSettings ├── WinterspringLauncher.sln ├── LICENSE ├── README.md └── .gitignore /MacAppBuilding/.gitignore: -------------------------------------------------------------------------------- 1 | output/ 2 | output_dmg/ 3 | 4 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | main: 3 | regex: ^stable$ 4 | -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "start_game": "Start Game" 3 | } 4 | -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/language-icons/source.txt: -------------------------------------------------------------------------------- 1 | https://www.flaticon.com/packs/countrys-flags 2 | -------------------------------------------------------------------------------- /WinterspringLauncher/7z.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/7z.dll -------------------------------------------------------------------------------- /winterspring-launcher-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/winterspring-launcher-icon.png -------------------------------------------------------------------------------- /MacAppBuilding/dmg_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/MacAppBuilding/dmg_background.png -------------------------------------------------------------------------------- /MacAppBuilding/AppTemplate/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/MacAppBuilding/AppTemplate/AppIcon.icns -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/reset.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "7.0.0", 4 | "rollForward": "latestMinor", 5 | "allowPrerelease": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /MacAppBuilding/AppTemplate/launch_wrapper: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CURRENTPATH=`dirname "${0}"` 4 | 5 | open "$CURRENTPATH/WinterspringLauncherTerminal" 6 | -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/folder.png -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/github.png -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/settings.png -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/fonts/RobotoMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/fonts/RobotoMono-Regular.ttf -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/language-icons/chinese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/language-icons/chinese.png -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/language-icons/english.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/language-icons/english.png -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/winterspring-launcher-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/winterspring-launcher-icon.ico -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/icons/winterspring-launcher-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0blu/WinterspringLauncher/HEAD/WinterspringLauncher/Assets/icons/winterspring-launcher-icon.png -------------------------------------------------------------------------------- /WinterspringLauncher/Assets/Resources.axaml: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /MacAppBuilding/AppTemplate/WinterspringLauncherTerminal: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CURRENTPATH=`dirname "${0}"` 4 | 5 | # Resize terminal 6 | printf '\e[8;27;110t' 7 | 8 | clear 9 | 10 | cd "$CURRENTPATH" 11 | 12 | DYLD_LIBRARY_PATH="$CURRENTPATH/Libs" ./WinterspringLauncher 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Launcher bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Wait a moment, 11 | are you sure you want to create an issue with **this** launcher? 12 | If you have gameplay issues start a new BugReport at HermesProxy: https://github.com/WowLegacyCore/HermesProxy 13 | -------------------------------------------------------------------------------- /MacAppBuilding/AppTemplate/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleExecutable 6 | launch_wrapper 7 | CFBundleGetInfoString 8 | WinterspringLauncher {{VERSION}} 9 | CFBundleVersion 10 | {{VERSION}} 11 | CFBundleShortVersionString 12 | {{VERSION}} 13 | CFBundleIconFile 14 | AppIcon.icns 15 | 16 | 17 | -------------------------------------------------------------------------------- /MacAppBuilding/build_dmg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ ! -d "output" ]; then 6 | echo "Error: no 'output' directory" 7 | exit 1 8 | fi 9 | 10 | sips --setProperty dpiWidth 144 --setProperty dpiHeight 144 dmg_backgroung.png 11 | 12 | rm -rf output_dmg 13 | mkdir output_dmg 14 | create-dmg \ 15 | --volname "Winterspring Launcher Installer" \ 16 | --background dmg_background.png \ 17 | --window-size 525 310 \ 18 | --icon-size 90 \ 19 | --icon "Winterspring Launcher.app" 0 120 \ 20 | --hide-extension "Winterspring Launcher.app" \ 21 | --app-drop-link 280 120 \ 22 | output_dmg/WinterspringLauncher.dmg output 23 | -------------------------------------------------------------------------------- /WinterspringLauncher.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True 3 | True 4 | True 5 | True -------------------------------------------------------------------------------- /WinterspringLauncher/App.axaml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | avares://WinterspringLauncher/Assets/fonts/RobotoMono-Regular.ttf# 11 | 12 | #111111 13 | 14 | 15 | -------------------------------------------------------------------------------- /WinterspringLauncher/LocaleDefaults.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | namespace WinterspringLauncher; 5 | 6 | public static class LocaleDefaults 7 | { 8 | public static bool ShouldUseAsiaPreferences { get; set; } = CultureInfo.CurrentCulture.Name.StartsWith("zh", StringComparison.InvariantCultureIgnoreCase); 9 | 10 | public static string GetBestWoWConfigLocale() 11 | { 12 | return ShouldUseAsiaPreferences ? "zhCN" : "enUS"; 13 | } 14 | 15 | public static string? GetBestGitHubMirror() 16 | { 17 | return ShouldUseAsiaPreferences ? "https://asia.cdn.everlook.aclon.cn/github-mirror/api/" : null; 18 | } 19 | 20 | public static string GetBestServerName() 21 | { 22 | return ShouldUseAsiaPreferences ? "Everlook (Asia)" : "Everlook (Europe)"; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /WinterspringLauncher/App.axaml.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using Avalonia.Controls.ApplicationLifetimes; 3 | using Avalonia.Markup.Xaml; 4 | using WinterspringLauncher.ViewModels; 5 | using WinterspringLauncher.Views; 6 | 7 | namespace WinterspringLauncher; 8 | 9 | public partial class App : Application 10 | { 11 | public override void Initialize() 12 | { 13 | AvaloniaXamlLoader.Load(this); 14 | } 15 | 16 | public override void OnFrameworkInitializationCompleted() 17 | { 18 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) 19 | { 20 | desktop.MainWindow = new MainWindow 21 | { 22 | DataContext = new MainWindowViewModel() // <-- Will also initialize LauncherLogic 23 | }; 24 | } 25 | 26 | base.OnFrameworkInitializationCompleted(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/UnixApi.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace WinterspringLauncher.Utils; 4 | 5 | public static class UnixApi 6 | { 7 | [DllImport("libc", SetLastError = true)] 8 | public static extern int chmod(string pathname, int mode); 9 | 10 | // user permissions 11 | public const int PERM_USR_R = 0x100; 12 | public const int PERM_USR_W = 0x80; 13 | public const int PERM_USR_X = 0x40; 14 | 15 | // group permission 16 | public const int PERM_GRP_R = 0x20; 17 | public const int PERM_GRP_W = 0x10; 18 | public const int PERM_GRP_X = 0x8; 19 | 20 | // other permissions 21 | public const int PERM_OTH_R = 0x4; 22 | public const int PERM_OTH_W = 0x2; 23 | public const int PERM_OTH_X = 0x1; 24 | 25 | public const int PERM_0777 = 26 | PERM_USR_R | PERM_USR_X | PERM_USR_W | 27 | PERM_GRP_R | PERM_GRP_X | PERM_GRP_W | 28 | PERM_OTH_R | PERM_OTH_X | PERM_OTH_W; 29 | } 30 | -------------------------------------------------------------------------------- /WinterspringLauncher/app.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /WinterspringLauncher.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinterspringLauncher", "WinterspringLauncher\WinterspringLauncher.csproj", "{605A76A1-9D23-4C31-8699-FC89E4B5394A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(SolutionProperties) = preSolution 14 | HideSolutionNode = FALSE 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {605A76A1-9D23-4C31-8699-FC89E4B5394A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {605A76A1-9D23-4C31-8699-FC89E4B5394A}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {605A76A1-9D23-4C31-8699-FC89E4B5394A}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {605A76A1-9D23-4C31-8699-FC89E4B5394A}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/UtilHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace WinterspringLauncher.Utils; 4 | 5 | public static class UtilHelper 6 | { 7 | // Converts some arbitrary byte number to binary human unit (1024 -> "1.0 KiB") 8 | public static string ToHumanFileSize(long sizeInByte) 9 | { 10 | string[] units = { "Byte", "KiB", "MiB", "GiB", "TiB" }; 11 | int unitIdx = 0; 12 | 13 | double size = sizeInByte; 14 | while (size >= 1024 && unitIdx < units.Length - 1) { 15 | unitIdx++; 16 | size /= 1024; 17 | } 18 | 19 | string unit = units[unitIdx]; 20 | return $"{size:0.0} {unit}"; 21 | } 22 | 23 | public static string ReplaceFirstOccurrence(this string source, string needle, string replacement, StringComparison comparison = StringComparison.InvariantCulture) 24 | { 25 | int pos = source.IndexOf(needle, comparison); 26 | if (pos == -1) 27 | return source; 28 | 29 | string result = source.Remove(pos, needle.Length).Insert(pos, replacement); 30 | return result; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 _BLU (https://github.com/0blu) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /WinterspringLauncher/Views/NewVersionAvailableDialog.axaml.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using Avalonia.Controls; 3 | using Avalonia.Interactivity; 4 | using Avalonia.Markup.Xaml; 5 | using WinterspringLauncher.UiElements; 6 | 7 | namespace WinterspringLauncher.Views; 8 | 9 | public partial class NewVersionAvailableDialog : Window 10 | { 11 | public string NewVersion { get; set; } 12 | 13 | public NewVersionAvailableDialog(LauncherVersion.UpdateInformation updateInformation) 14 | { 15 | InitializeComponent(); 16 | #if DEBUG 17 | this.AttachDevTools(); 18 | #endif 19 | 20 | TextBlock version = this.Find("VersionIndicator")!; 21 | HyperlinkTextBlock dlLinkIndicator = this.Find("DlLinkIndicator")!; 22 | 23 | version.Text = updateInformation.VersionName; 24 | dlLinkIndicator.NavigateUri = updateInformation.URLLinkToReleasePage; 25 | dlLinkIndicator.Text = updateInformation.URLLinkToReleasePage; 26 | } 27 | 28 | private void InitializeComponent() 29 | { 30 | AvaloniaXamlLoader.Load(this); 31 | } 32 | 33 | private void CloseButtonClick(object? sender, RoutedEventArgs e) 34 | { 35 | Close(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /WinterspringLauncher/Views/MainWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | using WinterspringLauncher.ViewModels; 6 | 7 | namespace WinterspringLauncher.Views; 8 | 9 | public partial class MainWindow : Window 10 | { 11 | public new MainWindowViewModel DataContext 12 | { 13 | get => base.DataContext as MainWindowViewModel; 14 | set => base.DataContext = value; 15 | } 16 | 17 | public MainWindow() 18 | { 19 | InitializeComponent(); 20 | LogScroller.PropertyChanged += LogChanged; 21 | } 22 | 23 | private void LogChanged(object? sender, AvaloniaPropertyChangedEventArgs e) 24 | { 25 | LogScroller.ScrollToEnd(); 26 | } 27 | 28 | private void ServerSelectionChanged(object? sender, SelectionChangedEventArgs e) 29 | { 30 | if (sender is ComboBox comboBox) 31 | { 32 | DataContext._selectedServerIdx = comboBox.SelectedIndex; 33 | DataContext.Logic.ChangeServerIdx(); 34 | } 35 | } 36 | 37 | protected override void OnClosing(WindowClosingEventArgs e) 38 | { 39 | DataContext.Logic.KillHermesProxy(); 40 | base.OnClosing(e); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/DirectoryCopy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace WinterspringLauncher.Utils; 5 | 6 | public static class DirectoryCopy 7 | { 8 | public static void Copy(string sourceDirectory, string targetDirectory) 9 | { 10 | DirectoryInfo diSource = new DirectoryInfo(sourceDirectory); 11 | DirectoryInfo diTarget = new DirectoryInfo(targetDirectory); 12 | 13 | CopyAll(diSource, diTarget); 14 | } 15 | 16 | public static void CopyAll(DirectoryInfo source, DirectoryInfo target) 17 | { 18 | Directory.CreateDirectory(target.FullName); 19 | 20 | // Copy each file into the new directory. 21 | foreach (FileInfo fi in source.GetFiles()) 22 | { 23 | Console.WriteLine(@"Copying {0}\{1}", target.FullName, fi.Name); 24 | fi.CopyTo(Path.Combine(target.FullName, fi.Name), true); 25 | } 26 | 27 | // Copy each subdirectory using recursion. 28 | foreach (DirectoryInfo diSourceSubDir in source.GetDirectories()) 29 | { 30 | DirectoryInfo nextTargetSubDir = 31 | target.CreateSubdirectory(diSourceSubDir.Name); 32 | CopyAll(diSourceSubDir, nextTargetSubDir); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/HashHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security.Cryptography; 4 | using System.Text; 5 | 6 | namespace WinterspringLauncher.Utils; 7 | 8 | public class HashHelper 9 | { 10 | public static string CreateHexSha256HashFromFilename(string filePath) 11 | { 12 | using (FileStream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) 13 | using (SHA256 sha256 = SHA256.Create()) 14 | { 15 | byte[] hashBytes = sha256.ComputeHash(stream); 16 | return ConvertBinarySha256ToHex(hashBytes); 17 | } 18 | } 19 | 20 | public static string CreateHexSha256HashFromFileBytes(byte[] fileContent) 21 | { 22 | using (SHA256 sha256 = SHA256.Create()) 23 | { 24 | byte[] hashBytes = sha256.ComputeHash(fileContent); 25 | return ConvertBinarySha256ToHex(hashBytes); 26 | } 27 | } 28 | 29 | public static string ConvertBinarySha256ToHex(byte[] binarySha256Hash) 30 | { 31 | if (binarySha256Hash.Length != 32) 32 | throw new ArgumentException("Expected a 32byte long Sha256 hash"); 33 | 34 | StringBuilder hashBuilder = new StringBuilder(32); 35 | foreach (byte b in binarySha256Hash) 36 | hashBuilder.Append(b.ToString("X2")); 37 | return hashBuilder.ToString(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /WinterspringLauncher/UiElements/HyperlinkSpan.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using Avalonia.Controls; 3 | using Avalonia.Input; 4 | using Avalonia.Media; 5 | 6 | namespace WinterspringLauncher.UiElements; 7 | 8 | public class HyperlinkTextBlock : TextBlock 9 | { 10 | public static readonly DirectProperty NavigateUriProperty = 11 | AvaloniaProperty.RegisterDirect( 12 | nameof(NavigateUri), 13 | o => o.NavigateUri, 14 | (o, v) => o.NavigateUri = v); 15 | 16 | private string _navigateUri; 17 | 18 | public string NavigateUri 19 | { 20 | get => _navigateUri; 21 | set => SetAndRaise(NavigateUriProperty, ref _navigateUri, value); 22 | } 23 | 24 | public HyperlinkTextBlock() 25 | { 26 | AddHandler(PointerPressedEvent, OnPointerPressed); 27 | PseudoClasses.Add(":pointerover"); 28 | Cursor = new Cursor(StandardCursorType.Hand); 29 | Foreground = Brush.Parse("#2E95D3"); 30 | } 31 | 32 | private void OnPointerPressed(object sender, PointerPressedEventArgs e) 33 | { 34 | if (!string.IsNullOrEmpty(NavigateUri)) 35 | { 36 | // Open the link here, for example, by launching a browser 37 | System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(NavigateUri) { UseShellExecute = true }); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /MacAppBuilding/build_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ $# -ne 2 ]; then 6 | echo "Error: Invalid arguments" 7 | echo "Run: buid_app.sh " 8 | exit 1 9 | fi 10 | 11 | if [ ! -d "AppTemplate" ]; then 12 | echo "Error: AppTemplate folder is not in cwd" 13 | exit 1 14 | fi 15 | 16 | VERSION="$1" 17 | EXE_FILE="$2" 18 | 19 | if [ ! -f "$EXE_FILE" ]; then 20 | echo "Error: '$EXE_FILE' is not a valid file" 21 | exit 1 22 | fi 23 | 24 | OPENSSL_DL="https://github.com/0blu/prebuilt-openssl3-for-macos/releases/download/openssl-3.0.7/openssl-3.0.7.zip" 25 | APP_PATH="output/Winterspring Launcher.app" 26 | 27 | echo "Building for:" 28 | echo "- Version: $VERSION" 29 | echo "- Binary: $EXE_FILE" 30 | echo "- Result: $APP_PATH" 31 | 32 | echo "Deleting existing app" 33 | rm -rf "$APP_PATH" 34 | mkdir -p "$APP_PATH" 35 | 36 | echo "Copy template" 37 | cp -r AppTemplate/* "$APP_PATH/." 38 | 39 | echo "Download openssl3" 40 | mkdir "$APP_PATH/Libs" 41 | curl --fail -SL "$OPENSSL_DL" -o "$APP_PATH/Libs/openssl3.zip" 42 | (cd "$APP_PATH/Libs/" \ 43 | && unzip ./openssl3.zip \ 44 | && mv openssl-3.*/*.3.dylib . \ 45 | && rm openssl3.zip \ 46 | && rm -rf openssl-3.* \ 47 | ) 48 | 49 | echo "Copying launcher executable" 50 | cp "$EXE_FILE" "$APP_PATH" 51 | 52 | echo "Replace version in info.plist" 53 | sed -i.bak "s/{{VERSION}}/$VERSION/g" "$APP_PATH/Resources/info.plist" 54 | 55 | echo "Making everthing executable" 56 | chmod -R a+x "$APP_PATH" 57 | 58 | echo "Done building '$APP_PATH'" 59 | -------------------------------------------------------------------------------- /WinterspringLauncher/Views/NewVersionAvailableDialog.axaml: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/SimpleFileDownloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using System.Net.Http; 4 | using System.Text.Json; 5 | 6 | namespace WinterspringLauncher.Utils; 7 | 8 | public static class SimpleFileDownloader 9 | { 10 | public static string PerformGetStringRequest(string url) 11 | { 12 | using var client = new HttpClient(); 13 | client.DefaultRequestHeaders.Add("User-Agent", "curl/7.0.0"); // otherwise we get blocked 14 | var response = client.GetAsync(url).GetAwaiter().GetResult(); 15 | response.EnsureSuccessStatusCode(); 16 | var rawData = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); 17 | return rawData; 18 | } 19 | 20 | public static TJsonResponse PerformGetJsonRequest(string url) 21 | { 22 | var rawData = PerformGetStringRequest(url); 23 | var parsedJson = JsonSerializer.Deserialize(rawData); 24 | if (parsedJson == null) 25 | { 26 | Console.WriteLine($"Debug: {rawData}"); 27 | throw new NoNullAllowedException("The web response resulted in an null object"); 28 | } 29 | return parsedJson; 30 | } 31 | 32 | public static byte[] PerformGetBytesRequest(string url) 33 | { 34 | using var client = new HttpClient(); 35 | client.DefaultRequestHeaders.Add("User-Agent", "curl/7.0.0"); // otherwise we get blocked 36 | var response = client.GetAsync(url).GetAwaiter().GetResult(); 37 | response.EnsureSuccessStatusCode(); 38 | var rawData = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); 39 | return rawData; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherLogic.OpenGameFolder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | 8 | namespace WinterspringLauncher; 9 | 10 | public partial class LauncherLogic 11 | { 12 | public void OpenGameFolder() 13 | { 14 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 15 | 16 | var serverInfo = _config.KnownServers.ElementAtOrDefault(_model.SelectedServerIdx); 17 | if (serverInfo == null) 18 | { 19 | _model.AddLogEntry("Error invalid server settings"); 20 | _model.InputIsAllowed = true; 21 | return; 22 | } 23 | 24 | var gameInstallation = _config.GameInstallations.GetValueOrDefault(serverInfo.UsedInstallation); 25 | if (gameInstallation == null) 26 | { 27 | _model.AddLogEntry($"Error cant find '{serverInfo.UsedInstallation}' installation in settings"); 28 | _model.InputIsAllowed = true; 29 | return; 30 | } 31 | 32 | var absPath = Path.GetFullPath(gameInstallation.Directory); 33 | if (!Directory.Exists(absPath)) 34 | { 35 | _model.AddLogEntry("Game folder does not exists"); 36 | _model.AddLogEntry($"Expected path: {absPath}"); 37 | return; 38 | } 39 | 40 | _model.AddLogEntry("Opening game folder"); 41 | try 42 | { 43 | if (weAreOnMacOs) 44 | Process.Start("open", $"-R \"{absPath}\""); 45 | else 46 | Process.Start("explorer.exe", absPath); 47 | } 48 | catch (Exception e) 49 | { 50 | _model.AddLogEntry($"An error occured while opening game folder"); 51 | Console.WriteLine(e); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /WinterspringLauncher/ProgramStartup.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using System; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | using System.Threading; 8 | using Avalonia.Logging; 9 | 10 | namespace WinterspringLauncher; 11 | 12 | class ProgramStartup 13 | { 14 | // Initialization code. Don't use any Avalonia, third-party APIs or any 15 | // SynchronizationContext-reliant code before AppMain is called: things aren't initialized 16 | // yet and stuff might break. 17 | [STAThread] 18 | public static void Main(string[] args) 19 | { 20 | CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; 21 | Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; 22 | 23 | if (LauncherUpdateHandler.HandleStartArguments(args)) 24 | return; 25 | 26 | if (args.Contains("--use-asia-defaults")) 27 | LocaleDefaults.ShouldUseAsiaPreferences = true; 28 | 29 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 30 | if (weAreOnMacOs) 31 | { 32 | string home = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "WinterspringLauncher"); 33 | Directory.CreateDirectory(home); 34 | Environment.CurrentDirectory = home; 35 | } 36 | else 37 | { 38 | Environment.CurrentDirectory = Path.GetDirectoryName(AppContext.BaseDirectory)!; 39 | } 40 | 41 | BuildAvaloniaApp() 42 | .StartWithClassicDesktopLifetime(args); 43 | } 44 | 45 | // Avalonia configuration, don't remove; also used by visual designer. 46 | public static AppBuilder BuildAvaloniaApp() 47 | => AppBuilder.Configure() 48 | .UsePlatformDetect() 49 | .WithInterFont() 50 | .LogToTrace(LogEventLevel.Verbose); 51 | } 52 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherVersion.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using WinterspringLauncher.Utils; 4 | 5 | namespace WinterspringLauncher; 6 | 7 | public static class LauncherVersion 8 | { 9 | public static string ShortVersionString 10 | { 11 | get 12 | { 13 | string version = GitVersionInformation.MajorMinorPatch; 14 | 15 | if (GitVersionInformation.CommitsSinceVersionSource != "0") 16 | version += $"+{GitVersionInformation.CommitsSinceVersionSource}"; 17 | 18 | if (GitVersionInformation.UncommittedChanges != "0") 19 | version += " dirty"; 20 | 21 | return version; 22 | } 23 | } 24 | 25 | public static string DetailedVersionString => GitVersionInformation.InformationalVersion; 26 | 27 | public static bool IsNotMainBranch => GitVersionInformation.CommitsSinceVersionSource != "0" || GitVersionInformation.UncommittedChanges != "0"; 28 | 29 | public static bool CheckIfUpdateIsAvailable([NotNullWhen(true)] out UpdateInformation? updateInformation) 30 | { 31 | updateInformation = null; 32 | 33 | if (IsNotMainBranch) 34 | { 35 | Console.WriteLine("Skip update check because not main branch (or local dev version)"); 36 | return false; // we are probably in a test branch 37 | } 38 | 39 | var latestLauncherVersion = GitHubApi.LatestReleaseVersion("0blu/WinterspringLauncher"); 40 | if (latestLauncherVersion.TagName == null) 41 | throw new Exception("No latest version?"); 42 | 43 | var myVersion = Version.Parse(GitVersionInformation.MajorMinorPatch); 44 | var newVersion = Version.Parse(latestLauncherVersion.TagName); 45 | if (newVersion > myVersion) 46 | { 47 | Console.WriteLine($"New launcher update {myVersion.ToString(fieldCount: 2)} => {newVersion.ToString(fieldCount: 2)}"); 48 | updateInformation = new UpdateInformation 49 | { 50 | ReleaseDate = latestLauncherVersion.PublishedAt, 51 | VersionName = latestLauncherVersion.TagName, 52 | URLLinkToReleasePage = "https://github.com/0blu/WinterspringLauncher/releases", 53 | }; 54 | return true; 55 | } 56 | 57 | return false; 58 | } 59 | 60 | public class UpdateInformation 61 | { 62 | public DateTime ReleaseDate; 63 | public string VersionName; 64 | public string URLLinkToReleasePage; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/GitHubApi.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Text.Json; 7 | using System.Text.Json.Serialization; 8 | 9 | namespace WinterspringLauncher.Utils; 10 | 11 | public static class GitHubApi 12 | { 13 | public static string GitHubApiAddress { get; set; } = "https://api.github.com/"; 14 | 15 | public static GitHubReleaseInfo LatestReleaseVersion(string repoName) 16 | { 17 | var releaseUrl = new Uri(new Uri(GitHubApiAddress), $"repos/{repoName}/releases/latest").ToString(); 18 | var releaseInfo = PerformWebRequest(releaseUrl); 19 | return releaseInfo; 20 | } 21 | 22 | private static TJsonResponse PerformWebRequest(string url) where TJsonResponse : new() 23 | { 24 | using var client = new HttpClient(); 25 | client.DefaultRequestHeaders.Add("User-Agent", "curl/7.0.0"); // otherwise we get blocked 26 | var response = client.GetAsync(url).GetAwaiter().GetResult(); 27 | if (response.StatusCode == HttpStatusCode.Forbidden) 28 | { 29 | if (response.ReasonPhrase == "rate limit exceeded") 30 | { 31 | Console.WriteLine("You are being rate-limited, did you open the launcher too many times in a short time?"); 32 | return new TJsonResponse(); 33 | } 34 | } 35 | response.EnsureSuccessStatusCode(); 36 | var rawJson = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); // easier to debug with a string and the performance is negligible for such small jsons 37 | var parsedJson = JsonSerializer.Deserialize(rawJson); 38 | if (parsedJson == null) 39 | { 40 | Console.WriteLine($"Debug: {rawJson}"); 41 | throw new NoNullAllowedException("The web response resulted in an null object"); 42 | } 43 | return parsedJson; 44 | } 45 | } 46 | 47 | public class GitHubReleaseInfo 48 | { 49 | [JsonPropertyName("name")] 50 | public string? Name { get; set; } 51 | 52 | [JsonPropertyName("published_at")] 53 | public DateTime PublishedAt { get; set; } 54 | 55 | [JsonPropertyName("tag_name")] 56 | public string? TagName { get; set; } 57 | 58 | [JsonPropertyName("assets")] 59 | public List? Assets { get; set; } 60 | 61 | public class Asset 62 | { 63 | [JsonPropertyName("name")] 64 | public string Name { get; set; } = null!; 65 | 66 | [JsonPropertyName("browser_download_url")] 67 | public string DownloadUrl { get; set; } = null!; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/Build_Launcher.yml: -------------------------------------------------------------------------------- 1 | name: Build Launcher 2 | 3 | on: ['push'] 4 | 5 | env: 6 | DOTNET_VERSION: '7.0.x' 7 | 8 | jobs: 9 | build_windows: 10 | strategy: 11 | matrix: 12 | os: ['windows'] 13 | runs-on: ${{ matrix.os }}-latest 14 | 15 | steps: 16 | - name: Checkout repository content 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup .NET Core SDK 22 | uses: actions/setup-dotnet@v2 23 | with: 24 | dotnet-version: ${{ env.DOTNET_VERSION }} 25 | 26 | - name: Install dependencies 27 | run: dotnet restore 28 | 29 | - name: Publish 30 | run: dotnet publish --configuration Release --use-current-runtime -p:UsePublishBuildSettings=true 31 | 32 | - name: Copy files 33 | run: cp -r ./WinterspringLauncher/bin/Release/*/publish/ publish 34 | 35 | - name: Upload build artifact 36 | uses: actions/upload-artifact@v3 37 | with: 38 | name: WinterspringLauncher-${{ matrix.os }}-${{ runner.arch }}-${{ github.sha }} 39 | path: publish 40 | if-no-files-found: error 41 | 42 | build_macos: 43 | strategy: 44 | matrix: 45 | os: ['macos'] 46 | runs-on: ${{ matrix.os }}-latest 47 | 48 | steps: 49 | - name: Checkout repository content 50 | uses: actions/checkout@v3 51 | with: 52 | fetch-depth: 0 53 | 54 | - name: Setup .NET Core SDK 55 | uses: actions/setup-dotnet@v2 56 | with: 57 | dotnet-version: ${{ env.DOTNET_VERSION }} 58 | 59 | - name: Install dependencies 60 | run: dotnet restore 61 | 62 | - name: Publish 63 | run: dotnet publish --configuration Release --runtime osx-arm64 -p:UsePublishBuildSettings=true 64 | 65 | - name: Determinante tag 66 | run: echo "GIT_TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV 67 | 68 | - name: Create .app 69 | working-directory: MacAppBuilding 70 | run: ./build_app.sh "$GIT_TAG" ../WinterspringLauncher/bin/Release/*/publish/WinterspringLauncher 71 | 72 | - name: Create .app zip 73 | working-directory: MacAppBuilding 74 | run: | 75 | cd output 76 | zip -vr ../../WinterspringLauncher-${{ matrix.os }}-arm64-${{ github.sha }}-APP.zip * 77 | 78 | - name: Upload .app 79 | uses: actions/upload-artifact@v3 80 | with: 81 | name: WinterspringLauncher-${{ matrix.os }}-arm64-${{ github.sha }}.app 82 | path: WinterspringLauncher-${{ matrix.os }}-arm64-${{ github.sha }}-APP.zip 83 | if-no-files-found: error 84 | 85 | - name: Install create-dmg 86 | run: brew install create-dmg 87 | 88 | - name: Create .dmg 89 | working-directory: MacAppBuilding 90 | run: ./build_dmg.sh 91 | 92 | - name: Upload .dmg 93 | uses: actions/upload-artifact@v3 94 | with: 95 | name: WinterspringLauncher-${{ matrix.os }}-arm64-${{ github.sha }}.dmg 96 | path: MacAppBuilding/output_dmg/ 97 | if-no-files-found: error 98 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherUpdateHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using System.Threading; 6 | 7 | namespace WinterspringLauncher; 8 | 9 | public static class LauncherUpdateHandler 10 | { 11 | public static bool/*exitNow*/ HandleStartArguments(string[] args) 12 | { 13 | if (args.Length != 2) 14 | return false; 15 | 16 | var actionName = args[0]; 17 | var targetPath = args[1]; 18 | if (string.IsNullOrWhiteSpace(targetPath)) 19 | { 20 | Console.WriteLine("AutoUpdate: Target path is empty"); 21 | return true; 22 | } 23 | 24 | switch (actionName) 25 | { 26 | case "--copy-self-to": 27 | { 28 | CreateTerminalWindowIfPossible(); 29 | Console.WriteLine($"Updating launcher '{targetPath}'"); 30 | var ourPath = Process.GetCurrentProcess().MainModule!.FileName!; 31 | bool wasSuccessful = false; 32 | const int maxTries = 20; 33 | for (int i = 0; i < maxTries; i++) 34 | { 35 | try 36 | { 37 | File.Copy(ourPath, targetPath, overwrite: true); 38 | wasSuccessful = true; 39 | } 40 | catch(IOException) 41 | { 42 | Console.WriteLine($"Need to wait for old process to close (this might take a bit) (try {i + 1}/{maxTries})"); 43 | Thread.Sleep(TimeSpan.FromMilliseconds(500)); 44 | } 45 | } 46 | 47 | if (!wasSuccessful) 48 | { 49 | Console.WriteLine("Update was not successful, please try again or update manually"); 50 | Thread.Sleep(TimeSpan.FromSeconds(10)); 51 | return true; 52 | } 53 | 54 | Console.WriteLine("Start new launcher"); 55 | Process.Start(new ProcessStartInfo{ 56 | FileName = targetPath, 57 | Arguments = $"--delete-tmp-updater-file \"{ourPath}\"", 58 | UseShellExecute = true, 59 | }); 60 | return true; 61 | } 62 | case "--delete-tmp-updater-file": 63 | { 64 | Thread.Sleep(TimeSpan.FromMilliseconds(500)); 65 | try 66 | { 67 | Console.WriteLine($"Removing tmp file '{targetPath}'"); 68 | File.Delete(targetPath); 69 | } 70 | catch 71 | { 72 | // Ignore 73 | } 74 | return false; // keep our current instance 75 | } 76 | default: 77 | return false; 78 | } 79 | } 80 | 81 | #if PLATFORM_WINDOWS 82 | [DllImport("kernel32.dll")] 83 | static extern bool AttachConsole(int dwProcessId); 84 | private const int ATTACH_PARENT_PROCESS = -1; 85 | #endif 86 | 87 | private static void CreateTerminalWindowIfPossible() 88 | { 89 | #if PLATFORM_WINDOWS 90 | AttachConsole(ATTACH_PARENT_PROCESS); 91 | #endif 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /WinterspringLauncher/WinterspringLauncher.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | WinExe 5 | net7.0 6 | enable 7 | true 8 | app.manifest 9 | Assets/icons/winterspring-launcher-icon.ico 10 | false 11 | embedded 12 | _BLU 13 | 14 | 15 | 16 | 17 | 18 | win-x64 19 | true 20 | true 21 | true 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | true 50 | 51 | 52 | 53 | PLATFORM_WINDOWS 54 | 55 | 56 | 57 | 58 | All 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | icon 3 |
4 | 5 | # Winterspring Launcher 6 | Allows you to play on [Everlook.org](https://everlook.org/) with modern 1.14 WoW Client! 7 | _This is not an official project from the Everlook team._ 8 | _Do not ask the Everlook team for support_ 9 | _(you can still ask in [#addons-and-ui](https://discord.com/channels/973529971740008448/983067524797177996) on Discord)_ 10 | 11 | ## Easy setup 12 | The launcher will do everything for you. 13 | It will download the 1.14 client, setup HermesProxy and launch the game. 14 | When HermesProxy has an update it is automatically applied. 15 | 16 | ### Windows windows 17 | 1. **Download [the latest .exe release](https://github.com/0blu/EverlookClassicLauncher/releases/latest)** 18 | 2. Place it in a separate directory (On first launch it will create subfolders and a desktop icon) 19 | 3. Run it 20 | 4. **Enjoy your stay on Everlook** 21 | 22 | ### MacOS macos 23 | 1. **Download [the latest .dmg release](https://github.com/0blu/EverlookClassicLauncher/releases/latest)** 24 | 2. Click on the downloaded .dmg file to open the installer 25 | 3. Drag the Launcher into your Applications folder 26 | 4. Go into your Applications folder, right click the Launcher and **click "Open"** 27 | 5. **Enjoy your stay on Everlook** 28 | _On MacOS the client will be stored in `/Users//WinterspringLauncher/`_ 29 | 30 | ## Addons 31 | Most addons for 1.14.0 should work. 32 | You can download older versions from CurseForge under the "Files" tab. ([Example](https://www.curseforge.com/wow/addons/questie/files/all?filter-game-version=2020709689%3A9094)) 33 | Here are some working recommendations: 34 | (click the [[dl](#)] link to get a working zip) 35 | ### Questing 36 | - [[dl](https://www.curseforge.com/wow/addons/questie/download/3519759)] [Questie](https://www.curseforge.com/wow/addons/questie) <- Must have. Even in combination with other addons. 37 | - [[dl](https://www.curseforge.com/wow/addons/guidelime/download/4026001)] [Guidelime (base)](https://www.curseforge.com/wow/addons/guidelime) 38 | - [[dl](https://www.curseforge.com/wow/addons/guidelime_sage/download/3810259)] [Guidelime: Sage Guide](https://www.curseforge.com/wow/addons/guidelime_sage) (Alliance) 39 | - [[dl](https://www.curseforge.com/wow/addons/guidelime-busteas-1-60-leveling/download/3521451)] [Guidelime: Busteas Guide](https://www.curseforge.com/wow/addons/guidelime-busteas-1-60-leveling) (Horde) 40 | 41 | ### Interface 42 | - [[dl](https://www.curseforge.com/wow/addons/modern-targetframe/download/4024275)] [ModernTargetFrame](https://www.curseforge.com/wow/addons/modern-targetframe) (To see the HP of mobs) 43 | - [[dl](https://github.com/tukui-org/ElvUI/archive/refs/tags/v1.48-classic.zip)] [ElvUI](https://github.com/tukui-org/ElvUI/releases/tag/v1.48-classic) (slightly older working version) 44 | 45 | (Feel free to give me new recommendations to extend the list) 46 | 47 | ## Why? 48 | The modern WoW-Classic client provides many improvements in hardware compatibility and accessibility. 49 | 50 | With Everlook we have the unique chance to improve HermesProxy with a large testing audience. 51 | The launcher will make the classic client accessible to everyone. 52 | So if you find any bugs [please report them](https://github.com/WowLegacyCore/HermesProxy/issues/new/choose). 53 | (If you find any bugs associated to the launcher report them [here](https://github.com/0blu/WinterspringLauncher/issues)) 54 | 55 | ## Is this allowed? 56 | Currently HermesProxy is tolerated on Everlook. 57 | ⚠️You **will** get suspended if you exploit any game breaking bugs. 58 | So far no-one has been banned for using official HermesProxy. 59 | Just use common sense and **play fair**! 60 | 61 | # Many thanks to 62 | - [HermesProxy](https://github.com/WowLegacyCore/HermesProxy) to translate legacy traffic to modern one 63 | - [Arctium WoW-Launcher](https://github.com/Arctium/WoW-Launcher) to patch client for custom server connections 64 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/BinaryPatchHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Security.Cryptography; 5 | using System.Text.Json.Serialization; 6 | 7 | namespace WinterspringLauncher.Utils; 8 | 9 | public class BinaryPatchHandler 10 | { 11 | public class PatchSummary 12 | { 13 | [JsonPropertyName("windows")] 14 | public PatchSummaryEntry? Windows { get; set; } 15 | 16 | [JsonPropertyName("macos")] 17 | public PatchSummaryEntry? MacOs { get; set; } 18 | 19 | public class PatchSummaryEntry 20 | { 21 | [JsonPropertyName("from_sha256")] 22 | public string FromSha256 { get; set; } = null!; 23 | 24 | [JsonPropertyName("to_sha256")] 25 | public string ToSha256 { get; set; } = null!; 26 | 27 | [JsonPropertyName("last_update")] 28 | public ulong LastUpdate { get; set; } = 0; 29 | 30 | [JsonPropertyName("patch_filename")] 31 | public string PatchFilename { get; set; } = null!; 32 | } 33 | } 34 | 35 | public static void ApplyPatch(byte[] patchFileContent, string sourceFile, string targetFile) 36 | { 37 | // Read header information 38 | byte[] magic = patchFileContent.Take(4).ToArray(); 39 | if (!magic.SequenceEqual(new byte[] { 0x42, 0x42, 0x50, 0x31 })) 40 | throw new ArgumentException("Invalid patch file format (expected BBP1)"); 41 | 42 | // Verifying signature 43 | { 44 | byte[] everythingButSignature = patchFileContent.SkipLast(256).ToArray(); 45 | byte[] signature = patchFileContent.TakeLast(256).ToArray(); 46 | 47 | VerifySignatureOrThrow(everythingButSignature, signature); 48 | } 49 | 50 | byte[] fileBytes = File.ReadAllBytes(sourceFile); 51 | 52 | string expectedOriginalHash = HashHelper.ConvertBinarySha256ToHex(patchFileContent.Skip(4).Take(32).ToArray()); 53 | string actualOriginalHash = HashHelper.CreateHexSha256HashFromFileBytes(fileBytes); 54 | if (actualOriginalHash != expectedOriginalHash) 55 | throw new Exception($"Cannot apply patch because the hash of source file is incorrect. Expected '{expectedOriginalHash}' Actual: '{actualOriginalHash}'"); 56 | 57 | string expectedHashAfterPatch = HashHelper.ConvertBinarySha256ToHex(patchFileContent.Skip(36).Take(32).ToArray()); 58 | ulong patchCount = BitConverter.ToUInt64(patchFileContent, startIndex: 68); 59 | 60 | long currentPosition = 76; // Start after the header 61 | ulong patchesApplied = 0; 62 | 63 | for (ulong patchEntryIdx = 0; patchEntryIdx < patchCount; patchEntryIdx++) 64 | { 65 | if (currentPosition + 12 > patchFileContent.Length) 66 | { 67 | throw new ArgumentException("Patch file is incomplete"); 68 | } 69 | 70 | int fileOffset = (int)BitConverter.ToUInt64(patchFileContent, (int)currentPosition); 71 | uint patchSize = BitConverter.ToUInt32(patchFileContent, (int)(currentPosition + 8)); 72 | 73 | currentPosition += 12; 74 | 75 | // If the file offset is out of bounds, extend the file and initialize with 0x00 76 | if (fileOffset > fileBytes.Length) 77 | Array.Resize(ref fileBytes, (int)(fileOffset + patchSize)); 78 | 79 | // Apply the patch to the source file 80 | for (int patchByteIdx = 0; patchByteIdx < patchSize; patchByteIdx++) 81 | fileBytes[fileOffset + patchByteIdx] = patchFileContent[currentPosition + patchByteIdx]; 82 | 83 | currentPosition += patchSize; 84 | 85 | patchesApplied++; 86 | } 87 | 88 | if (patchesApplied != patchCount) 89 | throw new InvalidOperationException("Not all patches were applied"); 90 | 91 | // Verify the integrity of the patched file 92 | string actualHashAfterPatch = HashHelper.CreateHexSha256HashFromFileBytes(fileBytes); 93 | if (actualHashAfterPatch != expectedHashAfterPatch) 94 | throw new Exception($"Invalid patch result. Expected '{expectedHashAfterPatch}' Actual: '{actualHashAfterPatch}'"); 95 | 96 | File.WriteAllBytes(targetFile, fileBytes); 97 | } 98 | 99 | private static void VerifySignatureOrThrow(byte[] bytesToVerify, byte[] signature) 100 | { 101 | if (signature.Length != 256) 102 | throw new ArgumentException("Signature must be 256 bytes long"); 103 | 104 | // ref https://wow-patches.blu.wtf/sign_key.pub 105 | const string publicKey = @" 106 | -----BEGIN PUBLIC KEY----- 107 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmy/cX6/VOlOpgLnQWnWS 108 | tFVqf9xAO2uNjeSeUHmiMTQTwfm8hnDbcEAz5V4ou987dfDxXZb5WGVxoHnugMS/ 109 | rUrOSZ8VQolH+3IanhFNrqRxRTOVk+ZlTrxV9k1iC34kXeoRryiQcqMYLlX4jT3E 110 | EupzAivNsJYm2X/jVGFgPfrDObwOjq23aLdey2uI3YA6SgIg/ayp/YyJEp775lr4 111 | Z+49t3p7WMNZw8VJkQvDB5/t64Bjd9bdIQxsO9jWyHl/z7QOrnAKv0uUPdcCCwWp 112 | kERTaAnq6tK0rAvcYMlJ230cihY+s/7QpIHpsq091La9n4nJCpFIunaaG1JyNHk5 113 | GQIDAQAB 114 | -----END PUBLIC KEY----- 115 | "; 116 | var rsa = new RSACryptoServiceProvider(); 117 | rsa.ImportFromPem(publicKey); 118 | 119 | bool signatureIsValid = rsa.VerifyData(bytesToVerify, signature, hashAlgorithm: HashAlgorithmName.SHA256, padding: RSASignaturePadding.Pkcs1); 120 | if (!signatureIsValid) 121 | throw new Exception("Signature not valid"); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/ProgressiveFileDownloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using System.Timers; 7 | 8 | namespace WinterspringLauncher.Utils; 9 | 10 | public class ProgressiveFileDownloader : IDisposable 11 | { 12 | private readonly string _downloadUrl; 13 | private readonly string _destinationFilePath; 14 | 15 | private readonly HttpClient _httpClient; 16 | 17 | public delegate void InitialFileInfoHandler(long? totalFileSize); 18 | public delegate void ProgressFixedChangedHandler(long? totalFileSize, long alreadyReceived, long currentBytesPerSecond); 19 | public delegate void DownloadDoneHandler(long downloadedBytes); 20 | 21 | public event InitialFileInfoHandler? InitialInfo; 22 | public event ProgressFixedChangedHandler? ProgressChangedFixedDelay; 23 | public event DownloadDoneHandler? DownloadDone; 24 | 25 | private readonly System.Timers.Timer _updateTimer; 26 | private DateTime? _lastUpdateInvoke; 27 | private long _lastReceivedBytes; 28 | private long? _totalFileSize; 29 | private long _alreadyReceivedBytes; 30 | 31 | private int _lastDownloadRatesIdx = 0; 32 | private readonly double?[] _lastDownloadRates = new double?[15]; 33 | private bool _hadZeroRate = false; 34 | 35 | public ProgressiveFileDownloader(string downloadUrl, string destinationFilePath) 36 | { 37 | _downloadUrl = downloadUrl; 38 | _destinationFilePath = destinationFilePath; 39 | _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }; 40 | _updateTimer = new System.Timers.Timer(500 /*ms*/); 41 | _updateTimer.Elapsed += TimerElapsed; 42 | } 43 | 44 | public async Task StartGetDownload() 45 | { 46 | using (var response = await _httpClient.GetAsync(_downloadUrl, HttpCompletionOption.ResponseHeadersRead)) 47 | await DownloadFileFromHttpResponseMessage(response); 48 | } 49 | 50 | private async Task DownloadFileFromHttpResponseMessage(HttpResponseMessage response) 51 | { 52 | response.EnsureSuccessStatusCode(); 53 | 54 | var totalBytes = response.Content.Headers.ContentLength; 55 | _totalFileSize = totalBytes; 56 | 57 | TriggerInitialInfo(totalBytes); 58 | 59 | await using (var contentStream = await response.Content.ReadAsStreamAsync()) 60 | { 61 | await ProcessContentStream(contentStream); 62 | } 63 | } 64 | 65 | private async Task ProcessContentStream(Stream contentStream) 66 | { 67 | long totalBytesRead = 0; 68 | long readCount = 0; 69 | var buffer = new byte[4096]; 70 | var isMoreToRead = true; 71 | 72 | _updateTimer.Start(); 73 | try 74 | { 75 | using (var fileStream = new FileStream(_destinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None, buffer.Length, true)) 76 | { 77 | do 78 | { 79 | var bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length); 80 | if (bytesRead == 0) 81 | { 82 | isMoreToRead = false; 83 | UpdateInternalProgress(totalBytesRead); 84 | continue; 85 | } 86 | 87 | await fileStream.WriteAsync(buffer, 0, bytesRead); 88 | 89 | totalBytesRead += bytesRead; 90 | readCount += 1; 91 | 92 | if (readCount % 1000 == 0) 93 | UpdateInternalProgress(totalBytesRead); 94 | } while (isMoreToRead); 95 | } 96 | } 97 | finally 98 | { 99 | _updateTimer.Stop(); 100 | } 101 | 102 | TriggerDownloadDone(totalBytesRead); 103 | } 104 | 105 | private void TriggerInitialInfo(long? totalDownloadSize) 106 | { 107 | InitialInfo?.Invoke(totalDownloadSize); 108 | } 109 | 110 | private void UpdateInternalProgress(long alreadyReceivedBytes) 111 | { 112 | _alreadyReceivedBytes = alreadyReceivedBytes; 113 | } 114 | 115 | private void TimerElapsed(object? sender, ElapsedEventArgs e) 116 | { 117 | UpdateAndTriggerProgressChanged(); 118 | } 119 | 120 | private void UpdateAndTriggerProgressChanged() 121 | { 122 | DateTime now = DateTime.Now; 123 | var elapsed = now - _lastUpdateInvoke; 124 | long amountDownloadedInPeriod = _alreadyReceivedBytes - _lastReceivedBytes; 125 | if (amountDownloadedInPeriod == 0 && !_hadZeroRate) 126 | { 127 | _hadZeroRate = true; 128 | return; 129 | } 130 | _hadZeroRate = false; 131 | _lastUpdateInvoke = now; 132 | _lastReceivedBytes = _alreadyReceivedBytes; 133 | 134 | if (elapsed != null) 135 | { 136 | double thisBytePerSec = amountDownloadedInPeriod / elapsed.Value.TotalSeconds; 137 | _lastDownloadRates[_lastDownloadRatesIdx] = thisBytePerSec; 138 | _lastDownloadRatesIdx = (_lastDownloadRatesIdx + 1) % _lastDownloadRates.Length; 139 | 140 | TriggerProgressChanged(); 141 | } 142 | } 143 | 144 | private void TriggerProgressChanged() 145 | { 146 | double dlRate = _lastDownloadRates.Where(x => x != null).Select(x => x!.Value).Average(); 147 | ProgressChangedFixedDelay?.Invoke(_totalFileSize, _alreadyReceivedBytes, (long)dlRate); 148 | } 149 | 150 | private void TriggerDownloadDone(long bytesDownloaded) 151 | { 152 | DownloadDone?.Invoke(bytesDownloaded); 153 | } 154 | 155 | public void Dispose() 156 | { 157 | _httpClient?.Dispose(); 158 | _updateTimer.Elapsed -= TimerElapsed; 159 | _updateTimer.Stop(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /WinterspringLauncher/ViewModels/MainWindowViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Linq; 5 | using Avalonia.Media; 6 | using Avalonia.Metadata; 7 | using CommunityToolkit.Mvvm.ComponentModel; 8 | 9 | namespace WinterspringLauncher.ViewModels; 10 | 11 | public partial class MainWindowViewModel : ObservableObject 12 | { 13 | public LauncherLogic Logic { get; } 14 | 15 | public MainWindowViewModel() 16 | { 17 | Logic = new LauncherLogic(this); 18 | } 19 | 20 | [ObservableProperty] 21 | private LanguageHolder _language = new LanguageHolder(); 22 | 23 | [ObservableProperty] 24 | private bool _inputIsAllowed = true; 25 | 26 | [ObservableProperty] 27 | public int _selectedServerIdx; 28 | 29 | [ObservableProperty] 30 | public string _thisLauncherVersion = LauncherVersion.ShortVersionString; 31 | 32 | [ObservableProperty] 33 | public string _thisLauncherVersionDetailed = LauncherVersion.DetailedVersionString; 34 | 35 | [ObservableProperty] 36 | private bool _gameFolderExists = false; 37 | 38 | [ObservableProperty] 39 | private bool _gameIsInstalled = false; 40 | 41 | [ObservableProperty] 42 | private string _gameVersion = ""; 43 | 44 | [ObservableProperty] 45 | public string? _hermesPidToolTipString = null; 46 | 47 | [ObservableProperty] 48 | public bool _hermesIsRunning = false; 49 | 50 | [ObservableProperty] 51 | public string? _detectedHermesVersion; 52 | 53 | [ObservableProperty] 54 | public bool _hermesIsInstalled; 55 | 56 | public void SetHermesPid(int? pid) 57 | { 58 | // TODO How to I remove this function and just have a HermesProxyPid Property that will assign the other ones? 59 | HermesPidToolTipString = pid.HasValue ? $"Hermes PID: {pid.Value}" : null; 60 | HermesIsRunning = pid.HasValue; 61 | } 62 | 63 | public void SetHermesVersion(string? versionStr) 64 | { 65 | DetectedHermesVersion = versionStr; 66 | HermesIsInstalled = versionStr != null; 67 | } 68 | 69 | [ObservableProperty] 70 | public ObservableCollection _knownServerList = new ObservableCollection(); 71 | 72 | public string LogEntriesCombined { get; private set; } 73 | 74 | public List LogEntriesArray = new List(); 75 | 76 | public void AddLogEntry(string logEntry) 77 | { 78 | OnPropertyChanging(nameof(LogEntriesCombined)); 79 | if (LogEntriesArray.Count > 50) 80 | LogEntriesArray.RemoveAt(0); 81 | LogEntriesArray.Add(logEntry); 82 | LogEntriesCombined = string.Join('\n', LogEntriesArray); 83 | OnPropertyChanged(nameof(LogEntriesCombined)); 84 | } 85 | 86 | public class LanguageHolder 87 | { 88 | public void SetLanguage(string languageShortName) 89 | { 90 | 91 | } 92 | } 93 | 94 | 95 | [ObservableProperty] 96 | private string _progressbarText = ""; 97 | 98 | // not observable 99 | private string _progressbarInternalTitle = ""; 100 | 101 | // not observable 102 | private ProgressbarInternalTimeTracker _progressbarInternalTimeTracker; 103 | 104 | [ObservableProperty] 105 | private double _progressbarPercent = 0; 106 | 107 | [ObservableProperty] 108 | private IBrush _progressbarColor = Brush.Parse("#FFFFFF"); 109 | 110 | public void SetProgressbar(string title, double progressPercent, IBrush color) 111 | { 112 | _progressbarInternalTitle = title; 113 | _progressbarInternalTimeTracker = new ProgressbarInternalTimeTracker(); 114 | ProgressbarPercent = progressPercent; 115 | ProgressbarColor = color; 116 | ProgressbarText = $"{progressPercent:0}% {_progressbarInternalTitle}"; 117 | } 118 | 119 | public void UpdateProgress(double progressPercent, string additionalText) 120 | { 121 | ProgressbarPercent = progressPercent; 122 | TimeSpan? estimatedTime = _progressbarInternalTimeTracker.GetEstimatedTimeAndUpdateRates(progressPercent); 123 | string timeLeft = estimatedTime.HasValue 124 | ? TimeSpan.FromSeconds((long) estimatedTime.Value.TotalSeconds).ToString() 125 | : "?".PadLeft("00:00:00".Length); 126 | 127 | ProgressbarText = $"{progressPercent:0}% {_progressbarInternalTitle} {additionalText} estimated time: {timeLeft}"; 128 | } 129 | 130 | private class ProgressbarInternalTimeTracker 131 | { 132 | private double _lastPercent = 0; 133 | private DateTime? _lastUpdateTime = null; 134 | private int _lastProgressRatesIdx = 0; 135 | private readonly double?[] _lastProgressRates = new double?[15]; 136 | 137 | public TimeSpan? GetEstimatedTimeAndUpdateRates(double percent) 138 | { 139 | var now = DateTime.Now; 140 | if (_lastUpdateTime != null) 141 | { 142 | TimeSpan timeDiff = now - _lastUpdateTime.Value; 143 | double progressDiff = percent - _lastPercent; 144 | double progressDiffPerSec = progressDiff / timeDiff.TotalSeconds; 145 | _lastProgressRates[_lastProgressRatesIdx] = progressDiffPerSec; 146 | _lastProgressRatesIdx = (_lastProgressRatesIdx + 1) % _lastProgressRates.Length; 147 | } 148 | _lastUpdateTime = now; 149 | _lastPercent = percent; 150 | 151 | var avgRate = _lastProgressRates.Where(x => x.HasValue).Select(x => x!.Value).DefaultIfEmpty(0).Average(); 152 | if (avgRate == 0) 153 | return null; 154 | 155 | const double maxPercent = 100; 156 | double time = (maxPercent - percent) / avgRate; 157 | if (double.IsNaN(time)) 158 | return null; 159 | 160 | return TimeSpan.FromSeconds(time); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherLogic.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.InteropServices; 6 | using System.Threading.Tasks; 7 | using Avalonia; 8 | using Avalonia.Controls.ApplicationLifetimes; 9 | using Avalonia.Threading; 10 | using WinterspringLauncher.Utils; 11 | using WinterspringLauncher.ViewModels; 12 | using WinterspringLauncher.Views; 13 | 14 | namespace WinterspringLauncher; 15 | 16 | public partial class LauncherLogic 17 | { 18 | private const string CONFIG_FILE_NAME = "winterspring-launcher-config.json"; 19 | 20 | private readonly MainWindowViewModel _model; 21 | private readonly LauncherConfig _config; 22 | 23 | private string FullPath(string subPath) => Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, subPath)); 24 | 25 | private static readonly string SubPathToWowOriginal = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 26 | ? "_classic_era_/World of Warcraft Classic.app/Contents/MacOS/World of Warcraft Classic" 27 | : "_classic_era_/WowClassic.exe"; 28 | 29 | private static readonly string SubPathToWowForCustomServers = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 30 | ? "_classic_era_/WoW For Custom Servers.app/Contents/MacOS/WoW For Custom Servers" 31 | : "_classic_era_/WowClassic_ForCustomServers.exe"; 32 | 33 | public LauncherLogic(MainWindowViewModel model) 34 | { 35 | _model = model; 36 | 37 | _config = LauncherConfig.LoadOrCreateDefault(CONFIG_FILE_NAME); 38 | 39 | if (_config.LastSelectedServerName == "") // first configuration 40 | { 41 | _config.LastSelectedServerName = LocaleDefaults.GetBestServerName(); 42 | _config.GitHubApiMirror = LocaleDefaults.GetBestGitHubMirror(); 43 | } 44 | 45 | if (!string.IsNullOrWhiteSpace(_config.GitHubApiMirror)) 46 | GitHubApi.GitHubApiAddress = _config.GitHubApiMirror; 47 | 48 | for (var i = 0; i < _config.KnownServers.Length; i++) 49 | { 50 | var knownServer = _config.KnownServers[i]; 51 | _model.KnownServerList.Add(knownServer.Name); 52 | if (_config.LastSelectedServerName == knownServer.Name) 53 | _model.SelectedServerIdx = i; 54 | } 55 | 56 | _model.Language.SetLanguage(_config.LauncherLanguage); 57 | 58 | _model.AddLogEntry($"Launcher started"); 59 | _model.AddLogEntry($"Base path: \"{FullPath(".")}\""); 60 | _model.AddLogEntry($"GitHub API Address: {GitHubApi.GitHubApiAddress}"); 61 | 62 | string? localHermesVersion = null; 63 | var hermesProxyVersionFile = Path.Combine(_config.HermesProxyLocation, "version.txt"); 64 | if (File.Exists(hermesProxyVersionFile)) 65 | { 66 | localHermesVersion = File.ReadLines(hermesProxyVersionFile).First().Split("|")[0]; 67 | } 68 | _model.SetHermesVersion(localHermesVersion); 69 | 70 | 71 | if (_config.CheckForLauncherUpdates) 72 | { 73 | Task.Run(() => 74 | { 75 | try 76 | { 77 | if (LauncherVersion.CheckIfUpdateIsAvailable(out var updateInformation)) 78 | { 79 | _model.AddLogEntry($"--------------------------"); 80 | _model.AddLogEntry($"This launcher has a new version {updateInformation.VersionName} ({updateInformation.ReleaseDate:yyyy-MM-dd})"); 81 | _model.AddLogEntry($"You can download it here {updateInformation.URLLinkToReleasePage}"); 82 | _model.AddLogEntry($"--------------------------"); 83 | CreateUpdatePopup(updateInformation); 84 | } 85 | Console.WriteLine("Launcher update check done"); 86 | } 87 | catch (Exception e) 88 | { 89 | _model.AddLogEntry("An error occured while checking for a launcher update"); 90 | Console.WriteLine(e); 91 | } 92 | }); 93 | } 94 | } 95 | 96 | private void CreateUpdatePopup(LauncherVersion.UpdateInformation updateInformation) 97 | { 98 | Dispatcher.UIThread.Post(() => 99 | { 100 | if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow != null) 101 | { 102 | var dialog = new NewVersionAvailableDialog(updateInformation); 103 | dialog.ShowDialog(desktop.MainWindow); 104 | } 105 | }); 106 | } 107 | 108 | public void ChangeServerIdx() 109 | { 110 | var serverInfo = _config.KnownServers.ElementAtOrDefault(_model.SelectedServerIdx); 111 | if (serverInfo == null) 112 | { 113 | _model.AddLogEntry("Error invalid server settings"); 114 | _model.InputIsAllowed = true; 115 | return; 116 | } 117 | 118 | Console.WriteLine($"Selected Server: {serverInfo.Name}"); 119 | var gameInstallation = _config.GameInstallations.GetValueOrDefault(serverInfo.UsedInstallation); 120 | if (gameInstallation == null) 121 | { 122 | _model.AddLogEntry($"Error cant find '{serverInfo.UsedInstallation}' installation in settings"); 123 | _model.InputIsAllowed = true; 124 | return; 125 | } 126 | 127 | _config.LastSelectedServerName = serverInfo.Name; 128 | _config.SaveConfig(CONFIG_FILE_NAME); 129 | 130 | var expectedPatchedClientLocation = Path.Combine(gameInstallation.Directory, SubPathToWowForCustomServers); 131 | _model.GameFolderExists = Directory.Exists(gameInstallation.Directory); 132 | _model.GameIsInstalled = File.Exists(expectedPatchedClientLocation); 133 | 134 | _model.GameVersion = string.Join('.', gameInstallation.Version.Split('.').SkipLast(1)); 135 | } 136 | 137 | private void RunDownload(string downloadUrl, string destLocation) 138 | { 139 | LauncherActions.DownloadFile(downloadUrl, destLocation, 140 | (totalBytes, alreadyDownloadedBytes, bytesPerSec) => 141 | { 142 | double percent = (totalBytes != null) 143 | ? (alreadyDownloadedBytes / (double)totalBytes.Value) * 100 144 | : 0; 145 | 146 | string additionalText = $" {UtilHelper.ToHumanFileSize(alreadyDownloadedBytes)}/{UtilHelper.ToHumanFileSize(totalBytes ?? 0)} {UtilHelper.ToHumanFileSize(bytesPerSec)}/s "; 147 | _model.UpdateProgress(percent, additionalText); 148 | }); 149 | } 150 | 151 | private void RunUnpack(string archiveLocation, string targetDir) 152 | { 153 | LauncherActions.Unpack(archiveLocation, targetDir, 154 | (totalFileCount, alreadyUnpacked) => 155 | { 156 | double percent = (alreadyUnpacked / (double)totalFileCount) * 100; 157 | _model.UpdateProgress(percent, $" {alreadyUnpacked} / {totalFileCount} "); 158 | }); 159 | } 160 | 161 | public void KillHermesProxy() 162 | { 163 | try 164 | { 165 | _hermesProcess?.Kill(); 166 | } 167 | catch(Exception e) 168 | { 169 | _model.AddLogEntry("Fail to stop HermesProxy"); 170 | Console.WriteLine("Fail to kill HermesProxy"); 171 | Console.WriteLine(e); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherActions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Runtime.InteropServices; 8 | using System.Text; 9 | using System.Threading; 10 | using WinterspringLauncher.Utils; 11 | 12 | namespace WinterspringLauncher; 13 | 14 | public static class LauncherActions 15 | { 16 | public delegate void DownloadProgressInfoHandler(long? totalBytes, long alreadyDownloadedBytes, long bytesPerSec); 17 | public delegate void UnpackProgressInfoHandler(long totalFileCount, long alreadyUnpackedFileCount); 18 | 19 | public static void DownloadFile(string downloadUrl, string downloadDestLocation, DownloadProgressInfoHandler progressInfoHandler) 20 | { 21 | using (var client = new ProgressiveFileDownloader(downloadUrl, downloadDestLocation)) 22 | { 23 | client.ProgressChangedFixedDelay += (totalBytes, alreadyDownloadedBytes, bytePerSec) => 24 | { 25 | progressInfoHandler(totalBytes, alreadyDownloadedBytes, bytePerSec); 26 | }; 27 | 28 | client.DownloadDone += (downloadedBytes) => { 29 | progressInfoHandler(downloadedBytes, downloadedBytes, 0); 30 | }; 31 | 32 | client.StartGetDownload().Wait(); 33 | } 34 | } 35 | 36 | public static void PrepareGameConfigWtf(string gamePath, string portalAddress) 37 | { 38 | var configWtfPath = Path.Combine(gamePath, "_classic_era_", "WTF", "Config.wtf"); 39 | var dirName = Path.GetDirectoryName(configWtfPath); 40 | Directory.CreateDirectory(dirName!); 41 | 42 | List configContent; 43 | if (!File.Exists(configWtfPath)) 44 | { 45 | configContent = new List(); 46 | string bestDefaultTextLocale = LocaleDefaults.GetBestWoWConfigLocale(); 47 | configContent.Add($"SET textLocale {bestDefaultTextLocale}"); 48 | } 49 | else 50 | { 51 | configContent = File.ReadAllLines(configWtfPath).ToList(); 52 | } 53 | 54 | var newLine = $"SET portal \"{portalAddress}\""; 55 | bool wasChanged = false; 56 | 57 | // Add SET PORTAL ... 58 | var currentPortalLine = configContent.FindIndex(l => l.StartsWith("SET portal ")); 59 | if (currentPortalLine != -1) 60 | { 61 | if (configContent[currentPortalLine] != newLine) 62 | { 63 | configContent[currentPortalLine] = newLine; 64 | wasChanged = true; 65 | } 66 | } 67 | else 68 | { 69 | configContent.Add(newLine); 70 | wasChanged = true; 71 | } 72 | 73 | // Remove disableServerNagle 74 | var disableServerNagleLine = configContent.FindIndex(l => l.StartsWith("SET disableServerNagle ")); 75 | if (disableServerNagleLine != -1) 76 | { 77 | configContent.RemoveAt(disableServerNagleLine); 78 | wasChanged = true; 79 | } 80 | 81 | if (wasChanged) 82 | { 83 | File.WriteAllLines(configWtfPath, configContent, Encoding.UTF8); 84 | } 85 | } 86 | 87 | public static void Unpack(string compressedArchivePath, string targetDir, UnpackProgressInfoHandler progressInfoHandler) 88 | { 89 | ArchiveCompression.Decompress(compressedArchivePath, targetDir, folderToSkipName: "World of Warcraft", // TODO: <-- move this check somewhere else 90 | (totalFileCount, alreadyUnpackedFileCount) => 91 | { 92 | progressInfoHandler(totalFileCount, alreadyUnpackedFileCount); 93 | }, shouldBeDecompressedPredicate: (filePath) => !filePath.Contains("World of Warcraft Launcher.exe")); // TODO: <-- move this check somewhere else 94 | } 95 | 96 | public delegate void OnLogLine(string logLine); 97 | 98 | public static Process StartHermesProxy(string hermesDir, ushort modernClientBuild, Dictionary settingsOverwrite, OnLogLine logLine) 99 | { 100 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 101 | 102 | var executableName = weAreOnMacOs 103 | ? "HermesProxy" 104 | : "HermesProxy.exe"; 105 | 106 | var executablePath = Path.Combine(hermesDir, executableName); 107 | 108 | var procInfo = new ProcessStartInfo 109 | { 110 | FileName = executablePath, 111 | WorkingDirectory = hermesDir, 112 | RedirectStandardOutput = true, 113 | CreateNoWindow = true, 114 | ArgumentList = { 115 | "--no-version-check", 116 | "--set", $"ClientBuild={modernClientBuild}", 117 | }, 118 | }; 119 | 120 | foreach (var (key, value) in settingsOverwrite) 121 | { 122 | procInfo.ArgumentList.Add("--set"); 123 | procInfo.ArgumentList.Add($"{key}={value}"); 124 | } 125 | 126 | Console.WriteLine("Starting HermesProxy with arguments: "); 127 | for (var i = 0; i < procInfo.ArgumentList.Count; i++) 128 | Console.WriteLine($"[{i}] {procInfo.ArgumentList[i]}"); 129 | var process = Process.Start(procInfo)!; 130 | 131 | process.EnableRaisingEvents = true; 132 | process.OutputDataReceived += new DataReceivedEventHandler((sender, e) => 133 | { 134 | if (!String.IsNullOrEmpty(e.Data)) 135 | { 136 | logLine(e.Data); 137 | } 138 | }); 139 | process.BeginOutputReadLine(); 140 | 141 | return process; 142 | } 143 | 144 | public static void StartGame(string executablePath) 145 | { 146 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 147 | 148 | ProcessStartInfo startInfo; 149 | if (weAreOnMacOs) 150 | { 151 | startInfo = new ProcessStartInfo 152 | { 153 | // We are here "_classic_era_/WoW For Custom Servers.app/Contents/MacOS/WoW For Custom Servers" 154 | // and want to be here "_classic_era_" 155 | 156 | FileName = "/usr/bin/open", 157 | ArgumentList = { "--new", "--wait-apps", $"./{Path.GetDirectoryName(Path.Combine(executablePath, "..", ".."))}" }, 158 | WorkingDirectory = Path.GetDirectoryName(Path.Combine(executablePath, "..", "..", "..")), 159 | UseShellExecute = true, 160 | CreateNoWindow = false, 161 | RedirectStandardError = false, 162 | RedirectStandardInput = false, 163 | RedirectStandardOutput = false, 164 | }; 165 | } 166 | else 167 | { 168 | startInfo = new ProcessStartInfo 169 | { 170 | FileName = Path.GetFileName(executablePath), 171 | WorkingDirectory = Path.GetDirectoryName(executablePath), 172 | UseShellExecute = true, 173 | CreateNoWindow = false, 174 | RedirectStandardError = false, 175 | RedirectStandardInput = false, 176 | RedirectStandardOutput = false, 177 | }; 178 | } 179 | 180 | //startInfo.EnvironmentVariables.Clear(); 181 | var process = Process.Start(startInfo)!; 182 | while (!process.HasExited && process.VirtualMemorySize64 < 100 * 1024 * 1024) 183 | { 184 | Thread.Sleep(TimeSpan.FromSeconds(1)); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /WinterspringLauncher/Utils/ArchiveCompression.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Linq; 5 | using System.Reflection; 6 | 7 | #if PLATFORM_WINDOWS 8 | using SevenZip; 9 | #endif 10 | 11 | namespace WinterspringLauncher.Utils; 12 | 13 | public static class ArchiveCompression 14 | { 15 | public delegate void UnpackProgressInfoHandler(long totalFileCount, long alreadyUnpackedFileCount); 16 | 17 | public static void Decompress(string archiveFilePath, string extractionFolderPath, string folderToSkipName, UnpackProgressInfoHandler progressHandler, Func shouldBeDecompressedPredicate) 18 | { 19 | byte[] buffer = new byte[4]; 20 | using (FileStream fileHandle = File.OpenRead(archiveFilePath)) 21 | { 22 | fileHandle.Read(buffer, 0, 4); 23 | } 24 | 25 | if (buffer.SequenceEqual(new byte[] { 0x52, 0x61, 0x72, 0x21 })) // Rar! 26 | { 27 | Decompress7ZWithProgress(archiveFilePath, extractionFolderPath, folderToSkipName, progressHandler, shouldBeDecompressedPredicate); 28 | } 29 | else if (buffer[..2].SequenceEqual(new byte[] { 0x50, 0x4B })) // Zip 30 | { 31 | DecompressZipWithProgress(archiveFilePath, extractionFolderPath, folderToSkipName, progressHandler, shouldBeDecompressedPredicate); 32 | } 33 | else // Error 34 | { 35 | throw new Exception("Unknown file format. Cannot decompress"); 36 | } 37 | } 38 | 39 | private static void DecompressZipWithProgress(string archiveFilePath, string extractionFolderPath, string folderToSkipName, UnpackProgressInfoHandler progressHandler, Func shouldBeDecompressedPredicate) 40 | { 41 | using var zip = ZipFile.OpenRead(archiveFilePath); 42 | bool ShouldBeDecompressed(ZipArchiveEntry entry) => !entry.FullName.EndsWith("\\") && !entry.FullName.EndsWith("/") && shouldBeDecompressedPredicate(entry.FullName); 43 | var totalSize = zip.Entries.Where(ShouldBeDecompressed).Sum(x => x.Length); 44 | var totalCount = zip.Entries.Where(ShouldBeDecompressed).Count(); 45 | 46 | string ToPath(string path) => Path.Combine(extractionFolderPath, path); 47 | 48 | Console.WriteLine($"Total size to decompress {UtilHelper.ToHumanFileSize(totalSize)}"); 49 | long alreadyDecompressedCount = 0; 50 | foreach (var entry in zip.Entries.Where(ShouldBeDecompressed)) 51 | { 52 | var destPath = ToPath(entry.FullName); 53 | Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); 54 | entry.ExtractToFile(destPath, overwrite: true); 55 | alreadyDecompressedCount++; 56 | progressHandler(totalCount, alreadyDecompressedCount); 57 | } 58 | progressHandler(totalCount, totalCount); 59 | } 60 | 61 | #if !PLATFORM_WINDOWS 62 | private static void Decompress7ZWithProgress(string archiveFilePath, string extractionFolderPath, string folderToSkipName, UnpackProgressInfoHandler progressHandler, Func shouldBeDecompressed) 63 | { 64 | throw new NotSupportedException("7z is only supported on Windows"); 65 | } 66 | #else 67 | private static void Decompress7ZWithProgress(string archiveFilePath, string extractionFolderPath, string folderToSkipName, UnpackProgressInfoHandler progressHandler, Func shouldBeDecompressedPredicate) 68 | { 69 | var assembly = Assembly.GetExecutingAssembly(); 70 | var resourceName = "WinterspringLauncher.7z.dll"; 71 | 72 | using (Stream stream = assembly.GetManifestResourceStream(resourceName)!) 73 | { 74 | try 75 | { 76 | using (var file = File.Open("7z.dll", FileMode.Create, FileAccess.Write)) 77 | { 78 | stream.CopyTo(file); 79 | } 80 | } 81 | catch(Exception e) 82 | { 83 | // Maybe the file is somehow already in use 84 | Console.WriteLine("Failed to write 7z.dll"); 85 | Console.WriteLine(e); 86 | } 87 | } 88 | 89 | SevenZipBase.SetLibraryPath("7z.dll"); 90 | string downloadedFile = Path.Combine(archiveFilePath); 91 | Console.WriteLine($"Extracting archive into {extractionFolderPath}"); 92 | using (var archiveFile = new SevenZipExtractor(downloadedFile)) 93 | { 94 | bool ShouldBeDecompressed(ArchiveFileInfo entry) => !entry.IsDirectory && shouldBeDecompressedPredicate(entry.FileName); 95 | string ToPath(string path) => path.ReplaceFirstOccurrence(folderToSkipName, extractionFolderPath); 96 | 97 | long totalSize = 0; 98 | long totalCount = 0; 99 | foreach (var entry in archiveFile.ArchiveFileData) 100 | { 101 | if (ShouldBeDecompressed(entry)) 102 | { 103 | totalSize += (long) entry.Size; 104 | totalCount++; 105 | } 106 | } 107 | 108 | Console.WriteLine($"Total size to decompress {UtilHelper.ToHumanFileSize(totalSize)}"); 109 | long alreadyDecompressedCount = 0; 110 | foreach (var entry in archiveFile.ArchiveFileData) 111 | { 112 | if (ShouldBeDecompressed(entry)) 113 | { 114 | var destName = ToPath(entry.FileName); 115 | Directory.CreateDirectory(Path.GetDirectoryName(destName)!); 116 | using (var fStream = File.Open(destName, FileMode.Create, FileAccess.Write)) 117 | { 118 | archiveFile.ExtractFile(entry.FileName, fStream); 119 | } 120 | alreadyDecompressedCount++; 121 | progressHandler(totalCount, alreadyDecompressedCount); 122 | } 123 | } 124 | progressHandler(totalCount, totalCount); 125 | } 126 | 127 | try 128 | { 129 | File.Delete("7z.dll"); 130 | } 131 | catch 132 | { 133 | // ignored 134 | } 135 | } 136 | #endif 137 | public static void DecompressSmartSkipFirstFolder(string zipFilePath, string outputDirectory) 138 | { 139 | using var zip = ZipFile.OpenRead(zipFilePath); 140 | string? zipBaseFolder = GetBaseFolderFromZip(zip); 141 | 142 | Console.WriteLine($"Unzipping {zipFilePath}, detected '{zipBaseFolder ?? ""}' as first folder"); 143 | 144 | string ToFilteredPath(string path) => zipBaseFolder != null 145 | ? path.ReplaceFirstOccurrence(zipBaseFolder, outputDirectory) 146 | : path; 147 | 148 | string GetCompletePath(ZipArchiveEntry entry) => Path.Combine(outputDirectory, ToFilteredPath(entry.FullName)); 149 | 150 | foreach (var entry in zip.Entries.Where(e => !e.IsFolder())) 151 | { 152 | var completePath = GetCompletePath(entry); 153 | Directory.CreateDirectory(Path.GetDirectoryName(completePath)!); 154 | entry.ExtractToFile(completePath, overwrite: true); 155 | } 156 | } 157 | 158 | private static bool IsFolder(this ZipArchiveEntry entry) 159 | { 160 | return entry.FullName.EndsWith("/"); 161 | } 162 | 163 | static string? GetBaseFolderFromZip(ZipArchive archive) 164 | { 165 | string[] entryPaths = archive.Entries.Select(entry => entry.FullName).ToArray(); 166 | if (entryPaths.Length == 0) 167 | return null; 168 | 169 | string[] parts = entryPaths[0].Split('/'); 170 | 171 | for (int i = 1; i < entryPaths.Length; i++) 172 | { 173 | string[] currentParts = entryPaths[i].Split('/'); 174 | 175 | int commonParts = parts.Zip(currentParts, (p1, p2) => p1 == p2).TakeWhile(b => b).Count(); 176 | if (commonParts == 0) 177 | return null; 178 | 179 | Array.Resize(ref parts, commonParts); 180 | } 181 | 182 | return string.Join("/", parts); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,rider,dotnetcore 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudio,visualstudiocode,rider,dotnetcore 3 | 4 | ### DotnetCore ### 5 | # .NET Core build folders 6 | bin/ 7 | obj/ 8 | 9 | # Common node modules locations 10 | /node_modules 11 | /wwwroot/node_modules 12 | 13 | ### Rider ### 14 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 15 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 16 | 17 | # User-specific stuff 18 | .idea/**/workspace.xml 19 | .idea/**/tasks.xml 20 | .idea/**/usage.statistics.xml 21 | .idea/**/dictionaries 22 | .idea/**/shelf 23 | 24 | # AWS User-specific 25 | .idea/**/aws.xml 26 | 27 | # Generated files 28 | .idea/**/contentModel.xml 29 | 30 | # Sensitive or high-churn files 31 | .idea/**/dataSources/ 32 | .idea/**/dataSources.ids 33 | .idea/**/dataSources.local.xml 34 | .idea/**/sqlDataSources.xml 35 | .idea/**/dynamic.xml 36 | .idea/**/uiDesigner.xml 37 | .idea/**/dbnavigator.xml 38 | 39 | # Gradle 40 | .idea/**/gradle.xml 41 | .idea/**/libraries 42 | 43 | # Gradle and Maven with auto-import 44 | # When using Gradle or Maven with auto-import, you should exclude module files, 45 | # since they will be recreated, and may cause churn. Uncomment if using 46 | # auto-import. 47 | # .idea/artifacts 48 | # .idea/compiler.xml 49 | # .idea/jarRepositories.xml 50 | # .idea/modules.xml 51 | # .idea/*.iml 52 | # .idea/modules 53 | # *.iml 54 | # *.ipr 55 | 56 | # CMake 57 | cmake-build-*/ 58 | 59 | # Mongo Explorer plugin 60 | .idea/**/mongoSettings.xml 61 | 62 | # File-based project format 63 | *.iws 64 | 65 | # IntelliJ 66 | out/ 67 | 68 | # mpeltonen/sbt-idea plugin 69 | .idea_modules/ 70 | 71 | # JIRA plugin 72 | atlassian-ide-plugin.xml 73 | 74 | # Cursive Clojure plugin 75 | .idea/replstate.xml 76 | 77 | # SonarLint plugin 78 | .idea/sonarlint/ 79 | 80 | # Crashlytics plugin (for Android Studio and IntelliJ) 81 | com_crashlytics_export_strings.xml 82 | crashlytics.properties 83 | crashlytics-build.properties 84 | fabric.properties 85 | 86 | # Editor-based Rest Client 87 | .idea/httpRequests 88 | 89 | # Android studio 3.1+ serialized cache file 90 | .idea/caches/build_file_checksums.ser 91 | 92 | ### VisualStudioCode ### 93 | .vscode/* 94 | !.vscode/settings.json 95 | !.vscode/tasks.json 96 | !.vscode/launch.json 97 | !.vscode/extensions.json 98 | !.vscode/*.code-snippets 99 | 100 | # Local History for Visual Studio Code 101 | .history/ 102 | 103 | # Built Visual Studio Code Extensions 104 | *.vsix 105 | 106 | ### VisualStudioCode Patch ### 107 | # Ignore all local history of files 108 | .history 109 | .ionide 110 | 111 | ### VisualStudio ### 112 | ## Ignore Visual Studio temporary files, build results, and 113 | ## files generated by popular Visual Studio add-ons. 114 | ## 115 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 116 | 117 | # User-specific files 118 | *.rsuser 119 | *.suo 120 | *.user 121 | *.userosscache 122 | *.sln.docstates 123 | 124 | # User-specific files (MonoDevelop/Xamarin Studio) 125 | *.userprefs 126 | 127 | # Mono auto generated files 128 | mono_crash.* 129 | 130 | # Build results 131 | [Dd]ebug/ 132 | [Dd]ebugPublic/ 133 | [Rr]elease/ 134 | [Rr]eleases/ 135 | x64/ 136 | x86/ 137 | [Ww][Ii][Nn]32/ 138 | [Aa][Rr][Mm]/ 139 | [Aa][Rr][Mm]64/ 140 | bld/ 141 | [Bb]in/ 142 | [Oo]bj/ 143 | [Ll]og/ 144 | [Ll]ogs/ 145 | 146 | # Visual Studio 2015/2017 cache/options directory 147 | .vs/ 148 | # Uncomment if you have tasks that create the project's static files in wwwroot 149 | #wwwroot/ 150 | 151 | # Visual Studio 2017 auto generated files 152 | Generated\ Files/ 153 | 154 | # MSTest test Results 155 | [Tt]est[Rr]esult*/ 156 | [Bb]uild[Ll]og.* 157 | 158 | # NUnit 159 | *.VisualState.xml 160 | TestResult.xml 161 | nunit-*.xml 162 | 163 | # Build Results of an ATL Project 164 | [Dd]ebugPS/ 165 | [Rr]eleasePS/ 166 | dlldata.c 167 | 168 | # Benchmark Results 169 | BenchmarkDotNet.Artifacts/ 170 | 171 | # .NET Core 172 | project.lock.json 173 | project.fragment.lock.json 174 | artifacts/ 175 | 176 | # ASP.NET Scaffolding 177 | ScaffoldingReadMe.txt 178 | 179 | # StyleCop 180 | StyleCopReport.xml 181 | 182 | # Files built by Visual Studio 183 | *_i.c 184 | *_p.c 185 | *_h.h 186 | *.ilk 187 | *.meta 188 | *.obj 189 | *.iobj 190 | *.pch 191 | *.pdb 192 | *.ipdb 193 | *.pgc 194 | *.pgd 195 | *.rsp 196 | *.sbr 197 | *.tlb 198 | *.tli 199 | *.tlh 200 | *.tmp 201 | *.tmp_proj 202 | *_wpftmp.csproj 203 | *.log 204 | *.tlog 205 | *.vspscc 206 | *.vssscc 207 | .builds 208 | *.pidb 209 | *.svclog 210 | *.scc 211 | 212 | # Chutzpah Test files 213 | _Chutzpah* 214 | 215 | # Visual C++ cache files 216 | ipch/ 217 | *.aps 218 | *.ncb 219 | *.opendb 220 | *.opensdf 221 | *.sdf 222 | *.cachefile 223 | *.VC.db 224 | *.VC.VC.opendb 225 | 226 | # Visual Studio profiler 227 | *.psess 228 | *.vsp 229 | *.vspx 230 | *.sap 231 | 232 | # Visual Studio Trace Files 233 | *.e2e 234 | 235 | # TFS 2012 Local Workspace 236 | $tf/ 237 | 238 | # Guidance Automation Toolkit 239 | *.gpState 240 | 241 | # ReSharper is a .NET coding add-in 242 | _ReSharper*/ 243 | *.[Rr]e[Ss]harper 244 | *.DotSettings.user 245 | 246 | # TeamCity is a build add-in 247 | _TeamCity* 248 | 249 | # DotCover is a Code Coverage Tool 250 | *.dotCover 251 | 252 | # AxoCover is a Code Coverage Tool 253 | .axoCover/* 254 | !.axoCover/settings.json 255 | 256 | # Coverlet is a free, cross platform Code Coverage Tool 257 | coverage*.json 258 | coverage*.xml 259 | coverage*.info 260 | 261 | # Visual Studio code coverage results 262 | *.coverage 263 | *.coveragexml 264 | 265 | # NCrunch 266 | _NCrunch_* 267 | .*crunch*.local.xml 268 | nCrunchTemp_* 269 | 270 | # MightyMoose 271 | *.mm.* 272 | AutoTest.Net/ 273 | 274 | # Web workbench (sass) 275 | .sass-cache/ 276 | 277 | # Installshield output folder 278 | [Ee]xpress/ 279 | 280 | # DocProject is a documentation generator add-in 281 | DocProject/buildhelp/ 282 | DocProject/Help/*.HxT 283 | DocProject/Help/*.HxC 284 | DocProject/Help/*.hhc 285 | DocProject/Help/*.hhk 286 | DocProject/Help/*.hhp 287 | DocProject/Help/Html2 288 | DocProject/Help/html 289 | 290 | # Click-Once directory 291 | publish/ 292 | 293 | # Publish Web Output 294 | *.[Pp]ublish.xml 295 | *.azurePubxml 296 | # Note: Comment the next line if you want to checkin your web deploy settings, 297 | # but database connection strings (with potential passwords) will be unencrypted 298 | *.pubxml 299 | *.publishproj 300 | 301 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 302 | # checkin your Azure Web App publish settings, but sensitive information contained 303 | # in these scripts will be unencrypted 304 | PublishScripts/ 305 | 306 | # NuGet Packages 307 | *.nupkg 308 | # NuGet Symbol Packages 309 | *.snupkg 310 | # The packages folder can be ignored because of Package Restore 311 | **/[Pp]ackages/* 312 | # except build/, which is used as an MSBuild target. 313 | !**/[Pp]ackages/build/ 314 | # Uncomment if necessary however generally it will be regenerated when needed 315 | #!**/[Pp]ackages/repositories.config 316 | # NuGet v3's project.json files produces more ignorable files 317 | *.nuget.props 318 | *.nuget.targets 319 | 320 | # Microsoft Azure Build Output 321 | csx/ 322 | *.build.csdef 323 | 324 | # Microsoft Azure Emulator 325 | ecf/ 326 | rcf/ 327 | 328 | # Windows Store app package directories and files 329 | AppPackages/ 330 | BundleArtifacts/ 331 | Package.StoreAssociation.xml 332 | _pkginfo.txt 333 | *.appx 334 | *.appxbundle 335 | *.appxupload 336 | 337 | # Visual Studio cache files 338 | # files ending in .cache can be ignored 339 | *.[Cc]ache 340 | # but keep track of directories ending in .cache 341 | !?*.[Cc]ache/ 342 | 343 | # Others 344 | ClientBin/ 345 | ~$* 346 | *~ 347 | *.dbmdl 348 | *.dbproj.schemaview 349 | *.jfm 350 | *.pfx 351 | *.publishsettings 352 | orleans.codegen.cs 353 | 354 | # Including strong name files can present a security risk 355 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 356 | #*.snk 357 | 358 | # Since there are multiple workflows, uncomment next line to ignore bower_components 359 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 360 | #bower_components/ 361 | 362 | # RIA/Silverlight projects 363 | Generated_Code/ 364 | 365 | # Backup & report files from converting an old project file 366 | # to a newer Visual Studio version. Backup files are not needed, 367 | # because we have git ;-) 368 | _UpgradeReport_Files/ 369 | Backup*/ 370 | UpgradeLog*.XML 371 | UpgradeLog*.htm 372 | ServiceFabricBackup/ 373 | *.rptproj.bak 374 | 375 | # SQL Server files 376 | *.mdf 377 | *.ldf 378 | *.ndf 379 | 380 | # Business Intelligence projects 381 | *.rdl.data 382 | *.bim.layout 383 | *.bim_*.settings 384 | *.rptproj.rsuser 385 | *- [Bb]ackup.rdl 386 | *- [Bb]ackup ([0-9]).rdl 387 | *- [Bb]ackup ([0-9][0-9]).rdl 388 | 389 | # Microsoft Fakes 390 | FakesAssemblies/ 391 | 392 | # GhostDoc plugin setting file 393 | *.GhostDoc.xml 394 | 395 | # Node.js Tools for Visual Studio 396 | .ntvs_analysis.dat 397 | node_modules/ 398 | 399 | # Visual Studio 6 build log 400 | *.plg 401 | 402 | # Visual Studio 6 workspace options file 403 | *.opt 404 | 405 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 406 | *.vbw 407 | 408 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 409 | *.vbp 410 | 411 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 412 | *.dsw 413 | *.dsp 414 | 415 | # Visual Studio 6 technical files 416 | 417 | # Visual Studio LightSwitch build output 418 | **/*.HTMLClient/GeneratedArtifacts 419 | **/*.DesktopClient/GeneratedArtifacts 420 | **/*.DesktopClient/ModelManifest.xml 421 | **/*.Server/GeneratedArtifacts 422 | **/*.Server/ModelManifest.xml 423 | _Pvt_Extensions 424 | 425 | # Paket dependency manager 426 | .paket/paket.exe 427 | paket-files/ 428 | 429 | # FAKE - F# Make 430 | .fake/ 431 | 432 | # CodeRush personal settings 433 | .cr/personal 434 | 435 | # Python Tools for Visual Studio (PTVS) 436 | __pycache__/ 437 | *.pyc 438 | 439 | # Cake - Uncomment if you are using it 440 | # tools/** 441 | # !tools/packages.config 442 | 443 | # Tabs Studio 444 | *.tss 445 | 446 | # Telerik's JustMock configuration file 447 | *.jmconfig 448 | 449 | # BizTalk build output 450 | *.btp.cs 451 | *.btm.cs 452 | *.odx.cs 453 | *.xsd.cs 454 | 455 | # OpenCover UI analysis results 456 | OpenCover/ 457 | 458 | # Azure Stream Analytics local run output 459 | ASALocalRun/ 460 | 461 | # MSBuild Binary and Structured Log 462 | *.binlog 463 | 464 | # NVidia Nsight GPU debugger configuration file 465 | *.nvuser 466 | 467 | # MFractors (Xamarin productivity tool) working folder 468 | .mfractor/ 469 | 470 | # Local History for Visual Studio 471 | .localhistory/ 472 | 473 | # Visual Studio History (VSHistory) files 474 | .vshistory/ 475 | 476 | # BeatPulse healthcheck temp database 477 | healthchecksdb 478 | 479 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 480 | MigrationBackup/ 481 | 482 | # Ionide (cross platform F# VS Code tools) working folder 483 | .ionide/ 484 | 485 | # Fody - auto-generated XML schema 486 | FodyWeavers.xsd 487 | 488 | # VS Code files for those working on multiple tools 489 | *.code-workspace 490 | 491 | # Local History for Visual Studio Code 492 | 493 | # Windows Installer files from build outputs 494 | *.cab 495 | *.msi 496 | *.msix 497 | *.msm 498 | *.msp 499 | 500 | # JetBrains Rider 501 | *.sln.iml 502 | 503 | ### VisualStudio Patch ### 504 | # Additional files built by Visual Studio 505 | 506 | # End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,rider,dotnetcore 507 | 508 | .idea/ 509 | .DS_Store 510 | 511 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.InteropServices; 6 | using System.Text; 7 | using System.Text.Json; 8 | using WinterspringLauncher.Utils; 9 | 10 | namespace WinterspringLauncher; 11 | 12 | public enum OperatingSystem 13 | { 14 | Windows, 15 | MacOs 16 | } 17 | 18 | public class VersionedBaseConfig 19 | { 20 | public int ConfigVersion { get; set; } = 3; 21 | } 22 | 23 | public class LauncherConfig : VersionedBaseConfig 24 | { 25 | private string _internalLastLoadedJsonString = string.Empty; 26 | 27 | public string LauncherLanguage { get; set; } = "en"; 28 | public string? GitHubApiMirror { get; set; } = null; // example "http://asia.cdn.everlook.aclon.cn/github-mirror/api/" + "/repos/{repoName}/releases/latest" 29 | public string LastSelectedServerName { get; set; } = ""; 30 | public bool CheckForLauncherUpdates { get; set; } = true; 31 | public bool CheckForHermesUpdates { get; set; } = true; 32 | public bool CheckForClientPatchUpdates { get; set; } = true; 33 | public bool CheckForClientBuildInfoUpdates { get; set; } = true; 34 | 35 | public ServerInfo[] KnownServers { get; set; } = new ServerInfo[] 36 | { 37 | new ServerInfo 38 | { 39 | Name = "Everlook (Europe)", 40 | RealmlistAddress = "logon.everlook.org", 41 | UsedInstallation = "Everlook EU 1.14.2 installation" 42 | }, 43 | new ServerInfo 44 | { 45 | Name = "Everlook (Asia)", 46 | RealmlistAddress = "asia.everlook-wow.net", 47 | UsedInstallation = "Everlook Asia 1.14.2 installation", 48 | }, 49 | new ServerInfo 50 | { 51 | Name = "Localhost (1.14.2)", 52 | RealmlistAddress = "127.0.0.1", 53 | UsedInstallation = "Default 1.14.2 installation", 54 | HermesSettings = new Dictionary 55 | { 56 | ["DebugOutput"] = "true", 57 | ["PacketsLog"] = "true", 58 | } 59 | }, 60 | }; 61 | 62 | public Dictionary GameInstallations { get; set; } = new Dictionary 63 | { 64 | ["Everlook EU 1.14.2 installation"] = new InstallationLocation 65 | { 66 | Directory = "./winterspring-data/WoW 1.14.2 Everlook", 67 | Version = "1.14.2.42597", 68 | ClientPatchInfoURL = "https://wow-patches.blu.wtf/patches/1.14.2.42597_summary.json", 69 | CustomBuildInfoURL = "https://eu.cdn.everlook.org/game-client-patch-cdn/everlook_eu_prod_1_14_2/latest-build-info", 70 | BaseClientDownloadURL = new Dictionary() { 71 | [OperatingSystem.Windows] = "https://download.wowdl.net/downloadFiles/Clients/WoW%20Classic%201.14.2.42597%20All%20Languages.rar", 72 | [OperatingSystem.MacOs] = "https://download.wowdl.net/downloadFiles/Clients/WoW_Classic_1.14.2.42597_macOS.zip", 73 | }, 74 | }, 75 | ["Everlook Asia 1.14.2 installation"] = new InstallationLocation 76 | { 77 | Directory = "./winterspring-data/WoW 1.14.2 Everlook Asia", 78 | Version = "1.14.2.42597", 79 | ClientPatchInfoURL = "https://wow-patches.blu.wtf/patches/1.14.2.42597_summary.json", 80 | CustomBuildInfoURL = "http://asia.cdn.everlook.aclon.cn/game-client-patch-cdn/everlook_asia_prod_1_14_2/latest-build-info", 81 | BaseClientDownloadURL = new Dictionary() { 82 | [OperatingSystem.Windows] = "http://asia.cdn.everlook.aclon.cn/game-client-patch-cdn/wow_classic_1_14_2_42597_all_languages.rar", 83 | [OperatingSystem.MacOs] = "http://asia.cdn.everlook.aclon.cn/game-client-patch-cdn/wow_classic_1_14_2_42597_all_languages_macos.rar", 84 | }, 85 | }, 86 | ["Default 1.14.2 installation"] = new InstallationLocation 87 | { 88 | Directory = "./winterspring-data/WoW 1.14.2", 89 | Version = "1.14.2.42597", 90 | ClientPatchInfoURL = "https://wow-patches.blu.wtf/patches/1.14.2.42597_summary.json", 91 | BaseClientDownloadURL = new Dictionary() { 92 | [OperatingSystem.Windows] = "https://download.wowdl.net/downloadFiles/Clients/WoW%20Classic%201.14.2.42597%20All%20Languages.rar", 93 | [OperatingSystem.MacOs] = "https://download.wowdl.net/downloadFiles/Clients/WoW_Classic_1.14.2.42597_macOS.zip", 94 | }, 95 | } 96 | }; 97 | 98 | public string HermesProxyLocation { get; set; } = "./winterspring-data/HermesProxy"; 99 | 100 | public class ServerInfo 101 | { 102 | public string Name { get; set; } 103 | public string RealmlistAddress { get; set; } 104 | public string UsedInstallation { get; set; } 105 | //public bool? RequiresHermes { get; set; } 106 | public Dictionary? HermesSettings { get; set; } 107 | } 108 | 109 | public class InstallationLocation 110 | { 111 | public string Version { get; set; } 112 | public string Directory { get; set; } 113 | public string ClientPatchInfoURL { get; set; } 114 | public string? CustomBuildInfoURL { get; set; } // Optional 115 | public Dictionary BaseClientDownloadURL { get; set; } 116 | } 117 | 118 | public static LauncherConfig GetDefaultConfig() => new LauncherConfig(); 119 | 120 | public void SaveConfig(string configPath) 121 | { 122 | var options = new JsonSerializerOptions { WriteIndented = true }; 123 | string jsonString = JsonSerializer.Serialize(this, options); 124 | if (jsonString != _internalLastLoadedJsonString) 125 | { 126 | File.WriteAllText(configPath, jsonString, Encoding.UTF8); 127 | } 128 | } 129 | 130 | public static LauncherConfig LoadOrCreateDefault(string configPath) 131 | { 132 | LauncherConfig config; 133 | if (!File.Exists(configPath)) 134 | { 135 | config = GetDefaultConfig(); 136 | } 137 | else 138 | { 139 | string configTextContent = File.ReadAllText(configPath, Encoding.UTF8); 140 | string updatedConfig = PatchConfigIfNeeded(configTextContent); 141 | var loadedJson = JsonSerializer.Deserialize(updatedConfig); 142 | if (loadedJson != null) 143 | { 144 | config = loadedJson; 145 | config._internalLastLoadedJsonString = configTextContent; 146 | } 147 | else 148 | { 149 | Console.WriteLine("Config is null after loading? Replacing it with default one"); 150 | config = GetDefaultConfig(); 151 | } 152 | } 153 | 154 | config.SaveConfig(configPath); 155 | 156 | return config; 157 | } 158 | 159 | private static string PatchConfigIfNeeded(string currentConfig) 160 | { 161 | var configVersion = JsonSerializer.Deserialize(currentConfig); 162 | if (configVersion == null) 163 | { 164 | Console.WriteLine("Unable to determine config version"); 165 | return currentConfig; 166 | } 167 | 168 | if (configVersion.ConfigVersion >= 3) 169 | return currentConfig; // already on latest version 170 | 171 | if (configVersion.ConfigVersion == 1) 172 | { 173 | var v1Config = JsonSerializer.Deserialize(currentConfig); 174 | if (v1Config == null) 175 | return currentConfig; // Error ? 176 | 177 | var newConfig = new LauncherConfig(); 178 | 179 | // If a official everlook server is detected switch the installation directory, so the client does not need to redownload it 180 | if (v1Config.Realmlist.Contains("everlook-wow.net", StringComparison.InvariantCultureIgnoreCase)) 181 | { 182 | var knownServer = newConfig.KnownServers.First(g => g.RealmlistAddress.Contains("everlook-wow", StringComparison.InvariantCultureIgnoreCase)); 183 | var knownInstallation = newConfig.GameInstallations.First(g => g.Key == knownServer.UsedInstallation); 184 | newConfig.GitHubApiMirror = "http://asia.cdn.everlook.aclon.cn/github-mirror/api/"; 185 | newConfig.LastSelectedServerName = knownServer.Name; 186 | TryUpgradeOldGameFolder(knownInstallation.Value.Directory, v1Config.GamePath); 187 | } 188 | else if (v1Config.Realmlist.Contains("everlook.org", StringComparison.InvariantCultureIgnoreCase)) 189 | { 190 | var knownServer = newConfig.KnownServers.First(g => g.RealmlistAddress.Contains("everlook.org", StringComparison.InvariantCultureIgnoreCase)); 191 | var knownInstallation = newConfig.GameInstallations.First(g => g.Key == knownServer.UsedInstallation); 192 | newConfig.LastSelectedServerName = knownServer.Name; 193 | TryUpgradeOldGameFolder(oldGameFolder: v1Config.GamePath, newGameFolder: knownInstallation.Value.Directory); 194 | } 195 | 196 | return JsonSerializer.Serialize(newConfig); 197 | } 198 | 199 | if (configVersion.ConfigVersion == 2) 200 | { 201 | var newConfig = JsonSerializer.Deserialize(currentConfig); 202 | 203 | if (newConfig.GitHubApiMirror == "http://asia.cdn.everlook-wow.net/github-mirror/api/") 204 | newConfig.GitHubApiMirror = "http://asia.cdn.everlook.aclon.cn/github-mirror/api/"; 205 | 206 | newConfig.ConfigVersion = 3; 207 | 208 | return JsonSerializer.Serialize(newConfig); 209 | } 210 | 211 | Console.WriteLine("Unknown version"); 212 | return currentConfig; 213 | } 214 | 215 | private static void TryUpgradeOldGameFolder(string oldGameFolder, string newGameFolder) 216 | { 217 | try 218 | { 219 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 220 | if (!weAreOnMacOs) 221 | { 222 | string known_1_14_2_client_hash = "43F407C7915602D195812620D68C3E5AE10F20740549D2D63A0B04658C02A123"; 223 | 224 | var gameExecutablePath = Path.Combine(oldGameFolder, "_classic_era_", "WoWClassic.exe"); 225 | 226 | if (File.Exists(gameExecutablePath) && HashHelper.CreateHexSha256HashFromFilename(gameExecutablePath) == known_1_14_2_client_hash) 227 | { 228 | // We can just move the whole folder 229 | Directory.Move(oldGameFolder, newGameFolder); // <-- might fail if target is not empty 230 | } 231 | else 232 | { 233 | // Just copy the WTF and Interface folder 234 | 235 | var oldInterfaceFolder = Path.Combine(oldGameFolder, "_classic_era_", "Interface"); 236 | var newInterfaceFolder = Path.Combine(newGameFolder, "_classic_era_", "Interface"); 237 | DirectoryCopy.Copy(oldInterfaceFolder, newInterfaceFolder); 238 | 239 | var oldWtfFolder = Path.Combine(oldGameFolder, "_classic_era_", "WTF"); 240 | var newWtfFolder = Path.Combine(newGameFolder, "_classic_era_", "WTF"); 241 | DirectoryCopy.Copy(oldWtfFolder, newWtfFolder); 242 | } 243 | } 244 | } 245 | catch (Exception e) 246 | { 247 | Console.WriteLine("Error while TryUpgradeOldGameFolder"); 248 | Console.WriteLine(e); 249 | } 250 | } 251 | 252 | private class LegacyV1Config : VersionedBaseConfig 253 | { 254 | public string GitRepoWinterspringLauncher { get; set; } 255 | public string GitRepoHermesProxy { get; set; } 256 | public string GitRepoArctiumLauncher { get; set; } 257 | 258 | public string WindowsGameDownloadUrl { get; set; } 259 | public string MacGameDownloadUrl { get; set; } 260 | public string GamePatcherUrl { get; set; } 261 | 262 | public string HermesProxyPath { get; set; } 263 | public string GamePath { get; set; } 264 | public string ArctiumLauncherPath { get; set; } 265 | public bool RecreateDesktopShortcut { get; set; } 266 | public bool AutoUpdateThisLauncher { get; set; } 267 | 268 | public string Realmlist { get; set; } 269 | } 270 | } 271 | 272 | -------------------------------------------------------------------------------- /WinterspringLauncher/Views/MainWindow.axaml: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 52 | 53 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 75 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 142 | 143 | 144 | Log 145 | 146 | 147 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | (running) 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | (installed) 186 | 187 | 188 | (Will be downloaded/patched) 189 | 190 | 191 | 192 | 204 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | -------------------------------------------------------------------------------- /WinterspringLauncher/LauncherLogic.StartGame.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | using System.Threading.Tasks; 8 | using Avalonia.Media; 9 | using WinterspringLauncher.Utils; 10 | 11 | namespace WinterspringLauncher; 12 | 13 | public partial class LauncherLogic 14 | { 15 | private Process? _hermesProcess; 16 | 17 | public void StartGame() 18 | { 19 | _model.InputIsAllowed = false; 20 | if (!_model.HermesIsRunning) 21 | { 22 | _model.AddLogEntry($"Launching..."); 23 | } 24 | 25 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 26 | 27 | var serverInfo = _config.KnownServers.ElementAtOrDefault(_model.SelectedServerIdx); 28 | if (serverInfo == null) 29 | { 30 | _model.AddLogEntry("Error invalid server settings"); 31 | _model.InputIsAllowed = true; 32 | return; 33 | } 34 | 35 | var gameInstallation = _config.GameInstallations.GetValueOrDefault(serverInfo.UsedInstallation); 36 | if (gameInstallation == null) 37 | { 38 | _model.AddLogEntry($"Error cant find '{serverInfo.UsedInstallation}' installation in settings"); 39 | _model.InputIsAllowed = true; 40 | return; 41 | } 42 | 43 | if (!_model.HermesIsRunning) 44 | { 45 | _model.AddLogEntry($"---------- Selected Server Config ----------"); 46 | _model.AddLogEntry($"Name: {serverInfo.Name}"); 47 | _model.AddLogEntry($"Realmlist: {serverInfo.RealmlistAddress}"); 48 | _model.AddLogEntry($"Game Directory: {gameInstallation.Directory}"); 49 | _model.AddLogEntry($"Game Version: {gameInstallation.Version}"); 50 | _model.AddLogEntry($"--------------------------------------------"); 51 | } 52 | 53 | IBrush overallProgressColor = Brush.Parse("#4caf50"); 54 | IBrush sideProgressColor = Brush.Parse("#553399"); 55 | Task.Run(async () => 56 | { 57 | if (_model.HermesIsRunning) 58 | { 59 | _model.AddLogEntry("Starting another game instance"); 60 | _model.SetProgressbar("Starting Game", 90, overallProgressColor); 61 | string bnetPortStr = serverInfo.HermesSettings?.GetValueOrDefault("BNetPort") ?? "1119"; 62 | LauncherActions.PrepareGameConfigWtf(gameInstallation.Directory, portalAddress: $"127.0.0.1:{bnetPortStr}"); 63 | 64 | _model.SetProgressbar("Starting Game", 95, overallProgressColor); 65 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 66 | LauncherActions.StartGame(Path.Combine(gameInstallation.Directory, SubPathToWowForCustomServers)); 67 | await Task.Delay(TimeSpan.FromSeconds(5)); 68 | return; 69 | } 70 | 71 | _model.SetProgressbar("Checking WoW installation", 10, overallProgressColor); 72 | _model.AddLogEntry("Checking WoW installation"); 73 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 74 | 75 | bool clientWasDownloadedInThisSession = false; // required to get at least the .build.info once, even if disabled 76 | var expectedPatchedClientLocation = Path.Combine(gameInstallation.Directory, SubPathToWowForCustomServers); 77 | if (!File.Exists(expectedPatchedClientLocation)) 78 | { 79 | _model.AddLogEntry($"Patched client was NOT found at \"{expectedPatchedClientLocation}\""); 80 | 81 | // Checking default WoW installation 82 | var expectedDefaultClientLocation = Path.Combine(gameInstallation.Directory, SubPathToWowOriginal); 83 | if (!File.Exists(expectedDefaultClientLocation)) 84 | { 85 | _model.AddLogEntry($"Default wow client was NOT found at \"{expectedDefaultClientLocation}\""); 86 | 87 | _model.AddLogEntry("Downloading WoW Client..."); 88 | 89 | if (!gameInstallation.BaseClientDownloadURL.TryGetValue(weAreOnMacOs ? OperatingSystem.MacOs : OperatingSystem.Windows, out string? downloadUrl)) 90 | { 91 | _model.AddLogEntry($"Cant find download url for \"{(weAreOnMacOs ? OperatingSystem.MacOs : OperatingSystem.Windows)}\""); 92 | return; 93 | } 94 | 95 | _model.AddLogEntry($"Download URL: {downloadUrl}"); 96 | var targetDir = new DirectoryInfo(FullPath(gameInstallation.Directory)).FullName; 97 | if (!Directory.Exists(targetDir)) 98 | Directory.CreateDirectory(targetDir); 99 | 100 | var downloadDestLocation = targetDir + ".partial-download"; 101 | _model.AddLogEntry($"Download Location: {downloadDestLocation}"); 102 | 103 | var exisingFile = new FileInfo(downloadDestLocation); 104 | if (exisingFile.Exists && exisingFile.Length > 2_000_000_000) // >2GB 105 | { 106 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 107 | _model.AddLogEntry("Detected downloaded file. Is it already downloaded?"); 108 | await Task.Delay(TimeSpan.FromSeconds(5)); 109 | _model.AddLogEntry("Skipping download"); 110 | await Task.Delay(TimeSpan.FromSeconds(5)); 111 | } 112 | else 113 | { 114 | _model.SetProgressbar("Downloading WoW", 0, Brush.Parse("#1976d2")); 115 | try 116 | { 117 | RunDownload(downloadUrl, downloadDestLocation); 118 | } 119 | catch when (false) 120 | { 121 | // TODO: Ask user for manual selecting a zip/rar file 122 | } 123 | } 124 | 125 | _model.AddLogEntry($"Unpack to: {targetDir}"); 126 | _model.SetProgressbar("Unpack WoW", 0, Brush.Parse("#d84315")); 127 | RunUnpack(downloadDestLocation, targetDir); 128 | 129 | #if !DEBUG 130 | try 131 | { 132 | File.Delete(downloadDestLocation); 133 | } 134 | catch(Exception e) 135 | { 136 | _model.AddLogEntry($"Failed to delete tmp file '{downloadDestLocation}'"); 137 | await Task.Delay(TimeSpan.FromSeconds(5)); 138 | } 139 | #endif 140 | } 141 | 142 | try { 143 | if (!File.Exists(expectedPatchedClientLocation) || (_config.CheckForClientPatchUpdates)) 144 | { 145 | _model.SetProgressbar("Checking WoW patch status", 30, overallProgressColor); 146 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 147 | 148 | string summaryUrl = gameInstallation.ClientPatchInfoURL; 149 | 150 | _model.AddLogEntry($"Summary URL: {summaryUrl}"); 151 | var patchSummary = SimpleFileDownloader.PerformGetJsonRequest(summaryUrl); 152 | 153 | var selectedPatchInfo = weAreOnMacOs ? patchSummary.MacOs : patchSummary.Windows; 154 | if (selectedPatchInfo == null) 155 | throw new Exception($"No path for '{(weAreOnMacOs ? "macos" : "windows")}' was found"); 156 | 157 | if (!File.Exists(expectedPatchedClientLocation) || selectedPatchInfo.ToSha256 != HashHelper.CreateHexSha256HashFromFilename(expectedDefaultClientLocation)) 158 | { 159 | _model.AddLogEntry("Patched client update required"); 160 | var patchUrl = string.Join("/", summaryUrl.Split("/").SkipLast(1)) + $"/{selectedPatchInfo.PatchFilename}"; 161 | _model.AddLogEntry($"Patch URL: {patchUrl}"); 162 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 163 | 164 | var patchFileContent = SimpleFileDownloader.PerformGetBytesRequest(patchUrl); 165 | BinaryPatchHandler.ApplyPatch(patchFileContent, sourceFile: expectedDefaultClientLocation, targetFile: expectedPatchedClientLocation); 166 | _model.AddLogEntry("Patch was applied!"); 167 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 168 | } 169 | } 170 | 171 | clientWasDownloadedInThisSession = true; 172 | } 173 | catch (Exception e) when (File.Exists(expectedPatchedClientLocation)) 174 | { 175 | _model.AddLogEntry("Failed to check for an update for the client"); 176 | _model.AddLogEntry("But since the file exists this error can be ignored"); 177 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 178 | _model.AddLogEntry(e.ToString()); 179 | await Task.Delay(TimeSpan.FromSeconds(5)); 180 | Console.WriteLine(e); 181 | } 182 | 183 | _model.GameIsInstalled = true; 184 | } 185 | 186 | bool buildInfoWasChanged = false; 187 | if (gameInstallation.CustomBuildInfoURL != null && (clientWasDownloadedInThisSession || _config.CheckForClientBuildInfoUpdates)) 188 | { 189 | _model.SetProgressbar("Checking BuildInfo status", 35, overallProgressColor); 190 | 191 | string buildInfoFilePath = Path.Combine(gameInstallation.Directory, ".build.info"); 192 | 193 | string newBuildInfo; 194 | try 195 | { 196 | newBuildInfo = SimpleFileDownloader.PerformGetStringRequest(gameInstallation.CustomBuildInfoURL); 197 | } 198 | catch 199 | { 200 | _model.AddLogEntry($"BuildInfo URL: {gameInstallation.CustomBuildInfoURL}"); 201 | throw; 202 | } 203 | 204 | string existingBuildInfo = File.Exists(buildInfoFilePath) ? File.ReadAllText(buildInfoFilePath) : string.Empty; 205 | 206 | if (newBuildInfo.ReplaceLineEndings() != existingBuildInfo.ReplaceLineEndings()) 207 | { 208 | _model.AddLogEntry("BuildInfo update detected"); 209 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 210 | File.WriteAllText(buildInfoFilePath, newBuildInfo); 211 | buildInfoWasChanged = true; 212 | } 213 | } 214 | 215 | _model.AddLogEntry("Checking HermesProxy status"); 216 | _model.SetProgressbar("Checking HermesProxy status", 50, overallProgressColor); 217 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 218 | 219 | await UpdateHermesProxyIfNecessary(); 220 | 221 | var modernBuild = ushort.Parse(gameInstallation.Version.Split(".").Last()); 222 | 223 | _model.AddLogEntry($"-----------------"); 224 | _model.SetProgressbar("Starting HermesProxy", 75, overallProgressColor); 225 | await Task.Delay(TimeSpan.FromSeconds(0.5)); 226 | 227 | _model.AddLogEntry($"ModernBuild: {modernBuild}"); 228 | 229 | var hermesSettingsOverwrite = new Dictionary(); 230 | 231 | var splittedRealmlist = serverInfo.RealmlistAddress.Split(':'); 232 | hermesSettingsOverwrite.Add("ServerAddress", splittedRealmlist.First()); 233 | if (splittedRealmlist.Length == 2) 234 | hermesSettingsOverwrite.Add("ServerPort", splittedRealmlist.Last()); 235 | 236 | if (serverInfo.HermesSettings != null) 237 | { 238 | foreach (var customSettings in serverInfo.HermesSettings) 239 | hermesSettingsOverwrite.Add(customSettings.Key, customSettings.Value); 240 | } 241 | 242 | _hermesProcess = LauncherActions.StartHermesProxy(_config.HermesProxyLocation, modernBuild, hermesSettingsOverwrite, (logLine) => { _model.AddLogEntry(logLine); }); 243 | _model.SetHermesPid(_hermesProcess.Id); 244 | _hermesProcess.Exited += (a, e) => 245 | { 246 | _model.AddLogEntry($"HERMES PROXY HAS CLOSED! Status: {_hermesProcess.ExitCode}"); 247 | _model.SetHermesPid(null); 248 | }; 249 | await Task.Delay(TimeSpan.FromSeconds(1)); 250 | if (_hermesProcess.HasExited) 251 | { 252 | _model.AddLogEntry($"HERMES PROXY HAS CLOSED PREMATURELY! Status: {_hermesProcess.ExitCode}"); 253 | _model.SetHermesPid(null); 254 | } 255 | 256 | _model.SetProgressbar("Starting Game", 90, overallProgressColor); 257 | { 258 | string bnetPortStr = serverInfo.HermesSettings?.GetValueOrDefault("BNetPort") ?? "1119"; 259 | LauncherActions.PrepareGameConfigWtf(gameInstallation.Directory, portalAddress: $"127.0.0.1:{bnetPortStr}"); 260 | } 261 | 262 | if (buildInfoWasChanged) 263 | _model.SetProgressbar("Your game is updating please wait a bit (check Task Manager!)", 95, sideProgressColor); 264 | else 265 | _model.SetProgressbar("Starting Game", 95, overallProgressColor); 266 | 267 | LauncherActions.StartGame(Path.Combine(gameInstallation.Directory, SubPathToWowForCustomServers)); 268 | await Task.Delay(TimeSpan.FromSeconds(5)); 269 | }).ContinueWith((t) => 270 | { 271 | if (t.Exception != null) 272 | { 273 | _model.AddLogEntry(t.Exception.ToString()); 274 | } 275 | else if (t.IsCompletedSuccessfully) 276 | { 277 | _model.SetProgressbar("Done", 100, overallProgressColor); 278 | _model.InputIsAllowed = true; 279 | } 280 | }); 281 | } 282 | 283 | private async Task UpdateHermesProxyIfNecessary() 284 | { 285 | string? localHermesVersion = null; 286 | var hermesProxyVersionFile = Path.Combine(_config.HermesProxyLocation, "version.txt"); 287 | if (File.Exists(hermesProxyVersionFile)) 288 | { 289 | localHermesVersion = File.ReadLines(hermesProxyVersionFile).First(); 290 | } 291 | 292 | if (localHermesVersion == null || _config.CheckForHermesUpdates) 293 | { 294 | GitHubReleaseInfo? releaseInfo; 295 | try 296 | { 297 | releaseInfo = GitHubApi.LatestReleaseVersion("WowLegacyCore/HermesProxy"); 298 | } 299 | catch (Exception e) when (localHermesVersion != null) 300 | { 301 | _model.AddLogEntry("Error: Failed to check HermesProxy version!"); 302 | Console.WriteLine("Exception while checking GitHub status of HermesProxy"); 303 | Console.WriteLine(e); 304 | await Task.Delay(TimeSpan.FromSeconds(5)); 305 | return; 306 | } 307 | 308 | bool weAreOnMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 309 | 310 | var versionString = $"{releaseInfo.TagName}|{releaseInfo.Name}"; 311 | if (localHermesVersion != versionString) 312 | { 313 | var osName = weAreOnMacOs ? "mac" : "win"; 314 | var possibleDownloads = releaseInfo.Assets!.FindAll(a => a.Name.Contains(osName, StringComparison.CurrentCultureIgnoreCase)); 315 | if (possibleDownloads.Count != 1) 316 | throw new Exception($"Found {possibleDownloads.Count} HermesProxy versions for your OS"); 317 | 318 | var targetDir = new DirectoryInfo(FullPath(_config.HermesProxyLocation)).FullName; 319 | if (!Directory.Exists(targetDir)) 320 | Directory.CreateDirectory(targetDir); 321 | 322 | var downloadDestLocation = targetDir + ".partial-download"; 323 | 324 | _model.SetProgressbar("Downloading HermesProxy", 0, Brush.Parse("#1976d2")); 325 | var downloadUrl = possibleDownloads[0].DownloadUrl; 326 | _model.AddLogEntry($"Download URL: {downloadUrl}"); 327 | _model.AddLogEntry($"Download Location: {downloadDestLocation}"); 328 | RunDownload(downloadUrl, downloadDestLocation); 329 | 330 | var directories = Directory.GetDirectories(targetDir); 331 | foreach (string directory in directories) 332 | { 333 | if (!directory.Contains("AccountData")) // we want to keep our AccountData 334 | Directory.Delete(directory, recursive: true); 335 | } 336 | 337 | Directory.CreateDirectory(targetDir); 338 | 339 | _model.SetProgressbar("Unpack HermesProxy", 0, Brush.Parse("#d84315")); 340 | RunUnpack(downloadDestLocation, targetDir); 341 | 342 | #if !DEBUG 343 | try 344 | { 345 | File.Delete(downloadDestLocation); 346 | } 347 | catch(Exception e) 348 | { 349 | _model.AddLogEntry($"Failed to delete tmp file '{downloadDestLocation}'"); 350 | await Task.Delay(TimeSpan.FromSeconds(5)); 351 | } 352 | #endif 353 | 354 | File.WriteAllLines(hermesProxyVersionFile, new string[] 355 | { 356 | versionString, 357 | $"Source: {downloadUrl}" 358 | }); 359 | 360 | _model.SetHermesVersion(releaseInfo.TagName); 361 | } 362 | } 363 | } 364 | } 365 | --------------------------------------------------------------------------------