diff --git a/Daybreak.GWCA/source/GameModule.cpp b/Daybreak.GWCA/source/GameModule.cpp index 3d8f19b8..a7da1171 100644 --- a/Daybreak.GWCA/source/GameModule.cpp +++ b/Daybreak.GWCA/source/GameModule.cpp @@ -398,7 +398,7 @@ namespace Daybreak::Modules { return gamePayload; } - auto playerAgentId = GW::Agents::GetPlayerId(); + auto playerAgentId = GW::Agents::GetControlledCharacterId(); std::list mapIcons; for (const auto& icon : worldContext->mission_map_icons) { diff --git a/Daybreak.GWCA/source/MainPlayerModule.cpp b/Daybreak.GWCA/source/MainPlayerModule.cpp index 382cb2d6..0aed75b3 100644 --- a/Daybreak.GWCA/source/MainPlayerModule.cpp +++ b/Daybreak.GWCA/source/MainPlayerModule.cpp @@ -419,7 +419,7 @@ namespace Daybreak::Modules { if (mapAgents.empty()) { return mainPlayer; } - auto playerAgentId = GW::Agents::GetPlayerId(); + auto playerAgentId = GW::Agents::GetControlledCharacterId(); auto foundMainAttr = std::find_if(attributes.begin(), attributes.end(), [&](GW::PartyAttribute attribute) { return playerAgentId == attribute.agent_id; diff --git a/Daybreak/Configuration/ProjectConfiguration.cs b/Daybreak/Configuration/ProjectConfiguration.cs index 99b22e39..03d656cd 100644 --- a/Daybreak/Configuration/ProjectConfiguration.cs +++ b/Daybreak/Configuration/ProjectConfiguration.cs @@ -38,7 +38,7 @@ using Daybreak.Services.Drawing.Modules.Entities; using Daybreak.Services.Drawing.Modules.MapIcons; using Daybreak.Services.Drawing.Modules; -using Daybreak.Services.Guildwars; +using Daybreak.Services.GuildWars; using Daybreak.Configuration.Options; using System.Configuration; using Daybreak.Services.UMod; @@ -89,6 +89,7 @@ using Daybreak.Services.Window; using Daybreak.Launch; using Daybreak.Utils; +using Daybreak.Views.Installation; namespace Daybreak.Configuration; @@ -182,7 +183,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()!); @@ -193,7 +194,7 @@ public override void RegisterServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -227,7 +228,8 @@ public override void RegisterViews(IViewProducer viewProducer) viewProducer.RegisterView(); viewProducer.RegisterView(); viewProducer.RegisterView(); - viewProducer.RegisterView(); + viewProducer.RegisterView(); + viewProducer.RegisterView(); viewProducer.RegisterView(); viewProducer.RegisterView(); viewProducer.RegisterView(); @@ -393,6 +395,8 @@ public override void RegisterNotificationHandlers(INotificationHandlerProducer n notificationHandlerProducer.RegisterNotificationHandler(); notificationHandlerProducer.RegisterNotificationHandler(); notificationHandlerProducer.RegisterNotificationHandler(); + notificationHandlerProducer.RegisterNotificationHandler(); + notificationHandlerProducer.RegisterNotificationHandler(); } public override void RegisterMods(IModsManager modsManager) @@ -404,6 +408,7 @@ public override void RegisterMods(IModsManager modsManager) modsManager.RegisterMod(); modsManager.RegisterMod(); modsManager.RegisterMod(singleton: true); + modsManager.RegisterMod(); } public override void RegisterBrowserExtensions(IBrowserExtensionsProducer browserExtensionsProducer) diff --git a/Daybreak/Controls/CircularLoadingWidget.xaml b/Daybreak/Controls/CircularLoadingWidget.xaml index f2dbcc32..de3fd654 100644 --- a/Daybreak/Controls/CircularLoadingWidget.xaml +++ b/Daybreak/Controls/CircularLoadingWidget.xaml @@ -372,7 +372,6 @@ - diff --git a/Daybreak/Controls/MenuList.xaml.cs b/Daybreak/Controls/MenuList.xaml.cs index 95c61adc..50a0405b 100644 --- a/Daybreak/Controls/MenuList.xaml.cs +++ b/Daybreak/Controls/MenuList.xaml.cs @@ -4,6 +4,7 @@ using Daybreak.Services.Notifications; using Daybreak.Views; using Daybreak.Views.Copy; +using Daybreak.Views.Installation; using Daybreak.Views.Launch; using Daybreak.Views.Onboarding.DirectSong; using Daybreak.Views.Onboarding.DSOAL; @@ -100,7 +101,7 @@ private void MetricsButton_Clicked(object sender, EventArgs e) private void DownloadGuildwarsButton_Clicked(object sender, EventArgs e) { - this.viewManager.ShowView(); + this.viewManager.ShowView(); } private void CopyGuildwarsButton_Clicked(object sender, EventArgs e) diff --git a/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml b/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml index ca28c37a..f0a1387f 100644 --- a/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml +++ b/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml @@ -6,12 +6,16 @@ xmlns:converters="clr-namespace:Daybreak.Converters" xmlns:local="clr-namespace:Daybreak.Controls" xmlns:buttons="clr-namespace:Daybreak.Controls.Buttons" + xmlns:glyphs="clr-namespace:Daybreak.Controls.Glyphs" + Unloaded="UserControl_Unloaded" mc:Ignorable="d" x:Name="_this" d:DesignHeight="450" d:DesignWidth="800"> - + + + @@ -34,10 +38,37 @@ ToolTip="Path to Gw.exe"/> + + + + + + + + + + + + + + - diff --git a/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml.cs b/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml.cs index e4f90430..bdbfabb9 100644 --- a/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml.cs +++ b/Daybreak/Controls/Templates/GuildwarsPathTemplate.xaml.cs @@ -1,7 +1,17 @@ -using Daybreak.Models; +using Daybreak.Controls.Buttons; +using Daybreak.Models; +using Daybreak.Models.Progress; +using Daybreak.Models.Versioning; +using Daybreak.Services.GuildWars; +using Daybreak.Services.Navigation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Win32; using System; +using System.Core.Extensions; +using System.Data; using System.Extensions; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Extensions; @@ -15,16 +25,57 @@ namespace Daybreak.Controls; [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0052:Remove unread private members", Justification = "Used by source generators")] public partial class GuildwarsPathTemplate : UserControl { + private readonly IGuildWarsInstaller guildWarsInstaller; + + private CancellationTokenSource? tokenSource; + public event EventHandler? RemoveClicked; [GenerateDependencyProperty] - private string path = string.Empty; + private bool noUpdateResult; + [GenerateDependencyProperty] + private bool checkingVersion; + [GenerateDependencyProperty] + private bool upToDate; + [GenerateDependencyProperty] + private string updateProgress = string.Empty; - public GuildwarsPathTemplate() + public GuildwarsPathTemplate() : + this(Launch.Launcher.Instance.ApplicationServiceProvider.GetRequiredService()) { + } + + public GuildwarsPathTemplate( + IGuildWarsInstaller guildWarsInstaller) + { + this.guildWarsInstaller = guildWarsInstaller.ThrowIfNull(); this.InitializeComponent(); } + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (e.Property == DataContextProperty) + { + if (e.OldValue is ExecutablePath oldPath) + { + oldPath.PropertyChanged -= this.ExecutablePath_PropertyChanged; + } + + if (e.NewValue is ExecutablePath newPath) + { + newPath.PropertyChanged += this.ExecutablePath_PropertyChanged; + } + + this.CheckExecutable(); + } + } + + private void ExecutablePath_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + throw new NotImplementedException(); + } + private void BinButton_Clicked(object sender, EventArgs e) { this.RemoveClicked?.Invoke(this, e); @@ -42,6 +93,99 @@ private void FilePickerGlyph_Clicked(object sender, EventArgs e) if (filePicker.ShowDialog() is true) { this.DataContext.As()!.Path = filePicker.FileName; + this.CheckExecutable(); + } + } + + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + this.tokenSource?.Dispose(); + } + + private void CheckExecutable() + { + if (this.DataContext is not ExecutablePath executablePath) + { + return; + } + + this.tokenSource?.Dispose(); + this.tokenSource = new CancellationTokenSource(); + new TaskFactory().StartNew(async () => + { + await this.Dispatcher.InvokeAsync(() => this.CheckingVersion = true); + await this.Dispatcher.InvokeAsync(() => this.NoUpdateResult = false); + if (await this.guildWarsInstaller.GetLatestVersionId(this.tokenSource.Token) is not int latestVersion) + { + await this.Dispatcher.InvokeAsync(() => this.CheckingVersion = false); + await this.Dispatcher.InvokeAsync(() => this.NoUpdateResult = true); + return; + } + + if (await this.guildWarsInstaller.GetVersionId(executablePath.Path, this.tokenSource.Token) is not int version) + { + await this.Dispatcher.InvokeAsync(() => this.CheckingVersion = false); + await this.Dispatcher.InvokeAsync(() => this.NoUpdateResult = false); + await this.Dispatcher.InvokeAsync(() => this.UpToDate = false); + return; + } + + await this.Dispatcher.InvokeAsync(() => this.CheckingVersion = false); + await this.Dispatcher.InvokeAsync(() => this.NoUpdateResult = false); + await this.Dispatcher.InvokeAsync(() => this.UpToDate = version == latestVersion); + }, this.tokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Current); + } + + private async void UpdateButton_Clicked(object sender, EventArgs e) + { + if (this.DataContext is not ExecutablePath path) + { + return; + } + + if (sender is not BackButton updateButton) + { + return; + } + + await this.Dispatcher.InvokeAsync(() => updateButton.IsEnabled = false); + await this.Dispatcher.InvokeAsync(() => this.CheckingVersion = true); + await this.Dispatcher.InvokeAsync(() => this.NoUpdateResult = false); + this.tokenSource?.Dispose(); + this.tokenSource = new CancellationTokenSource(); + var status = new GuildwarsInstallationStatus(); + status.PropertyChanged += this.UpdateStatus_PropertyChanged; + try + { + var result = await this.guildWarsInstaller.UpdateGuildwars(path.Path, status, this.tokenSource.Token); + await this.Dispatcher.InvokeAsync(() => this.CheckingVersion = false); + await this.Dispatcher.InvokeAsync(() => this.NoUpdateResult = false); + await this.Dispatcher.InvokeAsync(() => this.UpToDate = result); + } + finally + { + status.PropertyChanged -= this.UpdateStatus_PropertyChanged; + } + } + + private void UpdateStatus_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (sender is not GuildwarsInstallationStatus status) + { + return; + } + + if (status.CurrentStep is DownloadStatus.DownloadProgressStep progressStep) + { + // TODO: Kinda hacky way to display a continuous progress widget + if (progressStep is GuildwarsInstallationStatus.UnpackingProgressStep) + { + this.Dispatcher.Invoke(() => this.UpdateProgress = $"{(int)(50 + (progressStep.Progress * 50))}%"); + } + else + { + this.Dispatcher.Invoke(() => this.UpdateProgress = $"{(int)(progressStep.Progress * 50)}%"); + } } } } diff --git a/Daybreak/Daybreak.csproj b/Daybreak/Daybreak.csproj index cbcd4c0c..9d72386f 100644 --- a/Daybreak/Daybreak.csproj +++ b/Daybreak/Daybreak.csproj @@ -11,7 +11,7 @@ preview Daybreak.ico true - 0.9.9.44 + 0.9.9.45 true cfb2a489-db80-448d-a969-80270f314c46 True @@ -103,10 +103,11 @@ - + + diff --git a/Daybreak/Models/Guildwars/Map.cs b/Daybreak/Models/Guildwars/Map.cs index 0ebd44b0..9686a7b0 100644 --- a/Daybreak/Models/Guildwars/Map.cs +++ b/Daybreak/Models/Guildwars/Map.cs @@ -256,6 +256,7 @@ public sealed class Map : IWikiEntity public static readonly Map DwaynaVsGrenth = new() { Id = 253, Name = "Dwayna Vs Grenth", WikiUrl = "https://wiki.guildwars.com/wiki/Dwayna_Vs_Grenth" }; public static readonly Map SunjiangDistrictExplorable = new() { Id = 254, Name = "Sunjiang District", WikiUrl = "https://wiki.guildwars.com/wiki/Sunjiang_District" }; public static readonly Map NahpuiQuarterExplorable = new() { Id = 257, Name = "Nahpui Quarter", WikiUrl = "https://wiki.guildwars.com/wiki/Nahpui_Quarter" }; + public static readonly Map NahpuiQuarterExplorable2 = new() { Id = 265, Name = "Nahpui Quarter", WikiUrl = "https://wiki.guildwars.com/wiki/Nahpui_Quarter" }; public static readonly Map UrgozsWarren = new() { Id = 266, Name = "Urgozs Warren", WikiUrl = "https://wiki.guildwars.com/wiki/Urgozs_Warren" }; public static readonly Map TahnnakaiTempleExplorable = new() { Id = 267, Name = "Tahnnakai Temple", WikiUrl = "https://wiki.guildwars.com/wiki/Tahnnakai_Temple" }; public static readonly Map AltrummRuins = new() { Id = 270, Name = "Altrumm Ruins", WikiUrl = "https://wiki.guildwars.com/wiki/Altrumm_Ruins" }; diff --git a/Daybreak/Models/Progress/DownloadStatus.cs b/Daybreak/Models/Progress/DownloadStatus.cs index 4bfb69a9..4aa38dc0 100644 --- a/Daybreak/Models/Progress/DownloadStatus.cs +++ b/Daybreak/Models/Progress/DownloadStatus.cs @@ -22,7 +22,7 @@ internal DownloadStep(string name) : base(name) } } - public sealed class DownloadProgressStep : DownloadStep + public class DownloadProgressStep : DownloadStep { public TimeSpan? ETA { get; set; } diff --git a/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs b/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs index 5ba88c39..e71a447d 100644 --- a/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs +++ b/Daybreak/Models/Progress/GuildwarsInstallationStatus.cs @@ -4,9 +4,11 @@ namespace Daybreak.Models.Progress; public sealed class GuildwarsInstallationStatus : DownloadStatus { public static readonly LoadStatus StartingStep = new GuildwarsInstallationStep("Starting"); - public static readonly LoadStatus Finished = new GuildwarsInstallationStep("Installation has finished. The new file has been added to the executable list"); + public static readonly LoadStatus InstallFinished = new GuildwarsInstallationStep("Installation has finished. The new file has been added to the executable list", true); 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 static readonly LoadStatus UpdateFinished = new GuildwarsInstallationStep("Update has finished", true); + public static readonly LoadStatus Failed = new GuildwarsInstallationStep("Operation failed. Please check logs for details", true); + public static UnpackingProgressStep Unpacking(double progress, TimeSpan? eta) => new(progress, eta); public GuildwarsInstallationStatus() { @@ -15,7 +17,18 @@ public GuildwarsInstallationStatus() public sealed class GuildwarsInstallationStep : LoadStatus { - internal GuildwarsInstallationStep(string name) : base(name) + public bool Final { get; init; } = false; + + internal GuildwarsInstallationStep(string name, bool final = false) : base(name) + { + this.Final = final; + } + } + + public sealed class UnpackingProgressStep : DownloadProgressStep + { + public UnpackingProgressStep(double progress, TimeSpan? eta) + : base("Unpacking", progress, eta) { } } diff --git a/Daybreak/Services/GuildWars/GuildWarsBatchUpdateNotificationHandler.cs b/Daybreak/Services/GuildWars/GuildWarsBatchUpdateNotificationHandler.cs new file mode 100644 index 00000000..4bc2722b --- /dev/null +++ b/Daybreak/Services/GuildWars/GuildWarsBatchUpdateNotificationHandler.cs @@ -0,0 +1,95 @@ +using Daybreak.Models.Notifications; +using Daybreak.Models.Notifications.Handling; +using Daybreak.Models.Progress; +using Daybreak.Services.ExecutableManagement; +using Daybreak.Services.GuildWars.Models; +using Daybreak.Services.Navigation; +using Daybreak.Services.Notifications; +using Daybreak.Views.Installation; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Core.Extensions; +using System.Extensions; +using System.Threading; + +namespace Daybreak.Services.GuildWars; +internal sealed class GuildWarsBatchUpdateNotificationHandler : INotificationHandler +{ + private readonly IViewManager viewManager; + private readonly IGuildWarsInstaller guildWarsInstaller; + private readonly IGuildWarsExecutableManager guildWarsExecutableManager; + private readonly INotificationService notificationService; + private readonly ILogger logger; + + public GuildWarsBatchUpdateNotificationHandler( + IViewManager viewManager, + IGuildWarsInstaller guildWarsInstaller, + IGuildWarsExecutableManager guildWarsExecutableManager, + INotificationService notificationService, + ILogger logger) + { + this.viewManager = viewManager.ThrowIfNull(); + this.guildWarsInstaller = guildWarsInstaller.ThrowIfNull(); + this.guildWarsExecutableManager = guildWarsExecutableManager.ThrowIfNull(); + this.notificationService = notificationService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + } + + public async void OpenNotification(Notification notification) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OpenNotification), string.Empty); + if (await this.guildWarsInstaller.GetLatestVersionId(CancellationToken.None) is not int latestVersion) + { + scopedLogger.LogError("Failed to fetch latest version"); + this.notificationService.NotifyError( + title: "Guild Wars batch update failed", + description: "Failed to fetch latest Guild Wars version"); + return; + } + + var updateList = new List(); + var status = new GuildwarsInstallationStatus(); + var cancellationTokenSource = new CancellationTokenSource(); + var context = new GuildWarsDownloadContext { CancellationTokenSource = cancellationTokenSource, GuildwarsInstallationStatus = status }; + foreach(var executable in this.guildWarsExecutableManager.GetExecutableList()) + { + if (await this.guildWarsInstaller.GetVersionId(executable, CancellationToken.None) is int version && + latestVersion == version) + { + continue; + } + + updateList.Add(new GuildWarsUpdateRequest + { + ExecutablePath = executable, + CancellationToken = cancellationTokenSource.Token, + Status = status + }); + } + + if (updateList.None()) + { + scopedLogger.LogInformation("All executables are up to date"); + return; + } + + this.viewManager.ShowView(context); + await foreach (var result in this.guildWarsInstaller.CheckAndUpdateGuildWarsExecutables(updateList, cancellationTokenSource.Token)) + { + if (result.Result) + { + scopedLogger.LogInformation($"Updated {result.ExecutablePath}"); + this.notificationService.NotifyInformation( + title: "Updated executable", + description: $"Updated executable at {result.ExecutablePath}"); + } + else + { + scopedLogger.LogInformation($"Failed to update {result.ExecutablePath}"); + this.notificationService.NotifyInformation( + title: "Failed to update executable", + description: $"Failed to update executable at {result.ExecutablePath}"); + } + } + } +} diff --git a/Daybreak/Services/GuildWars/GuildWarsUpdateNotificationHandler.cs b/Daybreak/Services/GuildWars/GuildWarsUpdateNotificationHandler.cs new file mode 100644 index 00000000..5dd6c67c --- /dev/null +++ b/Daybreak/Services/GuildWars/GuildWarsUpdateNotificationHandler.cs @@ -0,0 +1,59 @@ +using Daybreak.Models.Notifications; +using Daybreak.Models.Notifications.Handling; +using Daybreak.Models.Progress; +using Daybreak.Services.GuildWars.Models; +using Daybreak.Services.Navigation; +using Daybreak.Services.Notifications; +using Daybreak.Views.Installation; +using Microsoft.Extensions.Logging; +using System.Core.Extensions; +using System.Extensions; +using System.IO; +using System.Threading; + +namespace Daybreak.Services.GuildWars; +internal sealed class GuildWarsUpdateNotificationHandler : INotificationHandler +{ + private readonly IViewManager viewManager; + private readonly IGuildWarsInstaller guildWarsInstaller; + private readonly INotificationService notificationService; + private readonly ILogger logger; + + public GuildWarsUpdateNotificationHandler( + IViewManager viewManager, + IGuildWarsInstaller guildWarsInstaller, + INotificationService notificationService, + ILogger logger) + { + this.viewManager = viewManager.ThrowIfNull(); + this.guildWarsInstaller = guildWarsInstaller.ThrowIfNull(); + this.notificationService = notificationService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + } + + public async void OpenNotification(Notification notification) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OpenNotification), notification.Metadata); + if (notification.Metadata is not string path) + { + scopedLogger.LogError("Notification does not have metadata"); + return; + } + + if (!File.Exists(path)) + { + scopedLogger.LogError("File does not exist"); + this.notificationService.NotifyInformation( + title: "Guild Wars update failed", + description: $"Executable does not exist at {path}"); + return; + } + + var status = new GuildwarsInstallationStatus(); + var cancellationTokenSource = new CancellationTokenSource(); + var context = new GuildWarsDownloadContext { CancellationTokenSource = cancellationTokenSource, GuildwarsInstallationStatus = status }; + this.viewManager.ShowView(context); + var response = await this.guildWarsInstaller.UpdateGuildwars(path, status, cancellationTokenSource.Token); + scopedLogger.LogInformation($"Update result {response}"); + } +} diff --git a/Daybreak/Services/GuildWars/GuildWarsVersionChecker.cs b/Daybreak/Services/GuildWars/GuildWarsVersionChecker.cs new file mode 100644 index 00000000..9b8a0781 --- /dev/null +++ b/Daybreak/Services/GuildWars/GuildWarsVersionChecker.cs @@ -0,0 +1,123 @@ +using Daybreak.Models.Mods; +using Daybreak.Services.ExecutableManagement; +using Daybreak.Services.Notifications; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Core.Extensions; +using System.Extensions; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Services.GuildWars; +internal sealed class GuildWarsVersionChecker : IGuildWarsVersionChecker +{ + public string Name => "GuildWars Version Checker"; + public bool IsEnabled { get; set; } = true; + public bool IsInstalled => true; + + private readonly IGuildWarsExecutableManager guildWarsExecutableManager; + private readonly IGuildWarsInstaller guildWarsInstaller; + private readonly INotificationService notificationService; + private readonly ILogger logger; + + public GuildWarsVersionChecker( + IGuildWarsExecutableManager guildWarsExecutableManager, + IGuildWarsInstaller guildWarsInstaller, + INotificationService notificationService, + ILogger logger) + { + this.guildWarsExecutableManager = guildWarsExecutableManager.ThrowIfNull(); + this.guildWarsInstaller = guildWarsInstaller.ThrowIfNull(); + this.notificationService = notificationService.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + } + + public IEnumerable GetCustomArguments() + { + return []; + } + + public async Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OnGuildWarsStarting), guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath); + var notificationToken = this.notificationService.NotifyInformation( + title: "Checking Guild Wars version", + description: "Checking if Guild Wars needs an update", + persistent: false, + expirationTime: DateTime.MaxValue); + if (await this.guildWarsInstaller.GetLatestVersionId(cancellationToken) is not int latestVersion) + { + scopedLogger.LogInformation("Failed to fetch latest version. Skipping version check"); + notificationToken.Cancel(); + return; + } + + if (await this.guildWarsInstaller.GetVersionId(guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath, cancellationToken) is not int version) + { + scopedLogger.LogInformation("Failed to read current version. Executable is probably damaged"); + notificationToken.Cancel(); + this.notificationService.NotifyError( + title: "Potentially damaged executable", + description: $"Daybreak has detected a potentially damaged executable at {guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath}. " + + $"It's recommended that you download the executable again through the menu. Daybreak will still attempt to launch it"); + return; + } + + if (version == latestVersion) + { + scopedLogger.LogInformation("Executable is up to date"); + notificationToken.Cancel(); + return; + } + + notificationToken.Cancel(); + scopedLogger.LogInformation("Found out of date executable. Prompting user to update"); + guildWarsStartingContext.CancelStartup = true; + this.notificationService.NotifyError( + title: "Guild Wars needs an update", + description: $"Click here to update the executable located at {guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath}", + metaData: guildWarsStartingContext.ApplicationLauncherContext.ExecutablePath, + expirationTime: DateTime.Now + TimeSpan.FromSeconds(15)); + } + + public void OnStartup() + { + _ = new TaskFactory().StartNew(this.CheckExecutables, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Current); + } + + public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken) => Task.CompletedTask; + + public void OnClosing() + { + } + + private async void CheckExecutables() + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OnStartup), string.Empty); + if (await this.guildWarsInstaller.GetLatestVersionId(CancellationToken.None) is not int latestVersion) + { + scopedLogger.LogError("Failed to fetch latest version. Skipping version check"); + return; + } + + foreach (var executable in this.guildWarsExecutableManager.GetExecutableList()) + { + if (await this.guildWarsInstaller.GetVersionId(executable, CancellationToken.None) is int version && + version == latestVersion) + { + continue; + } + + scopedLogger.LogInformation("Discovered out of date executable. Prompting user to update"); + this.notificationService.NotifyInformation( + title: "Guild Wars needs an update", + description: "One or more Guild Wars executables are out of date and require an update. Click here to start the update"); + return; + } + } +} diff --git a/Daybreak/Services/GuildWars/IGuildWarsVersionChecker.cs b/Daybreak/Services/GuildWars/IGuildWarsVersionChecker.cs new file mode 100644 index 00000000..f25d6773 --- /dev/null +++ b/Daybreak/Services/GuildWars/IGuildWarsVersionChecker.cs @@ -0,0 +1,7 @@ +using Daybreak.Services.Mods; +using System.Windows.Extensions.Services; + +namespace Daybreak.Services.GuildWars; +public interface IGuildWarsVersionChecker : IModService, IApplicationLifetimeService +{ +} diff --git a/Daybreak/Services/GuildWars/Models/GuildWarsDownloadContext.cs b/Daybreak/Services/GuildWars/Models/GuildWarsDownloadContext.cs new file mode 100644 index 00000000..a5a799dd --- /dev/null +++ b/Daybreak/Services/GuildWars/Models/GuildWarsDownloadContext.cs @@ -0,0 +1,9 @@ +using Daybreak.Models.Progress; +using System.Threading; + +namespace Daybreak.Services.GuildWars.Models; +internal sealed class GuildWarsDownloadContext +{ + public GuildwarsInstallationStatus? GuildwarsInstallationStatus { get; init; } + public CancellationTokenSource? CancellationTokenSource { get; init; } +} diff --git a/Daybreak/Services/GuildWars/Models/GuildWarsUpdateRequest.cs b/Daybreak/Services/GuildWars/Models/GuildWarsUpdateRequest.cs new file mode 100644 index 00000000..4caf4adf --- /dev/null +++ b/Daybreak/Services/GuildWars/Models/GuildWarsUpdateRequest.cs @@ -0,0 +1,10 @@ +using Daybreak.Models.Progress; +using System.Threading; + +namespace Daybreak.Services.GuildWars.Models; +public sealed class GuildWarsUpdateRequest +{ + public string? ExecutablePath { get; init; } + public GuildwarsInstallationStatus? Status { get; init; } + public CancellationToken CancellationToken { get; init; } +} diff --git a/Daybreak/Services/GuildWars/Models/GuildWarsUpdateResponse.cs b/Daybreak/Services/GuildWars/Models/GuildWarsUpdateResponse.cs new file mode 100644 index 00000000..a1613a31 --- /dev/null +++ b/Daybreak/Services/GuildWars/Models/GuildWarsUpdateResponse.cs @@ -0,0 +1,6 @@ +namespace Daybreak.Services.GuildWars.Models; +public sealed class GuildWarsUpdateResponse +{ + public bool Result { get; init; } + public string? ExecutablePath { get; init; } +} diff --git a/Daybreak/Services/GuildWars/Utils/GuildWarsExecutableParser.cs b/Daybreak/Services/GuildWars/Utils/GuildWarsExecutableParser.cs new file mode 100644 index 00000000..ee94796b --- /dev/null +++ b/Daybreak/Services/GuildWars/Utils/GuildWarsExecutableParser.cs @@ -0,0 +1,102 @@ +using PeNet; +using PeNet.Header.Pe; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Daybreak.Services.GuildWars.Utils; +/// +/// https://github.com/gwdevhub/gwlauncher/blob/master/GW%20Launcher/Guildwars/FileIdFinder.cs +/// +internal sealed class GuildWarsExecutableParser +{ + private readonly static byte[] VersionPattern = [0x8B, 0xC8, 0x33, 0xDB, 0x39, 0x8D, 0xC0, 0xFD, 0xFF, 0xFF, 0x0F, 0x95, 0xC3]; + + private readonly PeFile peFile; + private readonly ImageSectionHeader textSection; + + private GuildWarsExecutableParser(string path) + { + this.peFile = new PeFile(path); + this.textSection = this.peFile.ImageSectionHeaders?.FirstOrDefault(s => s.Name == ".text") ?? throw new InvalidOperationException("Unable to find .text section"); + } + + public Task GetVersion(CancellationToken cancellationToken) + { + return Task.Run(() => + { + var offset = this.Find(VersionPattern); + var functionRva = this.FollowCall(offset - 5); + var fileId = (int)this.Read(functionRva + 1); + return fileId; + }, cancellationToken); + } + + private uint Find(byte[] pattern, int offset = 0) + { + var buffer = new byte[this.textSection.SizeOfRawData]; + Buffer.BlockCopy(this.peFile.RawFile.ToArray(), (int)this.textSection.PointerToRawData, buffer, 0, (int)this.textSection.SizeOfRawData); + var pos = IndexOf(buffer, pattern); + if (pos == -1) + { + throw new Exception("Couldn't find the pattern"); + } + + return this.textSection.VirtualAddress + (uint)pos + (uint)offset; + } + + private uint FollowCall(uint callRva) + { + var posInFile = this.RvaToOffset(callRva); + var op = this.peFile.RawFile.ToArray()[posInFile]; + var callParam = BitConverter.ToInt32(this.peFile.RawFile.ToArray(), posInFile + 1); + + if (op != 0xE8 && op != 0xE9) + { + throw new Exception($"Unsupported opcode '0x{op:X2} ({op})'"); + } + + return callRva + (uint)callParam + 5; + } + + private uint Read(uint rva) + { + var posInFile = this.RvaToOffset(rva); + return BitConverter.ToUInt32(this.peFile.RawFile.ToArray(), posInFile); + } + + private int RvaToOffset(uint rva) + { + var section = this.peFile.ImageSectionHeaders!.FirstOrDefault(s => rva >= s.VirtualAddress && rva < s.VirtualAddress + s.VirtualSize); + return section is null + ? throw new Exception("Could not find section for RVA") + : (int)(rva - section.VirtualAddress + section.PointerToRawData); + } + + private static int IndexOf(byte[] haystack, byte[] needle) + { + for (int i = 0; i <= haystack.Length - needle.Length; i++) + { + if (needle.SequenceEqual(haystack.Skip(i).Take(needle.Length))) + { + return i; + } + } + + return -1; + } + + public static GuildWarsExecutableParser? TryParse(string filePath) + { + try + { + var parser = new GuildWarsExecutableParser(filePath); + return parser; + } + catch + { + return default; + } + } +} diff --git a/Daybreak/Services/Guildwars/GuildwarsCopyService.cs b/Daybreak/Services/Guildwars/GuildwarsCopyService.cs index fcdd24a7..71154b3f 100644 --- a/Daybreak/Services/Guildwars/GuildwarsCopyService.cs +++ b/Daybreak/Services/Guildwars/GuildwarsCopyService.cs @@ -8,9 +8,9 @@ using System.Threading.Tasks; using System.Windows.Forms; -namespace Daybreak.Services.Guildwars; +namespace Daybreak.Services.GuildWars; -internal sealed class GuildwarsCopyService : IGuildwarsCopyService +internal sealed class GuildWarsCopyService : IGuildWarsCopyService { private const string ExecutableName = "Gw.exe"; @@ -22,11 +22,11 @@ internal sealed class GuildwarsCopyService : IGuildwarsCopyService }; private readonly IGuildWarsExecutableManager guildWarsExecutableManager; - private readonly ILogger logger; + private readonly ILogger logger; - public GuildwarsCopyService( + public GuildWarsCopyService( IGuildWarsExecutableManager guildWarsExecutableManager, - ILogger logger) + ILogger logger) { this.guildWarsExecutableManager = guildWarsExecutableManager.ThrowIfNull(); this.logger = logger.ThrowIfNull(); diff --git a/Daybreak/Services/Guildwars/GuildwarsInstaller.cs b/Daybreak/Services/Guildwars/GuildwarsInstaller.cs deleted file mode 100644 index ed6ae36d..00000000 --- a/Daybreak/Services/Guildwars/GuildwarsInstaller.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Daybreak.Models.Progress; -using Daybreak.Services.Downloads; -using Daybreak.Services.Privilege; -using Daybreak.Views; -using Microsoft.Extensions.Logging; -using System; -using System.Core.Extensions; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Daybreak.Services.Guildwars; -internal sealed class GuildwarsInstaller : IGuildwarsInstaller -{ - private const string GuildwarsDownloadUri = "https://cloudfront.guildwars2.com/client/GwSetup.exe"; - private const string InstallationFileName = "GwSetup.exe"; - - private readonly IDownloadService downloadService; - private readonly IPrivilegeManager privilegeManager; - private readonly ILogger logger; - - public GuildwarsInstaller( - IPrivilegeManager privilegeManager, - IDownloadService downloadService, - ILogger logger) - { - this.privilegeManager = privilegeManager.ThrowIfNull(); - this.downloadService = downloadService.ThrowIfNull(); - this.logger = logger.ThrowIfNull(); - } - - public async Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) - { - if (this.privilegeManager.AdminPrivileges is false) - { - this.privilegeManager.RequestAdminPrivileges("Daybreak needs admin privileges to download and install Guildwars"); - return false; - } - - var exePath = Path.Combine(destinationPath, InstallationFileName); - if (!File.Exists(exePath)) - { - if ((await this.DownloadGuildwarsInstaller(exePath, installationStatus)) is false) - { - throw new InvalidOperationException("Failed to download executable"); - } - } - - var installationProcess = Process.Start(exePath); - while (installationProcess.HasExited is false) - { - await Task.Delay(1000); - } - - installationStatus.CurrentStep = GuildwarsInstallationStatus.Finished; - this.logger.LogInformation($"Installation finished with status code {installationProcess.ExitCode}"); - return true; - } - - private Task DownloadGuildwarsInstaller(string destinationPath, GuildwarsInstallationStatus installationStatus) - { - return this.downloadService.DownloadFile(GuildwarsDownloadUri, destinationPath, installationStatus); - } -} diff --git a/Daybreak/Services/Guildwars/IGuildwarsCopyService.cs b/Daybreak/Services/Guildwars/IGuildwarsCopyService.cs index 9f2ef9f1..f7c7cc26 100644 --- a/Daybreak/Services/Guildwars/IGuildwarsCopyService.cs +++ b/Daybreak/Services/Guildwars/IGuildwarsCopyService.cs @@ -2,9 +2,9 @@ using System.Threading; using System.Threading.Tasks; -namespace Daybreak.Services.Guildwars; +namespace Daybreak.Services.GuildWars; -public interface IGuildwarsCopyService +public interface IGuildWarsCopyService { Task CopyGuildwars(string existingExecutable, CopyStatus copyStatus, CancellationToken cancellationToken); } diff --git a/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs b/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs index d90c72a9..b1817e0b 100644 --- a/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs +++ b/Daybreak/Services/Guildwars/IGuildwarsInstaller.cs @@ -1,9 +1,15 @@ using Daybreak.Models.Progress; +using Daybreak.Services.GuildWars.Models; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -namespace Daybreak.Services.Guildwars; -public interface IGuildwarsInstaller +namespace Daybreak.Services.GuildWars; +public interface IGuildWarsInstaller { + Task UpdateGuildwars(string exePath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken); Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken); + Task GetLatestVersionId(CancellationToken cancellationToken); + Task GetVersionId(string executablePath, CancellationToken cancellationToken); + IAsyncEnumerable CheckAndUpdateGuildWarsExecutables(List requests, CancellationToken cancellationToken); } diff --git a/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs b/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs index 6d311d95..ca789f97 100644 --- a/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs +++ b/Daybreak/Services/Guildwars/IntegratedGuildwarsInstaller.cs @@ -1,22 +1,28 @@ using Daybreak.Models.Progress; using Daybreak.Services.ExecutableManagement; -using Daybreak.Services.Guildwars.Models; -using Daybreak.Services.Guildwars.Utils; +using Daybreak.Services.GuildWars.Models; +using Daybreak.Services.GuildWars.Utils; using Daybreak.Services.Notifications; +using Daybreak.Utils; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Core.Extensions; using System.Diagnostics; using System.Extensions; using System.IO; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -namespace Daybreak.Services.Guildwars; -internal sealed class IntegratedGuildwarsInstaller : IGuildwarsInstaller +namespace Daybreak.Services.GuildWars; +internal sealed class IntegratedGuildwarsInstaller : IGuildWarsInstaller { private const string ExeName = "Gw.exe"; - private const string TempExeName = "Gw.exe.temp"; + private const string CompressedTempExeName = $"Gw.{VersionPlaceholder}.temp"; + private const string UncompressedTempExeName = $"Gw.{VersionPlaceholder}.exe"; + private const string VersionPlaceholder = "[VERSION]"; + private static readonly string StagingFolder = PathUtils.GetAbsolutePathFromRoot("GuildWarsCache"); private readonly IGuildWarsExecutableManager guildWarsExecutableManager; private readonly INotificationService notificationService; @@ -32,6 +38,65 @@ public IntegratedGuildwarsInstaller( this.logger = logger.ThrowIfNull(); } + public async IAsyncEnumerable CheckAndUpdateGuildWarsExecutables(List requests, [EnumeratorCancellation]CancellationToken cancellationToken) + { + requests.ThrowIfNull(); + var mainScopedLogger = this.logger.CreateScopedLogger(nameof(this.CheckAndUpdateGuildWarsExecutables), string.Empty); + if (await this.GetLatestVersionId(CancellationToken.None) is not int latestVersion) + { + mainScopedLogger.LogError("Failed to fetch latest GuildWars version"); + throw new InvalidOperationException("Failed to fetch latest GuildWars version"); + } + + foreach(var request in requests) + { + if (request.ExecutablePath!.IsNullOrWhiteSpace() || + request.Status is null) + { + mainScopedLogger.LogError($"Invalid request for [{request.ExecutablePath}]"); + yield return new GuildWarsUpdateResponse { ExecutablePath = request.ExecutablePath, Result = false }; + continue; + } + + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.CheckAndUpdateGuildWarsExecutables), request.ExecutablePath!); + if (!File.Exists(request.ExecutablePath)) + { + scopedLogger.LogError($"File not found at [{request.ExecutablePath}]"); + yield return new GuildWarsUpdateResponse { ExecutablePath = request.ExecutablePath, Result = false }; + continue; + } + + if (await this.GetVersionId(request.ExecutablePath, request.CancellationToken) is int version && + version == latestVersion) + { + scopedLogger.LogInformation("Executable is already latest"); + yield return new GuildWarsUpdateResponse { ExecutablePath = request.ExecutablePath, Result = true }; + continue; + } + + var success = false; + try + { + success = await this.UpdateGuildwars(request.ExecutablePath, request.Status, request.CancellationToken); + } + catch(Exception e) + { + scopedLogger.LogError(e, "Encountered exception while processing"); + success = false; + } + + yield return new GuildWarsUpdateResponse { ExecutablePath = request.ExecutablePath, Result = success }; + } + } + + public async Task UpdateGuildwars(string exePath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + { + return await new TaskFactory().StartNew(_ => + { + return this.UpdateGuildwarsInternal(exePath, installationStatus, cancellationToken); + }, TaskCreationOptions.LongRunning, cancellationToken).Unwrap(); + } + public async Task InstallGuildwars(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) { return await new TaskFactory().StartNew(_ => { @@ -39,65 +104,98 @@ public async Task InstallGuildwars(string destinationPath, GuildwarsInstal }, TaskCreationOptions.LongRunning, cancellationToken).Unwrap(); } - private async Task InstallGuildwarsInternal(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + public async Task GetLatestVersionId(CancellationToken cancellationToken) { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.InstallGuildwarsInternal), destinationPath); - GuildwarsClientContext? maybeContext = default; + var guildWarsClient = new GuildWarsClient(); + var response = await guildWarsClient.Connect(cancellationToken); + return response?.Item2.LatestExe; + } + + public async Task GetVersionId(string executablePath, CancellationToken cancellationToken) + { + var parser = GuildWarsExecutableParser.TryParse(executablePath); + if (parser is null) + { + return default; + } + try { - var tempName = Path.Combine(destinationPath, TempExeName); - var exeName = Path.Combine(destinationPath, ExeName); + return await parser.GetVersion(cancellationToken); + } + catch + { + return default; + } + } + + private async Task UpdateGuildwarsInternal(string exePath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + { + var downloadedPath = await this.FetchLatestGuildwarsInternal(installationStatus, cancellationToken); + if (downloadedPath is null) + { + installationStatus.CurrentStep = GuildwarsInstallationStatus.Failed; + return false; + } + + File.Copy(downloadedPath, exePath, true); + installationStatus.CurrentStep = GuildwarsInstallationStatus.UpdateFinished; + return true; + } + private async Task FetchLatestGuildwarsInternal(GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.FetchLatestGuildwarsInternal), string.Empty); + GuildWarsClientContext? maybeContext = default; + try + { // Initialize the download client - var guildWarsClient = new GuildwarsClient(); + 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; + installationStatus.CurrentStep = GuildwarsInstallationStatus.Failed; + return default; } (var context, var manifest) = result.Value; maybeContext = context; + var tempName = Path.Combine(StagingFolder, CompressedTempExeName.Replace(VersionPlaceholder, manifest.LatestExe.ToString())); + var cacheName = Path.Combine(StagingFolder, UncompressedTempExeName.Replace(VersionPlaceholder, manifest.LatestExe.ToString())); + if (File.Exists(cacheName) && + await this.GetVersionId(cacheName, cancellationToken) is int cacheVersion && + cacheVersion == manifest.LatestExe) + { + return cacheName; + } + (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; + installationStatus.CurrentStep = GuildwarsInstallationStatus.Failed; + return default; } - if (!this.DecompressExecutable(tempName, exeName, expectedFinalSize, installationStatus)) + if (!this.DecompressExecutable(tempName, cacheName, expectedFinalSize, installationStatus)) { scopedLogger.LogError("Failed to decompress executable"); - installationStatus.CurrentStep = GuildwarsInstallationStatus.FailedDownload; - return false; + installationStatus.CurrentStep = GuildwarsInstallationStatus.Failed; + return default; } - 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; + return cacheName; } catch (Exception e) { this.notificationService.NotifyError( title: "Download exception", description: $"Encountered exception while downloading: {e}"); - installationStatus.CurrentStep = GuildwarsInstallationStatus.FailedDownload; + installationStatus.CurrentStep = GuildwarsInstallationStatus.Failed; this.logger.LogError(e, "Download failed. Encountered exception"); - return false; + return default; } finally { @@ -108,10 +206,37 @@ private async Task InstallGuildwarsInternal(string destinationPath, Guildw } } + private async Task InstallGuildwarsInternal(string destinationPath, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.InstallGuildwarsInternal), destinationPath); + var exePath = Path.Combine(destinationPath, ExeName); + var latestGwPath = await this.FetchLatestGuildwarsInternal(installationStatus, cancellationToken); + if (latestGwPath is null) + { + installationStatus.CurrentStep = GuildwarsInstallationStatus.Failed; + return false; + } + + var filePath = Path.GetFullPath(exePath); + File.Copy(latestGwPath, filePath, true); + 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); + installationStatus.CurrentStep = GuildwarsInstallationStatus.InstallFinished; + return true; + } + private async Task<(bool Success, int ExpectedSize)> DownloadCompressedExecutable( string fileName, - GuildwarsClient guildWarsClient, - GuildwarsClientContext context, + GuildWarsClient guildWarsClient, + GuildWarsClientContext context, ManifestResponse manifest, GuildwarsInstallationStatus installationStatus, CancellationToken cancellationToken) @@ -125,6 +250,18 @@ private async Task InstallGuildwarsInternal(string destinationPath, Guildw } using var downloadStream = maybeStream; + var directoryName = Path.GetDirectoryName(fileName); + if (directoryName is null) + { + scopedLogger.LogError("Failed to create destination folder"); + return (false, -1); + } + + if (!Directory.Exists(directoryName)) + { + Directory.CreateDirectory(directoryName); + } + using var writeFileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write); var expectedFinalSize = downloadStream.SizeDecompressed; var buffer = new Memory(new byte[2048]); diff --git a/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs b/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs index 564c4041..949ef592 100644 --- a/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs +++ b/Daybreak/Services/Guildwars/Models/FileMetadataResponse.cs @@ -1,4 +1,4 @@ -namespace Daybreak.Services.Guildwars.Models; +namespace Daybreak.Services.GuildWars.Models; internal readonly struct FileMetadataResponse { public readonly short Field1 { get; init; } diff --git a/Daybreak/Services/Guildwars/Models/FileRequest.cs b/Daybreak/Services/Guildwars/Models/FileRequest.cs index 08d37b03..97a59411 100644 --- a/Daybreak/Services/Guildwars/Models/FileRequest.cs +++ b/Daybreak/Services/Guildwars/Models/FileRequest.cs @@ -1,4 +1,4 @@ -namespace Daybreak.Services.Guildwars.Models; +namespace Daybreak.Services.GuildWars.Models; internal readonly struct FileRequest { public short Field1 { get; init; } diff --git a/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs b/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs index 3c73005a..3e69514a 100644 --- a/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs +++ b/Daybreak/Services/Guildwars/Models/FileRequestNextChunk.cs @@ -1,4 +1,4 @@ -namespace Daybreak.Services.Guildwars.Models; +namespace Daybreak.Services.GuildWars.Models; internal readonly struct FileRequestNextChunk { public short Field1 { get; init; } diff --git a/Daybreak/Services/Guildwars/Models/FileResponse.cs b/Daybreak/Services/Guildwars/Models/FileResponse.cs index e4bd84e5..4114f64f 100644 --- a/Daybreak/Services/Guildwars/Models/FileResponse.cs +++ b/Daybreak/Services/Guildwars/Models/FileResponse.cs @@ -1,4 +1,4 @@ -namespace Daybreak.Services.Guildwars.Models; +namespace Daybreak.Services.GuildWars.Models; internal readonly struct FileResponse { public int FileId { get; init; } diff --git a/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs b/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs index 3973424c..63fb70fa 100644 --- a/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs +++ b/Daybreak/Services/Guildwars/Models/GuildwarsClientContext.cs @@ -1,8 +1,8 @@ using System; using System.Net.Sockets; -namespace Daybreak.Services.Guildwars.Models; -internal readonly struct GuildwarsClientContext : IDisposable +namespace Daybreak.Services.GuildWars.Models; +internal readonly struct GuildWarsClientContext : IDisposable { public Socket Socket { get; init; } diff --git a/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs b/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs index 3cceecc9..364f0c2e 100644 --- a/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs +++ b/Daybreak/Services/Guildwars/Models/HandshakeRequest.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Daybreak.Services.Guildwars.Models; +namespace Daybreak.Services.GuildWars.Models; [StructLayout(LayoutKind.Sequential, Pack = 1)] internal readonly struct HandshakeRequest { diff --git a/Daybreak/Services/Guildwars/Models/ManifestResponse.cs b/Daybreak/Services/Guildwars/Models/ManifestResponse.cs index f37e9aee..d9d25881 100644 --- a/Daybreak/Services/Guildwars/Models/ManifestResponse.cs +++ b/Daybreak/Services/Guildwars/Models/ManifestResponse.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Daybreak.Services.Guildwars.Models; +namespace Daybreak.Services.GuildWars.Models; [StructLayout(LayoutKind.Sequential, Pack = 1)] internal readonly struct ManifestResponse { diff --git a/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs b/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs index df6a247f..cb76b745 100644 --- a/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs +++ b/Daybreak/Services/Guildwars/Utils/GuildwarsClient.cs @@ -1,14 +1,14 @@ -using Daybreak.Services.Guildwars.Models; +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 +namespace Daybreak.Services.GuildWars.Utils; +internal sealed class GuildWarsClient { - public async Task<(GuildwarsClientContext, ManifestResponse)?> Connect(CancellationToken cancellationToken) + public async Task<(GuildWarsClientContext, ManifestResponse)?> Connect(CancellationToken cancellationToken) { for(var i = 1; i < 13; i++) { @@ -21,7 +21,7 @@ internal sealed class GuildwarsClient try { await socket.ConnectAsync(url, 6112, cancellationToken); - var context = new GuildwarsClientContext { Socket = socket }; + var context = new GuildWarsClientContext { Socket = socket }; var handshakeRequest = new HandshakeRequest { Field1 = 1, @@ -46,7 +46,7 @@ internal sealed class GuildwarsClient return default; } - public async Task GetFileStream(GuildwarsClientContext guildwarsClientContext, int fileId, int version = 0, CancellationToken cancellationToken = default) + public async Task GetFileResponse(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); @@ -62,10 +62,16 @@ internal sealed class GuildwarsClient } var response = await this.ReceiveWait(guildwarsClientContext, cancellationToken); + return response; + } + + public async Task GetFileStream(GuildWarsClientContext guildwarsClientContext, int fileId, int version = 0, CancellationToken cancellationToken = default) + { + var response = await this.GetFileResponse(guildwarsClientContext, fileId, version, cancellationToken); return new GuildwarsFileStream(guildwarsClientContext, this, response.FileId, response.SizeCompressed, response.SizeDecompressed, response.Crc); } - public async Task ReceiveWait(GuildwarsClientContext context, CancellationToken cancellationToken) + public async Task ReceiveWait(GuildWarsClientContext context, CancellationToken cancellationToken) where T : struct { var size = Marshal.SizeOf(); @@ -89,7 +95,7 @@ public async Task ReceiveWait(GuildwarsClientContext context, Cancellation return str; } - public async Task Send(T str, GuildwarsClientContext context, CancellationToken cancellationToken) + public async Task Send(T str, GuildWarsClientContext context, CancellationToken cancellationToken) where T : struct { var size = Marshal.SizeOf(); diff --git a/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs b/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs index f030cdd4..9b1f2fc8 100644 --- a/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs +++ b/Daybreak/Services/Guildwars/Utils/GuildwarsFileStream.cs @@ -1,15 +1,15 @@ -using Daybreak.Services.Guildwars.Models; +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; +namespace Daybreak.Services.GuildWars.Utils; internal sealed class GuildwarsFileStream : Stream { - private readonly GuildwarsClient guildwarsClient; - private readonly GuildwarsClientContext guildwarsClientContext; + private readonly GuildWarsClient guildwarsClient; + private readonly GuildWarsClientContext guildwarsClientContext; private byte[]? chunkBuffer; private int positionInBuffer = 0; @@ -26,7 +26,7 @@ internal sealed class GuildwarsFileStream : Stream 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) + public GuildwarsFileStream(GuildWarsClientContext guildwarsClientContext, GuildWarsClient guildwarsClient, int fileId, int sizeCompressed, int sizeDecompressed, int crc) { this.guildwarsClient = guildwarsClient.ThrowIfNull(); this.guildwarsClientContext = guildwarsClientContext; diff --git a/Daybreak/Services/Guildwars/Utils/Huffman.cs b/Daybreak/Services/Guildwars/Utils/Huffman.cs index 4deb9f18..504ae686 100644 --- a/Daybreak/Services/Guildwars/Utils/Huffman.cs +++ b/Daybreak/Services/Guildwars/Utils/Huffman.cs @@ -1,4 +1,4 @@ -namespace Daybreak.Services.Guildwars.Utils; +namespace Daybreak.Services.GuildWars.Utils; internal static class Huffman { public static readonly (uint, uint)[] Table1 = diff --git a/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs b/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs index f9006efe..e4e9e660 100644 --- a/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs +++ b/Daybreak/Services/Guildwars/Utils/HuffmanTable.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace Daybreak.Services.Guildwars.Utils; +namespace Daybreak.Services.GuildWars.Utils; internal sealed class HuffmanTable { diff --git a/Daybreak/Views/Copy/GuildwarsCopyView.xaml.cs b/Daybreak/Views/Copy/GuildwarsCopyView.xaml.cs index d98f27b0..4181af82 100644 --- a/Daybreak/Views/Copy/GuildwarsCopyView.xaml.cs +++ b/Daybreak/Views/Copy/GuildwarsCopyView.xaml.cs @@ -1,5 +1,5 @@ using Daybreak.Models.Progress; -using Daybreak.Services.Guildwars; +using Daybreak.Services.GuildWars; using Daybreak.Services.Navigation; using Daybreak.Views.Launch; using Microsoft.Extensions.Logging; @@ -20,7 +20,7 @@ public partial class GuildwarsCopyView : UserControl private readonly CancellationTokenSource cancellationTokenSource = new(); private readonly ILogger logger; private readonly IViewManager viewManager; - private readonly IGuildwarsCopyService guildwarsCopyService; + private readonly IGuildWarsCopyService guildwarsCopyService; private readonly CopyStatus copyStatus = new(); [GenerateDependencyProperty(InitialValue = "")] @@ -33,7 +33,7 @@ public partial class GuildwarsCopyView : UserControl private bool progressVisible; public GuildwarsCopyView( - IGuildwarsCopyService guildwarsCopyService, + IGuildWarsCopyService guildwarsCopyService, ILogger logger, IViewManager viewManager) { diff --git a/Daybreak/Views/GuildwarsDownloadView.xaml.cs b/Daybreak/Views/GuildwarsDownloadView.xaml.cs deleted file mode 100644 index 36c8cbcc..00000000 --- a/Daybreak/Views/GuildwarsDownloadView.xaml.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Daybreak.Services.Navigation; -using Microsoft.Extensions.Logging; -using System.Windows; -using Daybreak.Models.Progress; -using System.Core.Extensions; -using System.Windows.Extensions; -using System.Windows.Forms; -using Daybreak.Launch; -using Daybreak.Models; -using Daybreak.Services.Guildwars; -using System.Threading; - -namespace Daybreak.Views; - -/// -/// Interaction logic for DownloadView.xaml -/// -public partial class GuildwarsDownloadView : System.Windows.Controls.UserControl -{ - private readonly ILogger logger; - 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; - [GenerateDependencyProperty] - private double progressValue; - [GenerateDependencyProperty] - private bool continueButtonEnabled; - [GenerateDependencyProperty] - private bool progressVisible; - - public GuildwarsDownloadView( - IGuildwarsInstaller guildwarsInstaller, - ILogger logger, - IViewManager viewManager) - { - this.guildwarsInstaller = guildwarsInstaller.ThrowIfNull(); - this.logger = logger.ThrowIfNull(); - this.viewManager = viewManager.ThrowIfNull(); - this.installationStatus.PropertyChanged += this.DownloadStatus_PropertyChanged!; - this.InitializeComponent(); - } - - private void DownloadStatus_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) - { - this.Dispatcher.Invoke(() => - { - this.ProgressVisible = false; - if (this.installationStatus.CurrentStep is DownloadStatus.DownloadProgressStep downloadUpdateStep) - { - this.ProgressValue = downloadUpdateStep.Progress * 100; - this.ProgressVisible = true; - } - - this.Description = this.installationStatus.CurrentStep.Description; - }); - } - - private async void DownloadView_Loaded(object sender, RoutedEventArgs e) - { - var folderPicker = new FolderBrowserDialog() - { - ShowNewFolderButton = true, - AutoUpgradeEnabled = true, - Description = "Select where to download the Guildwars installer", - UseDescriptionForTitle = true - }; - - var result = folderPicker.ShowDialog(); - if (result is DialogResult.Abort or DialogResult.Cancel or DialogResult.No) - { - this.installationStatus.CurrentStep = DownloadStatus.DownloadCancelled; - this.ContinueButtonEnabled = true; - return; - } - - var folderPath = folderPicker.SelectedPath; - this.logger.LogInformation("Starting download procedure"); - var success = await this.guildwarsInstaller.InstallGuildwars(folderPath, this.installationStatus, this.cancellationTokenSource.Token).ConfigureAwait(true); - if (success is false) - { - this.logger.LogError("Download procedure failed"); - } - else - { - this.logger.LogInformation("Installed guildwars"); - } - - this.ContinueButtonEnabled = true; - } - - 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/Daybreak/Views/Installation/GuildWarsDownloadSelectionView.xaml b/Daybreak/Views/Installation/GuildWarsDownloadSelectionView.xaml new file mode 100644 index 00000000..bff685db --- /dev/null +++ b/Daybreak/Views/Installation/GuildWarsDownloadSelectionView.xaml @@ -0,0 +1,13 @@ + + + + + diff --git a/Daybreak/Views/Installation/GuildWarsDownloadSelectionView.xaml.cs b/Daybreak/Views/Installation/GuildWarsDownloadSelectionView.xaml.cs new file mode 100644 index 00000000..735e6676 --- /dev/null +++ b/Daybreak/Views/Installation/GuildWarsDownloadSelectionView.xaml.cs @@ -0,0 +1,67 @@ +using Daybreak.Models.Progress; +using Daybreak.Services.GuildWars; +using Daybreak.Services.GuildWars.Models; +using Daybreak.Services.Navigation; +using Microsoft.Extensions.Logging; +using System.Core.Extensions; +using System.Threading; +using System.Windows; +using System.Windows.Forms; + +namespace Daybreak.Views.Installation; +/// +/// Interaction logic for GuildWarsDownloadSelectionView.xaml +/// +public partial class GuildWarsDownloadSelectionView : System.Windows.Controls.UserControl +{ + private readonly IViewManager viewManager; + private readonly IGuildWarsInstaller guildwarsInstaller; + private readonly ILogger logger; + + public GuildWarsDownloadSelectionView( + IViewManager viewManager, + IGuildWarsInstaller guildwarsInstaller, + ILogger logger) + { + this.viewManager = viewManager.ThrowIfNull(); + this.guildwarsInstaller = guildwarsInstaller.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + this.InitializeComponent(); + } + + private async void DownloadView_Loaded(object sender, RoutedEventArgs e) + { + var folderPicker = new FolderBrowserDialog() + { + ShowNewFolderButton = true, + AutoUpgradeEnabled = true, + Description = "Select where to download the Guildwars installer", + UseDescriptionForTitle = true + }; + + var result = folderPicker.ShowDialog(); + if (result is DialogResult.Abort or DialogResult.Cancel or DialogResult.No) + { + this.viewManager.ShowView(); + return; + } + + var context = new GuildWarsDownloadContext + { + CancellationTokenSource = new CancellationTokenSource(), + GuildwarsInstallationStatus = new GuildwarsInstallationStatus() + }; + var folderPath = folderPicker.SelectedPath; + this.logger.LogInformation("Starting download procedure"); + this.viewManager.ShowView(context); + var success = await this.guildwarsInstaller.InstallGuildwars(folderPath, context.GuildwarsInstallationStatus, context.CancellationTokenSource.Token); + if (success is false) + { + this.logger.LogError("Download procedure failed"); + } + else + { + this.logger.LogInformation("Installed guildwars"); + } + } +} diff --git a/Daybreak/Views/GuildwarsDownloadView.xaml b/Daybreak/Views/Installation/GuildWarsDownloadView.xaml similarity index 92% rename from Daybreak/Views/GuildwarsDownloadView.xaml rename to Daybreak/Views/Installation/GuildWarsDownloadView.xaml index 66d87d87..b7456930 100644 --- a/Daybreak/Views/GuildwarsDownloadView.xaml +++ b/Daybreak/Views/Installation/GuildWarsDownloadView.xaml @@ -1,14 +1,14 @@ - diff --git a/Daybreak/Views/Installation/GuildwarsDownloadView.xaml.cs b/Daybreak/Views/Installation/GuildwarsDownloadView.xaml.cs new file mode 100644 index 00000000..897a47d8 --- /dev/null +++ b/Daybreak/Views/Installation/GuildwarsDownloadView.xaml.cs @@ -0,0 +1,115 @@ +using Daybreak.Services.Navigation; +using Microsoft.Extensions.Logging; +using System.Windows; +using Daybreak.Models.Progress; +using System.Core.Extensions; +using System.Windows.Extensions; +using Daybreak.Services.GuildWars; +using System.Threading; +using System.Windows.Controls; +using Daybreak.Services.Menu; +using Daybreak.Services.GuildWars.Models; + +namespace Daybreak.Views.Installation; + +/// +/// Interaction logic for DownloadView.xaml +/// +public partial class GuildWarsDownloadView : UserControl +{ + private readonly IMenuService menuService; + private readonly ILogger logger; + private readonly IViewManager viewManager; + private readonly IGuildWarsInstaller guildwarsInstaller; + + private GuildwarsInstallationStatus? installationStatus; + private CancellationTokenSource? cancellationTokenSource; + + [GenerateDependencyProperty(InitialValue = "")] + private string description = string.Empty; + [GenerateDependencyProperty] + private double progressValue; + [GenerateDependencyProperty(InitialValue = true)] + private bool continueButtonEnabled; + [GenerateDependencyProperty] + private bool progressVisible; + + public GuildWarsDownloadView( + IMenuService menuService, + IGuildWarsInstaller guildwarsInstaller, + ILogger logger, + IViewManager viewManager) + { + this.menuService = menuService.ThrowIfNull(); + this.guildwarsInstaller = guildwarsInstaller.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + this.viewManager = viewManager.ThrowIfNull(); + this.InitializeComponent(); + } + + private void DownloadStatus_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + this.Dispatcher.Invoke(() => + { + this.ProgressVisible = false; + if (this.installationStatus?.CurrentStep is DownloadStatus.DownloadProgressStep downloadUpdateStep) + { + this.ProgressValue = downloadUpdateStep.Progress * 100; + this.ProgressVisible = true; + } + + if (this.installationStatus?.CurrentStep is GuildwarsInstallationStatus.GuildwarsInstallationStep step && + step.Final) + { + this.ContinueButtonEnabled = true; + } + else + { + this.ContinueButtonEnabled = false; + } + + this.Description = this.installationStatus?.CurrentStep.Description ?? string.Empty; + }); + } + + 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(); + } + + private void GuildWarsDownloadView_DataContextChanged(object _, DependencyPropertyChangedEventArgs __) + { + if (this.DataContext is not GuildWarsDownloadContext context || + context.GuildwarsInstallationStatus is null || + context.CancellationTokenSource is null) + { + return; + } + + if (this.cancellationTokenSource is not null) + { + this.cancellationTokenSource.Cancel(); + this.cancellationTokenSource.Dispose(); + this.cancellationTokenSource = null; + } + +#pragma warning disable CS8622 // Nullability of reference types in type of parameter doesn't match the target delegate (possibly because of nullability attributes). + if (this.installationStatus is not null) + { + this.installationStatus.PropertyChanged -= this.DownloadStatus_PropertyChanged; + } + + this.ContinueButtonEnabled = false; + this.menuService.CloseMenu(); + this.installationStatus = context.GuildwarsInstallationStatus; + this.cancellationTokenSource = context.CancellationTokenSource; + this.installationStatus.PropertyChanged += this.DownloadStatus_PropertyChanged; +#pragma warning restore CS8622 // Nullability of reference types in type of parameter doesn't match the target delegate (possibly because of nullability attributes). + } +} diff --git a/GWCA b/GWCA index c97379fa..f5945587 160000 --- a/GWCA +++ b/GWCA @@ -1 +1 @@ -Subproject commit c97379fa7bbf780112cedda33f92bde035e520d1 +Subproject commit f594558708788f06d681a8a3e6c748f50d93db2f