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()