From c81a41519d05fb426115efe60d356a2e9d451226 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 14 Oct 2024 20:49:55 +0100 Subject: [PATCH 01/16] Added: Throw if executable is already in use. --- .../NexusMods.Abstractions.Games/IGame.cs | 6 +++ .../Exceptions/ExecutableInUseException.cs | 15 ++++++ .../ISynchronizerService.cs | 2 + .../LeftMenu/Items/ApplyControlViewModel.cs | 8 ++++ .../Synchronizer/SynchronizerService.cs | 46 ++++++++++++++++++- 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts/Exceptions/ExecutableInUseException.cs diff --git a/src/Abstractions/NexusMods.Abstractions.Games/IGame.cs b/src/Abstractions/NexusMods.Abstractions.Games/IGame.cs index a7ec4d5c1b..418bcbfe13 100644 --- a/src/Abstractions/NexusMods.Abstractions.Games/IGame.cs +++ b/src/Abstractions/NexusMods.Abstractions.Games/IGame.cs @@ -44,4 +44,10 @@ public interface IGame : ILocatableGame /// also marks the installation was sourced from the given . /// public GameInstallation InstallationFromLocatorResult(GameLocatorResult metadata, EntityId dbId, IGameLocator locator); + + /// + /// Returns the primary (executable) file for the game. + /// + /// The store used for the game. + public GamePath GetPrimaryFile(GameStore store); } diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts/Exceptions/ExecutableInUseException.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts/Exceptions/ExecutableInUseException.cs new file mode 100644 index 0000000000..232e749af3 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts/Exceptions/ExecutableInUseException.cs @@ -0,0 +1,15 @@ +using System.Runtime.Serialization; +namespace NexusMods.Abstractions.Loadouts.Exceptions; + +/// +/// Exception thrown when an executable is in use +/// +public class ExecutableInUseException : Exception +{ + /// + public ExecutableInUseException() { } + /// + public ExecutableInUseException(string? message) : base(message) { } + /// + public ExecutableInUseException(string? message, Exception? innerException) : base(message, innerException) { } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts/ISynchronizerService.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts/ISynchronizerService.cs index f34d894d15..fffadecba1 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts/ISynchronizerService.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts/ISynchronizerService.cs @@ -1,4 +1,5 @@ using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Loadouts.Exceptions; using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Synchronizers; @@ -13,6 +14,7 @@ public interface ISynchronizerService /// Synchronize the loadout with the game folder, any changes in the game folder will be added to the loadout, and any /// new changes in the loadout will be applied to the game folder. /// + /// Thrown if the game EXE is in use, meaning that it's running. public Task Synchronize(LoadoutId loadout); /// diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index 63d6531bb7..bc10b4873d 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -9,6 +9,7 @@ using NexusMods.App.UI.Windows; using NexusMods.App.UI.WorkspaceSystem; using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -86,6 +87,13 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid } ); + + // We should prevent Apply from being available while a file is in use. + // A file may be in use because: + // - The user launched the game externally (e.g. through Steam). + // - Approximate this by seeing if any EXE in any of the game folders are running. + // - They're running a tool from within the App. + // - Check running jobs. } private async Task Apply() diff --git a/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs b/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs index 4ae84b9434..27e28d9928 100644 --- a/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs +++ b/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs @@ -3,9 +3,11 @@ using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.Loadouts.Exceptions; using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Synchronizers; using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; using ReactiveUI; namespace NexusMods.DataModel.Synchronizer; @@ -40,13 +42,13 @@ public FileDiffTree GetApplyDiffTree(LoadoutId loadoutId) var metaData = GameInstallMetadata.Load(_conn.Db, loadout.InstallationInstance.GameMetadataId); var diskState = metaData.DiskStateAsOf(metaData.LastScannedDiskStateTransaction); return synchronizer.LoadoutToDiskDiff(loadout, diskState); - } /// public async Task Synchronize(LoadoutId loadoutId) { var loadout = Loadout.Load(_conn.Db, loadoutId); + ThrowIfMainBinaryInUse(loadout); var loadoutState = GetOrAddLoadoutState(loadoutId); using var _ = loadoutState.WithLock(); @@ -203,4 +205,46 @@ private IObservable CreateStatusObservable(LoadoutId l return statusObservable; } + + private void ThrowIfMainBinaryInUse(Loadout.ReadOnly loadout) + { + // Note(sewer): + // Problem: Game may already be running. + // Edge Cases: - User may have multiple copies of a given game running. + // - Only on Windows. + // Solution: Check if EXE (primaryfile) is in use. + // Note: This doesn't account for CLI calls. I think that's fine; an external CLI user/caller + var game = loadout.InstallationInstance.GetGame() as AGame; + var primaryFile = game!.GetPrimaryFile(loadout.InstallationInstance.Store) + .Combine(loadout.InstallationInstance.LocationsRegister[LocationId.Game]); + if (IsFileInUse(primaryFile)) + throw new ExecutableInUseException("Game's main executable file is in use." + + "This is an indicator the game may have been started outside of the App; and therefore files may be in use."); + return; + + static bool IsFileInUse(AbsolutePath filePath) + { + if (!FileSystem.Shared.OS.IsWindows) + return false; + + if (!filePath.FileExists) + return false; + + try + { + using var fs = filePath.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None); + return false; + } + catch (IOException) + { + // The file is in use by another process + return true; + } + catch (UnauthorizedAccessException) + { + // The file is in use or you don't have permission + return true; + } + } + } } From e8053ab67296a1fa9d403bbbe7efec2fc7fec0d1 Mon Sep 17 00:00:00 2001 From: Sewer Date: Wed, 23 Oct 2024 11:27:06 +0100 Subject: [PATCH 02/16] Improved: Migrate Tools to Jobs --- .../NexusMods.Abstractions.Games/RunGameTool.cs | 15 ++++++++++++++- .../NexusMods.Abstractions.Loadouts/ITool.cs | 12 +++++++++--- .../IToolManager.cs | 5 ++++- .../NexusMods.Abstractions.Loadouts.csproj | 3 ++- .../Cyberpunk2077/Cyberpunk2077Synchronizer.cs | 1 - .../NexusMods.Games.RedEngine/RedModDeployTool.cs | 11 +++++++++++ .../LeftMenu/Items/ApplyControlViewModel.cs | 8 +++++--- .../LeftMenu/Items/LaunchButtonViewModel.cs | 7 ++++--- .../LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs | 4 +++- src/NexusMods.DataModel/ToolManager.cs | 11 ++++------- .../ListFilesTool.cs | 12 ++++++++++-- 11 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs b/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs index 34a1b57ebc..8ca435f9c9 100644 --- a/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs +++ b/src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs @@ -1,15 +1,18 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.CompilerServices; using CliWrap; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games.Stores.GOG; using NexusMods.Abstractions.Games.Stores.Steam; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.NexusWebApi.Types.V2; using NexusMods.CrossPlatform.Process; using NexusMods.Paths; +using R3; namespace NexusMods.Abstractions.Games; @@ -53,7 +56,7 @@ public RunGameTool(IServiceProvider serviceProvider, T game) /// public string Name => $"Run {_game.Name}"; - /// + /// public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken) { _logger.LogInformation("Starting {Name}", Name); @@ -249,4 +252,14 @@ protected virtual ValueTask GetGamePath(Loadout.ReadOnly loadout) return ValueTask.FromResult(_game.GetPrimaryFile(loadout.InstallationInstance.Store) .Combine(loadout.InstallationInstance.LocationsRegister[LocationId.Game])); } + + /// + public IJobTask StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken) + { + return monitor.Begin(this, async _ => + { + await Execute(loadout, cancellationToken); + return Unit.Default; + }); + } } diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts/ITool.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts/ITool.cs index 18899ac774..12a33883b9 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts/ITool.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts/ITool.cs @@ -1,5 +1,8 @@ +using System.Runtime.CompilerServices; using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.NexusWebApi.Types.V2; +using R3; namespace NexusMods.Abstractions.Loadouts; @@ -7,7 +10,7 @@ namespace NexusMods.Abstractions.Loadouts; /// Specifies a tool that is run outside of the app. Could be the game itself, /// a file generator, some sort of editor, patcher, etc. /// -public interface ITool +public interface ITool : IJobDefinition { /// /// List of supported game IDs. @@ -20,7 +23,10 @@ public interface ITool public string Name { get; } /// - /// Executes this tool against the given loadout. + /// Executes this tool against the given loadout using the . /// - public Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken); + /// The loadout to run the game with. + /// The monitor to which the task should be queued. + /// Allows you to prematurely cancel the task. + public IJobTask StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken); } diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts/IToolManager.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts/IToolManager.cs index b9f2d00c69..ead7532dc7 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts/IToolManager.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts/IToolManager.cs @@ -1,3 +1,5 @@ +using NexusMods.Abstractions.Jobs; + namespace NexusMods.Abstractions.Loadouts; /// @@ -19,8 +21,9 @@ public interface IToolManager /// /// /// + /// The job system executor. /// /// - public Task RunTool(ITool tool, Loadout.ReadOnly loadout, + public Task RunTool(ITool tool, Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken token = default); } diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts/NexusMods.Abstractions.Loadouts.csproj b/src/Abstractions/NexusMods.Abstractions.Loadouts/NexusMods.Abstractions.Loadouts.csproj index 024b083c4c..daeb09c1f5 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts/NexusMods.Abstractions.Loadouts.csproj +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts/NexusMods.Abstractions.Loadouts.csproj @@ -1,4 +1,4 @@ - + @@ -6,6 +6,7 @@ + diff --git a/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs b/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs index 30145ef8e5..ba1046349c 100644 --- a/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs +++ b/src/Games/NexusMods.Games.RedEngine/Cyberpunk2077/Cyberpunk2077Synchronizer.cs @@ -70,7 +70,6 @@ public override bool IsIgnoredPath(GamePath path) await _redModTool.Execute(loadout, CancellationToken.None); return await base.Synchronize(loadout); - } diff --git a/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs b/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs index 112e983396..4a00986c7f 100644 --- a/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs +++ b/src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs @@ -3,10 +3,12 @@ using Microsoft.Extensions.Logging; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games.Stores.Steam; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.NexusWebApi.Types.V2; using NexusMods.Games.Generic; using NexusMods.Paths; +using R3; using static NexusMods.Games.RedEngine.Constants; namespace NexusMods.Games.RedEngine; @@ -60,6 +62,15 @@ public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellati public string Name => "RedMod Deploy"; + public IJobTask StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken) + { + return monitor.Begin(this, async _ => + { + await Execute(loadout, cancellationToken); + return Unit.Default; + }); + } + private async Task ExtractTemporaryDeployScript() { var assembly = Assembly.GetExecutingAssembly(); diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index bc10b4873d..ec33186e27 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -1,7 +1,8 @@ -using System.Reactive; +using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.App.UI.Controls.Navigation; using NexusMods.App.UI.Pages.Diff.ApplyDiff; @@ -9,7 +10,6 @@ using NexusMods.App.UI.Windows; using NexusMods.App.UI.WorkspaceSystem; using NexusMods.MnemonicDB.Abstractions; -using NexusMods.Paths; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -19,6 +19,7 @@ public class ApplyControlViewModel : AViewModel, IApplyC { private readonly IConnection _conn; private readonly ISynchronizerService _syncService; + private readonly IJobMonitor _jobMonitor; private readonly LoadoutId _loadoutId; private readonly GameInstallMetadataId _gameMetadataId; @@ -32,11 +33,12 @@ public class ApplyControlViewModel : AViewModel, IApplyC public ILaunchButtonViewModel LaunchButtonViewModel { get; } - public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider) + public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider, IJobMonitor jobMonitor) { _loadoutId = loadoutId; _syncService = serviceProvider.GetRequiredService(); _conn = serviceProvider.GetRequiredService(); + _jobMonitor = serviceProvider.GetRequiredService(); var windowManager = serviceProvider.GetRequiredService(); _gameMetadataId = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, loadoutId).InstallationId; diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index a4e415192e..945be797a1 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -1,6 +1,5 @@ using System.Reactive; using System.Reactive.Linq; -using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; @@ -23,11 +22,13 @@ public class LaunchButtonViewModel : AViewModel, ILaunch private readonly IToolManager _toolManager; private readonly IConnection _conn; + private readonly IJobMonitor _monitor; - public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn) + public LaunchButtonViewModel(IToolManager toolManager, IConnection conn, IJobMonitor monitor) { _toolManager = toolManager; _conn = conn; + _monitor = monitor; Command = ReactiveCommand.CreateFromObservable(() => Observable.StartAsync(LaunchGame, RxApp.TaskpoolScheduler)); } @@ -39,7 +40,7 @@ private async Task LaunchGame(CancellationToken token) var tool = _toolManager.GetTools(marker).OfType().First(); await Task.Run(async () => { - await _toolManager.RunTool(tool, marker, token: token); + await _toolManager.RunTool(tool, marker, _monitor, token: token); }, token); Label = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; } diff --git a/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs index 47f37edb9f..a9773bcf00 100644 --- a/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs @@ -5,6 +5,7 @@ using DynamicData.Kernel; using Microsoft.Extensions.DependencyInjection; using NexusMods.Abstractions.Diagnostics; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.App.UI.Controls.Navigation; using NexusMods.App.UI.LeftMenu.Items; @@ -42,9 +43,10 @@ public LoadoutLeftMenuViewModel( { var diagnosticManager = serviceProvider.GetRequiredService(); var conn = serviceProvider.GetRequiredService(); + var monitor = serviceProvider.GetRequiredService(); WorkspaceId = workspaceId; - ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider); + ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider, monitor); var installedModsItem = new IconViewModel diff --git a/src/NexusMods.DataModel/ToolManager.cs b/src/NexusMods.DataModel/ToolManager.cs index 5039daeacd..8daef7682c 100644 --- a/src/NexusMods.DataModel/ToolManager.cs +++ b/src/NexusMods.DataModel/ToolManager.cs @@ -1,8 +1,7 @@ using Microsoft.Extensions.Logging; -using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.NexusWebApi.Types.V2; -using NexusMods.MnemonicDB.Abstractions; namespace NexusMods.DataModel; @@ -14,19 +13,16 @@ public class ToolManager : IToolManager private readonly ILookup _tools; private readonly ILogger _logger; private readonly ISynchronizerService _syncService; - private readonly IConnection _conn; - /// /// DI Constructor /// - public ToolManager(ILogger logger, IEnumerable tools, ISynchronizerService syncService, IConnection conn) + public ToolManager(ILogger logger, IEnumerable tools, ISynchronizerService syncService) { _logger = logger; _tools = tools.SelectMany(tool => tool.GameIds.Select(gameId => (gameId, tool))) .ToLookup(t => t.gameId, t => t.tool); _syncService = syncService; - _conn = conn; } /// @@ -39,6 +35,7 @@ public IEnumerable GetTools(Loadout.ReadOnly loadout) public async Task RunTool( ITool tool, Loadout.ReadOnly loadout, + IJobMonitor monitor, CancellationToken token = default) { if (!tool.GameIds.Contains(loadout.InstallationInstance.Game.GameId)) @@ -51,7 +48,7 @@ public IEnumerable GetTools(Loadout.ReadOnly loadout) _logger.LogInformation("Running tool {ToolName} for loadout {LoadoutId} on {GameName} {GameVersion}", tool.Name, appliedLoadout.Id, appliedLoadout.InstallationInstance.Game.Name, appliedLoadout.InstallationInstance.Version); - await tool.Execute(appliedLoadout, token); + await tool.StartJob(appliedLoadout, monitor, token); _logger.LogInformation("Ingesting loadout {LoadoutId} from {GameName} {GameVersion}", appliedLoadout.Id, appliedLoadout.InstallationInstance.Game.Name, appliedLoadout.InstallationInstance.Version); diff --git a/tests/NexusMods.StandardGameLocators.TestHelpers/ListFilesTool.cs b/tests/NexusMods.StandardGameLocators.TestHelpers/ListFilesTool.cs index 5c4ca14d89..cd431d76af 100644 --- a/tests/NexusMods.StandardGameLocators.TestHelpers/ListFilesTool.cs +++ b/tests/NexusMods.StandardGameLocators.TestHelpers/ListFilesTool.cs @@ -1,8 +1,8 @@ using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.NexusWebApi.Types.V2; -using NexusMods.MnemonicDB.Abstractions; +using R3; namespace NexusMods.StandardGameLocators.TestHelpers; @@ -20,6 +20,14 @@ public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellati await outPath.WriteAllLinesAsync(lines, cancellationToken); } + public IJobTask StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken) + { + return monitor.Begin(this, async _ => + { + await Execute(loadout, cancellationToken); + return Unit.Default; + }); + } public IEnumerable GameIds => [ GameId.From(uint.MaxValue) ]; From 7b225dacfe568a4be56ad1ecd81dc18f972edd4f Mon Sep 17 00:00:00 2001 From: Sewer Date: Thu, 24 Oct 2024 10:16:05 +0100 Subject: [PATCH 03/16] Added: Disable 'Apply' if the game is running. --- .../LeftMenu/Items/ApplyControlViewModel.cs | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index ec33186e27..ec13704ddf 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -1,7 +1,10 @@ using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData; +using DynamicData.Aggregation; using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.App.UI.Controls.Navigation; @@ -73,29 +76,33 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid .Switch(); var gameStatuses = _syncService.StatusForGame(_gameMetadataId); - - Observable.CombineLatest(loadoutStatuses, gameStatuses, (loadout, game) => (loadout, game)) + var isGameRunning = jobMonitor.ObserveActiveJobs() + .Count() + .Select(x => x > 0) + .StartWith(false); // fire an initial value because CombineLatest requires all stuff to have latest values. + + // We should prevent Apply from being available while a file is in use. + // A file may be in use because: + // - The user launched the game externally (e.g. through Steam). + // - Approximate this by seeing if any EXE in any of the game folders are running. + // - This is done in 'Synchronize' method. + // - They're running a tool from within the App. + // - Check running jobs. + loadoutStatuses.CombineLatest(gameStatuses, isGameRunning, (loadout, game, running) => (loadout, game, running)) .OnUI() .Subscribe(status => { - var (ldStatus, gameStatus) = status; - - CanApply = gameStatus != GameSynchronizerState.Busy - && ldStatus != LoadoutSynchronizerState.Pending - && ldStatus != LoadoutSynchronizerState.Current; - IsLaunchButtonEnabled = ldStatus == LoadoutSynchronizerState.Current && gameStatus != GameSynchronizerState.Busy; + var (ldStatus, gameStatus, running) = status; + + CanApply = gameStatus != GameSynchronizerState.Busy + && ldStatus != LoadoutSynchronizerState.Pending + && ldStatus != LoadoutSynchronizerState.Current + && !running; + IsLaunchButtonEnabled = ldStatus == LoadoutSynchronizerState.Current && gameStatus != GameSynchronizerState.Busy && !running; }) .DisposeWith(disposables); - } ); - - // We should prevent Apply from being available while a file is in use. - // A file may be in use because: - // - The user launched the game externally (e.g. through Steam). - // - Approximate this by seeing if any EXE in any of the game folders are running. - // - They're running a tool from within the App. - // - Check running jobs. } private async Task Apply() From 06f44932423d2474e8767f263639d6be3b24b49c Mon Sep 17 00:00:00 2001 From: Sewer Date: Thu, 24 Oct 2024 10:36:26 +0100 Subject: [PATCH 04/16] Improved: Stop launch 'text' from appearing if error happens while running game. --- .../LeftMenu/Items/LaunchButtonViewModel.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index 945be797a1..bbfbb52d95 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -1,5 +1,6 @@ using System.Reactive; using System.Reactive.Linq; +using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; @@ -20,12 +21,14 @@ public class LaunchButtonViewModel : AViewModel, ILaunch [Reactive] public Percent? Progress { get; set; } + private readonly ILogger _logger; private readonly IToolManager _toolManager; private readonly IConnection _conn; private readonly IJobMonitor _monitor; - public LaunchButtonViewModel(IToolManager toolManager, IConnection conn, IJobMonitor monitor) + public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor) { + _logger = logger; _toolManager = toolManager; _conn = conn; _monitor = monitor; @@ -36,12 +39,19 @@ public LaunchButtonViewModel(IToolManager toolManager, IConnection conn, IJobMon private async Task LaunchGame(CancellationToken token) { Label = Language.LaunchButtonViewModel_LaunchGame_RUNNING; - var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, LoadoutId); - var tool = _toolManager.GetTools(marker).OfType().First(); - await Task.Run(async () => + try { - await _toolManager.RunTool(tool, marker, _monitor, token: token); - }, token); + var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, LoadoutId); + var tool = _toolManager.GetTools(marker).OfType().First(); + await Task.Run(async () => + { + await _toolManager.RunTool(tool, marker, _monitor, token: token); + }, token); + } + catch (Exception ex) + { + _logger.LogError($"Error launching game: {ex.Message}\n{ex.StackTrace}"); + } Label = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; } } From b8c404fd2e133a78171c307438577d85451ea438 Mon Sep 17 00:00:00 2001 From: Sewer Date: Thu, 24 Oct 2024 10:53:28 +0100 Subject: [PATCH 05/16] Added: Missing newline in exception --- src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs b/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs index 27e28d9928..2131a5a095 100644 --- a/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs +++ b/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs @@ -218,7 +218,7 @@ private void ThrowIfMainBinaryInUse(Loadout.ReadOnly loadout) var primaryFile = game!.GetPrimaryFile(loadout.InstallationInstance.Store) .Combine(loadout.InstallationInstance.LocationsRegister[LocationId.Game]); if (IsFileInUse(primaryFile)) - throw new ExecutableInUseException("Game's main executable file is in use." + + throw new ExecutableInUseException("Game's main executable file is in use.\n" + "This is an indicator the game may have been started outside of the App; and therefore files may be in use."); return; From e88043e14ac0a494b0c653977da8a492706f9e6b Mon Sep 17 00:00:00 2001 From: Sewer Date: Thu, 24 Oct 2024 13:27:31 +0100 Subject: [PATCH 06/16] Improve: Show Modal if Game is Already Managed on Apply and Launch in-launcher. --- .../LeftMenu/Items/ApplyControlViewModel.cs | 20 ++++++++-- .../LeftMenu/Items/LaunchButtonViewModel.cs | 11 +++++- .../Loadout/LoadoutLeftMenuViewModel.cs | 4 +- .../MessageBox/Ok/IMessageBoxOkViewModel.cs | 2 + .../MessageBox/Ok/MessageBoxOkView.axaml.cs | 39 +++++-------------- .../MessageBox/Ok/MessageBoxOkViewModel.cs | 20 ++++++++++ .../Resources/Language.Designer.cs | 20 ++++++++++ src/NexusMods.App.UI/Resources/Language.resx | 8 ++++ src/NexusMods.App.UI/Services.cs | 3 ++ 9 files changed, 92 insertions(+), 35 deletions(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index ec13704ddf..bb2d334bf3 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -7,7 +7,10 @@ using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.Loadouts.Exceptions; using NexusMods.App.UI.Controls.Navigation; +using NexusMods.App.UI.Overlays; +using NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; using NexusMods.App.UI.Pages.Diff.ApplyDiff; using NexusMods.App.UI.Resources; using NexusMods.App.UI.Windows; @@ -25,6 +28,7 @@ public class ApplyControlViewModel : AViewModel, IApplyC private readonly IJobMonitor _jobMonitor; private readonly LoadoutId _loadoutId; + private readonly IOverlayController _overlayController; private readonly GameInstallMetadataId _gameMetadataId; [Reactive] private bool CanApply { get; set; } = true; @@ -36,9 +40,10 @@ public class ApplyControlViewModel : AViewModel, IApplyC public ILaunchButtonViewModel LaunchButtonViewModel { get; } - public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider, IJobMonitor jobMonitor) + public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider, IJobMonitor jobMonitor, IOverlayController overlayController) { _loadoutId = loadoutId; + _overlayController = overlayController; _syncService = serviceProvider.GetRequiredService(); _conn = serviceProvider.GetRequiredService(); _jobMonitor = serviceProvider.GetRequiredService(); @@ -107,9 +112,16 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid private async Task Apply() { - await Task.Run(async () => + try { - await _syncService.Synchronize(_loadoutId); - }); + await Task.Run(async () => + { + await _syncService.Synchronize(_loadoutId); + }); + } + catch (ExecutableInUseException) + { + await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController); + } } } diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index bbfbb52d95..c37b4d87ee 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -4,6 +4,9 @@ using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.Loadouts.Exceptions; +using NexusMods.App.UI.Overlays; +using NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; using NexusMods.App.UI.Resources; using NexusMods.MnemonicDB.Abstractions; using ReactiveUI; @@ -25,13 +28,15 @@ public class LaunchButtonViewModel : AViewModel, ILaunch private readonly IToolManager _toolManager; private readonly IConnection _conn; private readonly IJobMonitor _monitor; + private readonly IOverlayController _overlayController; - public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor) + public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor, IOverlayController overlayController) { _logger = logger; _toolManager = toolManager; _conn = conn; _monitor = monitor; + _overlayController = overlayController; Command = ReactiveCommand.CreateFromObservable(() => Observable.StartAsync(LaunchGame, RxApp.TaskpoolScheduler)); } @@ -48,6 +53,10 @@ await Task.Run(async () => await _toolManager.RunTool(tool, marker, _monitor, token: token); }, token); } + catch (ExecutableInUseException) + { + await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController); + } catch (Exception ex) { _logger.LogError($"Error launching game: {ex.Message}\n{ex.StackTrace}"); diff --git a/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs index a9773bcf00..e4c9ae5eaa 100644 --- a/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs @@ -9,6 +9,7 @@ using NexusMods.Abstractions.Loadouts; using NexusMods.App.UI.Controls.Navigation; using NexusMods.App.UI.LeftMenu.Items; +using NexusMods.App.UI.Overlays; using NexusMods.App.UI.Pages.Diagnostics; using NexusMods.App.UI.Pages.LibraryPage; using NexusMods.App.UI.Pages.LoadoutPage; @@ -44,9 +45,10 @@ public LoadoutLeftMenuViewModel( var diagnosticManager = serviceProvider.GetRequiredService(); var conn = serviceProvider.GetRequiredService(); var monitor = serviceProvider.GetRequiredService(); + var overlayController = serviceProvider.GetRequiredService(); WorkspaceId = workspaceId; - ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider, monitor); + ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider, monitor, overlayController); var installedModsItem = new IconViewModel diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs index 288806a896..d1279bc15c 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs @@ -6,4 +6,6 @@ namespace NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; /// public interface IMessageBoxOkViewModel : IOverlayViewModel { + public string Title { get; set; } + public string Description { get; set; } } diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs index 70b0004f89..20ff042144 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs @@ -1,6 +1,5 @@ -using Avalonia; +using System.Reactive.Disposables; using Avalonia.ReactiveUI; -using NexusMods.App.UI.Resources; using R3; using ReactiveUI; using ReactiveCommand = ReactiveUI.ReactiveCommand; @@ -9,39 +8,21 @@ namespace NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; public partial class MessageBoxOkView : ReactiveUserControl { - // ReSharper disable once MemberCanBePrivate.Global - public static readonly StyledProperty TitleProperty = - AvaloniaProperty.Register(nameof(Title), Language.CancelDownloadOverlayView_Title); - - // ReSharper disable once MemberCanBePrivate.Global - public static readonly StyledProperty DescriptionProperty = - AvaloniaProperty.Register(nameof(Description), "This is some very long text that spans multiple lines!! This text is super cool!!"); - - public string Title - { - get => GetValue(TitleProperty); - set => SetValue(TitleProperty, value); - } - - public string Description - { - get => GetValue(DescriptionProperty); - set => SetValue(DescriptionProperty, value); - } - public MessageBoxOkView() { InitializeComponent(); // Bind the View's properties to the UI elements - this.WhenAnyValue(x => x.Title) - .BindTo(this, x => x.HeadingText.Text); - - this.WhenAnyValue(x => x.Description) - .BindTo(this, x => x.MessageTextBlock.Text); - - this.WhenActivated(_ => + this.WhenActivated(disposables => { + // One-way binding from ViewModel to UI + this.OneWayBind(ViewModel, vm => vm.Title, v => v.HeadingText.Text) + .DisposeWith(disposables); + + this.OneWayBind(ViewModel, vm => vm.Description, v => v.MessageTextBlock.Text) + .DisposeWith(disposables); + + // Bind commands OkButton.Command = ReactiveCommand.Create(() => { ViewModel!.Complete(Unit.Default); diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs index 9312a2fb74..69ff19e0b6 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs @@ -1,6 +1,26 @@ +using NexusMods.App.UI.Resources; using R3; +using ReactiveUI.Fody.Helpers; + namespace NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; public class MessageBoxOkViewModel : AOverlayViewModel, IMessageBoxOkViewModel { + [Reactive] public string Title { get; set; } = Language.CancelDownloadOverlayView_Title; + + [Reactive] + public string Description { get; set; } = "This is some very long design only text that spans multiple lines!! This text is super cool!!"; + + /// + /// Shows the 'Game is already Running' error when you try to synchronize and a game is already running (usually on Windows). + /// + public static async Task ShowGameAlreadyRunningError(IOverlayController overlayController) + { + var viewModel = new MessageBoxOkViewModel() + { + Title = Language.ErrorGameAlreadyRunning_Title, + Description = Language.ErrorGameAlreadyRunning_Description, + }; + await overlayController.EnqueueAndWait(viewModel); + } } diff --git a/src/NexusMods.App.UI/Resources/Language.Designer.cs b/src/NexusMods.App.UI/Resources/Language.Designer.cs index 651af2e03f..390c8e8219 100644 --- a/src/NexusMods.App.UI/Resources/Language.Designer.cs +++ b/src/NexusMods.App.UI/Resources/Language.Designer.cs @@ -689,6 +689,26 @@ public static string EmptyLibraryTitleText { } } + /// + /// Looks up a localized string similar to The game has already been started outside of the App. + /// + ///It is not possible to currently apply a loadout.. + /// + public static string ErrorGameAlreadyRunning_Description { + get { + return ResourceManager.GetString("ErrorGameAlreadyRunning_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Game already running. + /// + public static string ErrorGameAlreadyRunning_Title { + get { + return ResourceManager.GetString("ErrorGameAlreadyRunning_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to The mod you're trying to access has been deleted or is missing.. /// diff --git a/src/NexusMods.App.UI/Resources/Language.resx b/src/NexusMods.App.UI/Resources/Language.resx index 32dfd1ea7a..e6033bf820 100644 --- a/src/NexusMods.App.UI/Resources/Language.resx +++ b/src/NexusMods.App.UI/Resources/Language.resx @@ -681,4 +681,12 @@ Installed Mods + + Game already running + + + The game has already been started outside of the App. + +It is not possible to currently apply a loadout. + diff --git a/src/NexusMods.App.UI/Services.cs b/src/NexusMods.App.UI/Services.cs index 0102274a4b..32ab06573d 100644 --- a/src/NexusMods.App.UI/Services.cs +++ b/src/NexusMods.App.UI/Services.cs @@ -33,6 +33,7 @@ using NexusMods.App.UI.Overlays; using NexusMods.App.UI.Overlays.AlphaWarning; using NexusMods.App.UI.Overlays.Download.Cancel; +using NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; using NexusMods.App.UI.Overlays.Generic.MessageBox.OkCancel; using NexusMods.App.UI.Overlays.LibraryDeleteConfirmation; using NexusMods.App.UI.Overlays.Login; @@ -117,6 +118,7 @@ public static IServiceCollection AddUI(this IServiceCollection c) .AddViewModel() .AddViewModel() .AddViewModel() + .AddViewModel() .AddViewModel() .AddViewModel() .AddViewModel() @@ -150,6 +152,7 @@ public static IServiceCollection AddUI(this IServiceCollection c) .AddView() .AddView() .AddView() + .AddView() .AddView() .AddView() .AddView() From aec5e6c0ed899eb4b1758ec2ec2b8d3c31ac8310 Mon Sep 17 00:00:00 2001 From: Sewer Date: Thu, 24 Oct 2024 13:58:31 +0100 Subject: [PATCH 07/16] Fixed: Start with correct original game state. --- src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index bb2d334bf3..a00a11278d 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -84,7 +84,8 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid var isGameRunning = jobMonitor.ObserveActiveJobs() .Count() .Select(x => x > 0) - .StartWith(false); // fire an initial value because CombineLatest requires all stuff to have latest values. + .StartWith(jobMonitor.Jobs.Any(x => x is { Definition: IRunGameTool, Status: JobStatus.Running })); + // fire an initial value because CombineLatest requires all stuff to have latest values. // We should prevent Apply from being available while a file is in use. // A file may be in use because: From 54473e79020cfe645f5b73e6d0341a2338c1d765 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 28 Oct 2024 17:24:00 +0000 Subject: [PATCH 08/16] Improved: Fix bug where navigating to a 2nd loadout while game is open, then closing does not re-show the 'play' button. --- .../LeftMenu/Items/ApplyControlViewModel.cs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index a00a11278d..c30f41d260 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -81,13 +81,28 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid .Switch(); var gameStatuses = _syncService.StatusForGame(_gameMetadataId); - var isGameRunning = jobMonitor.ObserveActiveJobs() - .Count() - .Select(x => x > 0) - .StartWith(jobMonitor.Jobs.Any(x => x is { Definition: IRunGameTool, Status: JobStatus.Running })); - // fire an initial value because CombineLatest requires all stuff to have latest values. + var isGameRunning = jobMonitor.GetObservableChangeSet() + .TransformOnObservable(job => job.ObservableStatus) + .Select(_ => + { + // TODO: We don't currently remove any old/stale jobs, this could be more efficient - sewer + return jobMonitor.Jobs.Any(x => x is { Definition: IRunGameTool, Status: JobStatus.Running }); + } + ) + .DistinctUntilChanged() + .StartWith(jobMonitor.Jobs.Any(x => x is { Definition: IRunGameTool, Status: JobStatus.Running })); - // We should prevent Apply from being available while a file is in use. + // Note(sewer): + // Fire an initial value with StartWith because CombineLatest requires all stuff to have latest values. + // Note: This observable is a bit complex. We can't just start listening to games closed or + // new games started in isolation because we don't know the initial state here. + // + // For example, assume we listen only to started jobs. If a game is already running and the + // user navigates to another loadout, then the 'isGameRunning' observable will be initialized with + // `true` as initial state. However, because there is no prior state, closing the game will not yield + // `false`. + // + // In any case, we should prevent Apply from being available while a file is in use. // A file may be in use because: // - The user launched the game externally (e.g. through Steam). // - Approximate this by seeing if any EXE in any of the game folders are running. From 4358ab8b4aeafba89586bc98a1648935a69322ab Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 28 Oct 2024 18:21:44 +0000 Subject: [PATCH 09/16] Improved: Manually count number of running game jobs for efficiency --- .../IJobMonitorExtensions.cs | 3 +- .../NexusMods.Abstractions.Jobs/JobStatus.cs | 79 +++++++++++++++++++ .../LeftMenu/Items/ApplyControlViewModel.cs | 31 +++++--- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/Abstractions/NexusMods.Abstractions.Jobs/IJobMonitorExtensions.cs b/src/Abstractions/NexusMods.Abstractions.Jobs/IJobMonitorExtensions.cs index e8012652b9..d16413ea71 100644 --- a/src/Abstractions/NexusMods.Abstractions.Jobs/IJobMonitorExtensions.cs +++ b/src/Abstractions/NexusMods.Abstractions.Jobs/IJobMonitorExtensions.cs @@ -23,8 +23,7 @@ public static IObservable> ObserveActiveJobs(t where TJobType : IJobDefinition { return jobMonitor.GetObservableChangeSet() - .FilterOnObservable(job => job.ObservableStatus - .Select(status => status is JobStatus.Running or JobStatus.Paused)); + .FilterOnObservable(job => job.ObservableStatus.Select(status => status.IsActive())); } diff --git a/src/Abstractions/NexusMods.Abstractions.Jobs/JobStatus.cs b/src/Abstractions/NexusMods.Abstractions.Jobs/JobStatus.cs index 82dec74db4..958e0cc2f2 100644 --- a/src/Abstractions/NexusMods.Abstractions.Jobs/JobStatus.cs +++ b/src/Abstractions/NexusMods.Abstractions.Jobs/JobStatus.cs @@ -1,3 +1,4 @@ +using DynamicData.Kernel; using JetBrains.Annotations; namespace NexusMods.Abstractions.Jobs; @@ -66,3 +67,81 @@ public enum JobStatus : byte /// Failed = 6, } + +/// +/// Extensions for the type. +/// +public static class JobStatusExtensions +{ + /// + /// Determines if a job is "active" based on the current job status. + /// A job is considered "active" if the status is or . + /// + /// The current job status. + /// + /// true if the job is active, false otherwise. + /// + public static bool IsActive(this JobStatus currentStatus) => currentStatus is JobStatus.Running or JobStatus.Paused; + + /// + /// Determines if a job was "activated" based on the previous and current job status. + /// A job is considered "activated" if the status changed to or from any other state. + /// + /// The current job status. + /// The previous job status, or if the job is being created. + /// + /// true if the job was activated, false otherwise. + /// + public static bool WasActivated(this JobStatus currentStatus, Optional previousStatus) + { + // We set to 'none' because the activation queue is setting to `Running` or `Paused`. + return WasActivated(currentStatus, !previousStatus.HasValue ? JobStatus.None : previousStatus.Value); + } + + /// + /// Determines if a job was "deactivated" based on the previous and current job status. + /// A job is considered "deactivated" if the status changed from or to any other state. + /// + /// The current job status. + /// The previous job status, or if the job is being created. + /// + /// true if the job was deactivated, false otherwise. + /// + public static bool WasDeactivated(this JobStatus currentStatus, Optional previousStatus) + { + // We set to 'running' on null because the deactivation queue is setting away from `Running` or `Paused`. + return WasDeactivated(currentStatus, !previousStatus.HasValue ? JobStatus.Running : previousStatus.Value); + } + + /// + /// Determines if a job was "activated" based on the previous and current job status. + /// A job is considered "activated" if the status changed to or from any other state. + /// + /// The current job status. + /// The previous job status, or if the job is being created. + /// + /// true if the job was activated, false otherwise. + /// + public static bool WasActivated(this JobStatus currentStatus, JobStatus previousStatus) + { + var isCurrentStatusActivated = currentStatus is JobStatus.Running or JobStatus.Paused; + var wasPreviousStatusActivated = previousStatus is JobStatus.Running or JobStatus.Paused; + return isCurrentStatusActivated && !wasPreviousStatusActivated; + } + + /// + /// Determines if a job was "deactivated" based on the previous and current job status. + /// A job is considered "deactivated" if the status changed from or to any other state. + /// + /// The current job status. + /// The previous job status, or if the job is being created. + /// + /// true if the job was deactivated, false otherwise. + /// + public static bool WasDeactivated(this JobStatus currentStatus, JobStatus previousStatus) + { + var wasPreviousStatusActivated = previousStatus is JobStatus.Running or JobStatus.Paused; + var isCurrentStatusActivated = currentStatus is JobStatus.Running or JobStatus.Paused; + return wasPreviousStatusActivated && !isCurrentStatusActivated; + } +} diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index c30f41d260..1fc3732378 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -80,28 +80,35 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid var loadoutStatuses = Observable.FromAsync(() => _syncService.StatusForLoadout(_loadoutId)) .Switch(); + // Note(sewer): Yes, this is technically a race condition; however it's infinitely unlikely and + // does not cause fatal issues. + var numRunningJobs = jobMonitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); var gameStatuses = _syncService.StatusForGame(_gameMetadataId); var isGameRunning = jobMonitor.GetObservableChangeSet() .TransformOnObservable(job => job.ObservableStatus) - .Select(_ => + .Select(changes => { - // TODO: We don't currently remove any old/stale jobs, this could be more efficient - sewer - return jobMonitor.Jobs.Any(x => x is { Definition: IRunGameTool, Status: JobStatus.Running }); + // Note(sewer): We don't currently remove any old/stale jobs, so it's inefficient to + // check the whole job set in case the App has been running for a long time. + // Therefore, we instead count and manually maintain a running job count. + foreach (var change in changes) + { + var isActivated = change.Current.WasActivated(change.Previous); + var isDeactivated = change.Current.WasDeactivated(change.Previous); + if (isActivated) + numRunningJobs++; + if (isDeactivated) + numRunningJobs--; + } + + return numRunningJobs > 0; } ) .DistinctUntilChanged() - .StartWith(jobMonitor.Jobs.Any(x => x is { Definition: IRunGameTool, Status: JobStatus.Running })); + .StartWith(numRunningJobs > 0); // Note(sewer): // Fire an initial value with StartWith because CombineLatest requires all stuff to have latest values. - // Note: This observable is a bit complex. We can't just start listening to games closed or - // new games started in isolation because we don't know the initial state here. - // - // For example, assume we listen only to started jobs. If a game is already running and the - // user navigates to another loadout, then the 'isGameRunning' observable will be initialized with - // `true` as initial state. However, because there is no prior state, closing the game will not yield - // `false`. - // // In any case, we should prevent Apply from being available while a file is in use. // A file may be in use because: // - The user launched the game externally (e.g. through Steam). From 6e88d509613369c5590f97b296b7ea89f03e5bf5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 28 Oct 2024 19:12:16 +0000 Subject: [PATCH 10/16] Improved: Moved out game running logic to service, and fix setting button to 'Running' immediately on Loadout switch. --- src/NexusMods.App.UI/GameRunningTracker.cs | 61 +++++++++++++++++++ .../LeftMenu/Items/ApplyControlViewModel.cs | 29 +-------- .../LeftMenu/Items/ILaunchButtonViewModel.cs | 6 ++ .../Items/LaunchButtonDesignViewModel.cs | 3 + .../LeftMenu/Items/LaunchButtonView.axaml.cs | 27 +++----- .../LeftMenu/Items/LaunchButtonViewModel.cs | 5 +- .../Loadout/LoadoutLeftMenuViewModel.cs | 5 +- src/NexusMods.App.UI/Services.cs | 1 + 8 files changed, 87 insertions(+), 50 deletions(-) create mode 100644 src/NexusMods.App.UI/GameRunningTracker.cs diff --git a/src/NexusMods.App.UI/GameRunningTracker.cs b/src/NexusMods.App.UI/GameRunningTracker.cs new file mode 100644 index 0000000000..b6944875aa --- /dev/null +++ b/src/NexusMods.App.UI/GameRunningTracker.cs @@ -0,0 +1,61 @@ +using System.Reactive.Linq; +using DynamicData; +using NexusMods.Abstractions.Games; +using NexusMods.Abstractions.Jobs; +namespace NexusMods.App.UI; + +/// +/// This class helps us efficiently identify whether a game is currently running. +/// +/// +/// There are multiple places in the App where it's necessary to determine if +/// there is already a game running; however the necessary calculation for this +/// can be prohibitively expensive. Therefore we re-use the logic inside this class. +/// +public class GameRunningTracker +{ + /// + /// Allows you to listen to when any game is running via the App. + /// + private readonly IObservable _isGameRunning; + + /// + /// Number of currently running game jobs. + /// + private int _numRunningJobs = 0; + + /// + /// Retrieves the current state of the game running tracker, + /// with the current state being immediately emitted as the first item. + /// + public IObservable GetWithCurrentStateAsStarting() => _isGameRunning.StartWith(_numRunningJobs > 0); + + public GameRunningTracker(IJobMonitor monitor) + { + // Note(sewer): Yes, this technically can lead to a race condition; + // however it's not possible to start a game before activating GameRunningTracker + // singleton for an end user. + _numRunningJobs = monitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); + _isGameRunning = monitor.GetObservableChangeSet() + .TransformOnObservable(job => job.ObservableStatus) + .Select(changes => + { + // Note(sewer): We don't currently remove any old/stale jobs, so it's inefficient to + // check the whole job set in case the App has been running for a long time. + // Therefore, we instead count and manually maintain a running job count. + foreach (var change in changes) + { + var isActivated = change.Current.WasActivated(change.Previous); + var isDeactivated = change.Current.WasDeactivated(change.Previous); + if (isActivated) + _numRunningJobs++; + if (isDeactivated) + _numRunningJobs--; + } + + return _numRunningJobs > 0; + } + ) + .DistinctUntilChanged(); + } +} diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index 1fc3732378..7fa23e900b 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -40,7 +40,7 @@ public class ApplyControlViewModel : AViewModel, IApplyC public ILaunchButtonViewModel LaunchButtonViewModel { get; } - public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider, IJobMonitor jobMonitor, IOverlayController overlayController) + public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider, IJobMonitor jobMonitor, IOverlayController overlayController, GameRunningTracker gameRunningTracker) { _loadoutId = loadoutId; _overlayController = overlayController; @@ -80,33 +80,8 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid var loadoutStatuses = Observable.FromAsync(() => _syncService.StatusForLoadout(_loadoutId)) .Switch(); - // Note(sewer): Yes, this is technically a race condition; however it's infinitely unlikely and - // does not cause fatal issues. - var numRunningJobs = jobMonitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); var gameStatuses = _syncService.StatusForGame(_gameMetadataId); - var isGameRunning = jobMonitor.GetObservableChangeSet() - .TransformOnObservable(job => job.ObservableStatus) - .Select(changes => - { - // Note(sewer): We don't currently remove any old/stale jobs, so it's inefficient to - // check the whole job set in case the App has been running for a long time. - // Therefore, we instead count and manually maintain a running job count. - foreach (var change in changes) - { - var isActivated = change.Current.WasActivated(change.Previous); - var isDeactivated = change.Current.WasDeactivated(change.Previous); - if (isActivated) - numRunningJobs++; - if (isDeactivated) - numRunningJobs--; - } - return numRunningJobs > 0; - } - ) - .DistinctUntilChanged() - .StartWith(numRunningJobs > 0); - // Note(sewer): // Fire an initial value with StartWith because CombineLatest requires all stuff to have latest values. // In any case, we should prevent Apply from being available while a file is in use. @@ -116,7 +91,7 @@ public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvid // - This is done in 'Synchronize' method. // - They're running a tool from within the App. // - Check running jobs. - loadoutStatuses.CombineLatest(gameStatuses, isGameRunning, (loadout, game, running) => (loadout, game, running)) + loadoutStatuses.CombineLatest(gameStatuses, gameRunningTracker.GetWithCurrentStateAsStarting(), (loadout, game, running) => (loadout, game, running)) .OnUI() .Subscribe(status => { diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs index ad5d7f5141..579d6968b0 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs @@ -17,6 +17,12 @@ public interface ILaunchButtonViewModel : ILeftMenuItemViewModel /// public ReactiveCommand Command { get; set; } + /// + /// Returns an observable which signals whether the game is currently running. + /// This signals the initials state immediately upon subscribing. + /// + public IObservable IsRunningObservable { get; } + /// /// Text to display on the button. /// diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs index bb1a79951f..6be6a042ff 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs @@ -1,4 +1,5 @@ using System.Reactive; +using System.Reactive.Subjects; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using ReactiveUI; @@ -14,6 +15,8 @@ public class LaunchButtonDesignViewModel : AViewModel, I [Reactive] public ReactiveCommand Command { get; set; } + public IObservable IsRunningObservable { get; } = new Subject(); + [Reactive] public string Label { get; set; } = "PLAY"; diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs index ff33bdce3e..f40cd6dad5 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs @@ -12,32 +12,19 @@ public LaunchButtonView() InitializeComponent(); this.WhenActivated(d => { - var isRunning = - this.WhenAnyValue(view => view.ViewModel!.Command.CanExecute) - .SelectMany(x => x) - .Select(running => !running); - - this.WhenAnyValue(view => view.ViewModel!.Command.CanExecute) - .SelectMany(x => x) - .BindToUi(this, view => view.LaunchButton.IsEnabled) + var isRunning = ViewModel!.IsRunningObservable; + var isNotRunning = ViewModel!.IsRunningObservable.Select(isRunning => !isRunning); + isNotRunning.BindToUi(this, view => view.LaunchButton.IsVisible) .DisposeWith(d); - this.WhenAnyValue(view => view.ViewModel!.Command) - .BindToUi(this, view => view.LaunchButton.Command) + isNotRunning.BindToUi(this, view => view.LaunchButton.IsEnabled) .DisposeWith(d); - this.WhenAnyValue(view => view.ViewModel!.Command) - .SelectMany(cmd => cmd.IsExecuting) - .CombineLatest(isRunning) - .Select(ex => ex is { First: false, Second: false }) - .BindToUi(this, view => view.LaunchButton.IsVisible) + isRunning.BindToUi(this, view => view.ProgressBarControl.IsVisible) .DisposeWith(d); - + this.WhenAnyValue(view => view.ViewModel!.Command) - .SelectMany(cmd => cmd.IsExecuting) - .CombineLatest(isRunning) - .Select(ex => ex.First || ex.Second) - .BindToUi(this, view => view.ProgressBarControl.IsVisible) + .BindToUi(this, view => view.LaunchButton.Command) .DisposeWith(d); this.WhenAnyValue(view => view.ViewModel!.Progress) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index c37b4d87ee..0303d51e37 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -20,6 +20,8 @@ public class LaunchButtonViewModel : AViewModel, ILaunch [Reactive] public ReactiveCommand Command { get; set; } + public IObservable IsRunningObservable { get; } + [Reactive] public string Label { get; set; } = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; [Reactive] public Percent? Progress { get; set; } @@ -30,7 +32,7 @@ public class LaunchButtonViewModel : AViewModel, ILaunch private readonly IJobMonitor _monitor; private readonly IOverlayController _overlayController; - public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor, IOverlayController overlayController) + public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor, IOverlayController overlayController, GameRunningTracker gameRunningTracker) { _logger = logger; _toolManager = toolManager; @@ -38,6 +40,7 @@ public LaunchButtonViewModel(ILogger logger, IToolManage _monitor = monitor; _overlayController = overlayController; + IsRunningObservable = gameRunningTracker.GetWithCurrentStateAsStarting(); Command = ReactiveCommand.CreateFromObservable(() => Observable.StartAsync(LaunchGame, RxApp.TaskpoolScheduler)); } diff --git a/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs index e4c9ae5eaa..3e4720eb44 100644 --- a/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Loadout/LoadoutLeftMenuViewModel.cs @@ -46,9 +46,10 @@ public LoadoutLeftMenuViewModel( var conn = serviceProvider.GetRequiredService(); var monitor = serviceProvider.GetRequiredService(); var overlayController = serviceProvider.GetRequiredService(); - + var gameRunningTracker = serviceProvider.GetRequiredService(); + WorkspaceId = workspaceId; - ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider, monitor, overlayController); + ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider, monitor, overlayController, gameRunningTracker); var installedModsItem = new IconViewModel diff --git a/src/NexusMods.App.UI/Services.cs b/src/NexusMods.App.UI/Services.cs index 32ab06573d..6c7b88af5b 100644 --- a/src/NexusMods.App.UI/Services.cs +++ b/src/NexusMods.App.UI/Services.cs @@ -84,6 +84,7 @@ public static IServiceCollection AddUI(this IServiceCollection c) // Type Finder .AddSingleton() + .AddSingleton() .AddTransient() // Services From 8235897b28297734c9098035aa8647017e9d1b63 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 28 Oct 2024 19:56:44 +0000 Subject: [PATCH 11/16] Fixed: Running... State is now correctly set on switch to cached loadout. --- src/NexusMods.App.UI/GameRunningTracker.cs | 7 ++++++- .../LeftMenu/Items/ILaunchButtonViewModel.cs | 16 ++++++++++++++++ .../Items/LaunchButtonDesignViewModel.cs | 1 + .../LeftMenu/Items/LaunchButtonView.axaml.cs | 9 ++++++++- .../LeftMenu/Items/LaunchButtonViewModel.cs | 18 +++++++++++++++--- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/NexusMods.App.UI/GameRunningTracker.cs b/src/NexusMods.App.UI/GameRunningTracker.cs index b6944875aa..72424a2a6e 100644 --- a/src/NexusMods.App.UI/GameRunningTracker.cs +++ b/src/NexusMods.App.UI/GameRunningTracker.cs @@ -28,7 +28,12 @@ public class GameRunningTracker /// Retrieves the current state of the game running tracker, /// with the current state being immediately emitted as the first item. /// - public IObservable GetWithCurrentStateAsStarting() => _isGameRunning.StartWith(_numRunningJobs > 0); + public IObservable GetWithCurrentStateAsStarting() => _isGameRunning.StartWith(GetInitialRunningState()); + + /// + /// Gets the initial state, being `true` if we're running something, and `false` otherwise. + /// + public bool GetInitialRunningState() => _numRunningJobs > 0; public GameRunningTracker(IJobMonitor monitor) { diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs index 579d6968b0..cd854fdbb4 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs @@ -28,4 +28,20 @@ public interface ILaunchButtonViewModel : ILeftMenuItemViewModel /// public string Label { get; } public Percent? Progress { get; } + + /// + /// Refreshes initial state obtained during initialization of the view model. + /// + /// + /// The Nexus App persists the left menu as part of workspaces in eternity, + /// meaning that spawning a new view will not re-evaluate existing state + /// if the workspace has already been created. Some state however needs to be + /// shared between workspaces, for example, if a given game is already running. + /// + /// This achieves this in the meantime; however in the longer run, the code + /// around the 'launch' button and friends could be further refactored here. + /// Whether we're running games, and which games we're running should be considered + /// global state, per game, as opposed to ViewModel specific state. + /// + public void Refresh(); } diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs index 6be6a042ff..e0faef04d4 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs @@ -22,6 +22,7 @@ public class LaunchButtonDesignViewModel : AViewModel, I [Reactive] public Percent? Progress { get; set; } + public void Refresh() { } public LaunchButtonDesignViewModel() { diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs index f40cd6dad5..d9752ca4dc 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs @@ -13,20 +13,26 @@ public LaunchButtonView() this.WhenActivated(d => { var isRunning = ViewModel!.IsRunningObservable; - var isNotRunning = ViewModel!.IsRunningObservable.Select(isRunning => !isRunning); + var isNotRunning = ViewModel!.IsRunningObservable.Select(running => !running); + ViewModel.Refresh(); + + // Hide launch button when running isNotRunning.BindToUi(this, view => view.LaunchButton.IsVisible) .DisposeWith(d); isNotRunning.BindToUi(this, view => view.LaunchButton.IsEnabled) .DisposeWith(d); + // Show progress bar when running isRunning.BindToUi(this, view => view.ProgressBarControl.IsVisible) .DisposeWith(d); + // Bind the 'launch' button. this.WhenAnyValue(view => view.ViewModel!.Command) .BindToUi(this, view => view.LaunchButton.Command) .DisposeWith(d); + // Bind the progress to the progress bar. this.WhenAnyValue(view => view.ViewModel!.Progress) .Select(p => p == null) .BindToUi(this, view => view.ProgressBarControl.IsIndeterminate) @@ -38,6 +44,7 @@ public LaunchButtonView() .BindToUi(this, view => view.ProgressBarControl.Value) .DisposeWith(d); + // Set the 'play' / 'running' text. this.WhenAnyValue(view => view.ViewModel!.Label) .BindToUi(this, view => view.LaunchText.Text) .DisposeWith(d); diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index 0303d51e37..4c2c9432a6 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -20,7 +20,7 @@ public class LaunchButtonViewModel : AViewModel, ILaunch [Reactive] public ReactiveCommand Command { get; set; } - public IObservable IsRunningObservable { get; } + public IObservable IsRunningObservable => _gameRunningTracker.GetWithCurrentStateAsStarting(); [Reactive] public string Label { get; set; } = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; @@ -31,6 +31,7 @@ public class LaunchButtonViewModel : AViewModel, ILaunch private readonly IConnection _conn; private readonly IJobMonitor _monitor; private readonly IOverlayController _overlayController; + private readonly GameRunningTracker _gameRunningTracker; public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor, IOverlayController overlayController, GameRunningTracker gameRunningTracker) { @@ -39,14 +40,15 @@ public LaunchButtonViewModel(ILogger logger, IToolManage _conn = conn; _monitor = monitor; _overlayController = overlayController; + _gameRunningTracker = gameRunningTracker; + Refresh(); - IsRunningObservable = gameRunningTracker.GetWithCurrentStateAsStarting(); Command = ReactiveCommand.CreateFromObservable(() => Observable.StartAsync(LaunchGame, RxApp.TaskpoolScheduler)); } private async Task LaunchGame(CancellationToken token) { - Label = Language.LaunchButtonViewModel_LaunchGame_RUNNING; + SetLabelToRunning(); try { var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, LoadoutId); @@ -66,4 +68,14 @@ await Task.Run(async () => } Label = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; } + private void SetLabelToRunning() + { + Label = Language.LaunchButtonViewModel_LaunchGame_RUNNING; + } + + public void Refresh() + { + if (_gameRunningTracker.GetInitialRunningState()) + SetLabelToRunning(); + } } From 72c41b184de4b3ac6ea44aede1b643a7d4ecc82a Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 29 Oct 2024 14:35:35 +0000 Subject: [PATCH 12/16] Added: Implement Design's App Already Running Dialog --- .../LeftMenu/Items/ApplyControlViewModel.cs | 3 ++- .../LeftMenu/Items/LaunchButtonViewModel.cs | 4 ++-- .../Generic/MessageBox/Ok/MessageBoxOkViewModel.cs | 4 ++-- src/NexusMods.App.UI/Resources/Language.Designer.cs | 7 +++---- src/NexusMods.App.UI/Resources/Language.resx | 7 +++---- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index 1fc3732378..29847908f3 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -144,7 +144,8 @@ await Task.Run(async () => } catch (ExecutableInUseException) { - await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController); + var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, _loadoutId); + await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController, marker.Installation.Name); } } } diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index c37b4d87ee..bd88d0e4ca 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -44,9 +44,9 @@ public LaunchButtonViewModel(ILogger logger, IToolManage private async Task LaunchGame(CancellationToken token) { Label = Language.LaunchButtonViewModel_LaunchGame_RUNNING; + var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, LoadoutId); try { - var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, LoadoutId); var tool = _toolManager.GetTools(marker).OfType().First(); await Task.Run(async () => { @@ -55,7 +55,7 @@ await Task.Run(async () => } catch (ExecutableInUseException) { - await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController); + await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController, marker.Installation.Name); } catch (Exception ex) { diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs index 69ff19e0b6..278107ead5 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs @@ -14,12 +14,12 @@ public class MessageBoxOkViewModel : AOverlayViewModel /// Shows the 'Game is already Running' error when you try to synchronize and a game is already running (usually on Windows). /// - public static async Task ShowGameAlreadyRunningError(IOverlayController overlayController) + public static async Task ShowGameAlreadyRunningError(IOverlayController overlayController, string gameName) { var viewModel = new MessageBoxOkViewModel() { Title = Language.ErrorGameAlreadyRunning_Title, - Description = Language.ErrorGameAlreadyRunning_Description, + Description = string.Format(Language.ErrorGameAlreadyRunning_Description, gameName) , }; await overlayController.EnqueueAndWait(viewModel); } diff --git a/src/NexusMods.App.UI/Resources/Language.Designer.cs b/src/NexusMods.App.UI/Resources/Language.Designer.cs index 390c8e8219..9b044f4295 100644 --- a/src/NexusMods.App.UI/Resources/Language.Designer.cs +++ b/src/NexusMods.App.UI/Resources/Language.Designer.cs @@ -690,9 +690,8 @@ public static string EmptyLibraryTitleText { } /// - /// Looks up a localized string similar to The game has already been started outside of the App. - /// - ///It is not possible to currently apply a loadout.. + /// Looks up a localized string similar to It looks like {0} is already running. + ///To apply changes or launch the game again, please close the game first.. /// public static string ErrorGameAlreadyRunning_Description { get { @@ -701,7 +700,7 @@ public static string ErrorGameAlreadyRunning_Description { } /// - /// Looks up a localized string similar to Game already running. + /// Looks up a localized string similar to Game is currently running. /// public static string ErrorGameAlreadyRunning_Title { get { diff --git a/src/NexusMods.App.UI/Resources/Language.resx b/src/NexusMods.App.UI/Resources/Language.resx index e6033bf820..ca305eb1fc 100644 --- a/src/NexusMods.App.UI/Resources/Language.resx +++ b/src/NexusMods.App.UI/Resources/Language.resx @@ -682,11 +682,10 @@ Installed Mods - Game already running + Game is currently running - The game has already been started outside of the App. - -It is not possible to currently apply a loadout. + It looks like {0} is already running. +To apply changes or launch the game again, please close the game first. From 9f3d6639c09f2b33ce0ba1ecf231da01bf743a00 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 29 Oct 2024 19:00:33 +0000 Subject: [PATCH 13/16] Improved: Added final-ish version of GameRunningTracker & ViewModels --- src/NexusMods.App.UI/GameRunningTracker.cs | 49 +++++-------------- .../LeftMenu/Items/ILaunchButtonViewModel.cs | 16 ------ .../Items/LaunchButtonDesignViewModel.cs | 1 - .../LeftMenu/Items/LaunchButtonView.axaml.cs | 25 ++++++++-- .../LeftMenu/Items/LaunchButtonViewModel.cs | 27 +++++----- 5 files changed, 48 insertions(+), 70 deletions(-) diff --git a/src/NexusMods.App.UI/GameRunningTracker.cs b/src/NexusMods.App.UI/GameRunningTracker.cs index 72424a2a6e..2fb0001ce2 100644 --- a/src/NexusMods.App.UI/GameRunningTracker.cs +++ b/src/NexusMods.App.UI/GameRunningTracker.cs @@ -14,53 +14,28 @@ namespace NexusMods.App.UI; /// public class GameRunningTracker { - /// - /// Allows you to listen to when any game is running via the App. - /// - private readonly IObservable _isGameRunning; - - /// - /// Number of currently running game jobs. - /// - private int _numRunningJobs = 0; - + private readonly IJobMonitor _monitor; + private readonly IObservable _observable; + /// /// Retrieves the current state of the game running tracker, /// with the current state being immediately emitted as the first item. /// - public IObservable GetWithCurrentStateAsStarting() => _isGameRunning.StartWith(GetInitialRunningState()); - - /// - /// Gets the initial state, being `true` if we're running something, and `false` otherwise. - /// - public bool GetInitialRunningState() => _numRunningJobs > 0; + public IObservable GetWithCurrentStateAsStarting() => _observable; public GameRunningTracker(IJobMonitor monitor) { + _monitor = monitor; + // Note(sewer): Yes, this technically can lead to a race condition; // however it's not possible to start a game before activating GameRunningTracker // singleton for an end user. - _numRunningJobs = monitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); - _isGameRunning = monitor.GetObservableChangeSet() + var numRunning = _monitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); + _observable = _monitor.GetObservableChangeSet() .TransformOnObservable(job => job.ObservableStatus) - .Select(changes => - { - // Note(sewer): We don't currently remove any old/stale jobs, so it's inefficient to - // check the whole job set in case the App has been running for a long time. - // Therefore, we instead count and manually maintain a running job count. - foreach (var change in changes) - { - var isActivated = change.Current.WasActivated(change.Previous); - var isDeactivated = change.Current.WasDeactivated(change.Previous); - if (isActivated) - _numRunningJobs++; - if (isDeactivated) - _numRunningJobs--; - } - - return _numRunningJobs > 0; - } - ) - .DistinctUntilChanged(); + .QueryWhenChanged(query => query.Items.Any(x => x.IsActive())) + .StartWith(numRunning > 0) + .Replay(1) + .RefCount(); } } diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs index cd854fdbb4..579d6968b0 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ILaunchButtonViewModel.cs @@ -28,20 +28,4 @@ public interface ILaunchButtonViewModel : ILeftMenuItemViewModel /// public string Label { get; } public Percent? Progress { get; } - - /// - /// Refreshes initial state obtained during initialization of the view model. - /// - /// - /// The Nexus App persists the left menu as part of workspaces in eternity, - /// meaning that spawning a new view will not re-evaluate existing state - /// if the workspace has already been created. Some state however needs to be - /// shared between workspaces, for example, if a given game is already running. - /// - /// This achieves this in the meantime; however in the longer run, the code - /// around the 'launch' button and friends could be further refactored here. - /// Whether we're running games, and which games we're running should be considered - /// global state, per game, as opposed to ViewModel specific state. - /// - public void Refresh(); } diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs index e0faef04d4..6be6a042ff 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonDesignViewModel.cs @@ -22,7 +22,6 @@ public class LaunchButtonDesignViewModel : AViewModel, I [Reactive] public Percent? Progress { get; set; } - public void Refresh() { } public LaunchButtonDesignViewModel() { diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs index d9752ca4dc..0a044736c9 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs @@ -14,17 +14,34 @@ public LaunchButtonView() { var isRunning = ViewModel!.IsRunningObservable; var isNotRunning = ViewModel!.IsRunningObservable.Select(running => !running); - ViewModel.Refresh(); // Hide launch button when running - isNotRunning.BindToUi(this, view => view.LaunchButton.IsVisible) + isNotRunning + .OnUI() + .Subscribe(x => + { + LaunchButton.IsVisible = x; + } + ) .DisposeWith(d); - isNotRunning.BindToUi(this, view => view.LaunchButton.IsEnabled) + isNotRunning + .OnUI() + .Subscribe(x => + { + LaunchButton.IsEnabled = x; + } + ) .DisposeWith(d); // Show progress bar when running - isRunning.BindToUi(this, view => view.ProgressBarControl.IsVisible) + isRunning + .OnUI() + .Subscribe(x => + { + ProgressBarControl.IsVisible = x; + } + ) .DisposeWith(d); // Bind the 'launch' button. diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index af96bf9b98..889aea2f81 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs @@ -1,4 +1,5 @@ using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Games; @@ -41,7 +42,17 @@ public LaunchButtonViewModel(ILogger logger, IToolManage _monitor = monitor; _overlayController = overlayController; _gameRunningTracker = gameRunningTracker; - Refresh(); + + this.WhenActivated(cd => + { + _gameRunningTracker.GetWithCurrentStateAsStarting().Subscribe(isRunning => + { + if (isRunning) + SetLabelToRunning(); + else + SetLabelToLaunch(); + }).DisposeWith(cd); + }); Command = ReactiveCommand.CreateFromObservable(() => Observable.StartAsync(LaunchGame, RxApp.TaskpoolScheduler)); } @@ -66,16 +77,8 @@ await Task.Run(async () => { _logger.LogError($"Error launching game: {ex.Message}\n{ex.StackTrace}"); } - Label = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; - } - private void SetLabelToRunning() - { - Label = Language.LaunchButtonViewModel_LaunchGame_RUNNING; - } - - public void Refresh() - { - if (_gameRunningTracker.GetInitialRunningState()) - SetLabelToRunning(); + SetLabelToLaunch(); } + private void SetLabelToRunning() => Label = Language.LaunchButtonViewModel_LaunchGame_RUNNING; + private void SetLabelToLaunch() => Label = Language.LaunchButtonViewModel_LaunchGame_LAUNCH; } From 542f4a064f7bca22585c94e009adc5ae883d0ebb Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 29 Oct 2024 19:05:47 +0000 Subject: [PATCH 14/16] Revert: Temporary change to Subscribe for Debugging --- .../LeftMenu/Items/LaunchButtonView.axaml.cs | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs index 0a044736c9..5b480f978e 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonView.axaml.cs @@ -14,34 +14,16 @@ public LaunchButtonView() { var isRunning = ViewModel!.IsRunningObservable; var isNotRunning = ViewModel!.IsRunningObservable.Select(running => !running); - + // Hide launch button when running - isNotRunning - .OnUI() - .Subscribe(x => - { - LaunchButton.IsVisible = x; - } - ) + isNotRunning.BindToUi(this, view => view.LaunchButton.IsVisible) .DisposeWith(d); - isNotRunning - .OnUI() - .Subscribe(x => - { - LaunchButton.IsEnabled = x; - } - ) + isNotRunning.BindToUi(this, view => view.LaunchButton.IsEnabled) .DisposeWith(d); // Show progress bar when running - isRunning - .OnUI() - .Subscribe(x => - { - ProgressBarControl.IsVisible = x; - } - ) + isRunning.BindToUi(this, view => view.ProgressBarControl.IsVisible) .DisposeWith(d); // Bind the 'launch' button. From 4ec3c565190c52f8e667cab4d1c97ff37eaa1a38 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 29 Oct 2024 19:31:43 +0000 Subject: [PATCH 15/16] Improved: Don't lift out monitor to variable. --- src/NexusMods.App.UI/GameRunningTracker.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/NexusMods.App.UI/GameRunningTracker.cs b/src/NexusMods.App.UI/GameRunningTracker.cs index 2fb0001ce2..6a0d5d6a9d 100644 --- a/src/NexusMods.App.UI/GameRunningTracker.cs +++ b/src/NexusMods.App.UI/GameRunningTracker.cs @@ -14,7 +14,6 @@ namespace NexusMods.App.UI; /// public class GameRunningTracker { - private readonly IJobMonitor _monitor; private readonly IObservable _observable; /// @@ -25,13 +24,11 @@ public class GameRunningTracker public GameRunningTracker(IJobMonitor monitor) { - _monitor = monitor; - // Note(sewer): Yes, this technically can lead to a race condition; // however it's not possible to start a game before activating GameRunningTracker // singleton for an end user. - var numRunning = _monitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); - _observable = _monitor.GetObservableChangeSet() + var numRunning = monitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive()); + _observable = monitor.GetObservableChangeSet() .TransformOnObservable(job => job.ObservableStatus) .QueryWhenChanged(query => query.Items.Any(x => x.IsActive())) .StartWith(numRunning > 0) From 780f0e70bd24e672e5b3bd56db199d1394101f31 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 30 Oct 2024 11:50:40 +0000 Subject: [PATCH 16/16] Update: SynchronizerService.cs --- src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs b/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs index 2131a5a095..c56b6e362c 100644 --- a/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs +++ b/src/NexusMods.DataModel/Synchronizer/SynchronizerService.cs @@ -219,7 +219,8 @@ private void ThrowIfMainBinaryInUse(Loadout.ReadOnly loadout) .Combine(loadout.InstallationInstance.LocationsRegister[LocationId.Game]); if (IsFileInUse(primaryFile)) throw new ExecutableInUseException("Game's main executable file is in use.\n" + - "This is an indicator the game may have been started outside of the App; and therefore files may be in use."); + "This is an indicator the game may have been started outside of the App; and therefore files may be in use.\n" + + "This means that we are unable to perform a Synchronize (Apply) operation."); return; static bool IsFileInUse(AbsolutePath filePath)