diff --git a/Daybreak.GWCA/CMakeLists.txt b/Daybreak.GWCA/CMakeLists.txt index 4320d7a2..25462eed 100644 --- a/Daybreak.GWCA/CMakeLists.txt +++ b/Daybreak.GWCA/CMakeLists.txt @@ -11,7 +11,7 @@ endif() set(VERSION_MAJOR 0) set(VERSION_MINOR 9) set(VERSION_PATCH 9) -set(VERSION_TWEAK 43) +set(VERSION_TWEAK 44) set(VERSION_RC "${CMAKE_CURRENT_BINARY_DIR}/version.rc") configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.rc.in" "${VERSION_RC}" @ONLY) diff --git a/Daybreak.GWCA/source/DebugModule.cpp b/Daybreak.GWCA/source/DebugModule.cpp index 04f1da32..cd5a376f 100644 --- a/Daybreak.GWCA/source/DebugModule.cpp +++ b/Daybreak.GWCA/source/DebugModule.cpp @@ -20,15 +20,15 @@ namespace Daybreak::Modules { char buffer[20]; DebugPayload debugPayload; const auto gameContext = GW::GetGameContext(); - std::sprintf(buffer, "%p", static_cast(gameContext)); + std::snprintf(buffer, 20, "%p", static_cast(gameContext)); debugPayload.GameContextAddress = std::string(buffer); - std::sprintf(buffer, "%p", static_cast(gameContext->agent)); + std::snprintf(buffer, 20, "%p", static_cast(gameContext->agent)); debugPayload.AgentContextAddress = std::string(buffer); - std::sprintf(buffer, "%p", static_cast(gameContext->character)); + std::snprintf(buffer, 20, "%p", static_cast(gameContext->character)); debugPayload.CharContextAddress = std::string(buffer); - std::sprintf(buffer, "%p", static_cast(gameContext->world)); + std::snprintf(buffer, 20, "%p", static_cast(gameContext->world)); debugPayload.WorldContextAddress = std::string(buffer); - std::sprintf(buffer, "%p", static_cast(gameContext->map)); + std::snprintf(buffer, 20, "%p", static_cast(gameContext->map)); debugPayload.MapContextAddress = std::string(buffer); return debugPayload; diff --git a/Daybreak.Tests/Daybreak.Tests.csproj b/Daybreak.Tests/Daybreak.Tests.csproj index 571b575a..5ea0ecc7 100644 --- a/Daybreak.Tests/Daybreak.Tests.csproj +++ b/Daybreak.Tests/Daybreak.Tests.csproj @@ -9,9 +9,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Daybreak/Configuration/ProjectConfiguration.cs b/Daybreak/Configuration/ProjectConfiguration.cs index 9e1dbdde..99b22e39 100644 --- a/Daybreak/Configuration/ProjectConfiguration.cs +++ b/Daybreak/Configuration/ProjectConfiguration.cs @@ -88,6 +88,7 @@ using Daybreak.Services.ApplicationArguments.ArgumentHandling; using Daybreak.Services.Window; using Daybreak.Launch; +using Daybreak.Utils; namespace Daybreak.Configuration; @@ -100,72 +101,6 @@ public override void RegisterResolvers(IServiceManager serviceManager) serviceManager.RegisterOptionsManager(); serviceManager.RegisterResolver(new LoggerResolver()); serviceManager.RegisterResolver(new ClientWebSocketResolver()); - serviceManager - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupChromeImpersonationUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient>() - .WithMessageHandler(this.SetupLoggingAndMetrics>) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient>() - .WithMessageHandler(this.SetupLoggingAndMetrics>) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .WithTimeout(TimeSpan.FromSeconds(5)) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build() - .RegisterHttpClient() - .WithMessageHandler(this.SetupLoggingAndMetrics) - .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) - .Build(); } public override void RegisterServices(IServiceCollection services) @@ -173,7 +108,7 @@ public override void RegisterServices(IServiceCollection services) services.ThrowIfNull(); this.RegisterLiteCollections(services); - + this.RegisterHttpClients(services); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -201,7 +136,7 @@ public override void RegisterServices(IServiceCollection services) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService().Cast()); - services.AddSingleton(sp => new LiteDatabase("Daybreak.db")); + services.AddSingleton(sp => new LiteDatabase(PathUtils.GetAbsolutePathFromRoot("Daybreak.db"))); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -247,7 +182,7 @@ public override void RegisterServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService().As()!); @@ -490,4 +425,75 @@ private void RegisterLiteCollections(IServiceCollection services) this.RegisterLiteCollection(services); this.RegisterLiteCollection(services); } + + private IServiceCollection RegisterHttpClients(IServiceCollection services) + { + return services + .ThrowIfNull() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupChromeImpersonationUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient>() + .WithMessageHandler(this.SetupLoggingAndMetrics>) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient>() + .WithMessageHandler(this.SetupLoggingAndMetrics>) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .WithTimeout(TimeSpan.FromSeconds(5)) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build() + .RegisterHttpClient() + .WithMessageHandler(this.SetupLoggingAndMetrics) + .WithDefaultRequestHeadersSetup(this.SetupDaybreakUserAgent) + .Build(); + } } diff --git a/Daybreak/Controls/AsyncImage.xaml b/Daybreak/Controls/AsyncImage.xaml new file mode 100644 index 00000000..9c5f3217 --- /dev/null +++ b/Daybreak/Controls/AsyncImage.xaml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/Daybreak/Controls/AsyncImage.xaml.cs b/Daybreak/Controls/AsyncImage.xaml.cs new file mode 100644 index 00000000..af0f03e5 --- /dev/null +++ b/Daybreak/Controls/AsyncImage.xaml.cs @@ -0,0 +1,98 @@ +using Daybreak.Services.Images; +using Microsoft.Extensions.DependencyInjection; +using System.Core.Extensions; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Extensions; +using System.Windows.Media; + +namespace Daybreak.Controls; +/// +/// Interaction logic for AsyncImage.xaml +/// +public partial class AsyncImage : UserControl +{ + private readonly IImageCache imageCache; + + private string? imageUriCache; + private CancellationTokenSource? cancellationTokenSource; + + [GenerateDependencyProperty] + private bool loading; + [GenerateDependencyProperty] + private ImageSource imageSource = default!; + [GenerateDependencyProperty] + private string imageUri = string.Empty; + [GenerateDependencyProperty] + private Stretch stretch; + [GenerateDependencyProperty(InitialValue = BitmapScalingMode.HighQuality)] + private BitmapScalingMode scalingMode = BitmapScalingMode.HighQuality; + + public AsyncImage( + IImageCache imageCache) + { + this.imageCache = imageCache.ThrowIfNull(); + this.InitializeComponent(); + this.SetImageScalingMode(); + } + + public AsyncImage() + : this(Launch.Launcher.Instance.ApplicationServiceProvider.GetRequiredService()) + { + } + + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (e.Property == ScalingModeProperty) + { + this.SetImageScalingMode(); + } + else if (e.Property == ImageSourceProperty) + { + this.Loading = false; + } + else if (e.Property == ImageUriProperty && + this.ImageUri != this.imageUriCache) + { + this.Loading = true; + this.UpdateImage(this.ImageUri); + } + } + + private void SetImageScalingMode() + { + RenderOptions.SetBitmapScalingMode(this.Image, this.ScalingMode); + } + + private void UpdateImage(string maybeUri) + { + this.cancellationTokenSource?.Dispose(); + this.cancellationTokenSource = new(); + var token = this.cancellationTokenSource.Token; + new TaskFactory().StartNew(() => this.imageCache.GetImage(maybeUri), token, TaskCreationOptions.LongRunning, TaskScheduler.Current) + .ContinueWith(async t => + { + var result = await await t; + this.Dispatcher.Invoke(() => + { + this.ImageSource = result; + this.Loading = false; + this.imageUriCache = maybeUri; + }); + }); + } + + private void AsyncImage_Loaded(object sender, RoutedEventArgs e) + { + + } + + private void AsyncImage_Unloaded(object sender, RoutedEventArgs e) + { + this.cancellationTokenSource?.Dispose(); + this.cancellationTokenSource = null; + } +} diff --git a/Daybreak/Controls/ChromiumBrowserWrapper.xaml.cs b/Daybreak/Controls/ChromiumBrowserWrapper.xaml.cs index ce76fc96..39a151f9 100644 --- a/Daybreak/Controls/ChromiumBrowserWrapper.xaml.cs +++ b/Daybreak/Controls/ChromiumBrowserWrapper.xaml.cs @@ -172,7 +172,7 @@ private void InitializeEnvironment() return; } - CoreWebView2Environment ??= System.Extensions.TaskExtensions.RunSync(() => CoreWebView2Environment.CreateAsync(null, "BrowserData", new CoreWebView2EnvironmentOptions + CoreWebView2Environment ??= System.Extensions.TaskExtensions.RunSync(() => CoreWebView2Environment.CreateAsync(null, PathUtils.GetAbsolutePathFromRoot("BrowserData"), new CoreWebView2EnvironmentOptions { EnableTrackingPrevention = true, AllowSingleSignOnUsingOSPrimaryAccount = true, diff --git a/Daybreak/Controls/ImageViewer.xaml.cs b/Daybreak/Controls/ImageViewer.xaml.cs index c4dda4a9..64e920b4 100644 --- a/Daybreak/Controls/ImageViewer.xaml.cs +++ b/Daybreak/Controls/ImageViewer.xaml.cs @@ -27,16 +27,11 @@ public ImageViewer() this.Image2.Opacity = 0; } - public async void ShowImage(ImageSource imageSource) + public void ShowImage(ImageSource imageSource) { var currentVisible = this.CurrentVisible(); var nextVisible = this.NextVisible(); - if (nextVisible.Source is BitmapImage bitmapImage) - { - await bitmapImage.StreamSource.DisposeAsync().ConfigureAwait(true); - } - nextVisible.Source = imageSource; Transition(currentVisible, nextVisible); } diff --git a/Daybreak/Controls/Templates/BuildTemplate.xaml b/Daybreak/Controls/Templates/BuildTemplate.xaml index 53e93983..1c470ea3 100644 --- a/Daybreak/Controls/Templates/BuildTemplate.xaml +++ b/Daybreak/Controls/Templates/BuildTemplate.xaml @@ -199,7 +199,8 @@ MaximizeClicked="SkillBrowser_MaximizeClicked" /> - + @@ -219,7 +220,7 @@ ScrollViewer.CanContentScroll="True" ItemsSource="{Binding ElementName=_this, Path=AvailableSkills, Mode=OneWay}" VirtualizingPanel.IsVirtualizing="True" - VirtualizingPanel.VirtualizationMode="Recycling" + VirtualizingPanel.VirtualizationMode="Standard" VirtualizingPanel.IsVirtualizingWhenGrouping="True"> @@ -251,9 +252,12 @@ BorderThickness="0, 0, 0, 1" TextWrapping="Wrap" Height="30" - Title="{Binding Name}" Clicked="HighlightButton_Clicked" - Cursor="Hand"> + Cursor="Hand"> + + + + diff --git a/Daybreak/Controls/Templates/BuildTemplate.xaml.cs b/Daybreak/Controls/Templates/BuildTemplate.xaml.cs index 9e538fd6..c86b7ec6 100644 --- a/Daybreak/Controls/Templates/BuildTemplate.xaml.cs +++ b/Daybreak/Controls/Templates/BuildTemplate.xaml.cs @@ -202,7 +202,6 @@ private void HideInfoBrowser() private void ShowSkillListView() { this.HideInfoBrowser(); - this.SkillListContainer.Width = 400; this.SkillListContainer.Visibility = Visibility.Visible; this.showingSkillList = true; if (this.AvailableSkills is not null && @@ -218,8 +217,7 @@ private void ShowSkillListView() private void HideSkillListView() { - this.SkillListContainer.Visibility = Visibility.Hidden; - this.SkillListContainer.Width = 0; + this.SkillListContainer.Visibility = Visibility.Collapsed; this.showingSkillList = false; } diff --git a/Daybreak/Controls/Templates/SkillListEntryTemplate.xaml b/Daybreak/Controls/Templates/SkillListEntryTemplate.xaml new file mode 100644 index 00000000..20d8690f --- /dev/null +++ b/Daybreak/Controls/Templates/SkillListEntryTemplate.xaml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/Daybreak/Controls/Templates/SkillListEntryTemplate.xaml.cs b/Daybreak/Controls/Templates/SkillListEntryTemplate.xaml.cs new file mode 100644 index 00000000..afd7ac65 --- /dev/null +++ b/Daybreak/Controls/Templates/SkillListEntryTemplate.xaml.cs @@ -0,0 +1,107 @@ +using Daybreak.Models.Guildwars; +using Daybreak.Services.IconRetrieve; +using Daybreak.Services.TradeChat; +using Daybreak.Utils; +using Microsoft.Extensions.DependencyInjection; +using System.Core.Extensions; +using System.Extensions; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Controls; +using System.Windows.Extensions; + +namespace Daybreak.Controls.Templates; +/// +/// Interaction logic for SkillListEntryTemplate.xaml +/// +public partial class SkillListEntryTemplate : UserControl +{ + private readonly IIconCache iconCache; + + private Skill? skillCache; + + private CancellationTokenSource? cancellationTokenSource; + + [GenerateDependencyProperty] + private string skillImageUri = string.Empty; + + public SkillListEntryTemplate( + IIconCache iconCache) + { + this.iconCache = iconCache.ThrowIfNull(); + this.InitializeComponent(); + } + + public SkillListEntryTemplate() + : this(Launch.Launcher.Instance.ApplicationServiceProvider.GetRequiredService()) + { + } + + private void UserControl_DataContextChanged(object _, System.Windows.DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is Skill skill) + { + if (skill != Skill.NoSkill) + { + this.FetchImage(); + } + else + { + this.SkillImageUri = string.Empty; + } + } + } + + private async void FetchImage() + { + if (this.DataContext is not Skill skill) + { + return; + } + + if (this.skillCache == skill) + { + return; + } + + this.cancellationTokenSource?.Dispose(); + this.cancellationTokenSource = new CancellationTokenSource(); + var token = this.cancellationTokenSource.Token; + await Task.Run(async () => + { + // Wait for the control to be visible + while(await this.Dispatcher.InvokeAsync(() => + { + var container = this.FindParent(); + if (container is null || + !this.IsElementVisible(container)) + { + // Don't load any image since the current element is not visible on the screen + return true; + } + + return false; + }, System.Windows.Threading.DispatcherPriority.Background, token)) + { + await Task.Delay(100, token); + } + + var maybeUri = await this.iconCache.GetIconUri(skill, false); + await this.Dispatcher.InvokeAsync(() => + { + this.SkillImageUri = maybeUri; + }, System.Windows.Threading.DispatcherPriority.Background, token); + this.skillCache = skill; + }, this.cancellationTokenSource.Token); + } + + private void UserControl_Loaded(object sender, System.Windows.RoutedEventArgs e) + { + } + + private void UserControl_Unloaded(object sender, System.Windows.RoutedEventArgs e) + { + this.cancellationTokenSource?.Dispose(); + this.cancellationTokenSource = null; + } +} diff --git a/Daybreak/Controls/Templates/SkillTemplate.xaml b/Daybreak/Controls/Templates/SkillTemplate.xaml index b6971a32..9b52060d 100644 --- a/Daybreak/Controls/Templates/SkillTemplate.xaml +++ b/Daybreak/Controls/Templates/SkillTemplate.xaml @@ -19,11 +19,11 @@ - + ImageUri="{Binding ElementName=_this, Path=ImageUri, Mode=OneWay}" + Stretch="UniformToFill"/> diff --git a/Daybreak/Controls/Templates/SkillTemplate.xaml.cs b/Daybreak/Controls/Templates/SkillTemplate.xaml.cs index 57b2d7b5..9566e8b8 100644 --- a/Daybreak/Controls/Templates/SkillTemplate.xaml.cs +++ b/Daybreak/Controls/Templates/SkillTemplate.xaml.cs @@ -1,14 +1,13 @@ using Daybreak.Launch; using Daybreak.Models.Guildwars; using Daybreak.Services.IconRetrieve; -using Daybreak.Services.Images; using Microsoft.Extensions.DependencyInjection; using System; using System.Core.Extensions; +using System.Extensions; using System.Windows; using System.Windows.Controls; using System.Windows.Extensions; -using System.Windows.Media; namespace Daybreak.Controls; @@ -22,25 +21,21 @@ public partial class SkillTemplate : UserControl public event EventHandler? Clicked; public event EventHandler? RemoveClicked; - private IImageCache imageCache; private IIconCache iconRetriever; [GenerateDependencyProperty] - private ImageSource imageSource = default!; + private string imageUri = string.Empty; [GenerateDependencyProperty] private double borderOpacity; public SkillTemplate() - : this(Launcher.Instance.ApplicationServiceProvider.GetRequiredService(), - Launcher.Instance.ApplicationServiceProvider.GetRequiredService()) + : this(Launcher.Instance.ApplicationServiceProvider.GetRequiredService()) { } public SkillTemplate( - IImageCache imageCache, IIconCache iconCache) { - this.imageCache = imageCache.ThrowIfNull(); this.iconRetriever = iconCache.ThrowIfNull(); this.InitializeComponent(); this.DataContextChanged += this.SkillTemplate_DataContextChanged; @@ -58,11 +53,11 @@ private async void SkillTemplate_DataContextChanged(object sender, DependencyPro if (skill != Skill.NoSkill) { var maybeUri = await this.iconRetriever.GetIconUri(skill).ConfigureAwait(true); - this.ImageSource = await this.imageCache.GetImage(maybeUri).ConfigureAwait(true); + this.ImageUri = maybeUri; } - else if (this.ImageSource is not null) + else if (!this.ImageUri.IsNullOrWhiteSpace()) { - this.ImageSource = null; + this.ImageUri = string.Empty; } } } diff --git a/Daybreak/Daybreak.csproj b/Daybreak/Daybreak.csproj index 97066fbe..cbcd4c0c 100644 --- a/Daybreak/Daybreak.csproj +++ b/Daybreak/Daybreak.csproj @@ -11,7 +11,7 @@ preview Daybreak.ico true - 0.9.9.43 + 0.9.9.44 true cfb2a489-db80-448d-a969-80270f314c46 True @@ -96,15 +96,15 @@ - + - - + + @@ -119,7 +119,7 @@ - + diff --git a/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs b/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs index 5565bab1..5ba88c39 100644 --- a/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs +++ b/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs @@ -1,9 +1,12 @@ -namespace Daybreak.Models.Progress; +using System; + +namespace Daybreak.Models.Progress; public sealed class GuildwarsInstallationStatus : DownloadStatus { public static readonly LoadStatus StartingStep = new GuildwarsInstallationStep("Starting"); - public static readonly LoadStatus Installing = new GuildwarsInstallationStep("Installer is running. Waiting for installer to finish"); - public static readonly LoadStatus Finished = new GuildwarsInstallationStep("Installation has finished. Please add the Guild Wars executable to the executable list"); + public static readonly LoadStatus Finished = new GuildwarsInstallationStep("Installation has finished. The new file has been added to the executable list"); + public static readonly LoadStatus StartingExecutable = new GuildwarsInstallationStep("Starting Guildwars. Finish the installation process and close the installer"); + public static DownloadProgressStep Unpacking(double progress, TimeSpan? eta) => new("Unpacking", progress, eta); public GuildwarsInstallationStatus() { diff --git a/Daybreak/Services/DSOAL/DSOALService.cs b/Daybreak/Services/DSOAL/DSOALService.cs index 6d2435d6..7aeadaac 100644 --- a/Daybreak/Services/DSOAL/DSOALService.cs +++ b/Daybreak/Services/DSOAL/DSOALService.cs @@ -6,6 +6,7 @@ using Daybreak.Services.Notifications; using Daybreak.Services.Privilege; using Daybreak.Services.Registry; +using Daybreak.Utils; using Daybreak.Views; using Microsoft.Extensions.Logging; using System; @@ -29,13 +30,15 @@ internal sealed class DSOALService : IDSOALService public const string DSOALFixRegistryKey = "DSOAL/FixSymbolicLink"; private const string DownloadUrl = "https://github.com/ChthonVII/dsoal-GW1/releases/download/r420%2Bgw1_rev1/dsoal-GW1_r420+gw1_rev1.zip"; private const string ArchiveName = "dsoal-GW1_r420+gw1_rev1.zip"; - private const string DSOALDirectory = "DSOAL"; + private const string DSOALDirectorySubPath = "DSOAL"; private const string HRTFArchiveName = "HRTF_OAL_1.19.0.zip"; private const string DsoundDll = "dsound.dll"; private const string DSOALAldrvDll = "dsoal-aldrv.dll"; private const string AlsoftIni = "alsoft.ini"; private const string OpenAlDirectory = "openal"; + private static readonly string DSOALDirectory = PathUtils.GetAbsolutePathFromRoot(DSOALDirectorySubPath); + private readonly INotificationService notificationService; private readonly IRegistryService registryService; private readonly IPrivilegeManager privilegeManager; @@ -196,12 +199,12 @@ private bool EnsureSymbolicLinkExists() return false; } - Directory.CreateSymbolicLink(openalPath, Path.GetFullPath(DSOALDirectory)); + Directory.CreateSymbolicLink(openalPath, DSOALDirectory); return true; } var fi = new FileInfo(openalPath); - var desiredPath = Path.GetFullPath(DSOALDirectory); + var desiredPath = DSOALDirectory; if (fi.LinkTarget == desiredPath) { return true; @@ -215,16 +218,16 @@ private bool EnsureSymbolicLinkExists() } Directory.Delete(openalPath); - Directory.CreateSymbolicLink(openalPath, Path.GetFullPath(DSOALDirectory)); + Directory.CreateSymbolicLink(openalPath, DSOALDirectory); return true; } private void ExtractFiles() { - ZipFile.ExtractToDirectory(ArchiveName, Path.Combine(Directory.GetCurrentDirectory(), DSOALDirectory), true); - ZipFile.ExtractToDirectory(Path.Combine(DSOALDirectory, HRTFArchiveName), Path.Combine(Directory.GetCurrentDirectory(), DSOALDirectory), true); + ZipFile.ExtractToDirectory(ArchiveName, DSOALDirectory, true); + ZipFile.ExtractToDirectory(Path.Combine(DSOALDirectory, HRTFArchiveName), DSOALDirectory, true); var options = this.options.Value; - options.Path = Path.GetFullPath(DSOALDirectory); + options.Path = DSOALDirectory; this.options.UpdateOption(); File.Delete(ArchiveName); File.Delete(Path.Combine(DSOALDirectory, HRTFArchiveName)); @@ -240,6 +243,6 @@ private void SetupHrtfAndPresetFiles() Directory.Delete(openalPath); } - Directory.CreateSymbolicLink(openalPath, Path.GetFullPath(DSOALDirectory)); + Directory.CreateSymbolicLink(openalPath, DSOALDirectory); } } diff --git a/Daybreak/Services/DirectSong/DirectSongService.cs b/Daybreak/Services/DirectSong/DirectSongService.cs index 60d3efcb..835c7173 100644 --- a/Daybreak/Services/DirectSong/DirectSongService.cs +++ b/Daybreak/Services/DirectSong/DirectSongService.cs @@ -1,14 +1,13 @@ using Daybreak.Configuration.Options; -using Daybreak.Models; using Daybreak.Models.Mods; using Daybreak.Models.Progress; using Daybreak.Services.Downloads; using Daybreak.Services.Notifications; using Daybreak.Services.Privilege; using Daybreak.Services.SevenZip; +using Daybreak.Utils; using Daybreak.Views; using Microsoft.Extensions.Logging; -using System; using System.Collections.Generic; using System.Configuration; using System.Core.Extensions; @@ -23,11 +22,13 @@ internal sealed class DirectSongService : IDirectSongService { private const string DownloadUrl = "https://guildwarslegacy.com/DirectSong.7z"; private const string RegistryEditorName = "RegisterDirectSongDirectory.exe"; - private const string InstallationDirectory = "DirectSong"; + private const string InstallationDirectorySubPath = "DirectSong"; private const string DestinationZipFile = "DirectSong.7z"; private const string WMVCOREDll = "WMVCORE.DLL"; private const string DsGuildwarsDll = "ds_GuildWars.dll"; + private readonly static string InstallationDirectory = PathUtils.GetAbsolutePathFromRoot(InstallationDirectorySubPath); + private readonly INotificationService notificationService; private readonly IPrivilegeManager privilegeManager; private readonly ISevenZipExtractor sevenZipExtractor; @@ -123,7 +124,8 @@ public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWa public Task SetupDirectSong(DirectSongInstallationStatus directSongInstallationStatus, CancellationToken cancellationToken) { - if (this.InstallationTask is not null) + if (this.InstallationTask is not null && + !this.InstallationTask.IsCompleted) { return this.InstallationTask; } @@ -144,12 +146,16 @@ private async Task SetupDirectSongInternal(DirectSongInstallationStatus di if (this.IsInstalled) { scopedLogger.LogInformation("Already installed"); + this.InstallationTask = default; + this.CachedInstallationStatus = default; return true; } if (!this.privilegeManager.AdminPrivileges) { this.privilegeManager.RequestAdminPrivileges("DirectSong installation requires Administrator privileges in order to set up the registry entries"); + this.InstallationTask = default; + this.CachedInstallationStatus = default; return false; } @@ -164,6 +170,8 @@ private async Task SetupDirectSongInternal(DirectSongInstallationStatus di if (!await this.downloadService.DownloadFile(DownloadUrl, destinationPath, directSongInstallationStatus, cancellationToken)) { scopedLogger.LogError("Download failed. Check logs"); + this.InstallationTask = default; + this.CachedInstallationStatus = default; return false; } } @@ -176,6 +184,8 @@ private async Task SetupDirectSongInternal(DirectSongInstallationStatus di }, cancellationToken)) { scopedLogger.LogError("Extraction failed"); + this.InstallationTask = default; + this.CachedInstallationStatus = default; return false; } @@ -184,6 +194,8 @@ private async Task SetupDirectSongInternal(DirectSongInstallationStatus di if (!await RunRegisterDirectSongDirectory(cancellationToken)) { scopedLogger.LogError("Failed to set up registry entries"); + this.InstallationTask = default; + this.CachedInstallationStatus = default; return false; } diff --git a/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs b/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs index d2ff7c5e..a4ca086f 100644 --- a/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs +++ b/Daybreak/Services/ExceptionHandling/ExceptionHandler.cs @@ -1,9 +1,11 @@ using Daybreak.Exceptions; using Daybreak.Models.Notifications.Handling; using Daybreak.Services.Notifications; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using System; using System.Core.Extensions; +using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -30,11 +32,13 @@ public bool HandleException(Exception e) { if (e is null) { + WriteCrashDump(); return false; } if (this.logger is null) { + WriteCrashDump(); return false; } @@ -43,6 +47,7 @@ public bool HandleException(Exception e) this.logger.LogCritical(e, $"{nameof(FatalException)} encountered. Closing application"); MessageBox.Show(fatalException.ToString()); File.WriteAllText("crash.log", e.ToString()); + WriteCrashDump(); return false; } else if (e is TaskCanceledException) @@ -55,6 +60,7 @@ public bool HandleException(Exception e) this.logger.LogCritical(e, $"{nameof(FatalException)} encountered. Closing application"); MessageBox.Show(innerFatalException.ToString()); File.WriteAllText("crash.log", e.ToString()); + WriteCrashDump(); return false; } else if (e is AggregateException aggregateException) @@ -84,4 +90,12 @@ public bool HandleException(Exception e) this.notificationService.NotifyError("Encountered exception", e.ToString()); return true; } + + private static void WriteCrashDump() + { + string dumpFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"crash-{DateTime.Now.ToOADate()}.dmp"); + using var fs = new FileStream(dumpFilePath, FileMode.Create, FileAccess.Write); + var process = Process.GetCurrentProcess(); + NativeMethods.MiniDumpWriteDump(process.Handle, process.Id, fs.SafeFileHandle, NativeMethods.MinidumpType.MiniDumpWithFullMemory, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + } } diff --git a/Daybreak/Services/ExecutableManagement/GuildWarsExecutableManager.cs b/Daybreak/Services/ExecutableManagement/GuildWarsExecutableManager.cs index 61a63081..e38d66a4 100644 --- a/Daybreak/Services/ExecutableManagement/GuildWarsExecutableManager.cs +++ b/Daybreak/Services/ExecutableManagement/GuildWarsExecutableManager.cs @@ -15,7 +15,7 @@ namespace Daybreak.Services.ExecutableManagement; internal sealed class GuildWarsExecutableManager : IGuildWarsExecutableManager, IApplicationLifetimeService { private readonly static TimeSpan ExecutableVerificationLatency = TimeSpan.FromSeconds(5); - private readonly static object Lock = new(); + private readonly static SemaphoreSlim ExecutablesSemaphore = new(1, 1); private readonly CancellationTokenSource cancellationTokenSource = new(); private readonly ILiveUpdateableOptions liveUpdateableOptions; @@ -31,10 +31,10 @@ public GuildWarsExecutableManager( public IEnumerable GetExecutableList() { - while (!Monitor.TryEnter(Lock)) { } + ExecutablesSemaphore.Wait(); var list = this.liveUpdateableOptions.Value.ExecutablePaths.Where(this.IsValidExecutable).ToList(); - Monitor.Exit(Lock); + ExecutablesSemaphore.Release(); return list; } @@ -42,7 +42,7 @@ public IEnumerable GetExecutableList() public void AddExecutable(string executablePath) { var fullPath = Path.GetFullPath(executablePath); - while (!Monitor.TryEnter(Lock)) { } + ExecutablesSemaphore.Wait(); var list = this.liveUpdateableOptions.Value.ExecutablePaths; if (list.None(e => e == executablePath)) @@ -52,13 +52,13 @@ public void AddExecutable(string executablePath) this.liveUpdateableOptions.Value.ExecutablePaths = list; this.liveUpdateableOptions.UpdateOption(); - Monitor.Exit(Lock); + ExecutablesSemaphore.Release(); } public void RemoveExecutable(string executablePath) { var fullPath = Path.GetFullPath(executablePath); - while (!Monitor.TryEnter(Lock)) { } + ExecutablesSemaphore.Wait(); var list = this.liveUpdateableOptions.Value.ExecutablePaths; if (list.Any(e => e == executablePath)) @@ -68,7 +68,7 @@ public void RemoveExecutable(string executablePath) this.liveUpdateableOptions.Value.ExecutablePaths = list; this.liveUpdateableOptions.UpdateOption(); - Monitor.Exit(Lock); + ExecutablesSemaphore.Release(); } public bool IsValidExecutable(string executablePath) @@ -90,7 +90,7 @@ private async void VerifyExecutables(CancellationToken cancellationToken) var scopedLogger = this.logger.CreateScopedLogger(nameof(this.VerifyExecutables), string.Empty); while (!cancellationToken.IsCancellationRequested) { - while (!Monitor.TryEnter(Lock)) { } + await ExecutablesSemaphore.WaitAsync(cancellationToken); var executables = this.liveUpdateableOptions.Value.ExecutablePaths; var deletedExecutable = false; @@ -114,7 +114,7 @@ private async void VerifyExecutables(CancellationToken cancellationToken) this.liveUpdateableOptions.UpdateOption(); } - Monitor.Exit(Lock); + ExecutablesSemaphore.Release(); await Task.Delay(ExecutableVerificationLatency, cancellationToken); } } diff --git a/Daybreak/Services/GWCA/GWCAInjector.cs b/Daybreak/Services/GWCA/GWCAInjector.cs index a4cdce69..ec4e3a17 100644 --- a/Daybreak/Services/GWCA/GWCAInjector.cs +++ b/Daybreak/Services/GWCA/GWCAInjector.cs @@ -3,12 +3,14 @@ using Daybreak.Models.Mods; using Daybreak.Services.Injection; using Daybreak.Services.Notifications; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Configuration; using System.Core.Extensions; using System.Extensions; -using System.Linq; +using System.IO; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -17,7 +19,7 @@ namespace Daybreak.Services.GWCA; internal sealed class GWCAInjector : IGWCAInjector { private const int MaxRetries = 10; - private const string ModulePath = "GWCA/Daybreak.GWCA.dll"; + private const string ModuleSubPath = "GWCA/Daybreak.GWCA.dll"; private readonly INotificationService notificationService; private readonly IGWCAClient gwcaClient; @@ -65,8 +67,9 @@ public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWa public async Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) { + var modulePath = PathUtils.GetAbsolutePathFromRoot(ModuleSubPath); var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OnGuildWarsCreated), guildWarsCreatedContext.ApplicationLauncherContext.ExecutablePath); - if (!await this.injector.Inject(guildWarsCreatedContext.ApplicationLauncherContext.Process, ModulePath, cancellationToken)) + if (!await this.injector.Inject(guildWarsCreatedContext.ApplicationLauncherContext.Process, modulePath, cancellationToken)) { scopedLogger.LogError("Unable to inject GWCA plugin into Guild Wars. Check above error messages for details"); this.notificationService.NotifyError( diff --git a/Daybreak/Services/Guildwars/GuildwarsInstaller.cs b/Daybreak/Services/Guildwars/GuildwarsInstaller.cs index 5d3265d2..ed6ae36d 100644 --- a/Daybreak/Services/Guildwars/GuildwarsInstaller.cs +++ b/Daybreak/Services/Guildwars/GuildwarsInstaller.cs @@ -7,6 +7,7 @@ using System.Core.Extensions; using System.Diagnostics; using System.IO; +using System.Threading; using System.Threading.Tasks; namespace Daybreak.Services.Guildwars; @@ -29,7 +30,7 @@ public GuildwarsInstaller( this.logger = logger.ThrowIfNull(); } - public async Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus) + public async Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) { if (this.privilegeManager.AdminPrivileges is false) { diff --git a/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs b/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs index 6c018f19..d90c72a9 100644 --- a/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs +++ b/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs @@ -1,8 +1,9 @@ using Daybreak.Models.Progress; +using System.Threading; using System.Threading.Tasks; namespace Daybreak.Services.Guildwars; public interface IGuildwarsInstaller { - Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus); + Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken); } diff --git a/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs b/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs new file mode 100644 index 00000000..6d311d95 --- /dev/null +++ b/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs @@ -0,0 +1,221 @@ +using Daybreak.Models.Progress; +using Daybreak.Services.ExecutableManagement; +using Daybreak.Services.Guildwars.Models; +using Daybreak.Services.Guildwars.Utils; +using Daybreak.Services.Notifications; +using Microsoft.Extensions.Logging; +using System; +using System.Core.Extensions; +using System.Diagnostics; +using System.Extensions; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Services.Guildwars; +internal sealed class IntegratedGuildwarsInstaller : IGuildwarsInstaller +{ + private const string ExeName = "Gw.exe"; + private const string TempExeName = "Gw.exe.temp"; + + private readonly IGuildWarsExecutableManager guildWarsExecutableManager; + private readonly INotificationService notificationService; + private readonly ILogger logger; + + public IntegratedGuildwarsInstaller( + IGuildWarsExecutableManager guildWarsExecutableManager, + INotificationService notificationService, + ILogger logger) + { + this.guildWarsExecutableManager = guildWarsExecutableManager.ThrowIfNull(); + this.notificationService = notificationService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + } + + public async Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + { + return await new TaskFactory().StartNew(_ => { + return this.InstallGuildwarsInternal(destinationPath, installationStatus, cancellationToken); + }, TaskCreationOptions.LongRunning, cancellationToken).Unwrap(); + } + + private async Task InstallGuildwarsInternal(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.InstallGuildwarsInternal), destinationPath); + GuildwarsClientContext? maybeContext = default; + try + { + var tempName = Path.Combine(destinationPath, TempExeName); + var exeName = Path.Combine(destinationPath, ExeName); + + // Initialize the download client + var guildWarsClient = new GuildwarsClient(); + var result = await guildWarsClient.Connect(cancellationToken); + if (!result.HasValue) + { + scopedLogger.LogError("Failed to connect to ArenaNet servers"); + installationStatus.CurrentStep = GuildwarsInstallationStatus.FailedDownload; + return false; + } + + (var context, var manifest) = result.Value; + maybeContext = context; + (var downloadResult, var expectedFinalSize) = await this.DownloadCompressedExecutable(tempName, guildWarsClient, context, manifest, installationStatus, cancellationToken); + if (!downloadResult) + { + scopedLogger.LogError("Failed to download compressed executable"); + installationStatus.CurrentStep = GuildwarsInstallationStatus.FailedDownload; + return false; + } + + if (!this.DecompressExecutable(tempName, exeName, expectedFinalSize, installationStatus)) + { + scopedLogger.LogError("Failed to decompress executable"); + installationStatus.CurrentStep = GuildwarsInstallationStatus.FailedDownload; + return false; + } + + var filePath = Path.GetFullPath(exeName); + installationStatus.CurrentStep = GuildwarsInstallationStatus.StartingExecutable; + await Task.Delay(100, cancellationToken); + using var process = Process.Start(filePath); + scopedLogger.LogInformation("Starting executable. Waiting for the process to end before finishing installation"); + while (!process.HasExited) + { + await Task.Delay(1000, cancellationToken); + } + + this.guildWarsExecutableManager.AddExecutable(filePath); + File.Delete(tempName); + installationStatus.CurrentStep = GuildwarsInstallationStatus.Finished; + return true; + } + catch (Exception e) + { + this.notificationService.NotifyError( + title: "Download exception", + description: $"Encountered exception while downloading: {e}"); + installationStatus.CurrentStep = GuildwarsInstallationStatus.FailedDownload; + this.logger.LogError(e, "Download failed. Encountered exception"); + return false; + } + finally + { + if (maybeContext.HasValue) + { + maybeContext.Value.Dispose(); + } + } + } + + private async Task<(bool Success, int ExpectedSize)> DownloadCompressedExecutable( + string fileName, + GuildwarsClient guildWarsClient, + GuildwarsClientContext context, + ManifestResponse manifest, + GuildwarsInstallationStatus installationStatus, + CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.DownloadCompressedExecutable), fileName); + var maybeStream = await guildWarsClient.GetFileStream(context, manifest.LatestExe, 0, cancellationToken); + if (maybeStream is null) + { + scopedLogger.LogError("Failed to get download stream"); + return (false, -1); + } + + using var downloadStream = maybeStream; + using var writeFileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write); + var expectedFinalSize = downloadStream.SizeDecompressed; + var buffer = new Memory(new byte[2048]); + var readBytes = 0; + do + { + readBytes = await downloadStream.ReadAsync(buffer, cancellationToken); + await writeFileStream.WriteAsync(buffer, cancellationToken); + installationStatus.CurrentStep = GuildwarsInstallationStatus.Downloading((double)downloadStream.Position / downloadStream.Length, default); + } while (readBytes > 0); + + return (true, expectedFinalSize); + } + + private bool DecompressExecutable( + string tempName, + string exeName, + int expectedFinalSize, + GuildwarsInstallationStatus installationStatus) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.DecompressExecutable), tempName); + try + { + var byteBuffer = new Memory(new byte[1]); + using var readFileStream = new FileStream(tempName, FileMode.Open, FileAccess.Read); + using var finalExeStream = new FileStream(exeName, FileMode.Create, FileAccess.ReadWrite); + var bitStream = new BitStream(readFileStream); + bitStream.Consume(4); + var first4Bits = bitStream.Read(4); + while (finalExeStream.Length < expectedFinalSize) + { + installationStatus.CurrentStep = GuildwarsInstallationStatus.Unpacking((double)finalExeStream.Length / expectedFinalSize, default); + var litHuffman = HuffmanTable.BuildHuffmanTable(bitStream); + var distHuffman = HuffmanTable.BuildHuffmanTable(bitStream); + var blockSize = (bitStream.Read(4) + 1) * 4096; + for (var i = 0; i < blockSize; i++) + { + if (finalExeStream.Length == expectedFinalSize) + { + break; + } + + var code = litHuffman.GetNextCode(bitStream); + if (code < 0x100) + { + finalExeStream.WriteByte((byte)code); + } + else + { + var blen = Huffman.ExtraBitsLength[code - 256]; + code = Huffman.Table3[code - 256]; + if (blen > 0) + { + code |= bitStream.Read((int)blen); + } + + var backtrackCount = first4Bits + code + 1; + code = distHuffman.GetNextCode(bitStream); + blen = Huffman.ExtraBitsDistance[code]; + var backtrack = Huffman.BacktrackTable[code]; + if (blen > 0) + { + backtrack |= bitStream.Read((int)blen); + } + + if (backtrack >= finalExeStream.Length) + { + throw new InvalidOperationException("Failed to decompress executable. backtrack >= finalExeStream.Length"); + } + + var src = finalExeStream.Length - (backtrack + 1); + for (var j = src; j < src + backtrackCount; j++) + { + finalExeStream.Seek(j, SeekOrigin.Begin); + var b = finalExeStream.ReadByte(); + finalExeStream.Seek(0, SeekOrigin.End); + finalExeStream.WriteByte((byte)b); + } + } + } + } + + return true; + } + catch(Exception e) + { + scopedLogger.LogError(e, "Encountered exception when decompressing executable"); + this.notificationService.NotifyError( + title: "Failed to decompress", + description: $"Encountered exception while decompressing: {e}"); + return false; + } + } +} diff --git a/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs b/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs new file mode 100644 index 00000000..564c4041 --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs @@ -0,0 +1,6 @@ +namespace Daybreak.Services.Guildwars.Models; +internal readonly struct FileMetadataResponse +{ + public readonly short Field1 { get; init; } + public readonly short Field2 { get; init; } +} diff --git a/Daybreak/Services/Guildwars/Models/FileRequest.cs b/Daybreak/Services/Guildwars/Models/FileRequest.cs new file mode 100644 index 00000000..08d37b03 --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/FileRequest.cs @@ -0,0 +1,8 @@ +namespace Daybreak.Services.Guildwars.Models; +internal readonly struct FileRequest +{ + public short Field1 { get; init; } + public short Field2 { get; init; } + public int FileId { get; init; } + public int Version { get; init; } +} diff --git a/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs b/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs new file mode 100644 index 00000000..3c73005a --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs @@ -0,0 +1,7 @@ +namespace Daybreak.Services.Guildwars.Models; +internal readonly struct FileRequestNextChunk +{ + public short Field1 { get; init; } + public short Field2 { get; init; } + public uint Field3 { get; init; } +} diff --git a/Daybreak/Services/Guildwars/Models/FileResponse.cs b/Daybreak/Services/Guildwars/Models/FileResponse.cs new file mode 100644 index 00000000..e4bd84e5 --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/FileResponse.cs @@ -0,0 +1,8 @@ +namespace Daybreak.Services.Guildwars.Models; +internal readonly struct FileResponse +{ + public int FileId { get; init; } + public int SizeDecompressed { get; init; } + public int SizeCompressed { get; init; } + public int Crc { get; init; } +} diff --git a/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs b/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs new file mode 100644 index 00000000..3973424c --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs @@ -0,0 +1,13 @@ +using System; +using System.Net.Sockets; + +namespace Daybreak.Services.Guildwars.Models; +internal readonly struct GuildwarsClientContext : IDisposable +{ + public Socket Socket { get; init; } + + public void Dispose() + { + this.Socket.Dispose(); + } +} diff --git a/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs b/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs new file mode 100644 index 00000000..3cceecc9 --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs @@ -0,0 +1,14 @@ +using System.Runtime.InteropServices; + +namespace Daybreak.Services.Guildwars.Models; +[StructLayout(LayoutKind.Sequential, Pack = 1)] +internal readonly struct HandshakeRequest +{ + public byte Field1 { get; init; } + public uint Field2 { get; init; } + public ushort Field3 { get; init; } + public ushort Field4 { get; init; } + public uint Field5 { get; init; } + public uint Field6 { get; init; } + public uint Field7 { get; init; } +} diff --git a/Daybreak/Services/Guildwars/Models/ManifestResponse.cs b/Daybreak/Services/Guildwars/Models/ManifestResponse.cs new file mode 100644 index 00000000..f37e9aee --- /dev/null +++ b/Daybreak/Services/Guildwars/Models/ManifestResponse.cs @@ -0,0 +1,16 @@ +using System.Runtime.InteropServices; + +namespace Daybreak.Services.Guildwars.Models; +[StructLayout(LayoutKind.Sequential, Pack = 1)] +internal readonly struct ManifestResponse +{ + public readonly short Field1; + public readonly short Field2; + public readonly int Field3; + public readonly int Manifest; + public readonly int BackupExe; + public readonly int Field6; + public readonly int Field7; + public readonly int Field8; + public readonly int LatestExe; +} diff --git a/Daybreak/Services/Guildwars/Utils/BitStream.cs b/Daybreak/Services/Guildwars/Utils/BitStream.cs new file mode 100644 index 00000000..15c5c978 --- /dev/null +++ b/Daybreak/Services/Guildwars/Utils/BitStream.cs @@ -0,0 +1,86 @@ +using System; +using System.Core.Extensions; +using System.IO; + +/// +/// https://github.com/reduf/Headquarter/blob/decomp/tools/inflate.py +/// +internal sealed class BitStream +{ + private readonly Stream input; + private uint buf1; + private uint buf2; + private int idx; + private int avail; + + public BitStream(Stream input) + { + if (!input.CanRead) + { + throw new ArgumentException("Input must be readable stream"); + } + + if (input.Length - input.Position < 8) + { + throw new ArgumentException("Input length must be at least 8"); + } + + this.input = input.ThrowIfNull(); + var tempBuffer = new byte[8]; + input.ReadAtLeast(tempBuffer, 8); + this.buf1 = BitConverter.ToUInt32(tempBuffer, 0); + this.buf2 = BitConverter.ToUInt32(tempBuffer, 4); + this.idx = 8; + this.avail = 32; + } + + public uint Peek(int count) + { + if (count > 32) + throw new ArgumentOutOfRangeException(nameof(count), "Count must be less than or equal to 32"); + + return this.buf1 >> (32 - count); + } + + public uint Read(int count) + { + uint result = this.Peek(count); + this.Consume(count); + return result; + } + + public void Consume(int count) + { + this.buf1 = (this.buf2 >> (32 - count)) | U32(this.buf1 << count); + + if (this.avail < count) + { + if (this.idx >= this.input.Length) + { + this.avail = 0; + this.buf2 = 0; + } + else + { + var bytes = new byte[4]; + this.input.Read(bytes, 0, 4); + this.buf2 = BitConverter.ToUInt32(bytes); + this.idx += 4; + var newAvail = (this.avail + 32) - count; + this.buf1 += this.buf2 >> newAvail; + this.buf2 = U32(this.buf2 << (count - this.avail)); + this.avail = newAvail; + } + } + else + { + this.avail -= count; + this.buf2 = U32(this.buf2 << count); + } + } + + private static uint U32(uint v) + { + return v & 0xFFFFFFFF; + } +} diff --git a/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs b/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs new file mode 100644 index 00000000..df6a247f --- /dev/null +++ b/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs @@ -0,0 +1,104 @@ +using Daybreak.Services.Guildwars.Models; +using System; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Services.Guildwars.Utils; +internal sealed class GuildwarsClient +{ + public async Task<(GuildwarsClientContext, ManifestResponse)?> Connect(CancellationToken cancellationToken) + { + for(var i = 1; i < 13; i++) + { + var url = $"file{i}.arenanetworks.com"; + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + Blocking = false, + ReceiveTimeout = 100 + }; + try + { + await socket.ConnectAsync(url, 6112, cancellationToken); + var context = new GuildwarsClientContext { Socket = socket }; + var handshakeRequest = new HandshakeRequest + { + Field1 = 1, + Field2 = 0, + Field3 = 0xF1, + Field4 = 0x10, + Field5 = 1, + Field6 = 0, + Field7 = 0 + }; + + await this.Send(handshakeRequest, context, cancellationToken); + var manifest = await this.ReceiveWait(context, cancellationToken); + return (context, manifest); + } + catch + { + socket.Dispose(); + } + } + + return default; + } + + public async Task GetFileStream(GuildwarsClientContext guildwarsClientContext, int fileId, int version = 0, CancellationToken cancellationToken = default) + { + await this.Send(new FileRequest { Field1 = 0x3F2, Field2 = 0xC, FileId = fileId, Version = version }, guildwarsClientContext, cancellationToken); + var metadata = await this.ReceiveWait(guildwarsClientContext, cancellationToken); + if (metadata.Field1 == 0x4F2) + { + // Could not find file + return default; + } + else if (metadata.Field1 != 0x5F2) + { + // Unknown response + return default; + } + + var response = await this.ReceiveWait(guildwarsClientContext, cancellationToken); + return new GuildwarsFileStream(guildwarsClientContext, this, response.FileId, response.SizeCompressed, response.SizeDecompressed, response.Crc); + } + + public async Task ReceiveWait(GuildwarsClientContext context, CancellationToken cancellationToken) + where T : struct + { + var size = Marshal.SizeOf(); + var buffer = new Memory(new byte[size]); + var received = 0; + while (received < size) + { + var receiveTask = context.Socket.ReceiveAsync(buffer, cancellationToken).AsTask(); + if (await Task.WhenAny(receiveTask, Task.Delay(5000, cancellationToken)) != receiveTask) + { + throw new TaskCanceledException($"Timed out while receiving {typeof(T).Name}"); + } + + received += await receiveTask; + } + + var bytes = buffer.ToArray(); + var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + var str = Marshal.PtrToStructure(handle.AddrOfPinnedObject()); + handle.Free(); + return str; + } + + public async Task Send(T str, GuildwarsClientContext context, CancellationToken cancellationToken) + where T : struct + { + var size = Marshal.SizeOf(); + var bytes = new byte[size]; + var ptr = Marshal.AllocHGlobal(size); + Marshal.StructureToPtr(str, ptr, true); + Marshal.Copy(ptr, bytes, 0, size); + Marshal.FreeHGlobal(ptr); + + _ = await context.Socket.SendAsync(bytes, cancellationToken); + } +} diff --git a/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs b/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs new file mode 100644 index 00000000..f030cdd4 --- /dev/null +++ b/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs @@ -0,0 +1,130 @@ +using Daybreak.Services.Guildwars.Models; +using System; +using System.Core.Extensions; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Services.Guildwars.Utils; +internal sealed class GuildwarsFileStream : Stream +{ + private readonly GuildwarsClient guildwarsClient; + private readonly GuildwarsClientContext guildwarsClientContext; + + private byte[]? chunkBuffer; + private int positionInBuffer = 0; + private int chunkSize = 0; + + public int FileId { get; init; } + public int SizeCompressed { get; init; } + public int SizeDecompressed { get; init; } + public int Crc { get; init; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => this.SizeCompressed; + public override long Position { get; set; } + + public GuildwarsFileStream(GuildwarsClientContext guildwarsClientContext, GuildwarsClient guildwarsClient, int fileId, int sizeCompressed, int sizeDecompressed, int crc) + { + this.guildwarsClient = guildwarsClient.ThrowIfNull(); + this.guildwarsClientContext = guildwarsClientContext; + this.FileId = fileId; + this.SizeCompressed = sizeCompressed; + this.SizeDecompressed = sizeDecompressed; + this.Crc = crc; + } + + public override void Flush() + { + throw new System.NotImplementedException(); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (this.Position >= this.Length) + { + return 0; + } + + if (this.positionInBuffer < this.chunkSize) + { + var read = this.ReadCurrentChunkBytes(buffer, offset, count); + this.Position += read; + return read; + } + + // If we have already requested a previous chunk, we need to request more data + if (this.chunkSize > 0) + { + await this.guildwarsClient.Send(new FileRequestNextChunk { Field1 = 0x7F3, Field2 = 0x8, Field3 = (uint)this.chunkSize }, this.guildwarsClientContext, cancellationToken); + } + + var meta = await this.guildwarsClient.ReceiveWait(this.guildwarsClientContext, cancellationToken); + if (meta.Field1 != 0x6F2 && meta.Field1 != 0x6F3) + { + throw new InvalidOperationException($"Unknown header in response {meta.Field1:X4}"); + } + + this.chunkSize = meta.Field2 - 4; + if (this.chunkBuffer is null || + this.chunkBuffer.Length != this.chunkSize) + { + this.chunkBuffer = new byte[this.chunkSize]; + } + + var downloadedChunkSize = 0; + do + { + var buf = new byte[Math.Min(4096, this.chunkSize - downloadedChunkSize)]; + var readTask = this.guildwarsClientContext.Socket.ReceiveAsync(buf, cancellationToken).AsTask(); + if (await Task.WhenAny(readTask, Task.Delay(5000, cancellationToken)) != readTask) + { + throw new TaskCanceledException("Timed out waiting for download"); + } + + var read = await readTask; + Array.Copy(buf, 0, this.chunkBuffer, downloadedChunkSize, read); + downloadedChunkSize += read; + } while (downloadedChunkSize < this.chunkSize); + + this.positionInBuffer = 0; + var chunkRead = this.ReadCurrentChunkBytes(buffer, offset, count); + this.Position += chunkRead; + return chunkRead; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return System.Extensions.TaskExtensions.RunSync(() => this.ReadAsync(buffer, offset, count)); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new System.NotImplementedException(); + } + + public override void SetLength(long value) + { + throw new System.NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new System.NotImplementedException(); + } + + private int ReadCurrentChunkBytes(byte[] buffer, int offset, int count) + { + if (this.chunkBuffer is null) + { + throw new InvalidOperationException("No chunk buffer ready"); + } + + var bytesToRead = Math.Min(count, this.chunkSize - this.positionInBuffer); + Array.Copy(this.chunkBuffer, this.positionInBuffer, buffer, offset, bytesToRead); + this.positionInBuffer += bytesToRead; + return bytesToRead; + } +} diff --git a/Daybreak/Services/Guildwars/Utils/Huffman.cs b/Daybreak/Services/Guildwars/Utils/Huffman.cs new file mode 100644 index 00000000..4deb9f18 --- /dev/null +++ b/Daybreak/Services/Guildwars/Utils/Huffman.cs @@ -0,0 +1,74 @@ +namespace Daybreak.Services.Guildwars.Utils; +internal static class Huffman +{ + public static readonly (uint, uint)[] Table1 = + [ + (0xa0000000, 2), + (0x60000000, 6), + (0x40000000, 10), + (0x20000000, 18), + (0x12000000, 25), + (0x0c000000, 31), + (0x07000000, 41), + (0x03000000, 57), + (0x01600000, 70), + (0x00f00000, 77), + (0x00c00000, 83), + (0x00b00000, 87), + (0x00a00000, 95), + (0x00000000, 255), + ]; + + public static readonly uint[] Table2 = + [ + 0x08, 0x09, 0x0A, 0x00, 0x07, 0x0B, 0x0C, 0x06, 0x29, 0x2A, 0xE0, 0x04, 0x05, 0x20, 0x28, 0x2B, 0x2C, 0x40, + 0x4A, 0x03, 0x0D, 0x25, 0x26, 0x27, 0x48, 0x49, 0x24, 0x47, 0x4B, 0x4C, 0x69, 0x6A, 0x23, 0x46, 0x60, 0x63, + 0x67, 0x68, 0x88, 0x89, 0xA0, 0xE8, 0x01, 0x02, 0x2D, 0x43, 0x44, 0x45, 0x65, 0x66, 0x80, 0x87, 0x8A, 0xA8, + 0xA9, 0xC0, 0xC9, 0xE9, 0x0E, 0x4D, 0x64, 0x6B, 0x6C, 0x84, 0x85, 0x8B, 0xA4, 0xA5, 0xAA, 0xC8, 0xE5, 0x83, + 0x86, 0xA6, 0xA7, 0xC7, 0xCA, 0xE7, 0x22, 0x2E, 0x8C, 0xC4, 0xE4, 0xE6, 0x4E, 0x6D, 0xC6, 0xEC, 0x0F, 0x10, + 0x11, 0x8D, 0xAB, 0xAC, 0xCC, 0xEA, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, + 0x1E, 0x1F, 0x21, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, + 0x3E, 0x3F, 0x41, 0x42, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, + 0x5D, 0x5E, 0x5F, 0x61, 0x62, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, + 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x81, 0x82, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, + 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA1, 0xA2, 0xA3, 0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, + 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC1, 0xC2, 0xC3, 0xC5, 0xCB, 0xCD, 0xCE, + 0xCF, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xE1, + 0xE2, 0xE3, 0xEB, 0xED, 0xEE, 0xEF, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, + 0xFC, 0xFD, 0xFE, 0xFF, + ]; + + public static readonly uint[] Table3 = + [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0A, 0x0C, 0x0E, 0x10, 0x14, 0x18, 0x1C, + 0x20, 0x28, 0x30, 0x38, 0x40, 0x50, 0x60, 0x70, 0x80, 0xA0, 0xC0, 0xE0, 0xFF, 0x00, 0x00, 0x00, + ]; + + public static readonly uint[] ExtraBitsLength = + [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, + 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x05, 0x00, + ]; + + public static readonly uint[] ExtraBitsDistance = + [ + 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x02, 0x03, 0x03, 0x04, 0x04, 0x05, 0x05, 0x06, 0x06, + 0x07, 0x07, 0x08, 0x08, 0x09, 0x09, 0x0A, 0x0A, 0x0B, 0x0B, 0x0C, 0x0C, 0x0D, 0x0D, 0x0E, 0x0E, + ]; + + public static readonly uint[] BacktrackTable = + [ + 0x0000, 0x0001, 0x0002, 0x0003, + 0x0004, 0x0006, 0x0008, 0x000C, + 0x0010, 0x0018, 0x0020, 0x0030, + 0x0040, 0x0060, 0x0080, 0x00C0, + 0x0100, 0x0180, 0x0200, 0x0300, + 0x0400, 0x0600, 0x0800, 0x0C00, + 0x1000, 0x1800, 0x2000, 0x3000, + 0x4000, 0x6000, 0x0100, 0x0302, + 0x0504, 0x0706, 0x0A08, 0x0E0C, + 0x1410, 0x1C18, 0x2820, 0x3830, + 0x5040, 0x7060, 0xA080, 0xE0C0, + 0x00FF, 0x0000, + ]; +} diff --git a/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs b/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs new file mode 100644 index 00000000..f9006efe --- /dev/null +++ b/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Daybreak.Services.Guildwars.Utils; + +internal sealed class HuffmanTable +{ + private readonly (uint, uint)[] nodes = new (uint, uint)[256]; + private readonly List<(uint, int, int)> largeSymbolTranslation = []; + private readonly List largeSymbolValues = []; + + private HuffmanTable((uint, uint)[] nodes, int largeSymbolCount) + { + this.nodes = nodes; + this.largeSymbolTranslation = new List<(uint, int, int)>(24); + for (int i = 0; i < 24; i++) + { + this.largeSymbolTranslation.Add((0, 0, 0)); + } + + this.largeSymbolValues = new List(largeSymbolCount); + } + + public uint GetNextCode(BitStream stream) + { + var bits = stream.Peek(8); + (var encLen, var encVal) = this.nodes[bits]; + + if (encLen == 0xFFFFFFFF) + { + var buf1 = stream.Peek(32); + (var firstEncoding, var lastIndex, var encLength) = this.largeSymbolTranslation.First(tuple => tuple.Item1 <= buf1); + encLen = (uint)encLength; + var groupIndex = (buf1 - firstEncoding) >> (32 - encLength); + var largeEncIndex = lastIndex - (int)groupIndex; + if (largeEncIndex < 0 || largeEncIndex >= this.largeSymbolValues.Count) + { + throw new InvalidOperationException("Failed to get next Huffman Table code. largeEncIndex >= this.largeSymbolValues.Count"); + } + + encVal = this.largeSymbolValues[largeEncIndex]; + } + + stream.Consume((int)encLen); + return encVal; + } + + public static HuffmanTable BuildHuffmanTable(BitStream stream) + { + var symbolFollowTableRoot = new uint[32]; + for (int i = 0; i < symbolFollowTableRoot.Length; i++) + { + symbolFollowTableRoot[i] = 0xFFFFFFFF; + } + + var symbolCount = (int)stream.Read(16); + var symbolFollowTable = new int[symbolCount]; + var totalSymbolCount = 0; + + var symbolIdx = symbolCount - 1; + while (symbolIdx != -1) + { + var buf1 = stream.Peek(32); + int idx; + for (idx = 0; idx < Huffman.Table1.Length; idx++) + { + if (Huffman.Table1[idx].Item1 <= buf1) + { + break; + } + } + + if (idx == Huffman.Table1.Length) + { + throw new InvalidOperationException("Failed to build Huffman Table. Index out of table1 bounds"); + } + + var bitCount = idx + 3; + var offset = (int)((buf1 - Huffman.Table1[idx].Item1) >> (32 - bitCount)); + stream.Consume(bitCount); + + var temp = Huffman.Table2[Huffman.Table1[idx].Item2 - offset]; + var numberOfSymbol = temp >> 5; + var symbolLen = temp & 0x1F; + + if (symbolLen != 0 || symbolCount < 2) + { + numberOfSymbol += 1; + totalSymbolCount += (int)numberOfSymbol; + for (int i = 0; i < numberOfSymbol; i++) + { + symbolFollowTable[symbolIdx] = (int)symbolFollowTableRoot[symbolLen]; + symbolFollowTableRoot[symbolLen] = (uint)symbolIdx; + symbolIdx--; + } + } + else + { + symbolIdx -= (int)(numberOfSymbol + 1); + } + } + + if (totalSymbolCount == 0) + { + symbolFollowTable[symbolCount - 1] = (int)symbolFollowTableRoot[0]; + symbolFollowTableRoot[0] = (uint)(symbolCount - 1); + totalSymbolCount = 1; + } + + var nextBitsEncoding = 1; + var symbolInHuffmanTable = 0; + var nodes = new (uint, uint)[256]; + for (var encLen = 1; encLen <= 8; encLen++) + { + var currentSymbol = symbolFollowTableRoot[encLen]; + while (currentSymbol != 0xFFFFFFFF) + { + if (currentSymbol >= symbolCount) + { + throw new InvalidOperationException("Failed to build Huffman Table. currentSymbol >= symbolCount"); + } + + if (nextBitsEncoding >= (1 << encLen)) + { + throw new InvalidOperationException("Failed to build Huffman Table. nextBitsEncoding >= (1 << encLen)"); + } + + var firstSymbol = nextBitsEncoding << (8 - encLen); + var iterCount = 1 << (8 - encLen); + + for (var idx = firstSymbol; idx < firstSymbol + iterCount; idx++) + { + nodes[idx] = ((uint)encLen, (uint)currentSymbol); + } + + currentSymbol = (uint)symbolFollowTable[currentSymbol]; + symbolInHuffmanTable++; + nextBitsEncoding--; + } + + nextBitsEncoding = (nextBitsEncoding << 1) + 1; + } + + var largeSymbolCount = totalSymbolCount - symbolInHuffmanTable; + var huffman = new HuffmanTable(nodes, largeSymbolCount); + if (symbolInHuffmanTable == totalSymbolCount) + { + return huffman; + } + + for (var encLen = 9; encLen < 32; encLen++) + { + var currentSymbol = symbolFollowTableRoot[encLen]; + while (currentSymbol != 0xFFFFFFFF) + { + if (currentSymbol >= symbolCount) + { + throw new InvalidOperationException("Failed to build Huffman Table. currentSymbol >= symbolCount"); + } + if (nextBitsEncoding >= (1 << encLen)) + { + throw new InvalidOperationException("Failed to build Huffman Table. nextBitsEncoding >= (1 << encLen)"); + } + + int partialEncoding = nextBitsEncoding >> (encLen - 8); + huffman.nodes[partialEncoding] = (0xFFFFFFFF, 0); + huffman.largeSymbolValues.Add((uint)currentSymbol); + currentSymbol = (uint)symbolFollowTable[currentSymbol]; + nextBitsEncoding--; + } + + uint firstEncoding = (uint)((nextBitsEncoding + 1) << (32 - encLen)); + int lastIndex = huffman.largeSymbolValues.Count - 1; + huffman.largeSymbolTranslation[encLen - 9] = (firstEncoding, lastIndex, encLen); + + nextBitsEncoding = (nextBitsEncoding << 1) + 1; + } + + return huffman; + } +} diff --git a/Daybreak/Services/IconRetrieve/IIconCache.cs b/Daybreak/Services/IconRetrieve/IIconCache.cs index 556c2dc8..d3cb4d7b 100644 --- a/Daybreak/Services/IconRetrieve/IIconCache.cs +++ b/Daybreak/Services/IconRetrieve/IIconCache.cs @@ -1,11 +1,10 @@ using Daybreak.Models.Guildwars; -using System; using System.Threading.Tasks; namespace Daybreak.Services.IconRetrieve; public interface IIconCache { - Task GetIconUri(Skill skill); + Task GetIconUri(Skill skill, bool prefHighQuality = true); Task GetIconUri(ItemBase itemBase); } diff --git a/Daybreak/Services/IconRetrieve/IconCache.cs b/Daybreak/Services/IconRetrieve/IconCache.cs index 08ed0cf8..93807ad5 100644 --- a/Daybreak/Services/IconRetrieve/IconCache.cs +++ b/Daybreak/Services/IconRetrieve/IconCache.cs @@ -1,5 +1,6 @@ using Daybreak.Configuration.Options; using Daybreak.Models.Guildwars; +using Daybreak.Utils; using HtmlAgilityPack; using Microsoft.Extensions.Logging; using System; @@ -8,6 +9,8 @@ using System.Extensions; using System.IO; using System.Net.Http; +using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace Daybreak.Services.IconRetrieve; @@ -17,9 +20,11 @@ internal sealed class IconCache : IIconCache private const string HighResolutionGalleryUrl = $"https://wiki.guildwars.com/wiki/File:{NamePlaceholder}_(large).jpg"; private const string WikiUrl = "https://wiki.guildwars.com"; private const string NamePlaceholder = "[NAME]"; - private const string IconsDirectoryName = "Icons"; - private const string IconsLocation = $"{IconsDirectoryName}/{NamePlaceholder}.jpg"; + private const string IconsDirectoryNameSubpath = "Icons"; + private readonly static string IconsDirectory = PathUtils.GetAbsolutePathFromRoot(IconsDirectoryNameSubpath); + private readonly static string IconsLocation = PathUtils.GetAbsolutePathFromRoot(IconsDirectoryNameSubpath, $"{NamePlaceholder}.jpg"); + private readonly SemaphoreSlim diskSemaphore = new(1, 1); private readonly IHttpClient httpClient; private readonly ILiveOptions options; private readonly ILogger logger; @@ -33,13 +38,13 @@ public IconCache( this.options = options.ThrowIfNull(); this.logger = logger.ThrowIfNull(); - if (Directory.Exists(IconsDirectoryName) is false) + if (Directory.Exists(IconsDirectory) is false) { - Directory.CreateDirectory(IconsDirectoryName); + Directory.CreateDirectory(IconsDirectory); } } - public async Task GetIconUri(Skill skill) + public async Task GetIconUri(Skill skill, bool prefHighQuality = true) { if (skill is null || skill.Name!.IsNullOrWhiteSpace()) @@ -48,22 +53,28 @@ public IconCache( } var curedSkillName = CureSkillName(skill); - var fileName = SkillFileSafeName(skill); + var highQFileName = SkillFileSafeName(skill, false); + var lowQFileName = SkillFileSafeName(skill, false); if (curedSkillName!.IsNullOrWhiteSpace() || - fileName!.IsNullOrWhiteSpace()) + lowQFileName!.IsNullOrWhiteSpace() || + highQFileName!.IsNullOrWhiteSpace()) { return default; } - var highResWikiUri = $"{WikiUrl}/wiki/File:{curedSkillName}_(large).jpg"; - var highResResult = await this.GetIconUriInternal(curedSkillName!, fileName!, highResWikiUri, false); - if (highResResult is string) + if (prefHighQuality) { - return highResResult; + var highResWikiUri = $"{WikiUrl}/wiki/File:{curedSkillName}_(large).jpg"; + var highResResult = await this.GetIconUriInternal(curedSkillName!, highQFileName!, highResWikiUri, false); + if (highResResult is not null) + { + return highResResult; + } } + var wikiUri = $"{WikiUrl}/wiki/{curedSkillName}"; - return await this.GetIconUriInternal(curedSkillName!, fileName!, wikiUri, false); + return await this.GetIconUriInternal(curedSkillName!, lowQFileName!, wikiUri, false); } public async Task GetIconUri(ItemBase itemBase) @@ -75,7 +86,7 @@ public IconCache( } var curedName = CureName(itemBase.Name); - var fileName = FileSafeName(itemBase.Name); + var fileName = FileSafeName(itemBase.Name, false); if (curedName!.IsNullOrWhiteSpace() || fileName!.IsNullOrWhiteSpace()) { @@ -95,20 +106,30 @@ public IconCache( private async Task GetIconUriInternal(string curedName, string fileName, string wikiUri, bool directLink) { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.GetIconUriInternal), string.Empty); - var maybeIconUri = this.GetLocalIcon(fileName); - if (maybeIconUri is string uri) + await this.diskSemaphore.WaitAsync(); + try { - return uri; - } + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.GetIconUriInternal), string.Empty); + var maybeIconUri = this.GetLocalIcon(fileName); + if (maybeIconUri is string uri) + { + return uri; + } - if (!this.options.Value.DownloadIcons) + if (!this.options.Value.DownloadIcons) + { + scopedLogger.LogWarning("Icon not found and download disabled. Returning empty icon uri"); + + return default; + } + + var localUri = await this.DownloadAndRetrieveIcon(curedName, fileName, wikiUri, directLink); + return localUri; + } + finally { - scopedLogger.LogWarning("Icon not found and download disabled. Returning empty icon uri"); - return default; + this.diskSemaphore.Release(); } - - return await this.DownloadAndRetrieveIcon(curedName, fileName, wikiUri, directLink); } private async Task DownloadAndRetrieveIcon(string curedName, string fileName, string wikiUri, bool directLink) @@ -207,14 +228,14 @@ public IconCache( return CureName(skill.AlternativeName!.IsNullOrWhiteSpace() ? skill.Name : skill.AlternativeName); } - private static string? SkillFileSafeName(Skill? skill) + private static string? SkillFileSafeName(Skill? skill, bool highQuality) { if (skill is null) { return default; } - return FileSafeName(skill.AlternativeName!.IsNullOrWhiteSpace() ? skill.Name : skill.AlternativeName); + return FileSafeName(skill.AlternativeName!.IsNullOrWhiteSpace() ? skill.Name : skill.AlternativeName, highQuality); } private static string? CureName(string? name) @@ -226,9 +247,8 @@ public IconCache( .Replace("\"", "%22"); } - private static string? FileSafeName(string? name) + private static string? FileSafeName(string? name, bool highQuality) { - return name? - .Replace("\"", string.Empty); + return $"{name?.Replace("\"", string.Empty)}{(highQuality ? "-HQ" : string.Empty)}"; } } diff --git a/Daybreak/Services/Images/ImageCache.cs b/Daybreak/Services/Images/ImageCache.cs index 3eebc0a5..6eb96bf5 100644 --- a/Daybreak/Services/Images/ImageCache.cs +++ b/Daybreak/Services/Images/ImageCache.cs @@ -1,4 +1,5 @@ using Daybreak.Configuration.Options; +using Daybreak.Controls.Options; using Daybreak.Models.Metrics; using Daybreak.Services.Images.Models; using Daybreak.Services.Metrics; @@ -11,6 +12,8 @@ using System.Core.Extensions; using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Drawing; +using System.Drawing.Imaging; using System.Extensions; using System.IO; using System.Logging; @@ -30,8 +33,7 @@ internal sealed class ImageCache : IImageCache private const string CacheSizeMetricDescription = "Size of the image cache in bytes"; private const string CacheSizeMetricUnitName = "bytes"; - private static readonly object CacheLock = new(); - + private readonly SemaphoreSlim cacheSemaphore = new(1, 1); private readonly ILiveOptions options; private readonly ILogger logger; private readonly ConcurrentDictionary imageEntryCache = new(); @@ -76,7 +78,7 @@ public ImageCache( private async Task GetImageInternal(string uri, ScopedLogger scopedLogger) { var stopwatch = Stopwatch.StartNew(); - if (this.imageEntryCache.TryGetValue(uri, out var entry)) + if (this.imageEntryCache.TryGetValue($"{uri}", out var entry)) { this.imageRetrievalLatency.Record(stopwatch.ElapsedMilliseconds); this.imageCacheSize.Record(this.currentCacheSize); @@ -84,7 +86,7 @@ private async Task GetImageInternal(string uri, ScopedLogger this.options.Value.MemoryImageCacheLimit * 10e5) { @@ -95,7 +97,7 @@ private async Task GetImageInternal(string uri, ScopedLogger AddToCache(string uri) return imageEntry; }); } + + private static BitmapSource BitmapToBitmapSource(Bitmap bmp) + { + var bitmapData = bmp.LockBits( + new Rectangle(0, 0, bmp.Width, bmp.Height), + ImageLockMode.ReadOnly, bmp.PixelFormat); + + var bitmapSource = BitmapSource.Create( + bitmapData.Width, bitmapData.Height, + bmp.HorizontalResolution, bmp.VerticalResolution, + PixelFormats.Bgr24, null, + bitmapData.Scan0, bitmapData.Stride * bitmapData.Height, bitmapData.Stride); + + bmp.UnlockBits(bitmapData); + + return bitmapSource; + } } diff --git a/Daybreak/Services/Metrics/MetricsService.cs b/Daybreak/Services/Metrics/MetricsService.cs index d5d8abdb..674e8212 100644 --- a/Daybreak/Services/Metrics/MetricsService.cs +++ b/Daybreak/Services/Metrics/MetricsService.cs @@ -14,7 +14,8 @@ internal sealed class MetricsService : IMetricsService, IDisposable private const string MetricsNamespace = "Daybreak"; private const string MetricsStoreName = "MetricsStore"; - private readonly object metricStoreLock = new object(); + private static readonly SemaphoreSlim MetricsSemaphore = new(1, 1); + private readonly MeterListener meterListener; private readonly Meter daybreakMeter; private readonly ConcurrentDictionary> metricStore = new(); @@ -116,9 +117,7 @@ private void MeasurementRecorded(Instrument instrument, T measurement, ReadOn { MetricSet? newMetricSet = default; RecordedMetric? newRecordedMetric = default; - while(Monitor.TryEnter(this.metricStoreLock) is false) - { - } + MetricsSemaphore.Wait(); if (measurement is null) { @@ -141,7 +140,7 @@ private void MeasurementRecorded(Instrument instrument, T measurement, ReadOn } newRecordedMetric = new RecordedMetric { Instrument = instrument, Metric = metric }; - Monitor.Exit(this.metricStoreLock); + MetricsSemaphore.Release(); if (newMetricSet is not null) { diff --git a/Daybreak/Services/Options/OptionsManager.cs b/Daybreak/Services/Options/OptionsManager.cs index 15a573cb..89f2c0ae 100644 --- a/Daybreak/Services/Options/OptionsManager.cs +++ b/Daybreak/Services/Options/OptionsManager.cs @@ -1,4 +1,5 @@ using Daybreak.Attributes; +using Daybreak.Utils; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -14,7 +15,9 @@ namespace Daybreak.Services.Options; internal sealed class OptionsManager : IOptionsManager, IOptionsProducer, IOptionsUpdateHook, IOptionsProvider { - private const string OptionsFile = "Daybreak.options"; + private const string OptionsFileSubPath = "Daybreak.options"; + + private static readonly string OptionsFile = PathUtils.GetAbsolutePathFromRoot(OptionsFileSubPath); private readonly Dictionary optionsCache = []; private readonly Dictionary> optionsUpdateHooks = []; diff --git a/Daybreak/Services/Plugins/PluginsService.cs b/Daybreak/Services/Plugins/PluginsService.cs index ecff61cd..b16f5b59 100644 --- a/Daybreak/Services/Plugins/PluginsService.cs +++ b/Daybreak/Services/Plugins/PluginsService.cs @@ -11,6 +11,7 @@ using Daybreak.Services.Plugins.Validators; using Daybreak.Services.Startup; using Daybreak.Services.Updater.PostUpdate; +using Daybreak.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Plumsy; @@ -33,10 +34,10 @@ namespace Daybreak.Services.Plugins; internal sealed class PluginsService : IPluginsService { private const string DllExtension = ".dll"; - private const string PluginsDirectory = "Plugins"; - - private static readonly object Lock = new(); + private const string PluginsDirectorySubPath = "Plugins"; + private static readonly string PluginsDirectory = PathUtils.GetAbsolutePathFromRoot(PluginsDirectorySubPath); + private readonly SemaphoreSlim pluginsSemaphore = new(1, 1); private readonly List loadedPlugins = []; private readonly ILiveUpdateableOptions liveUpdateableOptions; private readonly ILogger logger; @@ -109,10 +110,9 @@ public void LoadPlugins( browserExtensionsProducer.ThrowIfNull(); argumentHandlerProducer.ThrowIfNull(); - while (!Monitor.TryEnter(Lock)) { } - + this.pluginsSemaphore.Wait(); var scopedLogger = this.logger.CreateScopedLogger(nameof(this.LoadPlugins), string.Empty); - var pluginsPath = Path.GetFullPath(PluginsDirectory); + var pluginsPath = PluginsDirectory; if (!Directory.Exists(pluginsPath)) { scopedLogger.LogInformation("Creating plugins folder"); @@ -196,7 +196,7 @@ public void LoadPlugins( } } - Monitor.Exit(Lock); + this.pluginsSemaphore.Release(); } public async Task AddPlugin(string pathToPlugin) diff --git a/Daybreak/Services/ReShade/ReShadeService.cs b/Daybreak/Services/ReShade/ReShadeService.cs index 1995c7ce..ce8f1d3c 100644 --- a/Daybreak/Services/ReShade/ReShadeService.cs +++ b/Daybreak/Services/ReShade/ReShadeService.cs @@ -9,6 +9,7 @@ using Daybreak.Services.ReShade.Notifications; using Daybreak.Services.ReShade.Utils; using Daybreak.Services.Scanner; +using Daybreak.Utils; using HtmlAgilityPack; using IniParser.Parser; using Ionic.Zip; @@ -18,6 +19,7 @@ using System.Collections.Generic; using System.Configuration; using System.Core.Extensions; +using System.Data; using System.Extensions; using System.IO; using System.Linq; @@ -29,9 +31,10 @@ namespace Daybreak.Services.ReShade; internal sealed class ReShadeService : IReShadeService, IApplicationLifetimeService { + private const string DesiredVersion = "4.9.1"; private const string PackagesIniUrl = "https://raw.githubusercontent.com/crosire/reshade-shaders/list/EffectPackages.ini"; private const string ReShadeHomepageUrl = "https://reshade.me"; - private const string ReShadePath = "ReShade"; + private const string ReShadeSubPath = "ReShade"; private const string ReShadeInstallerName = "ReShade_Setup.exe"; private const string DllName = "ReShade32.dll"; private const string ConfigIni = "ReShade.ini"; @@ -39,11 +42,13 @@ internal sealed class ReShadeService : IReShadeService, IApplicationLifetimeServ private const string ReShadeLog = "ReShade.log"; private const string PresetsFolder = "reshade-shaders"; - private static readonly string ReShadeDllPath = Path.Combine(Path.GetFullPath(ReShadePath), DllName); - private static readonly string ConfigIniPath = Path.Combine(Path.GetFullPath(ReShadePath), ConfigIni); - private static readonly string ReShadePresetPath = Path.Combine(Path.GetFullPath(ReShadePath), ReShadePreset); - private static readonly string ReShadeLogPath = Path.Combine(Path.GetFullPath(ReShadePath), ReShadeLog); - private static readonly string SourcePresetsFolderPath = Path.Combine(Path.GetFullPath(ReShadePath), PresetsFolder); + private static readonly string ReShadePath = PathUtils.GetAbsolutePathFromRoot(ReShadeSubPath); + + private static readonly string ReShadeDllPath = Path.Combine(ReShadePath, DllName); + private static readonly string ConfigIniPath = Path.Combine(ReShadePath, ConfigIni); + private static readonly string ReShadePresetPath = Path.Combine(ReShadePath, ReShadePreset); + private static readonly string ReShadeLogPath = Path.Combine(ReShadePath, ReShadeLog); + private static readonly string SourcePresetsFolderPath = Path.Combine(ReShadePath, PresetsFolder); private static readonly string[] TextureExtensions = [".png", ".jpg", ".jpeg"]; private static readonly string[] FxExtensions = [".fx",]; private static readonly string[] FxHeaderExtensions = [".fxh"]; @@ -222,7 +227,7 @@ public async Task SetupReShade(ReShadeInstallationStatus reShadeInstallati var scopedLogger = this.logger.CreateScopedLogger(nameof(this.SetupReShade), string.Empty); scopedLogger.LogInformation("Retrieving ReShade homepage"); reShadeInstallationStatus.CurrentStep = ReShadeInstallationStatus.RetrievingLatestVersionUrl; - var downloadUrl = await this.GetLatestDownloadUrl(cancellationToken); + var downloadUrl = $"{ReShadeHomepageUrl}/downloads/ReShade_Setup_4.9.1.exe"; if (downloadUrl?.IsNullOrWhiteSpace() is not false) { scopedLogger.LogError("Unable to retrieve latest download url"); @@ -636,14 +641,29 @@ private async Task CheckUpdates() return; } - if (version.CompareTo(installedVersion) > 0) + if (!Models.Versioning.Version.TryParse(DesiredVersion, out var desiredVersion)) + { + scopedLogger.LogError("Unable to parse desired version"); + return; + } + + if (installedVersion.CompareTo(desiredVersion) != 0) { - scopedLogger.LogInformation($"Found an update for ReShade. Current version: {installedVersion}. Latest version: {version}"); + scopedLogger.LogInformation($"Current ReShade version does not match desired version {desiredVersion}. Fetching desired version"); if (await this.SetupReShade(new ReShadeInstallationStatus(), CancellationToken.None) is not true) { scopedLogger.LogError("Failed to update ReShade"); } } + + //if (version.CompareTo(installedVersion) > 0) + //{ + // scopedLogger.LogInformation($"Found an update for ReShade. Current version: {installedVersion}. Latest version: {version}"); + // if (await this.SetupReShade(new ReShadeInstallationStatus(), CancellationToken.None) is not true) + // { + // scopedLogger.LogError("Failed to update ReShade"); + // } + //} } /// diff --git a/Daybreak/Services/Screenshots/OnlinePictureClient.cs b/Daybreak/Services/Screenshots/OnlinePictureClient.cs index 37a82e45..b1107cae 100644 --- a/Daybreak/Services/Screenshots/OnlinePictureClient.cs +++ b/Daybreak/Services/Screenshots/OnlinePictureClient.cs @@ -3,6 +3,7 @@ using Daybreak.Services.Images; using Daybreak.Services.Scanner; using Daybreak.Services.Screenshots.Models; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -12,6 +13,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using System.Windows.Media; @@ -22,7 +24,10 @@ internal sealed class OnlinePictureClient : IOnlinePictureClient { private const string CloudFlareCookieValue = "fcfd523b2470336531e47baff3d2c2d6a0e2412a.1689426482.1"; private const string CloudFlareCookieKey = "wschkid"; - private const string CacheFolder = "ImageCache"; + private const string CacheFolderSubPath = "ImageCache"; + + private static readonly string CacheFolder = PathUtils.GetAbsolutePathFromRoot(CacheFolderSubPath); + private readonly IImageCache imageCache; private readonly IGuildwarsMemoryCache guildwarsMemoryCache; private readonly IHttpClient httpClient; diff --git a/Daybreak/Services/SevenZip/SevenZipExtractor.cs b/Daybreak/Services/SevenZip/SevenZipExtractor.cs index 415606cf..e8a15191 100644 --- a/Daybreak/Services/SevenZip/SevenZipExtractor.cs +++ b/Daybreak/Services/SevenZip/SevenZipExtractor.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using Daybreak.Utils; +using Microsoft.Extensions.Logging; using System; using System.Core.Extensions; using System.Diagnostics; @@ -28,7 +29,7 @@ public async Task ExtractToDirectory(string sourceFile, string destination { StartInfo = new ProcessStartInfo { - FileName = ExtractorExeName, + FileName = PathUtils.GetAbsolutePathFromRoot(ExtractorExeName), Arguments = $"\"{sourceFile}\" \"{destinationDirectory}\"", RedirectStandardOutput = true, CreateNoWindow = true, diff --git a/Daybreak/Services/Startup/Actions/RenameInstallerAction.cs b/Daybreak/Services/Startup/Actions/RenameInstallerAction.cs index 7c59c2c3..24d46456 100644 --- a/Daybreak/Services/Startup/Actions/RenameInstallerAction.cs +++ b/Daybreak/Services/Startup/Actions/RenameInstallerAction.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using Daybreak.Utils; +using Microsoft.Extensions.Logging; using System.Core.Extensions; using System.IO; @@ -6,8 +7,11 @@ namespace Daybreak.Services.Startup.Actions; internal sealed class RenameInstallerAction : StartupActionBase { - private const string TemporaryInstallerFileName = "Daybreak.Installer.Temp.exe"; - private const string InstallerFileName = "Daybreak.Installer.exe"; + private const string TemporaryInstallerFileNameSubPath = "Daybreak.Installer.Temp.exe"; + private const string InstallerFileNameSubPath = "Daybreak.Installer.exe"; + + private static readonly string TemporaryInstallerFileName = PathUtils.GetAbsolutePathFromRoot(TemporaryInstallerFileNameSubPath); + private static readonly string InstallerFileName = PathUtils.GetAbsolutePathFromRoot(InstallerFileNameSubPath); private readonly ILogger logger; diff --git a/Daybreak/Services/Toolbox/ToolboxService.cs b/Daybreak/Services/Toolbox/ToolboxService.cs index 06d81dda..afc574c6 100644 --- a/Daybreak/Services/Toolbox/ToolboxService.cs +++ b/Daybreak/Services/Toolbox/ToolboxService.cs @@ -8,6 +8,7 @@ using Daybreak.Services.Scanner; using Daybreak.Services.Toolbox.Models; using Daybreak.Services.Toolbox.Utilities; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using Microsoft.Win32; using System; @@ -24,8 +25,9 @@ namespace Daybreak.Services.Toolbox; internal sealed class ToolboxService : IToolboxService { - private const string ToolboxDestinationDirectory = "GWToolbox"; + private const string ToolboxDestinationDirectorySubPath = "GWToolbox"; + private static readonly string ToolboxDestinationDirectoryPath = PathUtils.GetAbsolutePathFromRoot(ToolboxDestinationDirectorySubPath); private static readonly string UsualToolboxLocation = Path.GetFullPath( Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "GWToolboxpp", "GWToolboxdll.dll")); diff --git a/Daybreak/Services/UBlockOrigin/UBlockOriginService.cs b/Daybreak/Services/UBlockOrigin/UBlockOriginService.cs index 63b6b06a..ef8a1cf6 100644 --- a/Daybreak/Services/UBlockOrigin/UBlockOriginService.cs +++ b/Daybreak/Services/UBlockOrigin/UBlockOriginService.cs @@ -16,6 +16,7 @@ using Newtonsoft.Json.Linq; using Daybreak.Services.Notifications; using Daybreak.Services.Browser; +using Daybreak.Utils; namespace Daybreak.Services.UBlockOrigin; public sealed class UBlockOriginService : IBrowserExtension @@ -23,10 +24,11 @@ public sealed class UBlockOriginService : IBrowserExtension private const string TagPlaceholder = "[TAG_PLACEHOLDER]"; private const string ReleaseUrl = "https://github.com/gorhill/uBlock/releases/download/[TAG_PLACEHOLDER]/uBlock0_[TAG_PLACEHOLDER].chromium.zip"; private const string ReleasesUrl = "https://api.github.com/repos/gorhill/uBlock/git/refs/tags"; - private const string InstallationPath = "BrowserExtensions"; + private const string InstallationSubPath = "BrowserExtensions"; private const string ZipName = "ublock.chromium.zip"; private const string InstallationFolderName = "uBlock0.chromium"; + private static readonly string InstallationPath = PathUtils.GetAbsolutePathFromRoot(InstallationSubPath); private static readonly SemaphoreSlim SemaphoreSlim = new(1); private static volatile bool VersionUpToDate; @@ -111,7 +113,7 @@ private async Task CheckAndUpdateInternal(string browserVersion) } using var zipFile = ZipFile.Read(zipFilePath); - zipFile.ExtractAll(Path.GetFullPath(InstallationPath), ExtractExistingFileAction.OverwriteSilently); + zipFile.ExtractAll(InstallationPath, ExtractExistingFileAction.OverwriteSilently); zipFile.Dispose(); File.Delete(zipFilePath); VersionUpToDate = true; @@ -125,7 +127,7 @@ private async Task CheckAndUpdateInternal(string browserVersion) var scopedLogger = this.logger.CreateScopedLogger(nameof(this.GetLatestVersion), version); scopedLogger.LogInformation($"Retrieving version {version}"); var downloadUrl = ReleaseUrl.Replace(TagPlaceholder, version); - var destinationFolder = Path.GetFullPath(InstallationPath); + var destinationFolder = InstallationPath; var destinationPath = Path.Combine(destinationFolder, ZipName); var success = await this.downloadService.DownloadFile(downloadUrl, destinationPath, new UpdateStatus(), cancellationToken); if (!success) @@ -138,7 +140,7 @@ private async Task CheckAndUpdateInternal(string browserVersion) private async Task GetCurrentVersion(CancellationToken cancellationToken) { - var manifestFilePath = Path.GetFullPath(Path.Combine(InstallationPath, Path.Combine(InstallationFolderName, "manifest.json"))); + var manifestFilePath = Path.GetFullPath(InstallationPath, Path.Combine(InstallationFolderName, "manifest.json")); var fileInfo = new FileInfo(manifestFilePath); if (!fileInfo.Exists) { diff --git a/Daybreak/Services/UMod/UModService.cs b/Daybreak/Services/UMod/UModService.cs index c83f9c58..f98fe5ae 100644 --- a/Daybreak/Services/UMod/UModService.cs +++ b/Daybreak/Services/UMod/UModService.cs @@ -8,6 +8,7 @@ using Daybreak.Services.Injection; using Daybreak.Services.Notifications; using Daybreak.Services.Toolbox.Models; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -28,10 +29,12 @@ internal sealed class UModService : IUModService private const string TagPlaceholder = "[TAG_PLACEHOLDER]"; private const string ReleaseUrl = "https://github.com/gwdevhub/gMod/releases/download/[TAG_PLACEHOLDER]/gMod.dll"; private const string ReleasesUrl = "https://api.github.com/repos/gwdevhub/gMod/git/refs/tags"; - private const string UModDirectory = "uMod"; + private const string UModDirectorySubPath = "uMod"; private const string UModDll = "uMod.dll"; private const string UModModList = "modlist.txt"; + private static readonly string UModDirectory = PathUtils.GetAbsolutePathFromRoot(UModDirectorySubPath); + private readonly IProcessInjector processInjector; private readonly INotificationService notificationService; private readonly IDownloadService downloadService; @@ -86,10 +89,10 @@ public UModService( public async Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) { - var modListFilePath = Path.Combine(Path.GetFullPath(UModDirectory), UModModList); + var modListFilePath = Path.Combine(UModDirectory, UModModList); var lines = this.uModOptions.Value.Mods.Where(e => e.Enabled && e.PathToFile is not null).Select(e => e.PathToFile).ToList(); await File.WriteAllLinesAsync(modListFilePath, lines!, cancellationToken); - var result = await this.processInjector.Inject(guildWarsCreatedContext.ApplicationLauncherContext.Process, Path.Combine(Path.GetFullPath(UModDirectory), UModDll), cancellationToken); + var result = await this.processInjector.Inject(guildWarsCreatedContext.ApplicationLauncherContext.Process, Path.Combine(UModDirectory, UModDll), cancellationToken); if (result) { this.notificationService.NotifyInformation( @@ -180,7 +183,7 @@ public void SaveMods(List list) public async Task CheckAndUpdateUMod(CancellationToken cancellationToken) { var scopedLogger = this.logger.CreateScopedLogger(nameof(this.CheckAndUpdateUMod), string.Empty); - var existingUMod = Path.Combine(Path.GetFullPath(UModDirectory), UModDll); + var existingUMod = Path.Combine(UModDirectory, UModDll); if (!this.IsInstalled) { scopedLogger.LogInformation("UMod is not installed"); @@ -285,7 +288,7 @@ private async Task GetLatestVersion(UModInstallationSta scopedLogger.LogInformation($"Retrieving version {tag}"); var downloadUrl = ReleaseUrl.Replace(TagPlaceholder, tag); - var destinationFolder = Path.GetFullPath(UModDirectory); + var destinationFolder = UModDirectory; var destinationPath = Path.Combine(destinationFolder, UModDll); var success = await this.downloadService.DownloadFile(downloadUrl, destinationPath, uModInstallationStatus, cancellationToken); if (!success) diff --git a/Daybreak/Services/Updater/ApplicationUpdater.cs b/Daybreak/Services/Updater/ApplicationUpdater.cs index ea915785..cf59b61b 100644 --- a/Daybreak/Services/Updater/ApplicationUpdater.cs +++ b/Daybreak/Services/Updater/ApplicationUpdater.cs @@ -9,9 +9,11 @@ using Daybreak.Services.Registry; using Daybreak.Services.Updater.Models; using Daybreak.Services.Updater.PostUpdate; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.ComponentModel.Design.Serialization; using System.Configuration; using System.Core.Extensions; using System.Data; @@ -32,10 +34,11 @@ namespace Daybreak.Services.Updater; internal sealed class ApplicationUpdater : IApplicationUpdater { - private const string TempInstallerFileName = "Daybreak.Installer.Temp.exe"; - private const string InstallerFileName = "Daybreak.Installer.exe"; + private const string UpdatePkgSubPath = "update.pkg"; + private const string TempInstallerFileNameSubPath = "Daybreak.Installer.Temp.exe"; + private const string InstallerFileNameSubPath = "Daybreak.Installer.exe"; private const string UpdatedKey = "LauncherUpdating"; - private const string TempFile = "tempfile.zip"; + private const string TempFileSubPath = "tempfile.zip"; private const string VersionTag = "{VERSION}"; private const string FileTag = "{FILE}"; private const string RefTagPrefix = "/refs/tags"; @@ -45,6 +48,11 @@ internal sealed class ApplicationUpdater : IApplicationUpdater private const string BlobStorageUrl = $"https://daybreak.blob.core.windows.net/{VersionTag}/{FileTag}"; private const int DownloadParallelTasks = 10; + private readonly static string TempInstallerFileName = PathUtils.GetAbsolutePathFromRoot(TempInstallerFileNameSubPath); + private readonly static string InstallerFileName = PathUtils.GetAbsolutePathFromRoot(InstallerFileNameSubPath); + private readonly static string TempFile = PathUtils.GetAbsolutePathFromRoot(TempFileSubPath); + private readonly static string UpdatePkg = PathUtils.GetAbsolutePathFromRoot(UpdatePkgSubPath); + private readonly static TimeSpan DownloadInfoUpdateInterval = TimeSpan.FromMilliseconds(16); private readonly CancellationTokenSource updateCancellationTokenSource = new(); @@ -281,7 +289,7 @@ private async Task DownloadUpdateInternalBlob(List metadata, Ver }) .ToList(); - using var packageStream = new FileStream("update.pkg", FileMode.Create); + using var packageStream = new FileStream(UpdatePkg, FileMode.Create); var downloaded = 0d; var downloadBuffer = new byte[8192]; var sizeToDownload = (double)filesToDownload.Sum(m => m.Size); @@ -359,7 +367,7 @@ private async Task DownloadUpdateInternalBlob(List metadata, Ver updateStatus.CurrentStep = DownloadStatus.Downloading(1, TimeSpan.Zero); - scopedLogger.LogInformation($"Prepared update package at {Path.GetFullPath("update.pkg")}"); + scopedLogger.LogInformation($"Prepared update package at {UpdatePkg}"); updateStatus.CurrentStep = UpdateStatus.PendingRestart; return true; } diff --git a/Daybreak/Utils/DependencyObjectExtensions.cs b/Daybreak/Utils/DependencyObjectExtensions.cs index bc83a3d7..0bd2b8b4 100644 --- a/Daybreak/Utils/DependencyObjectExtensions.cs +++ b/Daybreak/Utils/DependencyObjectExtensions.cs @@ -22,4 +22,16 @@ public static class DependencyObjectExtensions return FindParent(parentObject); } } + + public static bool IsElementVisible(this FrameworkElement element, FrameworkElement container) + { + if (!element.IsVisible) + return false; + + var bounds = element.TransformToAncestor(container) + .TransformBounds(new Rect(0.0, 0.0, element.RenderSize.Width, element.RenderSize.Height)); + var viewport = new Rect(0.0, 0.0, container.ActualWidth, container.ActualHeight); + + return viewport.IntersectsWith(bounds); + } } diff --git a/Daybreak/Utils/NativeMethods.cs b/Daybreak/Utils/NativeMethods.cs index 365b8057..db36e174 100644 --- a/Daybreak/Utils/NativeMethods.cs +++ b/Daybreak/Utils/NativeMethods.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; +using static Daybreak.Utils.NativeMethods; namespace Daybreak.Utils; @@ -16,6 +17,33 @@ internal static class NativeMethods public delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); + [Flags] + public enum MinidumpType : uint + { + MiniDumpNormal = 0x00000000, + MiniDumpWithDataSegs = 0x00000001, + MiniDumpWithFullMemory = 0x00000002, + MiniDumpWithHandleData = 0x00000004, + MiniDumpFilterMemory = 0x00000008, + MiniDumpScanMemory = 0x00000010, + MiniDumpWithUnloadedModules = 0x00000020, + MiniDumpWithIndirectlyReferencedMemory = 0x00000040, + MiniDumpFilterModulePaths = 0x00000080, + MiniDumpWithProcessThreadData = 0x00000100, + MiniDumpWithPrivateReadWriteMemory = 0x00000200, + MiniDumpWithoutOptionalData = 0x00000400, + MiniDumpWithFullMemoryInfo = 0x00000800, + MiniDumpWithThreadInfo = 0x00001000, + MiniDumpWithCodeSegs = 0x00002000, + MiniDumpWithoutAuxiliaryState = 0x00004000, + MiniDumpWithFullAuxiliaryState = 0x00008000, + MiniDumpWithPrivateWriteCopyMemory = 0x00010000, + MiniDumpIgnoreInaccessibleMemory = 0x00020000, + MiniDumpWithTokenInformation = 0x00040000, + MiniDumpWithModuleHeaders = 0x00080000, + MiniDumpFilterTriage = 0x00100000, + MiniDumpValidTypeFlags = 0x001fffff, + } [StructLayout(LayoutKind.Sequential)] internal struct ProcessEntry32 { @@ -332,6 +360,8 @@ public enum CreationFlags : uint public const int WM_SYSCOMMAND = 0x112; + [DllImport("Dbghelp.dll", SetLastError = true)] + public static extern bool MiniDumpWriteDump(IntPtr hProcess, int processId, SafeHandle hFile, MinidumpType dumpType, IntPtr expParam, IntPtr userStreamParam, IntPtr callbackParam); [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID); [DllImport("kernel32.dll")] diff --git a/Daybreak/Utils/PathUtils.cs b/Daybreak/Utils/PathUtils.cs new file mode 100644 index 00000000..228db0bc --- /dev/null +++ b/Daybreak/Utils/PathUtils.cs @@ -0,0 +1,21 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Daybreak.Utils; +public static class PathUtils +{ + private static readonly Lazy RootPath = new(() => Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? throw new InvalidOperationException("Unable to obtain application root path")); + + public static string GetRootFolder() + { + return RootPath.Value; + } + + public static string GetAbsolutePathFromRoot(params string[] subPaths) + { + var paths = subPaths.Prepend(GetRootFolder()).ToArray(); + return Path.GetFullPath(Path.Combine(paths)); + } +} diff --git a/Daybreak/Views/GuildwarsDownloadView.xaml b/Daybreak/Views/GuildwarsDownloadView.xaml index d9db0cbd..66d87d87 100644 --- a/Daybreak/Views/GuildwarsDownloadView.xaml +++ b/Daybreak/Views/GuildwarsDownloadView.xaml @@ -9,6 +9,7 @@ xmlns:controls="clr-namespace:Daybreak.Controls" xmlns:buttons="clr-namespace:Daybreak.Controls.Buttons" Loaded="DownloadView_Loaded" + Unloaded="DownloadView_Unloaded" d:DesignHeight="450" d:DesignWidth="800"> @@ -27,7 +28,8 @@ HighlightColor="{StaticResource MahApps.Brushes.Accent}" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" BorderBrush="{StaticResource MahApps.Brushes.ThemeForeground}" BorderThickness="1" - ToolTip="Continue"/> + ToolTip="Continue" + Visibility="{Binding ElementName=_this, Path=ContinueButtonEnabled, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"/> diff --git a/Daybreak/Views/GuildwarsDownloadView.xaml.cs b/Daybreak/Views/GuildwarsDownloadView.xaml.cs index b4676e08..36c8cbcc 100644 --- a/Daybreak/Views/GuildwarsDownloadView.xaml.cs +++ b/Daybreak/Views/GuildwarsDownloadView.xaml.cs @@ -8,6 +8,7 @@ using Daybreak.Launch; using Daybreak.Models; using Daybreak.Services.Guildwars; +using System.Threading; namespace Daybreak.Views; @@ -20,6 +21,7 @@ public partial class GuildwarsDownloadView : System.Windows.Controls.UserControl private readonly IViewManager viewManager; private readonly IGuildwarsInstaller guildwarsInstaller; private readonly GuildwarsInstallationStatus installationStatus = new(); + private readonly CancellationTokenSource cancellationTokenSource = new(); [GenerateDependencyProperty(InitialValue = "")] private string description = string.Empty; @@ -77,7 +79,7 @@ private async void DownloadView_Loaded(object sender, RoutedEventArgs e) var folderPath = folderPicker.SelectedPath; this.logger.LogInformation("Starting download procedure"); - var success = await this.guildwarsInstaller.InstallGuildwars(folderPath, this.installationStatus).ConfigureAwait(true); + var success = await this.guildwarsInstaller.InstallGuildwars(folderPath, this.installationStatus, this.cancellationTokenSource.Token).ConfigureAwait(true); if (success is false) { this.logger.LogError("Download procedure failed"); @@ -94,4 +96,10 @@ private void OpaqueButton_Clicked(object sender, System.EventArgs e) { this.viewManager.ShowView(); } + + private void DownloadView_Unloaded(object sender, RoutedEventArgs e) + { + this.cancellationTokenSource?.Cancel(); + this.cancellationTokenSource?.Dispose(); + } } diff --git a/Examples/Plugins/SimplePlugin/SimplePlugin/SimplePlugin.csproj b/Examples/Plugins/SimplePlugin/SimplePlugin/SimplePlugin.csproj index 85c5260d..8550a9c2 100644 --- a/Examples/Plugins/SimplePlugin/SimplePlugin/SimplePlugin.csproj +++ b/Examples/Plugins/SimplePlugin/SimplePlugin/SimplePlugin.csproj @@ -1,7 +1,7 @@  - net6.0-windows + net8.0-windows enable enable diff --git a/GWCA b/GWCA index 0cf80ed4..c97379fa 160000 --- a/GWCA +++ b/GWCA @@ -1 +1 @@ -Subproject commit 0cf80ed4a9cb94450354487c51b04d650096bdcf +Subproject commit c97379fa7bbf780112cedda33f92bde035e520d1