From a74a37bd8ab757db65fef2d729fc0480a52267e5 Mon Sep 17 00:00:00 2001 From: erri120 Date: Thu, 28 Sep 2023 16:59:42 +0200 Subject: [PATCH] Run games through Steam (#681) * Upgrade GameFinder to 4.0.0 * Include metadata in game installation * Run the game through Steam * Fix bugs * Fix OAuth issues --- Directory.Packages.props | 2 +- .../RunGameWithScriptExtender.cs | 11 +-- .../SkyrimLegendaryEditionGameTool.cs | 5 +- .../SkyrimSpecialEditionGameTool.cs | 5 +- .../JWTTokenEntity.cs | 16 ++++ .../LoginManager.cs | 34 ++++---- .../OAuth.cs | 6 +- .../OAuth2MessageFactory.cs | 11 +-- .../DTOs/OAuth/JwtTokenReply.cs | 15 ++-- .../LeftMenu/Items/LaunchButtonViewModel.cs | 4 +- src/NexusMods.DataModel/Games/AGame.cs | 10 +-- .../Games/GameInstallation.cs | 13 ++- src/NexusMods.DataModel/Games/RunGameTool.cs | 87 ++++++++++++++++--- 13 files changed, 155 insertions(+), 64 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index de456ced6f..cd74a776a3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,7 +86,7 @@ - + diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/RunGameWithScriptExtender.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/RunGameWithScriptExtender.cs index 239e318c4a..3748773aa2 100644 --- a/src/Games/NexusMods.Games.BethesdaGameStudios/RunGameWithScriptExtender.cs +++ b/src/Games/NexusMods.Games.BethesdaGameStudios/RunGameWithScriptExtender.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using NexusMods.Common; +using NexusMods.Common.OSInterop; using NexusMods.DataModel.Extensions; using NexusMods.DataModel.Games; using NexusMods.DataModel.Loadouts; @@ -14,15 +15,15 @@ namespace NexusMods.Games.BethesdaGameStudios; /// public abstract class RunGameWithScriptExtender : RunGameTool where T : AGame { // ReSharper disable once ContextualLoggerProblem - protected RunGameWithScriptExtender(ILogger> logger, T game, IProcessFactory processFactory) - : base(logger, game, processFactory) { } - + protected RunGameWithScriptExtender(ILogger> logger, T game, IProcessFactory processFactory, IOSInterop osInterop) + : base(logger, game, processFactory, osInterop) { } + protected abstract GamePath ScriptLoaderPath { get; } protected override AbsolutePath GetGamePath(Loadout loadout, ApplyPlan applyPlan) { - return applyPlan.Flattened.ContainsKey(ScriptLoaderPath) ? - ScriptLoaderPath.CombineChecked(loadout.Installation) : + return applyPlan.Flattened.ContainsKey(ScriptLoaderPath) ? + ScriptLoaderPath.CombineChecked(loadout.Installation) : base.GetGamePath(loadout, applyPlan); } } diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEditionGameTool.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEditionGameTool.cs index c34d4ee7ae..0883a2cdfc 100644 --- a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEditionGameTool.cs +++ b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimLegendaryEdition/SkyrimLegendaryEditionGameTool.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using NexusMods.Common; +using NexusMods.Common.OSInterop; using NexusMods.DataModel.Games; using NexusMods.Paths; @@ -8,7 +9,7 @@ namespace NexusMods.Games.BethesdaGameStudios; public class SkyrimLegendaryEditionGameTool : RunGameWithScriptExtender { // ReSharper disable once ContextualLoggerProblem - public SkyrimLegendaryEditionGameTool(ILogger> logger, SkyrimLegendaryEdition game, IProcessFactory processFactory) - : base(logger, game, processFactory) { } + public SkyrimLegendaryEditionGameTool(ILogger> logger, SkyrimLegendaryEdition game, IProcessFactory processFactory, IOSInterop osInterop) + : base(logger, game, processFactory, osInterop) { } protected override GamePath ScriptLoaderPath => new(LocationId.Game, "skse_loader.exe"); } diff --git a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEditionGameTool.cs b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEditionGameTool.cs index 535f70c57c..1e27ab9dee 100644 --- a/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEditionGameTool.cs +++ b/src/Games/NexusMods.Games.BethesdaGameStudios/SkyrimSpecialEdition/SkyrimSpecialEditionGameTool.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using NexusMods.Common; +using NexusMods.Common.OSInterop; using NexusMods.DataModel.Games; using NexusMods.Paths; @@ -8,7 +9,7 @@ namespace NexusMods.Games.BethesdaGameStudios; public class SkyrimSpecialEditionGameTool : RunGameWithScriptExtender { // ReSharper disable once ContextualLoggerProblem - public SkyrimSpecialEditionGameTool(ILogger> logger, SkyrimSpecialEdition game, IProcessFactory processFactory) - : base(logger, game, processFactory) { } + public SkyrimSpecialEditionGameTool(ILogger> logger, SkyrimSpecialEdition game, IProcessFactory processFactory, IOSInterop osInterop) + : base(logger, game, processFactory, osInterop) { } protected override GamePath ScriptLoaderPath => new(LocationId.Game, "skse64_loader.exe"); } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs index 2b68575273..b47038cff5 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/JWTTokenEntity.cs @@ -1,6 +1,7 @@ using NexusMods.DataModel.Abstractions; using NexusMods.DataModel.Abstractions.Ids; using NexusMods.DataModel.JsonConverters; +using NexusMods.Networking.NexusWebApi.DTOs.OAuth; namespace NexusMods.Networking.NexusWebApi.NMA; @@ -15,6 +16,21 @@ public record JWTTokenEntity : Entity /// public static readonly IId StoreId = new IdVariableLength(EntityCategory.AuthData, "NexusMods.Networking.NexusWebApi.JWTTokens"u8.ToArray()); + /// + /// Creates a new from a . + /// + public static JWTTokenEntity? From(JwtTokenReply? tokenReply) + { + if (tokenReply?.AccessToken is null || tokenReply.RefreshToken is null) return null; + return new JWTTokenEntity + { + AccessToken = tokenReply.AccessToken, + RefreshToken = tokenReply.RefreshToken, + ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(tokenReply.CreatedAt) + TimeSpan.FromSeconds(tokenReply.ExpiresIn), + DataStoreId = StoreId + }; + } + /// public override EntityCategory Category => StoreId.Category; diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs index e4b91c3433..9450c2d6b2 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/LoginManager.cs @@ -1,6 +1,7 @@ using System.Reactive.Concurrency; using System.Reactive.Linq; using JetBrains.Annotations; +using Microsoft.Extensions.Logging; using NexusMods.Common; using NexusMods.Common.ProtocolRegistration; using NexusMods.DataModel.Abstractions; @@ -14,6 +15,7 @@ namespace NexusMods.Networking.NexusWebApi.NMA; [PublicAPI] public sealed class LoginManager : IDisposable { + private readonly ILogger _logger; private readonly OAuth _oauth; private readonly IDataStore _dataStore; private readonly IProtocolRegistration _protocolRegistration; @@ -40,23 +42,23 @@ public sealed class LoginManager : IDisposable /// public IObservable Avatar => UserInfo.Select(info => info?.AvatarUrl); - /// - /// Nexus API client. - /// Used to check authentication status and ensure verified. - /// Helper class to deal with authentication messages. - /// Used for storing information about the current login session. - /// Used to register NXM protocol. - public LoginManager(Client client, + /// + /// Constructor. + /// + public LoginManager( + Client client, IAuthenticatingMessageFactory msgFactory, OAuth oauth, IDataStore dataStore, - IProtocolRegistration protocolRegistration) + IProtocolRegistration protocolRegistration, + ILogger logger) { _oauth = oauth; _msgFactory = msgFactory; _client = client; _dataStore = dataStore; _protocolRegistration = protocolRegistration; + _logger = logger; UserInfo = _dataStore.IdChanges // NOTE(err120): Since IDs don't change on startup, we can insert @@ -99,16 +101,14 @@ public async Task LoginAsync(CancellationToken token = default) await _protocolRegistration.RegisterSelf("nxm"); var jwtToken = await _oauth.AuthorizeRequest(token); - var createdAt = DateTimeOffset.FromUnixTimeSeconds(jwtToken.CreatedAt); - var expiresIn = TimeSpan.FromSeconds(jwtToken.ExpiresIn); - var expiresAt = createdAt + expiresIn; - - _dataStore.Put(JWTTokenEntity.StoreId, new JWTTokenEntity + var newTokenEntity = JWTTokenEntity.From(jwtToken); + if (newTokenEntity is null) { - RefreshToken = jwtToken.RefreshToken, - AccessToken = jwtToken.AccessToken, - ExpiresAt = expiresAt - }); + _logger.LogError("Invalid new token!"); + return; + } + + _dataStore.Put(JWTTokenEntity.StoreId, newTokenEntity); } /// diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs index 716ad720a2..62d43926bd 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth.cs @@ -56,7 +56,7 @@ public OAuth(ILogger logger, /// /// /// task with the jwt token once we receive one - public async Task AuthorizeRequest(CancellationToken cancellationToken) + public async Task AuthorizeRequest(CancellationToken cancellationToken) { // see https://www.rfc-editor.org/rfc/rfc7636#section-4.1 var codeVerifier = _idGen.UUIDv4().ToBase64(); @@ -97,7 +97,7 @@ private IInterprocessJob CreateJob(Uri url) /// the refresh token /// /// a new token reply - public async Task RefreshToken(string refreshToken, CancellationToken cancel) + public async Task RefreshToken(string refreshToken, CancellationToken cancel) { var request = new Dictionary { @@ -113,7 +113,7 @@ public async Task RefreshToken(string refreshToken, CancellationT return JsonSerializer.Deserialize(responseString); } - private async Task AuthorizeToken(string verifier, string code, CancellationToken cancel) + private async Task AuthorizeToken(string verifier, string code, CancellationToken cancel) { var request = new Dictionary { { "grant_type", "authorization_code" }, diff --git a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs index 513ace0a4e..26cafb4f9c 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi.NMA/OAuth2MessageFactory.cs @@ -43,13 +43,14 @@ public OAuth2MessageFactory( _logger.LogDebug("Refreshing expired OAuth token"); var newToken = await _auth.RefreshToken(_cachedTokenEntity.RefreshToken, cancellationToken); - _cachedTokenEntity = new JWTTokenEntity + var newTokenEntity = JWTTokenEntity.From(newToken); + if (newTokenEntity is null) { - RefreshToken = newToken.RefreshToken, - AccessToken = newToken.AccessToken, - ExpiresAt = DateTimeOffset.UtcNow, - }; + _logger.LogError("Invalid new token!"); + return null; + } + _cachedTokenEntity = newTokenEntity; _store.Put(JWTTokenEntity.StoreId, _cachedTokenEntity); _cachedTokenEntity.DataStoreId = JWTTokenEntity.StoreId; diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs b/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs index 4c782f1442..53a7743303 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/DTOs/OAuth/JwtTokenReply.cs @@ -5,33 +5,38 @@ namespace NexusMods.Networking.NexusWebApi.DTOs.OAuth; /// /// JWT Token info as provided by the OAuth server /// -public struct JwtTokenReply +public class JwtTokenReply { /// /// the token to use for authentication /// [JsonPropertyName("access_token")] - public string AccessToken { get; set; } + public string? AccessToken { get; set; } + /// /// token type, e.g. "Bearer" /// [JsonPropertyName("token_type")] - public string Type { get; set; } + public string? Type { get; set; } + /// /// when the access token expires in seconds /// [JsonPropertyName("expires_in")] public ulong ExpiresIn { get; set; } + /// /// token to use to refresh once this one has expired /// [JsonPropertyName("refresh_token")] - public string RefreshToken { get; set; } + public string? RefreshToken { get; set; } + /// /// space separated list of scopes. defined by the server, currently always "public"? /// [JsonPropertyName("scope")] - public string Scope { get; set; } + public string? Scope { get; set; } + /// /// unix timestamp (seconds resolution) of when the token was created /// diff --git a/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/LaunchButtonViewModel.cs index 6d2a935090..294c3d79f5 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.Collections.ObjectModel; using System.Reactive; +using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; using DynamicData; @@ -52,8 +53,7 @@ public LaunchButtonViewModel(ILogger logger, IToolManager .DisposeWith(d); var canExecute = _jobs.WhenAnyValue(coll => coll.Count, count => count == 0); - - Command = ReactiveCommand.CreateFromTask(LaunchGame, canExecute.OnUI()); + Command = ReactiveCommand.CreateFromObservable(() => Observable.StartAsync(LaunchGame, RxApp.TaskpoolScheduler), canExecute.OnUI()); }); } diff --git a/src/NexusMods.DataModel/Games/AGame.cs b/src/NexusMods.DataModel/Games/AGame.cs index 299365b9b2..b77fe28710 100644 --- a/src/NexusMods.DataModel/Games/AGame.cs +++ b/src/NexusMods.DataModel/Games/AGame.cs @@ -83,17 +83,15 @@ private List GetInstallations() return (_gamelocators.SelectMany(locator => locator.Find(this), (locator, installation) => { - var locations = GetLocations(installation.Path.FileSystem, - installation); + var locations = GetLocations(installation.Path.FileSystem, installation); return new GameInstallation { Game = this, - LocationsRegister = - new GameLocationsRegister( - new Dictionary(locations)), + LocationsRegister = new GameLocationsRegister(new Dictionary(locations)), InstallDestinations = GetInstallDestinations(locations), Version = installation.Version ?? GetVersion(installation), - Store = installation.Store + Store = installation.Store, + LocatorResultMetadata = installation.Metadata }; })) .DistinctBy(g => g.LocationsRegister[LocationId.Game]) diff --git a/src/NexusMods.DataModel/Games/GameInstallation.cs b/src/NexusMods.DataModel/Games/GameInstallation.cs index 7f36be603b..151f2ec356 100644 --- a/src/NexusMods.DataModel/Games/GameInstallation.cs +++ b/src/NexusMods.DataModel/Games/GameInstallation.cs @@ -9,6 +9,11 @@ namespace NexusMods.DataModel.Games; /// public class GameInstallation { + /// + /// Empty game installation, used for testing and some cases where a property must be set. + /// + public static GameInstallation Empty => new(); + /// /// The Version installed. /// @@ -30,14 +35,14 @@ public class GameInstallation public IGame Game { get; init; } = null!; /// - /// Empty game installation, used for testing and some cases where a property must be set. + /// The which was used to install the game. /// - public static GameInstallation Empty => new(); + public GameStore Store { get; init; } = GameStore.Unknown; /// - /// The which was used to install the game. + /// Gets the metadata returned by the game locator. /// - public GameStore Store { get; init; } = GameStore.Unknown; + public IGameLocatorResultMetadata? LocatorResultMetadata { get; init; } /// /// Returns the game name and version as diff --git a/src/NexusMods.DataModel/Games/RunGameTool.cs b/src/NexusMods.DataModel/Games/RunGameTool.cs index 871cb89435..583e5ce57c 100644 --- a/src/NexusMods.DataModel/Games/RunGameTool.cs +++ b/src/NexusMods.DataModel/Games/RunGameTool.cs @@ -1,8 +1,10 @@ using System.Diagnostics; +using System.Globalization; using System.Text; using CliWrap; using Microsoft.Extensions.Logging; using NexusMods.Common; +using NexusMods.Common.OSInterop; using NexusMods.DataModel.Extensions; using NexusMods.DataModel.Loadouts; using NexusMods.DataModel.Loadouts.LoadoutSynchronizerDTOs; @@ -24,22 +26,20 @@ public interface IRunGameTool : ITool /// /// public class RunGameTool : IRunGameTool -where T : AGame + where T : AGame { private readonly ILogger> _logger; private readonly T _game; private readonly IProcessFactory _processFactory; + private readonly IOSInterop _osInterop; - /// - /// The logger used to log execution. - /// The game to execute. - /// - /// - /// This constructor is usually called from DI. - /// - public RunGameTool(ILogger> logger, T game, IProcessFactory processFactory) + /// + /// Constructor. + /// + public RunGameTool(ILogger> logger, T game, IProcessFactory processFactory, IOSInterop osInterop) { _processFactory = processFactory; + _osInterop = osInterop; _game = game; _logger = logger; } @@ -53,10 +53,17 @@ public RunGameTool(ILogger> logger, T game, IProcessFactory proce /// public async Task Execute(Loadout loadout, ApplyPlan applyPlan, CancellationToken cancellationToken) { - var program = GetGamePath(loadout, applyPlan); - _logger.LogInformation("Running {Program}", program); + _logger.LogInformation("Starting {Name}", Name); + var program = GetGamePath(loadout, applyPlan); var primaryFile = _game.GetPrimaryFile(loadout.Installation.Store).CombineChecked(loadout.Installation); + + if (OSInformation.Shared.IsLinux && program.Equals(primaryFile) && loadout.Installation.LocatorResultMetadata is SteamLocatorResultMetadata steamLocatorResultMetadata) + { + await RunThroughSteam(steamLocatorResultMetadata.AppId, cancellationToken); + return; + } + var names = new HashSet() { program.FileName, @@ -83,7 +90,6 @@ public async Task Execute(Loadout loadout, ApplyPlan applyPlan, CancellationToke .WithValidation(CommandResultValidation.None) .WithWorkingDirectory(program.Parent.ToString()); - var result = await _processFactory.ExecuteAsync(command, cancellationToken); if (result.ExitCode != 0) _logger.LogError("While Running {Filename} : {Error} {Output}", program, stdErr, stdOut); @@ -107,6 +113,63 @@ public async Task Execute(Loadout loadout, ApplyPlan applyPlan, CancellationToke _logger.LogInformation("Finished running {Program}", program); } + private async Task RunThroughSteam(uint appId, CancellationToken cancellationToken) + { + var existingReaperProcesses = Process.GetProcessesByName("reaper"); + await _osInterop.OpenUrl(new Uri($"steam://rungameid//{appId.ToString(CultureInfo.InvariantCulture)}"), cancellationToken); + + if (OSInformation.Shared.IsWindows) + { + // TODO: + } else if (OSInformation.Shared.IsLinux) + { + var steam = await WaitForProcessToStart("steam", Array.Empty(), TimeSpan.FromMinutes(1), cancellationToken); + if (steam is null) return; + + // NOTE(erri120): Reaper is a custom tool for cleaning up child processes + // See https://github.com/sonic2kk/steamtinkerlaunch/wiki/Steam-Reaper for details. + var reaper = await WaitForProcessToStart("reaper", existingReaperProcesses, TimeSpan.FromMinutes(1), cancellationToken); + if (reaper is null) return; + + await reaper.WaitForExitAsync(cancellationToken); + } + else + { + throw OSInformation.Shared.CreatePlatformNotSupportedException(); + } + } + + private async ValueTask WaitForProcessToStart( + string processName, + Process[] existingProcesses, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + try + { + var start = DateTime.UtcNow; + while (!cancellationToken.IsCancellationRequested && start + timeout > DateTime.UtcNow) + { + var processes = Process.GetProcessesByName(processName); + var target = processes.FirstOrDefault(x => existingProcesses.All(p => p.Id != x.Id)); + if (target is not null) return target; + + await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken); + } + + return null; + } + catch (TaskCanceledException) + { + return null; + } + catch (Exception e) + { + _logger.LogError(e, "Exception while waiting for process \"{Process}\" to start", processName); + return null; + } + } + private static HashSet FindMatchingProcesses(HashSet names) { return Process.GetProcesses()