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