diff --git a/Daybreak/Configuration/Options/LauncherOptions.cs b/Daybreak/Configuration/Options/LauncherOptions.cs index 40b4850f..f7706573 100644 --- a/Daybreak/Configuration/Options/LauncherOptions.cs +++ b/Daybreak/Configuration/Options/LauncherOptions.cs @@ -1,9 +1,7 @@ using Daybreak.Attributes; -using Daybreak.Models; using Daybreak.Views; using Newtonsoft.Json; using System; -using System.Collections.Generic; namespace Daybreak.Configuration.Options; @@ -42,4 +40,8 @@ public sealed class LauncherOptions [JsonProperty(nameof(DownloadIcons))] [OptionName(Name = "Download Icons", Description = "If true, the launcher will download icons that are not found in the local cache")] public bool DownloadIcons { get; set; } = true; + + [JsonProperty(nameof(ModStartupTimeout))] + [OptionName(Name = "Mod Startup Timeout", Description = "Amount of seconds that Daybreak will wait for each mod to start-up before cancelling the tasks")] + public int ModStartupTimeout { get; set; } = 30; } diff --git a/Daybreak/Daybreak.csproj b/Daybreak/Daybreak.csproj index a4a0d6db..a81d2ee6 100644 --- a/Daybreak/Daybreak.csproj +++ b/Daybreak/Daybreak.csproj @@ -13,7 +13,7 @@ preview Daybreak.ico true - 0.9.8.125 + 0.9.8.126 true cfb2a489-db80-448d-a969-80270f314c46 True diff --git a/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs b/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs index 3f7dadbf..f54405c1 100644 --- a/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs +++ b/Daybreak/Services/ApplicationLauncher/ApplicationLauncher.cs @@ -20,6 +20,7 @@ using System.Runtime.InteropServices; using System.Security; using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Windows; using static Daybreak.Utils.NativeMethods; @@ -198,14 +199,57 @@ public void RestartDaybreakAsNormalUser() } }; - var preLaunchActions = this.modsManager.GetMods().Where(m => m.IsEnabled).Select(m => m.OnGuildwarsStarting(process)); - await Task.WhenAll(preLaunchActions); + foreach(var mod in mods) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(this.launcherOptions.Value.ModStartupTimeout)); + try + { + await mod.OnGuildwarsStarting(process, cts.Token); + } + catch (TaskCanceledException) + { + this.logger.LogError($"{mod.Name} timeout"); + this.notificationService.NotifyError( + title: $"{mod.Name} timeout", + description: $"Mod timed out while processing {nameof(mod.OnGuildwarsStarting)}"); + } + catch (Exception e) + { + this.KillGuildWarsProcess(process); + this.logger.LogError(e, $"{mod.Name} unhandled exception"); + this.notificationService.NotifyError( + title: $"{mod.Name} exception", + description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildwarsStarting)}"); + return default; + } + } + var pId = LaunchClient(executable, string.Join(" ", args), this.privilegeManager.AdminPrivileges, out var clientHandle); process = Process.GetProcessById(pId); - foreach(var mod in mods) + foreach (var mod in mods) { - await mod.OnGuildWarsCreated(process); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(this.launcherOptions.Value.ModStartupTimeout)); + try + { + await mod.OnGuildWarsCreated(process, cts.Token); + } + catch (TaskCanceledException) + { + this.logger.LogError($"{mod.Name} timeout"); + this.notificationService.NotifyError( + title: $"{mod.Name} timeout", + description: $"Mod timed out while processing {nameof(mod.OnGuildWarsCreated)}"); + } + catch (Exception e) + { + this.KillGuildWarsProcess(process); + this.logger.LogError(e, $"{mod.Name} unhandled exception"); + this.notificationService.NotifyError( + title: $"{mod.Name} exception", + description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildWarsCreated)}"); + return default; + } } if (clientHandle != IntPtr.Zero) @@ -219,7 +263,27 @@ public void RestartDaybreakAsNormalUser() */ foreach (var mod in mods) { - await mod.OnGuildwarsStarted(process); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(this.launcherOptions.Value.ModStartupTimeout)); + try + { + await mod.OnGuildwarsStarted(process, cts.Token); + } + catch (TaskCanceledException) + { + this.logger.LogError($"{mod.Name} timeout"); + this.notificationService.NotifyError( + title: $"{mod.Name} timeout", + description: $"Mod timed out while processing {nameof(mod.OnGuildwarsStarted)}"); + } + catch (Exception e) + { + this.KillGuildWarsProcess(process); + this.logger.LogError(e, $"{mod.Name} unhandled exception"); + this.notificationService.NotifyError( + title: $"{mod.Name} exception", + description: $"Mod encountered exception of type {e.GetType().Name} while processing {nameof(mod.OnGuildwarsStarted)}"); + return default; + } } var retries = 0; @@ -284,6 +348,34 @@ public IEnumerable GetGuildwarsProcesses() return Process.GetProcessesByName(ProcessName); } + public void KillGuildWarsProcess(Process process) + { + process.ThrowIfNull(); + try + { + if (process.StartInfo is not null && + process.StartInfo.FileName.Contains("Gw.exe", StringComparison.OrdinalIgnoreCase)) + { + process.Kill(true); + return; + } + + if (process.MainModule?.FileName is not null && + process.MainModule.FileName.Contains("Gw.exe", StringComparison.OrdinalIgnoreCase)) + { + process.Kill(true); + return; + } + } + catch(Exception e) + { + this.logger.LogError(e, $"Failed to kill GuildWars process with id {process?.Id}"); + this.notificationService.NotifyError( + title: "Failed to kill GuildWars process", + description: $"Encountered exception while trying to kill GuildWars process with id {process?.Id}. Check logs for details"); + } + } + private void ClearGwLocks(string path) { this.SetRegistryGuildwarsPath(path); diff --git a/Daybreak/Services/ApplicationLauncher/IApplicationLauncher.cs b/Daybreak/Services/ApplicationLauncher/IApplicationLauncher.cs index c35b5c81..347565a3 100644 --- a/Daybreak/Services/ApplicationLauncher/IApplicationLauncher.cs +++ b/Daybreak/Services/ApplicationLauncher/IApplicationLauncher.cs @@ -10,6 +10,7 @@ public interface IApplicationLauncher GuildWarsApplicationLaunchContext? GetGuildwarsProcess(LaunchConfigurationWithCredentials launchConfigurationWithCredentials); IEnumerable GetGuildwarsProcesses(params LaunchConfigurationWithCredentials[] launchConfigurationWithCredentials); IEnumerable GetGuildwarsProcesses(); + void KillGuildWarsProcess(Process process); Task LaunchGuildwars(LaunchConfigurationWithCredentials launchConfigurationWithCredentials); void RestartDaybreak(); void RestartDaybreakAsAdmin(); diff --git a/Daybreak/Services/DSOAL/DSOALService.cs b/Daybreak/Services/DSOAL/DSOALService.cs index c0ee022a..69c27c5c 100644 --- a/Daybreak/Services/DSOAL/DSOALService.cs +++ b/Daybreak/Services/DSOAL/DSOALService.cs @@ -14,6 +14,7 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace Daybreak.Services.DSOAL; @@ -41,6 +42,7 @@ public sealed class DSOALService : IDSOALService private readonly ILiveUpdateableOptions options; private readonly ILogger logger; + public string Name => "DSOAL"; public bool IsEnabled { get => this.options.Value.Enabled; @@ -118,17 +120,17 @@ public IEnumerable GetCustomArguments() } } - public Task OnGuildWarsCreated(Process process) + public Task OnGuildWarsCreated(Process process, CancellationToken cancellationToken) { return Task.CompletedTask; } - public Task OnGuildwarsStarted(Process process) + public Task OnGuildwarsStarted(Process process, CancellationToken cancellationToken) { return Task.CompletedTask; } - public Task OnGuildwarsStarting(Process process) + public Task OnGuildwarsStarting(Process process, CancellationToken cancellationToken) { var guildwarsDirectory = new FileInfo(process.StartInfo.FileName).Directory!.FullName; if (this.options.Value.Enabled) diff --git a/Daybreak/Services/Mods/IModService.cs b/Daybreak/Services/Mods/IModService.cs index 40674a53..24394ad0 100644 --- a/Daybreak/Services/Mods/IModService.cs +++ b/Daybreak/Services/Mods/IModService.cs @@ -1,11 +1,13 @@ using System.Collections.Generic; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; namespace Daybreak.Services.Mods; public interface IModService { + string Name { get; } bool IsEnabled { get; set; } bool IsInstalled { get; } IEnumerable GetCustomArguments(); @@ -13,15 +15,15 @@ public interface IModService /// Called before starting the guild wars process. /// Do mod preparation here. /// - Task OnGuildwarsStarting(Process process); + Task OnGuildwarsStarting(Process process, CancellationToken cancellationToken); /// /// Called when the process is created in suspended state. /// Do dll injection here. /// - Task OnGuildWarsCreated(Process process); + Task OnGuildWarsCreated(Process process, CancellationToken cancellationToken); /// /// Called after the process has been resumed. /// Do clean-up/integration with the guild wars process here. /// - Task OnGuildwarsStarted(Process process); + Task OnGuildwarsStarted(Process process, CancellationToken cancellationToken); } diff --git a/Daybreak/Services/ReShade/ReShadeService.cs b/Daybreak/Services/ReShade/ReShadeService.cs index d0e6006b..7458be29 100644 --- a/Daybreak/Services/ReShade/ReShadeService.cs +++ b/Daybreak/Services/ReShade/ReShadeService.cs @@ -54,6 +54,7 @@ public sealed class ReShadeService : IReShadeService, IApplicationLifetimeServic private readonly IDownloadService downloadService; private readonly ILogger logger; + public string Name => "ReShade"; public bool IsEnabled { get => this.liveUpdateableOptions.Value.Enabled; @@ -118,30 +119,31 @@ public void OnClosing() public IEnumerable GetCustomArguments() => Enumerable.Empty(); - public Task OnGuildWarsCreated(Process process) + public Task OnGuildWarsCreated(Process process, CancellationToken cancellationToken) { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OnGuildWarsCreated), process?.MainModule?.FileName ?? string.Empty); - if (this.processInjector.Inject(process!, ReShadeDllPath)) + return Task.Run(() => { - scopedLogger.LogInformation("Injected ReShade dll"); - this.notificationService.NotifyInformation( - title: "ReShade started", - description: "ReShade has been injected"); - } - else - { - scopedLogger.LogError("Failed to inject ReShade dll"); - this.notificationService.NotifyError( - title: "ReShade failed to start", - description: "Failed to inject ReShade"); - } - - return Task.CompletedTask; + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.OnGuildWarsCreated), process?.MainModule?.FileName ?? string.Empty); + if (this.processInjector.Inject(process!, ReShadeDllPath)) + { + scopedLogger.LogInformation("Injected ReShade dll"); + this.notificationService.NotifyInformation( + title: "ReShade started", + description: "ReShade has been injected"); + } + else + { + scopedLogger.LogError("Failed to inject ReShade dll"); + this.notificationService.NotifyError( + title: "ReShade failed to start", + description: "Failed to inject ReShade"); + } + }, cancellationToken); } - public Task OnGuildwarsStarted(Process process) => Task.CompletedTask; + public Task OnGuildwarsStarted(Process process, CancellationToken cancellationToken) => Task.CompletedTask; - public Task OnGuildwarsStarting(Process process) + public Task OnGuildwarsStarting(Process process, CancellationToken cancellationToken) { var destinationDirectory = Path.GetFullPath(new FileInfo(process.StartInfo.FileName).DirectoryName!); EnsureFileExistsInGuildwarsDirectory(ReShadeLog, destinationDirectory); diff --git a/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs b/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs index 94746172..88de743d 100644 --- a/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs +++ b/Daybreak/Services/Screens/GuildwarsScreenPlacer.cs @@ -32,6 +32,7 @@ public GuildwarsScreenPlacer( this.logger = logger.ThrowIfNull(); } + public string Name => "Screen placer"; public bool IsEnabled { get => this.liveOptions.Value.SetGuildwarsWindowSizeOnLaunch; @@ -44,7 +45,7 @@ public bool IsEnabled public bool IsInstalled => true; - public async Task OnGuildwarsStarted(Process process) + public async Task OnGuildwarsStarted(Process process, CancellationToken cancellationToken) { var screen = this.screenManager.Screens.Skip(this.liveOptions.Value.DesiredGuildwarsScreen).FirstOrDefault(); if (screen is null) @@ -55,7 +56,7 @@ public async Task OnGuildwarsStarted(Process process) var tries = 0; while (await this.guildwarsMemoryCache.ReadLoginData(CancellationToken.None) is null) { - await Task.Delay(1000); + await Task.Delay(1000, cancellationToken); tries++; if (tries > MaxTries) { @@ -70,7 +71,7 @@ public async Task OnGuildwarsStarted(Process process) public IEnumerable GetCustomArguments() => Enumerable.Empty(); - public Task OnGuildwarsStarting(Process process) => Task.CompletedTask; + public Task OnGuildwarsStarting(Process process, CancellationToken cancellationToken) => Task.CompletedTask; - public Task OnGuildWarsCreated(Process process) => Task.CompletedTask; + public Task OnGuildWarsCreated(Process process, CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/Daybreak/Services/Toolbox/ToolboxService.cs b/Daybreak/Services/Toolbox/ToolboxService.cs index 8ffeda35..4993c6b5 100644 --- a/Daybreak/Services/Toolbox/ToolboxService.cs +++ b/Daybreak/Services/Toolbox/ToolboxService.cs @@ -35,6 +35,7 @@ internal sealed class ToolboxService : IToolboxService private readonly ILiveUpdateableOptions toolboxOptions; private readonly ILogger logger; + public string Name => "GWToolbox"; public bool IsEnabled { get => this.toolboxOptions.Value.Enabled; @@ -62,14 +63,14 @@ public ToolboxService( this.logger = logger.ThrowIfNull(); } - public Task OnGuildwarsStarting(Process process) => Task.CompletedTask; + public Task OnGuildwarsStarting(Process process, CancellationToken cancellationToken) => Task.CompletedTask; - public async Task OnGuildWarsCreated(Process process) + public Task OnGuildWarsCreated(Process process, CancellationToken cancellationToken) { - await this.LaunchToolbox(process); + return Task.Run(() => this.LaunchToolbox(process), cancellationToken); } - public Task OnGuildwarsStarted(Process process) => Task.CompletedTask; + public Task OnGuildwarsStarted(Process process, CancellationToken cancellationToken) => Task.CompletedTask; public IEnumerable GetCustomArguments() { @@ -149,7 +150,7 @@ private async Task SetupToolboxDll(ToolboxInstallationStatus toolboxInstal return true; } - private async Task LaunchToolbox(Process process) + private void LaunchToolbox(Process process) { var scopedLogger = this.logger.CreateScopedLogger(nameof(this.LaunchToolbox), string.Empty); if (this.toolboxOptions.Value.Enabled is false) diff --git a/Daybreak/Services/UMod/UModService.cs b/Daybreak/Services/UMod/UModService.cs index 94ea9da6..04b56da9 100644 --- a/Daybreak/Services/UMod/UModService.cs +++ b/Daybreak/Services/UMod/UModService.cs @@ -39,6 +39,8 @@ public sealed class UModService : IUModService private readonly ILiveUpdateableOptions uModOptions; private readonly ILogger logger; + public string Name => "uMod"; + public bool IsEnabled { get => this.uModOptions.Value.Enabled; @@ -74,25 +76,48 @@ public IEnumerable GetCustomArguments() return Enumerable.Empty(); } - public async Task OnGuildwarsStarting(Process process) + public Task OnGuildwarsStarting(Process process, CancellationToken cancellationToken) { - this.uModClient.Initialize(CancellationToken.None); + this.uModClient.Initialize(cancellationToken); + return Task.CompletedTask; } - public Task OnGuildWarsCreated(Process process) + public Task OnGuildWarsCreated(Process process, CancellationToken cancellationToken) { - this.processInjector.Inject(process, Path.Combine(Path.GetFullPath(UModDirectory), D3D9Dll)); - return Task.CompletedTask; + return Task.Run(() => + { + this.processInjector.Inject(process, Path.Combine(Path.GetFullPath(UModDirectory), D3D9Dll)); + }, cancellationToken); } - public async Task OnGuildwarsStarted(Process process) + public async Task OnGuildwarsStarted(Process process, CancellationToken cancellationToken) { foreach(var entry in this.uModOptions.Value.Mods.Where(e => e.Enabled && e.PathToFile is not null)) { - await this.uModClient.AddFile(entry.PathToFile!, CancellationToken.None); + await this.uModClient.AddFile(entry.PathToFile!, cancellationToken); + } + + try + { + await this.uModClient.Send(cancellationToken); + } + catch(IOException e) + { + if (!e.Message.Contains("Pipe is broken", StringComparison.OrdinalIgnoreCase)) + { + throw; + } + + /* + * Known issue where Guild Wars updater breaks the executable, which in turn breaks the integration with uMod. + * Prompt the user to manually reinstall Guild Wars. + */ + this.notificationService.NotifyInformation( + title: "uMod failed to start", + description: "uMod failed to start due to a known issue with Guild Wars updating process. Please manually re-install Guild Wars in order to restore uMod functionality"); + return; } - await this.uModClient.Send(CancellationToken.None); this.uModClient.CloseConnection(); this.notificationService.NotifyInformation( title: "uMod started",