Skip to content

Commit

Permalink
Merge pull request #2209 from Nexus-Mods/disable-apply-if-not-running-2
Browse files Browse the repository at this point in the history
Improved: Edge Cases around the Apply & Launch Button
  • Loading branch information
Sewer56 authored Oct 30, 2024
2 parents 3a23441 + 780f0e7 commit c803194
Show file tree
Hide file tree
Showing 27 changed files with 417 additions and 89 deletions.
6 changes: 6 additions & 0 deletions src/Abstractions/NexusMods.Abstractions.Games/IGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ public interface IGame : ILocatableGame
/// also marks the installation was sourced from the given <see cref="IGameLocator"/>.
/// </summary>
public GameInstallation InstallationFromLocatorResult(GameLocatorResult metadata, EntityId dbId, IGameLocator locator);

/// <summary>
/// Returns the primary (executable) file for the game.
/// </summary>
/// <param name="store">The store used for the game.</param>
public GamePath GetPrimaryFile(GameStore store);
}
15 changes: 14 additions & 1 deletion src/Abstractions/NexusMods.Abstractions.Games/RunGameTool.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -53,7 +56,7 @@ public RunGameTool(IServiceProvider serviceProvider, T game)
/// <inheritdoc />
public string Name => $"Run {_game.Name}";

/// <inheritdoc />
/// <summary/>
public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken)
{
_logger.LogInformation("Starting {Name}", Name);
Expand Down Expand Up @@ -249,4 +252,14 @@ protected virtual ValueTask<AbsolutePath> GetGamePath(Loadout.ReadOnly loadout)
return ValueTask.FromResult(_game.GetPrimaryFile(loadout.InstallationInstance.Store)
.Combine(loadout.InstallationInstance.LocationsRegister[LocationId.Game]));
}

