diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index c3a561a9e..3d3f50d49 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; @@ -477,6 +478,108 @@ await Assert.ThrowsAsync(async () => }); } + /// + /// Verifies Steam launch setup is serialized across profiles that share an installation. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_ConcurrentSteamProfilesSharingInstallation_SerializesSetup() + { + // Arrange + var testRoot = Path.Combine( + Path.GetTempPath(), + "GenHub-GameLauncherAliasTests", + Guid.NewGuid().ToString("N")); + var physicalInstallationPath = Path.Combine(testRoot, "physical-installation"); + var installationAliasPath = Path.Combine(testRoot, "installation-alias"); + Directory.CreateDirectory(physicalInstallationPath); + CreateDirectoryAlias(installationAliasPath, physicalInstallationPath); + Assert.Equal( + InstallationPathLockKey.Create(physicalInstallationPath), + InstallationPathLockKey.Create(installationAliasPath), + InstallationPathLockKey.Comparer); + + var firstProfile = CreateTestProfile(); + firstProfile.UseSteamLaunch = true; + firstProfile.GameInstallationId = "physical-installation"; + var secondProfile = CreateTestProfile(); + secondProfile.UseSteamLaunch = true; + secondProfile.GameInstallationId = "installation-alias"; + + var physicalInstallation = new GameInstallation( + physicalInstallationPath, + GameInstallationType.Steam); + physicalInstallation.SetPaths(physicalInstallationPath, null); + var aliasInstallation = new GameInstallation( + installationAliasPath, + GameInstallationType.Steam); + aliasInstallation.SetPaths(installationAliasPath, null); + + _gameInstallationServiceMock.Setup(x => x.GetInstallationAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string installationId, CancellationToken _) => + OperationResult.CreateSuccess( + installationId == firstProfile.GameInstallationId + ? physicalInstallation + : aliasInstallation)); + + var cleanupStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCleanup = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupCalls = 0; + + _steamLauncherMock.Setup(x => x.CleanupGameDirectoryAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async () => + { + Interlocked.Increment(ref cleanupCalls); + cleanupStarted.TrySetResult(true); + await releaseCleanup.Task; + return OperationResult.CreateFailure("Injected cleanup stop."); + }); + + try + { + // Act + var firstLaunch = _gameLauncher.LaunchProfileAsync(firstProfile); + await cleanupStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var secondLaunch = _gameLauncher.LaunchProfileAsync(secondProfile); + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(250)); + Assert.Equal(1, Volatile.Read(ref cleanupCalls)); + } + finally + { + releaseCleanup.TrySetResult(true); + } + + // Assert + var results = await Task.WhenAll(firstLaunch, secondLaunch); + Assert.All(results, result => Assert.False(result.Success)); + Assert.Equal(2, Volatile.Read(ref cleanupCalls)); + } + finally + { + releaseCleanup.TrySetResult(true); + + if (Directory.Exists(installationAliasPath)) + { + Directory.Delete(installationAliasPath); + } + + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + /// /// Launches a profile with empty enabled content and asserts success. /// @@ -813,4 +916,30 @@ private static GameProfile CreateTestProfile() EnabledContentIds = ["1.0.genhub.mod.test"], }; } -} \ No newline at end of file + + private static void CreateDirectoryAlias(string aliasPath, string targetPath) + { + if (!OperatingSystem.IsWindows()) + { + Directory.CreateSymbolicLink(aliasPath, targetPath); + return; + } + + var startInfo = new ProcessStartInfo + { + FileName = "cmd.exe", + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("mklink"); + startInfo.ArgumentList.Add("/J"); + startInfo.ArgumentList.Add(aliasPath); + startInfo.ArgumentList.Add(targetPath); + + using var process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Failed to start junction creation process."); + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/SteamLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/SteamLauncherTests.cs new file mode 100644 index 000000000..491465c4a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/SteamLauncherTests.cs @@ -0,0 +1,424 @@ +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Launching; +using Microsoft.Extensions.Logging; +using Moq; + +namespace GenHub.Tests.Core.Features.Launching; + +/// +/// Filesystem tests for . +/// +public sealed class SteamLauncherTests : IDisposable +{ + private const string ExecutableName = "genhub-test-game.exe"; + private readonly string _tempDirectory; + private readonly string _gameInstallPath; + private readonly string _workspacePath; + private readonly string _originalExecutablePath; + private readonly string _workspaceExecutablePath; + private readonly string _proxySourcePath; + + /// + /// Initializes a new instance of the class. + /// + public SteamLauncherTests() + { + _tempDirectory = Path.Combine( + Path.GetTempPath(), + "GenHub-SteamLauncherTests", + Guid.NewGuid().ToString("N")); + _gameInstallPath = Path.Combine(_tempDirectory, "game"); + _workspacePath = Path.Combine(_tempDirectory, "workspace"); + _originalExecutablePath = Path.Combine(_gameInstallPath, ExecutableName); + _workspaceExecutablePath = Path.Combine(_workspacePath, "workspace-game.exe"); + _proxySourcePath = Path.Combine(_tempDirectory, SteamConstants.ProxyLauncherFileName); + + Directory.CreateDirectory(_gameInstallPath); + Directory.CreateDirectory(_workspacePath); + File.WriteAllText(_originalExecutablePath, "original executable"); + File.WriteAllText(_workspaceExecutablePath, "workspace executable"); + File.WriteAllText(_proxySourcePath, "proxy executable"); + } + + /// + /// Verifies a successful preparation deploys the proxy and preserves existing files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_ValidPaths_DeploysProxyAndPreservesExistingDependencies() + { + // Arrange + var backupPath = _originalExecutablePath + SteamConstants.BackupExtension; + var workspaceDependencyPath = Path.Combine(_workspacePath, "binkw32.dll"); + File.WriteAllText(Path.Combine(_gameInstallPath, "steam_api.dll"), "steam api"); + File.WriteAllText(Path.Combine(_gameInstallPath, "binkw32.dll"), "installation dependency"); + File.WriteAllText(workspaceDependencyPath, "pre-existing workspace dependency"); + + // Act + var result = await PrepareAsync(CreateLauncher(), steamAppId: "12345"); + + // Assert + Assert.True(result.Success, result.AllErrors); + Assert.Equal("proxy executable", File.ReadAllText(_originalExecutablePath)); + Assert.Equal("original executable", File.ReadAllText(backupPath)); + Assert.True(File.Exists(Path.Combine(_gameInstallPath, "proxy_config.json"))); + Assert.Equal("12345", File.ReadAllText(Path.Combine(_gameInstallPath, "steam_appid.txt"))); + Assert.Equal("12345", File.ReadAllText(Path.Combine(_workspacePath, "steam_appid.txt"))); + Assert.Equal("steam api", File.ReadAllText(Path.Combine(_workspacePath, "steam_api.dll"))); + Assert.Equal("pre-existing workspace dependency", File.ReadAllText(workspaceDependencyPath)); + Assert.Empty(GetRollbackArtifacts()); + } + + /// + /// Verifies invalid workspace input is rejected before the game executable is changed. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_MissingWorkspaceExecutable_DoesNotMutateInstallation() + { + // Arrange + var missingExecutable = Path.Combine(_workspacePath, "missing.exe"); + + // Act + var result = await PrepareAsync( + CreateLauncher(), + targetExecutablePath: missingExecutable); + + // Assert + Assert.False(result.Success); + Assert.Equal("original executable", File.ReadAllText(_originalExecutablePath)); + Assert.False(File.Exists(_originalExecutablePath + SteamConstants.BackupExtension)); + Assert.False(File.Exists(Path.Combine(_gameInstallPath, "proxy_config.json"))); + Assert.Empty(GetRollbackArtifacts()); + } + + /// + /// Verifies preparation can recover when a prior crash left only the executable backup. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_MissingTargetWithBackup_DeploysProxyFromRecoveryState() + { + // Arrange + var backupPath = _originalExecutablePath + SteamConstants.BackupExtension; + File.Move(_originalExecutablePath, backupPath); + + // Act + var result = await PrepareAsync(CreateLauncher()); + + // Assert + Assert.True(result.Success, result.AllErrors); + Assert.Equal("proxy executable", File.ReadAllText(_originalExecutablePath)); + Assert.Equal("original executable", File.ReadAllText(backupPath)); + Assert.Empty(GetRollbackArtifacts()); + } + + /// + /// Verifies a late write failure restores files changed by the current attempt. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_LateWriteFailure_RestoresOriginalAndPreExistingFiles() + { + // Arrange + var backupPath = _originalExecutablePath + SteamConstants.BackupExtension; + var configPath = Path.Combine(_gameInstallPath, "proxy_config.json"); + var workspaceAppIdPath = Path.Combine(_workspacePath, "steam_appid.txt"); + File.WriteAllText(_originalExecutablePath, "proxy executable"); + File.WriteAllText(backupPath, "pre-existing original executable"); + File.WriteAllText(configPath, "pre-existing config"); + File.WriteAllText(workspaceAppIdPath, "pre-existing app id"); + + var writeCount = 0; + async Task FailingWriter(string path, string contents, CancellationToken cancellationToken) + { + writeCount++; + await File.WriteAllTextAsync(path, contents, cancellationToken); + if (writeCount == 3) + { + throw new IOException("Injected late write failure."); + } + } + + // Act + var result = await PrepareAsync(CreateLauncher(FailingWriter), steamAppId: "12345"); + + // Assert + Assert.False(result.Success); + Assert.Contains("Injected late write failure", result.AllErrors); + Assert.Equal("pre-existing original executable", File.ReadAllText(_originalExecutablePath)); + Assert.Equal("pre-existing original executable", File.ReadAllText(backupPath)); + Assert.Equal("pre-existing config", File.ReadAllText(configPath)); + Assert.Equal("pre-existing app id", File.ReadAllText(workspaceAppIdPath)); + Assert.Empty(GetRollbackArtifacts()); + } + + /// + /// Verifies an uncertain pre-existing backup prevents preparation without changing either executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_UnrelatedPreExistingBackup_FailsWithoutMutation() + { + // Arrange + var backupPath = _originalExecutablePath + SteamConstants.BackupExtension; + File.WriteAllText(_originalExecutablePath, "current executable"); + File.WriteAllText(backupPath, "stale pre-existing backup"); + + // Act + var result = await PrepareAsync(CreateLauncher()); + + // Assert + Assert.False(result.Success); + Assert.Contains("unverified pre-existing backup", result.AllErrors); + Assert.Equal("current executable", File.ReadAllText(_originalExecutablePath)); + Assert.Equal("stale pre-existing backup", File.ReadAllText(backupPath)); + Assert.False(File.Exists(Path.Combine(_gameInstallPath, "proxy_config.json"))); + Assert.Empty(GetRollbackArtifacts()); + } + + /// + /// Verifies cleanup preserves an unrelated backup instead of overwriting a valid executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupGameDirectoryAsync_UnrelatedBackup_PreservesExecutableAndArtifacts() + { + // Arrange + var backupPath = _originalExecutablePath + SteamConstants.BackupExtension; + var configPath = Path.Combine(_gameInstallPath, "proxy_config.json"); + File.WriteAllText(backupPath, "stale pre-existing backup"); + File.WriteAllText(configPath, "pre-existing config"); + + // Act + var result = await CreateLauncher().CleanupGameDirectoryAsync( + _gameInstallPath, + ExecutableName); + + // Assert + Assert.False(result.Success); + Assert.Equal("original executable", File.ReadAllText(_originalExecutablePath)); + Assert.Equal("stale pre-existing backup", File.ReadAllText(backupPath)); + Assert.Equal("pre-existing config", File.ReadAllText(configPath)); + } + + /// + /// Verifies cleanup restores a backup only when the installed executable is the known proxy. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupGameDirectoryAsync_DeployedProxy_RestoresVerifiedBackup() + { + // Arrange + var backupPath = _originalExecutablePath + SteamConstants.BackupExtension; + var configPath = Path.Combine(_gameInstallPath, "proxy_config.json"); + File.WriteAllText(_originalExecutablePath, "proxy executable"); + File.WriteAllText(backupPath, "original executable"); + File.WriteAllText(configPath, "prepared config"); + + // Act + var result = await CreateLauncher().CleanupGameDirectoryAsync( + _gameInstallPath, + ExecutableName); + + // Assert + Assert.True(result.Success, result.AllErrors); + Assert.Equal("original executable", File.ReadAllText(_originalExecutablePath)); + Assert.False(File.Exists(backupPath)); + Assert.False(File.Exists(configPath)); + } + + /// + /// Verifies cleanup fails without deleting proxy artifacts when the original backup is missing. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupGameDirectoryAsync_DeployedProxyWithoutBackup_FailsClosed() + { + // Arrange + var configPath = Path.Combine(_gameInstallPath, "proxy_config.json"); + File.WriteAllText(_originalExecutablePath, "proxy executable"); + File.WriteAllText(configPath, "prepared config"); + + // Act + var result = await CreateLauncher().CleanupGameDirectoryAsync( + _gameInstallPath, + ExecutableName); + + // Assert + Assert.False(result.Success); + Assert.Equal("proxy executable", File.ReadAllText(_originalExecutablePath)); + Assert.Equal("prepared config", File.ReadAllText(configPath)); + } + + /// + /// Verifies concurrent preparations for one installation cannot overlap their mutations. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_ConcurrentProfilesSharingInstallation_SerializesMutations() + { + // Arrange + var firstWriteStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstWrite = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var secondWriteStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + async Task BlockingWriter(string path, string contents, CancellationToken cancellationToken) + { + firstWriteStarted.TrySetResult(true); + await releaseFirstWrite.Task.WaitAsync(cancellationToken); + await File.WriteAllTextAsync(path, contents, cancellationToken); + } + + async Task ObservedWriter(string path, string contents, CancellationToken cancellationToken) + { + secondWriteStarted.TrySetResult(true); + await File.WriteAllTextAsync(path, contents, cancellationToken); + } + + var firstPreparation = PrepareAsync( + CreateLauncher(BlockingWriter), + profileId: "first-profile"); + await firstWriteStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var secondPreparation = PrepareAsync( + CreateLauncher(ObservedWriter), + profileId: "second-profile"); + + try + { + var prematureSecondWrite = await Task.WhenAny( + secondWriteStarted.Task, + Task.Delay(TimeSpan.FromMilliseconds(250))); + Assert.NotSame(secondWriteStarted.Task, prematureSecondWrite); + } + finally + { + releaseFirstWrite.TrySetResult(true); + } + + // Assert + var results = await Task.WhenAll(firstPreparation, secondPreparation); + Assert.All(results, result => Assert.True(result.Success, result.AllErrors)); + Assert.True(secondWriteStarted.Task.IsCompleted); + } + + /// + /// Verifies cancellation after executable replacement restores the original executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_CanceledAfterMutation_RollsBackCurrentAttempt() + { + // Arrange + using var cancellationSource = new CancellationTokenSource(); + var writeCount = 0; + + async Task CancelingWriter(string path, string contents, CancellationToken cancellationToken) + { + writeCount++; + await File.WriteAllTextAsync(path, contents, cancellationToken); + if (writeCount == 2) + { + cancellationSource.Cancel(); + } + } + + // Act + var result = await PrepareAsync( + CreateLauncher(CancelingWriter), + steamAppId: "12345", + cancellationToken: cancellationSource.Token); + + // Assert + Assert.False(result.Success); + Assert.Contains("canceled", result.AllErrors, StringComparison.OrdinalIgnoreCase); + Assert.Equal("original executable", File.ReadAllText(_originalExecutablePath)); + Assert.False(File.Exists(_originalExecutablePath + SteamConstants.BackupExtension)); + Assert.False(File.Exists(Path.Combine(_gameInstallPath, "proxy_config.json"))); + Assert.Empty(GetRollbackArtifacts()); + } + + /// + /// Verifies rollback reports an unsafe executable conflict and retains recovery copies. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareForProfileAsync_ExecutableChangesDuringFailure_ReportsRollbackConflict() + { + // Arrange + async Task ConflictingWriter(string path, string contents, CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(path, contents, cancellationToken); + File.WriteAllText(_originalExecutablePath, "external executable change"); + throw new IOException("Injected write failure."); + } + + // Act + var result = await PrepareAsync(CreateLauncher(ConflictingWriter)); + + // Assert + Assert.False(result.Success); + Assert.Contains("did not overwrite unexpectedly changed executable", result.AllErrors); + Assert.Equal("external executable change", File.ReadAllText(_originalExecutablePath)); + Assert.Equal( + "original executable", + File.ReadAllText(_originalExecutablePath + SteamConstants.BackupExtension)); + Assert.NotEmpty(GetRollbackArtifacts()); + } + + /// + /// Deletes the temporary test directory. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + + GC.SuppressFinalize(this); + } + + private SteamLauncher CreateLauncher( + Func? writer = null) + { + var logger = new Mock>(); + return new SteamLauncher( + logger.Object, + _proxySourcePath, + writer ?? File.WriteAllTextAsync); + } + + private Task> PrepareAsync( + SteamLauncher launcher, + string? targetExecutablePath = null, + string? steamAppId = null, + string profileId = "test-profile", + CancellationToken cancellationToken = default) + { + return launcher.PrepareForProfileAsync( + _gameInstallPath, + profileId, + Array.Empty(), + ExecutableName, + targetExecutablePath ?? _workspaceExecutablePath, + _workspacePath, + steamAppId: steamAppId, + cancellationToken: cancellationToken); + } + + private string[] GetRollbackArtifacts() + { + return Directory.GetFiles( + _tempDirectory, + "*.genhub-rollback-*", + SearchOption.AllDirectories); + } +} diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index d0a7e017d..a9c0534d1 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -51,6 +51,9 @@ public class GameLauncher( IConfigurationProviderService configurationProvider) : IGameLauncher { private static readonly ConcurrentDictionary _profileLaunchLocks = new(); + private static readonly ConcurrentDictionary _steamInstallationLaunchLocks = + new(InstallationPathLockKey.Comparer); + private static readonly SearchValues InvalidArgChars = SearchValues.Create(";|&\n\r`$%"); /// @@ -62,6 +65,18 @@ public async Task AcquireProfileLockAsync(string profileId, Cancell return new SemaphoreReleaser(semaphore); } + private static async Task AcquireSteamInstallationLockAsync( + string installationPath, + CancellationToken cancellationToken) + { + var normalizedPath = InstallationPathLockKey.Create(installationPath); + var semaphore = _steamInstallationLaunchLocks.GetOrAdd( + normalizedPath, + _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + return new SemaphoreReleaser(semaphore); + } + /// /// Helper class to release semaphore when disposed. /// @@ -454,6 +469,8 @@ private static bool IsValidCommandArgument(string arg) private async Task> LaunchProfileAsync(GameProfile profile, bool skipUserDataCleanup, IProgress? progress, string launchId, CancellationToken cancellationToken) { + IDisposable? steamInstallationLock = null; + try { logger.LogInformation("[GameLauncher] === Starting launch for profile '{ProfileName}' (ID: {ProfileId}) ===", profile.Name, profile.Id); @@ -607,6 +624,9 @@ private async Task> LaunchProfileAsync(Gam if (isSteamLaunch) { + steamInstallationLock = await AcquireSteamInstallationLockAsync( + actualInstallationPath, + cancellationToken); logger.LogInformation("[GameLauncher] Steam launch detected - workspace will be adjacent to installation in .genhub-workspace directory"); // Workspace should be adjacent to the installation directory, not inside it @@ -649,7 +669,20 @@ private async Task> LaunchProfileAsync(Gam // Always use generals.exe for Steam cleanup (the actual Steam executable) var steamExecutableName = GameClientConstants.GeneralsExecutable; - await steamLauncher.CleanupGameDirectoryAsync(actualInstallationPath, steamExecutableName, cancellationToken); + var cleanupResult = await steamLauncher.CleanupGameDirectoryAsync( + actualInstallationPath, + steamExecutableName, + cancellationToken); + if (!cleanupResult.Success) + { + logger.LogError( + "[GameLauncher] Pre-launch Steam cleanup failed: {Error}", + cleanupResult.FirstError); + return LaunchOperationResult.CreateFailure( + $"Failed to restore the Steam installation before launch: {cleanupResult.FirstError}", + launchId, + profile.Id); + } } } @@ -1048,6 +1081,10 @@ private async Task> LaunchProfileAsync(Gam await launchRegistry.UnregisterLaunchAsync(launchId); return LaunchOperationResult.CreateFailure($"Launch failed: {ex.Message}", launchId, profile.Id); } + finally + { + steamInstallationLock?.Dispose(); + } } /// diff --git a/GenHub/GenHub/Features/Launching/InstallationPathLockKey.cs b/GenHub/GenHub/Features/Launching/InstallationPathLockKey.cs new file mode 100644 index 000000000..36f13af20 --- /dev/null +++ b/GenHub/GenHub/Features/Launching/InstallationPathLockKey.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; + +namespace GenHub.Features.Launching; + +/// +/// Creates stable lock keys for installation paths, including filesystem aliases. +/// +internal static class InstallationPathLockKey +{ + /// + /// Gets the platform-appropriate lock-key comparer. + /// + public static StringComparer Comparer { get; } = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + /// + /// Creates a lock key with existing symbolic-link and junction components resolved. + /// + /// The installation directory path. + /// The canonical installation lock key. + public static string Create(string installationPath) + { + var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(installationPath)); + return Path.TrimEndingDirectorySeparator(ResolvePathComponents(fullPath)); + } + + private static string ResolvePathComponents(string fullPath) + { + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + { + return fullPath; + } + + var currentPath = root; + var relativePath = fullPath[root.Length..]; + var separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + + foreach (var segment in relativePath.Split(separators, StringSplitOptions.RemoveEmptyEntries)) + { + currentPath = Path.Combine(currentPath, segment); + if (!Directory.Exists(currentPath)) + { + continue; + } + + var resolvedTarget = Directory.ResolveLinkTarget(currentPath, returnFinalTarget: true); + if (resolvedTarget is not null) + { + currentPath = ResolvePathComponents(Path.GetFullPath(resolvedTarget.FullName)); + } + } + + return currentPath; + } +} diff --git a/GenHub/GenHub/Features/Launching/SteamLauncher.cs b/GenHub/GenHub/Features/Launching/SteamLauncher.cs index 1aca8c0af..0de666b4f 100644 --- a/GenHub/GenHub/Features/Launching/SteamLauncher.cs +++ b/GenHub/GenHub/Features/Launching/SteamLauncher.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -18,19 +21,52 @@ namespace GenHub.Features.Launching; /// Service for preparing game directories for Steam-tracked profile launches. /// This approach uses a "Proxy Launcher" mechanism: /// 1. We start a Workspace as usual (isolated environment). -/// 2. We drop a sidecar ProxyLauncher.exe next to the original game executables (we do NOT overwrite them). +/// 2. We back up and replace the original game executable with the proxy. /// 3. We write a proxy_config.json telling the Proxy to launch the Workspace executable using direct paths. -/// 4. Steam launch options can point at the sidecar proxy; the proxy then runs the Workspace game. +/// 4. Steam launches the proxy under the original executable name; the proxy then runs the Workspace game. /// /// Each profile uses its own adjacent workspace: {installationRoot}\.genhub-workspace\{profileId}\ /// The proxy_config.json is regenerated on each launch with the correct workspace paths for that profile. -/// This keeps the game directory clean (no exe replacement) while maintaining Steam integration. /// -public class SteamLauncher( - ILogger logger) : ISteamLauncher +public class SteamLauncher : ISteamLauncher { private const string ProxyConfigFileName = "proxy_config.json"; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private static readonly StringComparer PathComparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private static readonly ConcurrentDictionary _installationMutationLocks = + new(InstallationPathLockKey.Comparer); + + private readonly ILogger _logger; + private readonly string? _proxySourcePathOverride; + private readonly Func _writeAllTextAsync; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public SteamLauncher(ILogger logger) + : this(logger, null, File.WriteAllTextAsync) + { + } + + /// + /// Initializes a new instance of the class with test seams. + /// + /// The logger. + /// An optional proxy source path override. + /// The text file writer. + internal SteamLauncher( + ILogger logger, + string? proxySourcePathOverride, + Func writeAllTextAsync) + { + _logger = logger; + _proxySourcePathOverride = proxySourcePathOverride; + _writeAllTextAsync = writeAllTextAsync; + } /// /// Configuration for the proxy launcher. @@ -58,153 +94,82 @@ public async Task> PrepareForProfileAsync string? steamAppId = null, CancellationToken cancellationToken = default) { + PreparationRollback? rollback = null; + IDisposable? installationMutationLock = null; + try { - logger.LogInformation( + gameInstallPath = Path.GetFullPath(gameInstallPath); + installationMutationLock = await AcquireInstallationMutationLockAsync( + gameInstallPath, + cancellationToken); + _logger.LogInformation( "[SteamLauncher] Preparing game directory {Path} for profile {ProfileId} using Proxy Launcher", gameInstallPath, profileId); - var proxyConfigPath = Path.Combine(gameInstallPath, ProxyConfigFileName); - var proxyDeployPath = Path.Combine(gameInstallPath, SteamConstants.ProxyLauncherFileName); - - // 1. Ensure we have the ProxyLauncher binary available - var currentBaseDir = AppDomain.CurrentDomain.BaseDirectory; - var proxySourcePath = Path.Combine(currentBaseDir, SteamConstants.ProxyLauncherFileName); + cancellationToken.ThrowIfCancellationRequested(); + // Validate every prerequisite that can be checked without changing the installation. + var proxySourcePath = ResolveProxySourcePath(); if (!File.Exists(proxySourcePath)) { - logger.LogDebug("[SteamLauncher] Proxy Launcher not found in base directory: {Path}. Checking fallbacks...", proxySourcePath); - - // Fallback 1: Development path relative to typical bin output structure - var devPaths = new[] - { - Path.GetFullPath(Path.Combine(currentBaseDir, "..", "..", "..", "..", "GenHub.ProxyLauncher", "bin", "Debug", "net8.0-windows", "win-x64", "GenHub.ProxyLauncher.exe")), - Path.GetFullPath(Path.Combine(currentBaseDir, "..", "..", "..", "..", "GenHub.ProxyLauncher", "bin", "Release", "net8.0-windows", "win-x64", "GenHub.ProxyLauncher.exe")), - Path.GetFullPath(Path.Combine(currentBaseDir, "net8.0-windows", "GenHub.ProxyLauncher.exe")), // In case it's in a subfolder - }; - - foreach (var devPath in devPaths) - { - if (File.Exists(devPath)) - { - proxySourcePath = devPath; - break; - } - } + return OperationResult.CreateFailure( + $"Proxy Launcher binary not found at {proxySourcePath}. Please build GenHub.ProxyLauncher project."); + } - if (!File.Exists(proxySourcePath)) - { - return OperationResult.CreateFailure( - $"Proxy Launcher binary not found at {proxySourcePath}. Please build GenHub.ProxyLauncher project."); - } + if (!Directory.Exists(gameInstallPath)) + { + return OperationResult.CreateFailure( + $"Game installation directory not found: {gameInstallPath}"); } - // 2. Deploy Proxy Launcher as the game executable - // Steam launches the game executable (e.g., generals.exe), so we replace it with our proxy. - // The proxy then launches the actual workspace executable. - // Original game exe is backed up to .ghbak for restoration and version detection. - var targetExeName = executableName; // e.g., "generals.exe" or "game.exe" (Zero Hour) - var targetExePath = Path.Combine(gameInstallPath, targetExeName); + var targetExePath = Path.Combine(gameInstallPath, executableName); var backupPath = targetExePath + SteamConstants.BackupExtension; + var proxyConfigPath = Path.Combine(gameInstallPath, ProxyConfigFileName); - // Backup original executable if not already backed up - if (!File.Exists(backupPath) && File.Exists(targetExePath)) + if (Directory.Exists(targetExePath)) { - logger.LogInformation( - "[SteamLauncher] Backing up original {Exe} to {Backup}", - targetExeName, - Path.GetFileName(backupPath)); - - try - { - File.Copy(targetExePath, backupPath, overwrite: false); - } - catch (Exception ex) - { - logger.LogWarning(ex, "[SteamLauncher] Failed to create backup of {Exe}", targetExeName); - return OperationResult.CreateFailure( - $"Failed to backup original executable: {ex.Message}"); - } + return OperationResult.CreateFailure( + $"Game executable path is a directory: {targetExePath}"); } - // Deploy proxy (always overwrite - if backup exists, target is our old proxy) - logger.LogInformation("[SteamLauncher] Deploying proxy launcher as {Exe}", targetExeName); - - try + if (!File.Exists(targetExePath) && !File.Exists(backupPath)) { - // Try to kill any running instances of the target executable to release file lock - var processName = Path.GetFileNameWithoutExtension(targetExePath); - var runningProcesses = Process.GetProcessesByName(processName); - foreach (var process in runningProcesses) - { - try - { - if (process.MainModule?.FileName?.Equals(targetExePath, StringComparison.OrdinalIgnoreCase) == true) - { - logger.LogWarning( - "[SteamLauncher] Killing running process {ProcessName} ({Pid}) to update proxy", - process.ProcessName, - process.Id); - process.Kill(); - process.WaitForExit(1000); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "[SteamLauncher] Failed to kill process {Pid}", process.Id); - } - } - - // Wait briefly for file lock to release - if (runningProcesses.Length > 0) - { - Thread.Sleep(500); - } - - File.Copy(proxySourcePath, targetExePath, overwrite: true); - logger.LogInformation("[SteamLauncher] Successfully deployed proxy as {Exe}", targetExeName); + return OperationResult.CreateFailure( + $"Original game executable not found: {targetExePath}"); } - catch (Exception ex) + + if (Directory.Exists(backupPath)) { - logger.LogError(ex, "[SteamLauncher] Failed to deploy proxy as {Exe}", targetExeName); return OperationResult.CreateFailure( - $"Failed to deploy proxy launcher: {ex.Message}"); + $"Backup executable path is a directory: {backupPath}"); } - var primaryDeployedPath = targetExePath; - - // 3. Create Proxy Config (always points to the workspace executable) - var effectiveTargetExecutable = targetExecutablePath; - - // Validate that the target executable exists + var effectiveTargetExecutable = Path.GetFullPath(targetExecutablePath); if (!File.Exists(effectiveTargetExecutable)) { return OperationResult.CreateFailure( $"Target executable not found: {effectiveTargetExecutable}. Workspace may not be properly prepared."); } - // Ensure paths are absolute - effectiveTargetExecutable = Path.GetFullPath(effectiveTargetExecutable); var effectiveWorkingDirectory = string.IsNullOrEmpty(targetWorkingDirectory) ? Path.GetDirectoryName(effectiveTargetExecutable) ?? string.Empty : Path.GetFullPath(targetWorkingDirectory); - // Use direct workspace paths - proxy_config.json is regenerated on each launch with correct paths - // Each profile uses its own adjacent workspace: {installationRoot}\.genhub-workspace\{profileId}\ - // This removes the .genhub-workspace-active junction indirection layer - logger.LogInformation( - "[SteamLauncher] Using direct workspace paths - Target: {Target}, WorkDir: {WorkDir}", - effectiveTargetExecutable, - effectiveWorkingDirectory); - - // Validate working directory exists if (!Directory.Exists(effectiveWorkingDirectory)) { return OperationResult.CreateFailure( $"Working directory not found: {effectiveWorkingDirectory}"); } + var targetDirectory = Path.GetDirectoryName(effectiveTargetExecutable); + if (string.IsNullOrEmpty(targetDirectory) || !Directory.Exists(targetDirectory)) + { + return OperationResult.CreateFailure( + $"Target executable directory not found: {targetDirectory}"); + } + var config = new ProxyConfig { TargetExecutable = effectiveTargetExecutable, @@ -214,52 +179,84 @@ public async Task> PrepareForProfileAsync }; var configJson = JsonSerializer.Serialize(config, JsonOptions); - await File.WriteAllTextAsync(proxyConfigPath, configJson, cancellationToken); - logger.LogInformation("[SteamLauncher] Wrote proxy config to {Path}", proxyConfigPath); - logger.LogInformation( - "[SteamLauncher] Proxy config - Target: {Target}, WorkDir: {WorkDir}, Args: {ArgCount}", - config.TargetExecutable, - config.WorkingDirectory, - config.Arguments.Length); - - // 5. CRITICAL: Always write steam_appid.txt next to BOTH the working directory and the target executable. - // Steam overlays/readiness logic checks the executable directory first. Many modded game clients call SteamAPI_Init - // and immediately look for steam_appid.txt adjacent to their EXE. If we only write it to the install dir, the - // workspace executable will trigger SteamAPI_RestartAppIfNecessary and surface the "invalid license" error. - if (!string.IsNullOrEmpty(steamAppId)) + var appIdDirectories = string.IsNullOrEmpty(steamAppId) + ? [] + : new[] { effectiveWorkingDirectory, targetDirectory, gameInstallPath } + .Distinct(PathComparer) + .ToArray(); + var filesToCapture = new List { proxyConfigPath }; + + foreach (var directory in appIdDirectories) { - await WriteSteamAppIdAsync(steamAppId, effectiveWorkingDirectory, cancellationToken); + filesToCapture.Add(Path.Combine(directory, "steam_appid.txt")); + } - var targetDirectory = Path.GetDirectoryName(effectiveTargetExecutable); - if (!string.IsNullOrEmpty(targetDirectory) && - !string.Equals(targetDirectory, effectiveWorkingDirectory, StringComparison.OrdinalIgnoreCase)) + foreach (var path in filesToCapture.Distinct(PathComparer)) + { + if (Directory.Exists(path)) { - await WriteSteamAppIdAsync(steamAppId, targetDirectory, cancellationToken); + return OperationResult.CreateFailure( + $"Required file path is a directory: {path}"); } } - // 6. Ensure steam_appid.txt also exists alongside the proxy (Steam sometimes checks the launch dir) - if (!string.IsNullOrEmpty(steamAppId)) + var dependencyCopies = GetRequiredDependencyCopies( + gameInstallPath, + [effectiveWorkingDirectory, targetDirectory]); + foreach (var (_, destinationPath) in dependencyCopies) { - await WriteSteamAppIdAsync(steamAppId, gameInstallPath, cancellationToken); + if (Directory.Exists(destinationPath)) + { + return OperationResult.CreateFailure( + $"Runtime dependency path is a directory: {destinationPath}"); + } } - // 7. Critical: Ensure steam_api.dll and other runtime dependencies exist where the target exe loads from. - // Some modded clients are distributed without these files in their manifests (to keep manifests minimal). - // Missing steam_api.dll is the primary cause of "invalid license" when the workspace executable calls SteamAPI_Init. - await EnsureRuntimeDependenciesAsync(gameInstallPath, effectiveWorkingDirectory, cancellationToken); + rollback = new PreparationRollback( + targetExePath, + backupPath, + proxySourcePath, + filesToCapture); + + cancellationToken.ThrowIfCancellationRequested(); + await StopRunningTargetProcessesAsync(targetExePath, cancellationToken); - var targetDirForDeps = Path.GetDirectoryName(effectiveTargetExecutable); - if (!string.IsNullOrEmpty(targetDirForDeps) && - !string.Equals(targetDirForDeps, effectiveWorkingDirectory, StringComparison.OrdinalIgnoreCase)) + rollback.PrepareExecutableBackup(); + rollback.DeployProxy(); + + _logger.LogInformation("[SteamLauncher] Successfully deployed proxy as {Exe}", executableName); + _logger.LogInformation( + "[SteamLauncher] Using direct workspace paths - Target: {Target}, WorkDir: {WorkDir}", + effectiveTargetExecutable, + effectiveWorkingDirectory); + + await rollback.WriteTextAsync(proxyConfigPath, configJson, _writeAllTextAsync, cancellationToken); + _logger.LogInformation("[SteamLauncher] Wrote proxy config to {Path}", proxyConfigPath); + _logger.LogInformation( + "[SteamLauncher] Proxy config - Target: {Target}, WorkDir: {WorkDir}, Args: {ArgCount}", + config.TargetExecutable, + config.WorkingDirectory, + config.Arguments.Length); + + foreach (var directory in appIdDirectories) { - await EnsureRuntimeDependenciesAsync(gameInstallPath, targetDirForDeps, cancellationToken); + await WriteSteamAppIdAsync(steamAppId!, directory, rollback, cancellationToken); } - // Return result + foreach (var (sourcePath, destinationPath) in dependencyCopies) + { + await rollback.CopyNewFileAsync(sourcePath, destinationPath, cancellationToken); + _logger.LogInformation( + "[SteamLauncher] Copied missing critical file {File} to {Destination}", + Path.GetFileName(sourcePath), + Path.GetDirectoryName(destinationPath)); + } + + cancellationToken.ThrowIfCancellationRequested(); + var result = new SteamLaunchPrepResult { - ExecutablePath = primaryDeployedPath, // Sidecar proxy path (leave originals untouched) + ExecutablePath = targetExePath, WorkingDirectory = gameInstallPath, ProfileId = profileId, FilesLinked = 0, @@ -268,72 +265,100 @@ public async Task> PrepareForProfileAsync SteamAppId = steamAppId, }; + rollback.Commit(); return OperationResult.CreateSuccess(result); } catch (Exception ex) { - logger.LogError(ex, "[SteamLauncher] Failed to prepare proxy for profile {ProfileId}", profileId); - return OperationResult.CreateFailure($"Failed to prepare proxy: {ex.Message}"); + _logger.LogError(ex, "[SteamLauncher] Failed to prepare proxy for profile {ProfileId}", profileId); + + var errors = new List + { + ex is OperationCanceledException + ? "Steam proxy preparation was canceled." + : $"Failed to prepare proxy: {ex.Message}", + }; + + if (rollback is not null) + { + errors.AddRange(rollback.Rollback()); + } + + return OperationResult.CreateFailure(errors); + } + finally + { + installationMutationLock?.Dispose(); } } /// - public Task> CleanupGameDirectoryAsync( + public async Task> CleanupGameDirectoryAsync( string gameInstallPath, string executableName, CancellationToken cancellationToken = default) { + IDisposable? installationMutationLock = null; + try { - logger.LogInformation("[SteamLauncher] Cleaning up game directory: {Path}", gameInstallPath); - - // 1. Remove Proxy Config - var proxyConfigPath = Path.Combine(gameInstallPath, ProxyConfigFileName); - if (File.Exists(proxyConfigPath)) - { - logger.LogDebug("[SteamLauncher] Removing proxy config: {Path}", proxyConfigPath); - File.Delete(proxyConfigPath); - } + gameInstallPath = Path.GetFullPath(gameInstallPath); + installationMutationLock = await AcquireInstallationMutationLockAsync( + gameInstallPath, + cancellationToken); + _logger.LogInformation("[SteamLauncher] Cleaning up game directory: {Path}", gameInstallPath); - // 2. Restore original game executable from backup var targetExePath = Path.Combine(gameInstallPath, executableName); var backupPath = targetExePath + SteamConstants.BackupExtension; if (File.Exists(backupPath)) { - logger.LogInformation( - "[SteamLauncher] Restoring original {Exe} from backup", - executableName); - - try + if (File.Exists(targetExePath)) { - // Delete the proxy (current exe) - if (File.Exists(targetExePath)) + var proxySourcePath = ResolveProxySourcePath(); + if (!File.Exists(proxySourcePath) || + !FilesAreEqual(targetExePath, proxySourcePath)) { - File.Delete(targetExePath); + var error = + $"Refusing to replace '{targetExePath}' from the unverified backup '{backupPath}'."; + _logger.LogError("[SteamLauncher] {Error}", error); + return OperationResult.CreateFailure(error); } - - // Restore original from backup - File.Move(backupPath, targetExePath); - logger.LogInformation( - "[SteamLauncher] Successfully restored original {Exe}", - executableName); - } - catch (Exception ex) - { - logger.LogWarning( - ex, - "[SteamLauncher] Failed to restore original {Exe} from backup", - executableName); } + + _logger.LogInformation( + "[SteamLauncher] Restoring original {Exe} from backup", + executableName); + File.Move(backupPath, targetExePath, overwrite: true); + _logger.LogInformation( + "[SteamLauncher] Successfully restored original {Exe}", + executableName); } else { - logger.LogDebug( + var proxySourcePath = ResolveProxySourcePath(); + if (File.Exists(targetExePath) && + File.Exists(proxySourcePath) && + FilesAreEqual(targetExePath, proxySourcePath)) + { + var error = + $"Cannot restore '{targetExePath}' because its original backup is missing."; + _logger.LogError("[SteamLauncher] {Error}", error); + return OperationResult.CreateFailure(error); + } + + _logger.LogDebug( "[SteamLauncher] No backup found for {Exe}, skipping restoration", executableName); } + var proxyConfigPath = Path.Combine(gameInstallPath, ProxyConfigFileName); + if (File.Exists(proxyConfigPath)) + { + _logger.LogDebug("[SteamLauncher] Removing proxy config: {Path}", proxyConfigPath); + File.Delete(proxyConfigPath); + } + // Cleanup any tracking file if it still exists from old version var trackingPath = Path.Combine(gameInstallPath, SteamConstants.TrackingFileName); if (File.Exists(trackingPath)) @@ -343,23 +368,38 @@ public Task> CleanupGameDirectoryAsync( // Note: .genhub-workspace-active junction cleanup removed - no longer using junctions // Each profile uses its own adjacent workspace directly - logger.LogInformation("[SteamLauncher] Cleaned up game directory artifacts: {Path}", gameInstallPath); - return Task.FromResult(OperationResult.CreateSuccess(true)); + _logger.LogInformation("[SteamLauncher] Cleaned up game directory artifacts: {Path}", gameInstallPath); + return OperationResult.CreateSuccess(true); } catch (Exception ex) { - logger.LogError(ex, "[SteamLauncher] Failed to cleanup game directory: {Path}", gameInstallPath); - return Task.FromResult(OperationResult.CreateFailure($"Failed to cleanup: {ex.Message}")); + _logger.LogError(ex, "[SteamLauncher] Failed to cleanup game directory: {Path}", gameInstallPath); + return OperationResult.CreateFailure($"Failed to cleanup: {ex.Message}"); + } + finally + { + installationMutationLock?.Dispose(); } } - private async Task WriteSteamAppIdAsync(string steamAppId, string directory, CancellationToken cancellationToken) + private static async Task AcquireInstallationMutationLockAsync( + string installationPath, + CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(directory)) - { - return; - } + var normalizedPath = InstallationPathLockKey.Create(installationPath); + var semaphore = _installationMutationLocks.GetOrAdd( + normalizedPath, + _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken); + return new SemaphoreReleaser(semaphore); + } + private async Task WriteSteamAppIdAsync( + string steamAppId, + string directory, + PreparationRollback rollback, + CancellationToken cancellationToken) + { var appIdPath = Path.Combine(directory, "steam_appid.txt"); // Check current content - only rewrite if different (avoid breaking hardlinks unnecessarily) @@ -370,47 +410,536 @@ private async Task WriteSteamAppIdAsync(string steamAppId, string directory, Can needsWrite = currentContent.Trim() != steamAppId; if (needsWrite) { - logger.LogWarning( + _logger.LogWarning( "[SteamLauncher] steam_appid.txt has wrong ID ({WrongId}), overwriting with correct ID ({CorrectId})", currentContent.Trim(), steamAppId); - - // Delete the hardlink to avoid modifying original - File.Delete(appIdPath); } } if (needsWrite) { - await File.WriteAllTextAsync(appIdPath, steamAppId, cancellationToken); - logger.LogInformation( + await rollback.WriteTextAsync(appIdPath, steamAppId, _writeAllTextAsync, cancellationToken); + _logger.LogInformation( "[SteamLauncher] Wrote steam_appid.txt ({AppId}) to {Path}", steamAppId, directory); } } - private async Task EnsureRuntimeDependenciesAsync(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + private string ResolveProxySourcePath() + { + if (!string.IsNullOrEmpty(_proxySourcePathOverride)) + { + return Path.GetFullPath(_proxySourcePathOverride); + } + + var currentBaseDir = AppDomain.CurrentDomain.BaseDirectory; + var defaultPath = Path.Combine(currentBaseDir, SteamConstants.ProxyLauncherFileName); + if (File.Exists(defaultPath)) + { + return defaultPath; + } + + _logger.LogDebug( + "[SteamLauncher] Proxy Launcher not found in base directory: {Path}. Checking fallbacks...", + defaultPath); + + var developmentPaths = new[] + { + Path.GetFullPath(Path.Combine(currentBaseDir, "..", "..", "..", "..", "GenHub.ProxyLauncher", "bin", "Debug", "net8.0-windows", "win-x64", "GenHub.ProxyLauncher.exe")), + Path.GetFullPath(Path.Combine(currentBaseDir, "..", "..", "..", "..", "GenHub.ProxyLauncher", "bin", "Release", "net8.0-windows", "win-x64", "GenHub.ProxyLauncher.exe")), + Path.GetFullPath(Path.Combine(currentBaseDir, "net8.0-windows", "GenHub.ProxyLauncher.exe")), + }; + + return developmentPaths.FirstOrDefault(File.Exists) ?? defaultPath; + } + + private async Task StopRunningTargetProcessesAsync( + string targetExePath, + CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(destinationDirectory)) + var processName = Path.GetFileNameWithoutExtension(targetExePath); + var runningProcesses = Process.GetProcessesByName(processName); + + try + { + foreach (var process in runningProcesses) + { + try + { + if (process.MainModule?.FileName is string processPath && + PathComparer.Equals(Path.GetFullPath(processPath), targetExePath)) + { + _logger.LogWarning( + "[SteamLauncher] Killing running process {ProcessName} ({Pid}) to update proxy", + process.ProcessName, + process.Id); + process.Kill(); + process.WaitForExit(1000); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[SteamLauncher] Failed to kill process {Pid}", process.Id); + } + } + + if (runningProcesses.Length > 0) + { + await Task.Delay(500, cancellationToken); + } + } + finally { - return; + foreach (var process in runningProcesses) + { + process.Dispose(); + } } + } + private List<(string SourcePath, string DestinationPath)> GetRequiredDependencyCopies( + string sourceDirectory, + IEnumerable destinationDirectories) + { var filesToEnsure = new[] { "steam_api.dll", "binkw32.dll", "mss32.dll" }; - foreach (var file in filesToEnsure) + var copies = new List<(string SourcePath, string DestinationPath)>(); + + foreach (var destinationDirectory in destinationDirectories.Distinct(PathComparer)) + { + foreach (var file in filesToEnsure) + { + var sourcePath = Path.Combine(sourceDirectory, file); + var destinationPath = Path.Combine(destinationDirectory, file); + if (File.Exists(sourcePath) && !File.Exists(destinationPath)) + { + copies.Add((sourcePath, destinationPath)); + } + } + } + + return copies; + } + + private bool FilesAreEqual(string firstPath, string secondPath) + { + var firstInfo = new FileInfo(firstPath); + var secondInfo = new FileInfo(secondPath); + if (firstInfo.Length != secondInfo.Length) + { + return false; + } + + using var firstStream = File.OpenRead(firstPath); + using var secondStream = File.OpenRead(secondPath); + return SHA256.HashData(firstStream).SequenceEqual(SHA256.HashData(secondStream)); + } + + private sealed class SemaphoreReleaser(SemaphoreSlim semaphore) : IDisposable + { + private readonly SemaphoreSlim _semaphore = semaphore; + private bool _disposed; + + public void Dispose() + { + if (!_disposed) + { + _semaphore.Release(); + _disposed = true; + } + } + } + + private sealed class PreparationRollback + { + private readonly string _targetExePath; + private readonly string _backupPath; + private readonly string _proxySourcePath; + private readonly string _executableSnapshotPath; + private readonly Dictionary _originalFiles = new(PathComparer); + private readonly Dictionary _preparedFiles = new(PathComparer); + private readonly List _mutatedFiles = []; + private readonly HashSet _temporaryFiles = new(PathComparer); + private bool _backupCreated; + private bool _executableMutationStarted; + private bool _targetInitiallyExisted; + private string? _executableRestoreSource; + private bool _completed; + + public PreparationRollback( + string targetExePath, + string backupPath, + string proxySourcePath, + IEnumerable filesToCapture) { - var sourcePath = Path.Combine(sourceDirectory, file); - var destPath = Path.Combine(destinationDirectory, file); - if (File.Exists(sourcePath) && !File.Exists(destPath)) + _targetExePath = targetExePath; + _backupPath = backupPath; + _proxySourcePath = proxySourcePath; + _executableSnapshotPath = CreateTemporaryPath(targetExePath); + + foreach (var path in filesToCapture.Distinct(PathComparer)) { - File.Copy(sourcePath, destPath); - logger.LogInformation("[SteamLauncher] Copied missing critical file {File} to {Destination}", file, destinationDirectory); + _originalFiles[path] = File.Exists(path) ? File.ReadAllBytes(path) : null; } } - // Yield control occasionally to respect cancellation - await Task.Yield(); - cancellationToken.ThrowIfCancellationRequested(); + public void PrepareExecutableBackup() + { + _targetInitiallyExisted = File.Exists(_targetExePath); + + if (!_targetInitiallyExisted) + { + if (!File.Exists(_backupPath)) + { + throw new FileNotFoundException( + "Neither the target executable nor its recovery backup is available.", + _targetExePath); + } + + _executableRestoreSource = _backupPath; + return; + } + + _temporaryFiles.Add(_executableSnapshotPath); + File.Copy(_targetExePath, _executableSnapshotPath, overwrite: false); + + if (File.Exists(_backupPath)) + { + if (!FilesAreEqual(_targetExePath, _proxySourcePath)) + { + throw new IOException( + $"Refusing to use unverified pre-existing backup '{_backupPath}' while " + + $"'{_targetExePath}' is not the GenHub proxy."); + } + + _executableRestoreSource = _backupPath; + return; + } + + _executableRestoreSource = _executableSnapshotPath; + var backupStagingPath = CreateTemporaryPath(_backupPath); + _temporaryFiles.Add(backupStagingPath); + File.Copy(_targetExePath, backupStagingPath, overwrite: false); + File.Move(backupStagingPath, _backupPath, overwrite: false); + _temporaryFiles.Remove(backupStagingPath); + _backupCreated = true; + } + + public void DeployProxy() + { + var stagingPath = CreateTemporaryPath(_targetExePath); + _temporaryFiles.Add(stagingPath); + File.Copy(_proxySourcePath, stagingPath, overwrite: false); + + if (_targetInitiallyExisted) + { + if (!File.Exists(_targetExePath) || + !FilesAreEqual(_targetExePath, _executableSnapshotPath)) + { + throw new IOException( + $"Game executable changed before proxy deployment: {_targetExePath}"); + } + } + else if (File.Exists(_targetExePath)) + { + throw new IOException( + $"Game executable appeared before proxy deployment: {_targetExePath}"); + } + + _executableMutationStarted = true; + File.Move(stagingPath, _targetExePath, overwrite: true); + _temporaryFiles.Remove(stagingPath); + } + + public async Task WriteTextAsync( + string path, + string contents, + Func writer, + CancellationToken cancellationToken) + { + var stagingPath = CreateTemporaryPath(path); + _temporaryFiles.Add(stagingPath); + + await writer(stagingPath, contents, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + EnsureCapturedFileIsUnchanged(path); + var preparedContents = File.ReadAllBytes(stagingPath); + File.Move(stagingPath, path, overwrite: true); + _preparedFiles[path] = preparedContents; + TrackMutation(path); + _temporaryFiles.Remove(stagingPath); + } + + public async Task CopyNewFileAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken) + { + await using var source = new FileStream( + sourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 81920, + useAsync: true); + await using var destination = new FileStream( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + useAsync: true); + + _originalFiles[destinationPath] = null; + TrackMutation(destinationPath); + await source.CopyToAsync(destination, cancellationToken); + } + + public void Commit() + { + var errors = new List(); + CleanupTemporaryFiles(errors); + if (errors.Count > 0) + { + throw new IOException(string.Join(" ", errors)); + } + + _completed = true; + } + + public IReadOnlyList Rollback() + { + if (_completed) + { + return []; + } + + var errors = new List(); + + for (var index = _mutatedFiles.Count - 1; index >= 0; index--) + { + var path = _mutatedFiles[index]; + + try + { + if (!CanRestoreCapturedFile(path)) + { + errors.Add($"Rollback did not overwrite unexpectedly changed file '{path}'."); + continue; + } + + RestoreCapturedFile(path, _originalFiles[path]); + } + catch (Exception ex) + { + errors.Add($"Rollback failed for '{path}': {ex.Message}"); + } + } + + var executableRestored = TryRestoreExecutable(errors); + if (executableRestored && _backupCreated) + { + try + { + File.Delete(_backupPath); + } + catch (Exception ex) + { + errors.Add($"Rollback failed to remove new backup '{_backupPath}': {ex.Message}"); + } + } + + if (executableRestored) + { + CleanupTemporaryFiles(errors); + } + else + { + foreach (var path in _temporaryFiles.Where(File.Exists)) + { + errors.Add($"Recovery file retained at '{path}'."); + } + + if (_backupCreated && File.Exists(_backupPath)) + { + errors.Add($"Recovery backup retained at '{_backupPath}'."); + } + } + + _completed = true; + return errors; + } + + private void TrackMutation(string path) + { + if (!_mutatedFiles.Contains(path, PathComparer)) + { + _mutatedFiles.Add(path); + } + } + + private bool CanRestoreCapturedFile(string path) + { + if (!_preparedFiles.TryGetValue(path, out var preparedContents)) + { + return true; + } + + if (!File.Exists(path)) + { + return _originalFiles[path] is null; + } + + return File.ReadAllBytes(path).SequenceEqual(preparedContents); + } + + private void EnsureCapturedFileIsUnchanged(string path) + { + var originalContents = _originalFiles[path]; + if (originalContents is null) + { + if (File.Exists(path) || Directory.Exists(path)) + { + throw new IOException($"File appeared before preparation could update it: {path}"); + } + + return; + } + + if (!File.Exists(path) || + !File.ReadAllBytes(path).SequenceEqual(originalContents)) + { + throw new IOException($"File changed before preparation could update it: {path}"); + } + } + + private bool TryRestoreExecutable(List errors) + { + if (!_executableMutationStarted) + { + return true; + } + + try + { + var restoreSource = GetExecutableRestoreSource(); + if (_targetInitiallyExisted && + File.Exists(_targetExePath) && + FilesAreEqual(_targetExePath, restoreSource)) + { + return true; + } + + if (File.Exists(_targetExePath) && + !FilesAreEqual(_targetExePath, _proxySourcePath)) + { + errors.Add( + $"Rollback did not overwrite unexpectedly changed executable '{_targetExePath}'."); + return false; + } + + AtomicCopy(restoreSource, _targetExePath); + return true; + } + catch (Exception ex) + { + errors.Add($"Rollback failed to restore executable '{_targetExePath}': {ex.Message}"); + return false; + } + } + + private string GetExecutableRestoreSource() + { + if (!string.IsNullOrEmpty(_executableRestoreSource) && + File.Exists(_executableRestoreSource)) + { + return _executableRestoreSource; + } + + if (File.Exists(_backupPath)) + { + return _backupPath; + } + + throw new FileNotFoundException( + "No recovery copy of the original executable is available.", + _executableRestoreSource); + } + + private void CleanupTemporaryFiles(List errors) + { + foreach (var path in _temporaryFiles.ToArray()) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + + _temporaryFiles.Remove(path); + } + catch (Exception ex) + { + errors.Add($"Failed to remove preparation artifact '{path}': {ex.Message}"); + } + } + } + + private void RestoreCapturedFile(string path, byte[]? originalContents) + { + if (originalContents is null) + { + if (File.Exists(path)) + { + File.Delete(path); + } + + return; + } + + var stagingPath = CreateTemporaryPath(path); + try + { + File.WriteAllBytes(stagingPath, originalContents); + File.Move(stagingPath, path, overwrite: true); + } + finally + { + if (File.Exists(stagingPath)) + { + File.Delete(stagingPath); + } + } + } + + private void AtomicCopy(string sourcePath, string destinationPath) + { + var stagingPath = CreateTemporaryPath(destinationPath); + _temporaryFiles.Add(stagingPath); + File.Copy(sourcePath, stagingPath, overwrite: false); + File.Move(stagingPath, destinationPath, overwrite: true); + _temporaryFiles.Remove(stagingPath); + } + + private bool FilesAreEqual(string firstPath, string secondPath) + { + var firstInfo = new FileInfo(firstPath); + var secondInfo = new FileInfo(secondPath); + if (firstInfo.Length != secondInfo.Length) + { + return false; + } + + using var firstStream = File.OpenRead(firstPath); + using var secondStream = File.OpenRead(secondPath); + return SHA256.HashData(firstStream).SequenceEqual(SHA256.HashData(secondStream)); + } + + private string CreateTemporaryPath(string path) + { + return $"{path}.genhub-rollback-{Guid.NewGuid():N}"; + } } }