/// <inheritdoc />
public IJobTask<ITool, Unit> StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken)
{
return monitor.Begin<ITool, Unit>(this, async _ =>
{
await Execute(loadout, cancellationToken);
return Unit.Default;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ public static IObservable<IChangeSet<IJob, JobId>> ObserveActiveJobs<TJobType>(t
where TJobType : IJobDefinition
{
return jobMonitor.GetObservableChangeSet<TJobType>()
.FilterOnObservable(job => job.ObservableStatus
.Select(status => status is JobStatus.Running or JobStatus.Paused));
.FilterOnObservable(job => job.ObservableStatus.Select(status => status.IsActive()));
}


Expand Down
79 changes: 79 additions & 0 deletions src/Abstractions/NexusMods.Abstractions.Jobs/JobStatus.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using DynamicData.Kernel;
using JetBrains.Annotations;

namespace NexusMods.Abstractions.Jobs;
Expand Down Expand Up @@ -66,3 +67,81 @@ public enum JobStatus : byte
/// </remarks>
Failed = 6,
}

/// <summary>
/// Extensions for the <see cref="JobStatus"/> type.
/// </summary>
public static class JobStatusExtensions
{
/// <summary>
/// Determines if a job is "active" based on the current job status.
/// A job is considered "active" if the status is <see cref="JobStatus.Running"/> or <see cref="JobStatus.Paused"/>.
/// </summary>
/// <param name="currentStatus">The current job status.</param>
/// <returns>
/// <c>true</c> if the job is active, <c>false</c> otherwise.
/// </returns>
public static bool IsActive(this JobStatus currentStatus) => currentStatus is JobStatus.Running or JobStatus.Paused;

/// <summary>
/// Determines if a job was "activated" based on the previous and current job status.
/// A job is considered "activated" if the status changed to <see cref="JobStatus.Running"/> or <see cref="JobStatus.Paused"/> from any other state.
/// </summary>
/// <param name="currentStatus">The current job status.</param>
/// <param name="previousStatus">The previous job status, or <see langword="null"/> if the job is being created.</param>
/// <returns>
/// <c>true</c> if the job was activated, <c>false</c> otherwise.
/// </returns>
public static bool WasActivated(this JobStatus currentStatus, Optional<JobStatus> previousStatus)
{
// We set to 'none' because the activation queue is setting to `Running` or `Paused`.
return WasActivated(currentStatus, !previousStatus.HasValue ? JobStatus.None : previousStatus.Value);
}

/// <summary>
/// Determines if a job was "deactivated" based on the previous and current job status.
/// A job is considered "deactivated" if the status changed from <see cref="JobStatus.Running"/> or <see cref="JobStatus.Paused"/> to any other state.
/// </summary>
/// <param name="currentStatus">The current job status.</param>
/// <param name="previousStatus">The previous job status, or <see langword="null"/> if the job is being created.</param>
/// <returns>
/// <c>true</c> if the job was deactivated, <c>false</c> otherwise.
/// </returns>
public static bool WasDeactivated(this JobStatus currentStatus, Optional<JobStatus> 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);
}

/// <summary>
/// Determines if a job was "activated" based on the previous and current job status.
/// A job is considered "activated" if the status changed to <see cref="JobStatus.Running"/> or <see cref="JobStatus.Paused"/> from any other state.
/// </summary>
/// <param name="currentStatus">The current job status.</param>
/// <param name="previousStatus">The previous job status, or <see langword="null"/> if the job is being created.</param>
/// <returns>
/// <c>true</c> if the job was activated, <c>false</c> otherwise.
/// </returns>
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;
}

/// <summary>
/// Determines if a job was "deactivated" based on the previous and current job status.
/// A job is considered "deactivated" if the status changed from <see cref="JobStatus.Running"/> or <see cref="JobStatus.Paused"/> to any other state.
/// </summary>
/// <param name="currentStatus">The current job status.</param>
/// <param name="previousStatus">The previous job status, or <see langword="null"/> if the job is being created.</param>
/// <returns>
/// <c>true</c> if the job was deactivated, <c>false</c> otherwise.
/// </returns>
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Runtime.Serialization;
namespace NexusMods.Abstractions.Loadouts.Exceptions;

/// <summary>
/// Exception thrown when an executable is in use
/// </summary>
public class ExecutableInUseException : Exception
{
/// <inheritdoc />
public ExecutableInUseException() { }
/// <inheritdoc />
public ExecutableInUseException(string? message) : base(message) { }
/// <inheritdoc />
public ExecutableInUseException(string? message, Exception? innerException) : base(message, innerException) { }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Loadouts.Exceptions;
using NexusMods.Abstractions.Loadouts.Ids;
using NexusMods.Abstractions.Loadouts.Synchronizers;

Expand All @@ -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.
/// </summary>
/// <throws cref="ExecutableInUseException">Thrown if the game EXE is in use, meaning that it's running.</throws>
public Task Synchronize(LoadoutId loadout);

/// <summary>
Expand Down
12 changes: 9 additions & 3 deletions src/Abstractions/NexusMods.Abstractions.Loadouts/ITool.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using System.Runtime.CompilerServices;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Jobs;
using NexusMods.Abstractions.NexusWebApi.Types.V2;
using R3;

namespace NexusMods.Abstractions.Loadouts;

/// <summary>
/// Specifies a tool that is run outside of the app. Could be the game itself,
/// a file generator, some sort of editor, patcher, etc.
/// </summary>
public interface ITool
public interface ITool : IJobDefinition<Unit>
{
/// <summary>
/// List of supported game IDs.
Expand All @@ -20,7 +23,10 @@ public interface ITool
public string Name { get; }

/// <summary>
/// Executes this tool against the given loadout.
/// Executes this tool against the given loadout using the <see cref="IJobMonitor"/>.
/// </summary>
public Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellationToken);
/// <param name="loadout">The loadout to run the game with.</param>
/// <param name="monitor">The monitor to which the task should be queued.</param>
/// <param name="cancellationToken">Allows you to prematurely cancel the task.</param>
public IJobTask<ITool, Unit> StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using NexusMods.Abstractions.Jobs;

namespace NexusMods.Abstractions.Loadouts;

/// <summary>
Expand All @@ -19,8 +21,9 @@ public interface IToolManager
/// <param name="tool"></param>
/// <param name="loadout"></param>
/// <param name="generatedFilesMod"></param>
/// <param name="monitor">The job system executor.</param>
/// <param name="token"></param>
/// <returns></returns>
public Task<Loadout.ReadOnly> RunTool(ITool tool, Loadout.ReadOnly loadout,
public Task<Loadout.ReadOnly> RunTool(ITool tool, Loadout.ReadOnly loadout, IJobMonitor monitor,
CancellationToken token = default);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<!-- NuGet Package Shared Details -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('NuGet.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<ItemGroup>
<ProjectReference Include="..\NexusMods.Abstractions.GameLocators\NexusMods.Abstractions.GameLocators.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.IO\NexusMods.Abstractions.IO.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.Jobs\NexusMods.Abstractions.Jobs.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.Library.Models\NexusMods.Abstractions.Library.Models.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.MnemonicDB.Analyzers\NexusMods.Abstractions.MnemonicDB.Analyzers.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.NexusWebApi\NexusMods.Abstractions.NexusWebApi.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ public override bool IsIgnoredPath(GamePath path)

await _redModTool.Execute(loadout, CancellationToken.None);
return await base.Synchronize(loadout);

}


Expand Down
11 changes: 11 additions & 0 deletions src/Games/NexusMods.Games.RedEngine/RedModDeployTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +62,15 @@ public async Task Execute(Loadout.ReadOnly loadout, CancellationToken cancellati

public string Name => "RedMod Deploy";

public IJobTask<ITool, Unit> StartJob(Loadout.ReadOnly loadout, IJobMonitor monitor, CancellationToken cancellationToken)
{
return monitor.Begin<ITool, Unit>(this, async _ =>
{
await Execute(loadout, cancellationToken);
return Unit.Default;
});
}

private async Task<TemporaryPath> ExtractTemporaryDeployScript()
{
var assembly = Assembly.GetExecutingAssembly();
Expand Down
38 changes: 38 additions & 0 deletions src/NexusMods.App.UI/GameRunningTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Reactive.Linq;
using DynamicData;
using NexusMods.Abstractions.Games;
using NexusMods.Abstractions.Jobs;
namespace NexusMods.App.UI;

/// <summary>
/// This class helps us efficiently identify whether a game is currently running.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class GameRunningTracker
{
private readonly IObservable<bool> _observable;

/// <summary>
/// Retrieves the current state of the game running tracker,
/// with the current state being immediately emitted as the first item.
/// </summary>
public IObservable<bool> GetWithCurrentStateAsStarting() => _observable;

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.
var numRunning = monitor.Jobs.Count(x => x is { Definition: IRunGameTool } && x.Status.IsActive());
_observable = monitor.GetObservableChangeSet<IRunGameTool>()
.TransformOnObservable(job => job.ObservableStatus)
.QueryWhenChanged(query => query.Items.Any(x => x.IsActive()))
.StartWith(numRunning > 0)
.Replay(1)
.RefCount();
}
}
Loading

0 comments on commit c803194

Please sign in to comment.