diff --git a/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs b/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs
index 36d661050..b2b0aaf07 100644
--- a/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs
+++ b/GenHub/GenHub.Core/Extensions/WorkspaceConfigurationExtensions.cs
@@ -1,4 +1,7 @@
+using System;
+using System.Collections.Generic;
using System.Linq;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Workspace;
@@ -11,34 +14,74 @@ public static class WorkspaceConfigurationExtensions
{
///
/// Gets all unique files from all manifests, deduplicated by relative path.
- /// When multiple manifests contain the same file path, returns the first occurrence.
+ /// When multiple manifests contain the same file path, returns the file from the highest priority manifest.
///
/// The workspace configuration to get files from.
/// An enumerable of unique manifest files.
public static IEnumerable GetAllUniqueFiles(
this WorkspaceConfiguration configuration)
{
+ if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
+ {
+ return [];
+ }
+
return configuration.Manifests
- .SelectMany(m => (m.Files ?? []).Select(f => new { File = f, Manifest = m }))
+ .SelectMany((m, index) => (m.Files ?? []).Select(f => new { File = f, Manifest = m, ManifestIndex = index }))
.GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))
+ .ThenByDescending(x => x.ManifestIndex)
.First().File);
}
///
/// Gets all unique files intended for the workspace from all manifests, deduplicated by relative path.
- /// Only includes files where is .
+ /// Only includes files where is .
+ /// Higher-priority manifests (such as mods and patches) take precedence over lower-priority manifests (such as base game installations).
///
/// The workspace configuration to get files from.
/// An enumerable of unique workspace-specific manifest files.
public static IEnumerable GetWorkspaceUniqueFiles(
this WorkspaceConfiguration configuration)
{
+ if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
+ {
+ return [];
+ }
+
return configuration.Manifests
- .SelectMany(m => (m.Files ?? []).Select(f => new { File = f, Manifest = m }))
- .Where(x => x.File.InstallTarget == GenHub.Core.Models.Enums.ContentInstallTarget.Workspace)
+ .SelectMany((m, index) => (m.Files ?? []).Select(f => new { File = f, Manifest = m, ManifestIndex = index }))
+ .Where(x => x.File.InstallTarget == ContentInstallTarget.Workspace)
.GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))
+ .ThenByDescending(x => x.ManifestIndex)
.First().File);
}
+
+ ///
+ /// Gets all unique workspace files paired with their owning manifest, deduplicated by relative path
+ /// and resolved by content type priority (higher-priority content types override lower-priority ones).
+ ///
+ /// The workspace configuration to get prioritized files from.
+ /// A read-only list of prioritized file and manifest pairs.
+ public static IReadOnlyList<(ManifestFile File, ContentManifest Manifest)> GetPrioritizedWorkspaceFiles(
+ this WorkspaceConfiguration configuration)
+ {
+ if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
+ {
+ return [];
+ }
+
+ return configuration.Manifests
+ .SelectMany((manifest, index) => (manifest.Files ?? Enumerable.Empty())
+ .Where(f => f.InstallTarget == ContentInstallTarget.Workspace)
+ .Select(file => new { File = file, Manifest = manifest, ManifestIndex = index }))
+ .GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)
+ .Select(g => g
+ .OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))
+ .ThenByDescending(x => x.ManifestIndex)
+ .First())
+ .Select(x => (x.File, x.Manifest))
+ .ToList();
+ }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs
index b8ed64087..ea1db3e90 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/GameProfileWorkspaceIntegrationTest.cs
@@ -163,14 +163,14 @@ public async Task PrepareWorkspace_FullCopyStrategy_CopiesGameInstallationAndCli
[Fact]
public async Task PrepareWorkspace_SymlinkStrategy_LinksGameInstallationAndClientFilesAsync()
{
- // Skip on systems that don't support symlinks
+ // Skip on Windows if not running with administrator privileges
bool isWindows = OperatingSystem.IsWindows();
bool isAdmin = isWindows &&
new System.Security.Principal.WindowsPrincipal(
System.Security.Principal.WindowsIdentity.GetCurrent())
.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
- if (!isWindows || !isAdmin)
+ if (isWindows && !isAdmin)
{
return;
}
@@ -228,7 +228,7 @@ public async Task PrepareWorkspace_MixedContentTypes_HandlesGameInstallationAndG
System.Security.Principal.WindowsIdentity.GetCurrent())
.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
- if (!isWindows || !isAdmin)
+ if (isWindows && !isAdmin)
{
return;
}
@@ -336,7 +336,7 @@ public async Task PrepareWorkspace_AllStrategies_HandleGameInstallationFilesAsyn
// Skip symlink strategies when not admin on Windows
if ((strategy == WorkspaceStrategy.SymlinkOnly || strategy == WorkspaceStrategy.HybridCopySymlink) &&
- (!isWindows || !isAdmin))
+ isWindows && !isAdmin)
{
return;
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs
index 63c16a164..4ecb78c22 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceIntegrationTests.cs
@@ -114,7 +114,7 @@ public async Task EndToEndWorkspaceCreation_AllStrategiesAsync(WorkspaceStrategy
if ((strategy == WorkspaceStrategy.SymlinkOnly ||
strategy == WorkspaceStrategy.HybridCopySymlink) &&
- (!isWindows || !isAdmin))
+ isWindows && !isAdmin)
{
return;
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs
index 336fbc985..afba3fc71 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspacePrioritizationVerifyTests.cs
@@ -1,15 +1,28 @@
+using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Extensions;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameClients;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Workspace;
+using GenHub.Features.Workspace;
+using GenHub.Features.Workspace.Strategies;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
using Xunit;
namespace GenHub.Tests.Core.Features.Workspace;
///
-/// Verification tests for workspace file prioritization logic.
+/// Verification tests for workspace file prioritization logic and user directory workspace isolation.
///
public class WorkspacePrioritizationVerifyTests
{
@@ -20,23 +33,23 @@ public class WorkspacePrioritizationVerifyTests
public void GetAllUniqueFiles_ShouldPrioritizeGameClientOverInstallation()
{
// Arrange
- var commonFile = new ManifestFile { RelativePath = "data.ini", Size = 100 };
+ var lowPriorityFile = new ManifestFile { RelativePath = "data.ini", Size = 100, SourcePath = "install" };
+ var highPriorityFile = new ManifestFile { RelativePath = "data.ini", Size = 150, SourcePath = "client" };
var installationManifest = new ContentManifest
{
Id = new ManifestId("install"),
- ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation,
- Files = [commonFile],
+ ContentType = ContentType.GameInstallation,
+ Files = [lowPriorityFile],
};
var clientManifest = new ContentManifest
{
Id = new ManifestId("client"),
- ContentType = GenHub.Core.Models.Enums.ContentType.GameClient,
- Files = [commonFile], // Same file
+ ContentType = ContentType.GameClient,
+ Files = [highPriorityFile],
};
- // Order matters for the BUG: usually installation comes first
var config = new WorkspaceConfiguration
{
Manifests = [installationManifest, clientManifest],
@@ -47,16 +60,19 @@ public void GetAllUniqueFiles_ShouldPrioritizeGameClientOverInstallation()
// Assert
Assert.Single(result);
-
- // We can't easily check WHICH file it is since they are identical objects/values here,
- // so let's make them distinguishable.
+ Assert.Equal(150, result[0].Size);
+ Assert.Equal("client", result[0].SourcePath);
}
///
- /// Verifies that high-priority content (like mods) correctly overwrites low-priority content (like installations).
+ /// Verifies that high-priority content (such as mods) correctly overwrites low-priority content (such as installations),
+ /// regardless of whether the installation or the mod appears first in the manifests list.
///
- [Fact]
- public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent()
+ /// Whether the mod manifest appears first in the collection.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent_RegardlessOfOrder(bool modFirst)
{
// Arrange
var lowPriorityFile = new ManifestFile { RelativePath = "config.ini", Size = 100, SourcePath = "low" };
@@ -65,21 +81,20 @@ public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent()
var installationManifest = new ContentManifest
{
Id = new ManifestId("install"),
- ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation,
+ ContentType = ContentType.GameInstallation,
Files = [lowPriorityFile],
};
var modManifest = new ContentManifest
{
Id = new ManifestId("mod"),
- ContentType = GenHub.Core.Models.Enums.ContentType.Mod,
+ ContentType = ContentType.Mod,
Files = [highPriorityFile],
};
- // Put installation first to trigger the potential bug (if it picks first)
var config = new WorkspaceConfiguration
{
- Manifests = [installationManifest, modManifest],
+ Manifests = modFirst ? [modManifest, installationManifest] : [installationManifest, modManifest],
};
// Act
@@ -87,10 +102,245 @@ public void GetAllUniqueFiles_ShouldPrioritizeHighPriorityContent()
// Assert
Assert.Single(uniqueFiles);
- var chosenFile = uniqueFiles.First();
+ var chosenFile = uniqueFiles[0];
- // Should be the mod file (size 200)
+ // Should always be the mod file (size 200)
Assert.Equal(200, chosenFile.Size);
Assert.Equal("high", chosenFile.SourcePath);
}
+
+ ///
+ /// Verifies that GetPrioritizedWorkspaceFiles returns the winning file and manifest pair for all content types.
+ ///
+ [Fact]
+ public void GetPrioritizedWorkspaceFiles_FullHierarchy_ResolvesCorrectWinningManifests()
+ {
+ // Arrange: Mod (100) > Patch (90) > GameClient (50) > ModdingTool (45) > Addon (40) > LanguagePack (35) > Map (30) > GameInstallation (10)
+ var fileMod = new ManifestFile { RelativePath = "shared.ini", Size = 1000, InstallTarget = ContentInstallTarget.Workspace };
+ var filePatch = new ManifestFile { RelativePath = "shared.ini", Size = 900, InstallTarget = ContentInstallTarget.Workspace };
+ var fileClient = new ManifestFile { RelativePath = "shared.ini", Size = 500, InstallTarget = ContentInstallTarget.Workspace };
+ var fileInstall = new ManifestFile { RelativePath = "shared.ini", Size = 100, InstallTarget = ContentInstallTarget.Workspace };
+
+ var manifestMod = new ContentManifest { Id = new ManifestId("mod"), ContentType = ContentType.Mod, Files = [fileMod] };
+ var manifestPatch = new ContentManifest { Id = new ManifestId("patch"), ContentType = ContentType.Patch, Files = [filePatch] };
+ var manifestClient = new ContentManifest { Id = new ManifestId("client"), ContentType = ContentType.GameClient, Files = [fileClient] };
+ var manifestInstall = new ContentManifest { Id = new ManifestId("install"), ContentType = ContentType.GameInstallation, Files = [fileInstall] };
+
+ // Test with installation first
+ var config1 = new WorkspaceConfiguration
+ {
+ Manifests = [manifestInstall, manifestClient, manifestPatch, manifestMod],
+ };
+
+ var result1 = config1.GetPrioritizedWorkspaceFiles();
+ Assert.Single(result1);
+ Assert.Equal("mod", result1[0].Manifest.Id.Value);
+ Assert.Equal(1000, result1[0].File.Size);
+
+ // Test with patch beating client and install
+ var config2 = new WorkspaceConfiguration
+ {
+ Manifests = [manifestInstall, manifestPatch, manifestClient],
+ };
+
+ var result2 = config2.GetPrioritizedWorkspaceFiles();
+ Assert.Single(result2);
+ Assert.Equal("patch", result2[0].Manifest.Id.Value);
+ Assert.Equal(900, result2[0].File.Size);
+ }
+
+ ///
+ /// Verifies that GetPrioritizedWorkspaceFiles filters out files not targeted to the workspace.
+ ///
+ [Fact]
+ public void GetPrioritizedWorkspaceFiles_FiltersNonWorkspaceInstallTargets()
+ {
+ // Arrange
+ var workspaceFile = new ManifestFile { RelativePath = "game.exe", Size = 1000, InstallTarget = ContentInstallTarget.Workspace };
+ var userDataFile = new ManifestFile { RelativePath = "options.ini", Size = 200, InstallTarget = ContentInstallTarget.UserData };
+
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId("test"),
+ ContentType = ContentType.Mod,
+ Files = [workspaceFile, userDataFile],
+ };
+
+ var config = new WorkspaceConfiguration
+ {
+ Manifests = [manifest],
+ };
+
+ // Act
+ var result = config.GetPrioritizedWorkspaceFiles();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("game.exe", result[0].File.RelativePath);
+ }
+
+ ///
+ /// Verifies that FullCopyStrategy materializes custom files with priority over base installation files in user workspaces.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task FullCopyStrategy_CustomFileOverInstallation_CopiesWinningFileAsync()
+ {
+ // Arrange
+ var tempBase = Path.Combine(Path.GetTempPath(), $"genhub_base_{Guid.NewGuid():N}");
+ var tempUserWorkspace = Path.Combine(Path.GetTempPath(), $"genhub_userws_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempBase);
+ Directory.CreateDirectory(tempUserWorkspace);
+
+ try
+ {
+ var baseFilePath = Path.Combine(tempBase, "GameData.ini");
+ var modFilePath = Path.Combine(tempBase, "ModGameData.ini");
+ var unmoddedBaseFilePath = Path.Combine(tempBase, "Generals.big");
+
+ await File.WriteAllTextAsync(baseFilePath, "BASE GAME CONTENT");
+ await File.WriteAllTextAsync(modFilePath, "CUSTOM MOD CONTENT");
+ await File.WriteAllTextAsync(unmoddedBaseFilePath, "UNTOUCHED BIG FILE");
+
+ var baseFile = new ManifestFile { RelativePath = "GameData.ini", SourcePath = baseFilePath, Size = 17, InstallTarget = ContentInstallTarget.Workspace };
+ var modFile = new ManifestFile { RelativePath = "GameData.ini", SourcePath = modFilePath, Size = 18, InstallTarget = ContentInstallTarget.Workspace };
+ var unchangedFile = new ManifestFile { RelativePath = "Generals.big", SourcePath = unmoddedBaseFilePath, Size = 18, InstallTarget = ContentInstallTarget.Workspace };
+
+ var installManifest = new ContentManifest { Id = new ManifestId("install"), ContentType = ContentType.GameInstallation, Files = [baseFile, unchangedFile] };
+ var modManifest = new ContentManifest { Id = new ManifestId("mod"), ContentType = ContentType.Mod, Files = [modFile] };
+
+ var config = new WorkspaceConfiguration
+ {
+ Id = "test-ws-priority",
+ WorkspaceRootPath = tempUserWorkspace,
+ BaseInstallationPath = tempBase,
+ Strategy = WorkspaceStrategy.FullCopy,
+ GameClient = new GameClient { Id = "test-client", ExecutablePath = "game.exe" },
+ Manifests = [installManifest, modManifest],
+ };
+
+ var mockFileOps = new Mock();
+ mockFileOps.Setup(f => f.CopyFileAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(async (src, dst, ct) =>
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(dst)!);
+ File.Copy(src, dst, true);
+ await Task.CompletedTask;
+ });
+
+ var strategy = new FullCopyStrategy(mockFileOps.Object, NullLogger.Instance);
+
+ // Act
+ var workspaceInfo = await strategy.PrepareAsync(config);
+
+ // Assert
+ Assert.True(workspaceInfo.IsPrepared);
+ var destGameData = Path.Combine(workspaceInfo.WorkspacePath, "GameData.ini");
+ var destBig = Path.Combine(workspaceInfo.WorkspacePath, "Generals.big");
+
+ Assert.True(File.Exists(destGameData));
+ Assert.True(File.Exists(destBig));
+
+ // Custom file content should be the mod content, not base game content
+ var gameDataContent = await File.ReadAllTextAsync(destGameData);
+ Assert.Equal("CUSTOM MOD CONTENT", gameDataContent);
+
+ // Base game directory files must be completely unmodified
+ Assert.Equal("BASE GAME CONTENT", await File.ReadAllTextAsync(baseFilePath));
+ Assert.Equal("UNTOUCHED BIG FILE", await File.ReadAllTextAsync(unmoddedBaseFilePath));
+ }
+ finally
+ {
+ if (Directory.Exists(tempBase))
+ {
+ Directory.Delete(tempBase, true);
+ }
+
+ if (Directory.Exists(tempUserWorkspace))
+ {
+ Directory.Delete(tempUserWorkspace, true);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that HybridCopySymlinkStrategy correctly prioritizes custom files over installation data,
+ /// even when the mod manifest is placed before or after the installation manifest.
+ ///
+ /// Whether the mod manifest appears first in the collection.
+ /// A representing the asynchronous unit test.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task HybridCopySymlinkStrategy_CustomFileOverInstallation_PrioritizesCorrectlyAsync(bool modFirst)
+ {
+ // Arrange
+ var tempBase = Path.Combine(Path.GetTempPath(), $"genhub_hybrid_base_{Guid.NewGuid():N}");
+ var tempUserWorkspace = Path.Combine(Path.GetTempPath(), $"genhub_hybrid_userws_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempBase);
+ Directory.CreateDirectory(tempUserWorkspace);
+
+ try
+ {
+ var baseFilePath = Path.Combine(tempBase, "config.ini");
+ var modFilePath = Path.Combine(tempBase, "mod_config.ini");
+
+ await File.WriteAllTextAsync(baseFilePath, "BASE CONFIG");
+ await File.WriteAllTextAsync(modFilePath, "MOD CONFIG");
+
+ var baseFile = new ManifestFile { RelativePath = "config.ini", SourcePath = baseFilePath, Size = 11, InstallTarget = ContentInstallTarget.Workspace };
+ var modFile = new ManifestFile { RelativePath = "config.ini", SourcePath = modFilePath, Size = 10, InstallTarget = ContentInstallTarget.Workspace };
+
+ var installManifest = new ContentManifest { Id = new ManifestId("install"), ContentType = ContentType.GameInstallation, Files = [baseFile] };
+ var modManifest = new ContentManifest { Id = new ManifestId("mod"), ContentType = ContentType.Mod, Files = [modFile] };
+
+ var config = new WorkspaceConfiguration
+ {
+ Id = "test-ws-hybrid",
+ WorkspaceRootPath = tempUserWorkspace,
+ BaseInstallationPath = tempBase,
+ Strategy = WorkspaceStrategy.HybridCopySymlink,
+ GameClient = new GameClient { Id = "test-client", ExecutablePath = "game.exe" },
+ Manifests = modFirst ? [modManifest, installManifest] : [installManifest, modManifest],
+ };
+
+ var mockFileOps = new Mock();
+ mockFileOps.Setup(f => f.CopyFileAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(async (src, dst, ct) =>
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(dst)!);
+ File.Copy(src, dst, true);
+ await Task.CompletedTask;
+ });
+
+ var strategy = new HybridCopySymlinkStrategy(mockFileOps.Object, NullLogger.Instance);
+
+ // Act
+ var workspaceInfo = await strategy.PrepareAsync(config);
+
+ // Assert
+ Assert.True(workspaceInfo.IsPrepared);
+ var destConfig = Path.Combine(workspaceInfo.WorkspacePath, "config.ini");
+ Assert.True(File.Exists(destConfig));
+
+ var configContent = await File.ReadAllTextAsync(destConfig);
+ Assert.Equal("MOD CONFIG", configContent);
+
+ // Original base file remains completely untouched
+ Assert.Equal("BASE CONFIG", await File.ReadAllTextAsync(baseFilePath));
+ }
+ finally
+ {
+ if (Directory.Exists(tempBase))
+ {
+ Directory.Delete(tempBase, true);
+ }
+
+ if (Directory.Exists(tempUserWorkspace))
+ {
+ Directory.Delete(tempUserWorkspace, true);
+ }
+ }
+ }
}
+
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs
index 1816753e7..6ba5f435f 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs
@@ -151,16 +151,16 @@ public void Dispose()
[Fact]
public async Task PrepareWorkspace_WithCasContent_CreatesCorrectLinksAsync()
{
- // Skip test if running on non-Windows or without admin privileges
+ // Skip test on Windows if not running with administrator privileges
bool isWindows = OperatingSystem.IsWindows();
bool isAdmin = isWindows &&
new System.Security.Principal.WindowsPrincipal(
System.Security.Principal.WindowsIdentity.GetCurrent())
.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
- if (!isWindows || !isAdmin)
+ if (isWindows && !isAdmin)
{
- return; // Skip test on non-Windows or non-admin
+ return; // Skip test on Windows without admin
}
// The CAS object should already be verified to exist from constructor
diff --git a/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs
index 2fe8f370f..91272b280 100644
--- a/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs
+++ b/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs
@@ -53,16 +53,13 @@ public override long EstimateDiskUsage(WorkspaceConfiguration configuration)
return 0;
long totalSize = 0;
- foreach (var manifest in configuration.Manifests)
+ foreach (var file in configuration.GetWorkspaceUniqueFiles())
{
- foreach (var file in (manifest.Files ?? Enumerable.Empty()).Where(f => f.InstallTarget == ContentInstallTarget.Workspace))
- {
- // Prevent negative sizes and overflow
- long safeSize = Math.Max(0, file.Size);
- if (long.MaxValue - totalSize < safeSize)
- return long.MaxValue; // Indicate overflow
- totalSize += safeSize;
- }
+ // Prevent negative sizes and overflow
+ long safeSize = Math.Max(0, file.Size);
+ if (long.MaxValue - totalSize < safeSize)
+ return long.MaxValue; // Indicate overflow
+ totalSize += safeSize;
}
return totalSize;
@@ -96,9 +93,10 @@ public override async Task PrepareAsync(
// Create workspace directory
Directory.CreateDirectory(workspacePath);
+ // Deduplicate files by RelativePath with priority ordering (higher priority content wins)
// ONLY include files where InstallTarget is Workspace.
- var allFiles = configuration.GetWorkspaceUniqueFiles().ToList();
- var totalFiles = allFiles.Count;
+ var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles();
+ var totalFiles = prioritizedFiles.Count;
var processedFiles = 0;
long totalBytesProcessed = 0;
@@ -123,84 +121,64 @@ public override async Task PrepareAsync(
degreeOfParallelism = Environment.ProcessorCount * 2;
}
- // Group files by destination path to handle conflicts
- // include files where InstallTarget is Workspace.
- var filesByDestination = configuration.Manifests
- .SelectMany(m => (m.Files ?? Enumerable.Empty())
- .Where(f => f.InstallTarget == ContentInstallTarget.Workspace)
- .Select(f => new { Manifest = m, File = f }))
- .GroupBy(item => item.File.RelativePath, StringComparer.OrdinalIgnoreCase)
- .ToList();
-
await Parallel.ForEachAsync(
- filesByDestination,
+ prioritizedFiles,
new ParallelOptions
{
MaxDegreeOfParallelism = degreeOfParallelism,
CancellationToken = cancellationToken,
},
- async (fileGroup, ct) =>
+ async (item, ct) =>
{
- // For each destination path, process files in priority order (lowest to highest)
- // Priority: GameInstallation (10) < Addon (40) < GameClient (50) < Patch (90) < Mod (100)
- // This ensures higher priority content overwrites lower priority
- var orderedFiles = fileGroup
- .OrderBy(item => ContentTypePriority.GetPriority(item.Manifest.ContentType))
- .ToList();
-
- // Process all versions of this file in priority order
- // The last one (highest priority) will be the final version
- foreach (var item in orderedFiles)
- {
- var destinationPath = Path.Combine(workspacePath, item.File.RelativePath);
+ var file = item.File;
+ var manifest = item.Manifest;
+ var destinationPath = Path.Combine(workspacePath, file.RelativePath);
- try
+ try
+ {
+ if (file.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(file.Hash))
+ {
+ // Use CAS content
+ await CreateCasLinkAsync(file.Hash, destinationPath, manifest.ContentType, ct);
+ }
+ else
{
- if (item.File.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(item.File.Hash))
+ // Resolve source path supporting multi-source installations
+ var sourcePath = ResolveSourcePath(file, manifest, configuration);
+
+ if (!ValidateSourceFile(sourcePath, file.RelativePath))
{
- // Use CAS content
- await CreateCasLinkAsync(item.File.Hash, destinationPath, item.Manifest.ContentType, ct);
+ return;
}
- else
- {
- // Resolve source path supporting multi-source installations
- var sourcePath = ResolveSourcePath(item.File, item.Manifest, configuration);
-
- if (!ValidateSourceFile(sourcePath, item.File.RelativePath))
- {
- continue;
- }
- await FileOperations.CopyFileAsync(sourcePath, destinationPath, ct);
+ await FileOperations.CopyFileAsync(sourcePath, destinationPath, ct);
- // Verify file integrity if hash is provided
- if (!string.IsNullOrEmpty(item.File.Hash))
+ // Verify file integrity if hash is provided
+ if (!string.IsNullOrEmpty(file.Hash))
+ {
+ var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, file.Hash, ct);
+ if (!hashValid)
{
- var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, item.File.Hash, ct);
- if (!hashValid)
- {
- Logger.LogWarning("Hash verification failed for file: {RelativePath}", item.File.RelativePath);
- }
+ Logger.LogWarning("Hash verification failed for file: {RelativePath}", file.RelativePath);
}
}
}
- catch (Exception ex)
+
+ Interlocked.Add(ref totalBytesProcessed, file.Size);
+ var current = Interlocked.Increment(ref processedFiles);
+ if (current % 50 == 0 || current == totalFiles)
{
- Logger.LogError(
- ex,
- "Failed to copy file {RelativePath} to {DestinationPath}",
- item.File.RelativePath,
- destinationPath);
- throw new InvalidOperationException($"Failed to copy file {item.File.RelativePath}: {ex.Message}", ex);
+ ReportProgress(progress, current, totalFiles, "Copying files", file.RelativePath);
}
}
-
- // Only count the file group once for progress reporting
- Interlocked.Add(ref totalBytesProcessed, orderedFiles.First().File.Size);
- var current = Interlocked.Increment(ref processedFiles);
- if (current % 50 == 0 || current == totalFiles)
+ catch (Exception ex)
{
- ReportProgress(progress, current, totalFiles, "Copying files", orderedFiles.First().File.RelativePath);
+ Logger.LogError(
+ ex,
+ "Failed to copy file {RelativePath} to {DestinationPath}",
+ file.RelativePath,
+ destinationPath);
+ throw new InvalidOperationException($"Failed to copy file {file.RelativePath}: {ex.Message}", ex);
}
});
diff --git a/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs
index 5edee62ba..978b51fbc 100644
--- a/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs
+++ b/GenHub/GenHub/Features/Workspace/Strategies/HardLinkStrategy.cs
@@ -80,19 +80,9 @@ public override async Task PrepareAsync(
// Create workspace directory
Directory.CreateDirectory(workspacePath);
- // Deduplicate files by RelativePath with priority ordering (GameClient > GameInstallation)
- // so lower-priority sources cannot overwrite higher-priority files like modded clients.
+ // Deduplicate files by RelativePath with priority ordering (higher priority content wins)
// ONLY include files where InstallTarget is Workspace.
- var prioritizedFiles = configuration.Manifests
- .SelectMany((manifest, index) => (manifest.Files ?? Enumerable.Empty())
- .Where(f => f.InstallTarget == ContentInstallTarget.Workspace)
- .Select(file => new { File = file, Manifest = manifest, ManifestIndex = index }))
- .GroupBy(x => x.File.RelativePath, StringComparer.OrdinalIgnoreCase)
- .Select(g => g
- .OrderByDescending(x => ContentTypePriority.GetPriority(x.Manifest.ContentType))
- .ThenByDescending(x => x.ManifestIndex) // deterministic tie-breaker
- .First())
- .ToList();
+ var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles();
var totalFiles = prioritizedFiles.Count;
var processedFiles = 0;
@@ -107,12 +97,9 @@ public override async Task PrepareAsync(
Logger.LogDebug("Processing {TotalFiles} files (prioritized by content type)", totalFiles);
ReportProgress(progress, 0, totalFiles, "Initializing", string.Empty);
- foreach (var prioritized in prioritizedFiles)
+ foreach (var (file, manifest) in prioritizedFiles)
{
cancellationToken.ThrowIfCancellationRequested();
-
- var manifest = prioritized.Manifest;
- var file = prioritized.File;
var destinationPath = Path.Combine(workspacePath, file.RelativePath);
try
diff --git a/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs
index d816c130d..3c3197910 100644
--- a/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs
+++ b/GenHub/GenHub/Features/Workspace/Strategies/HybridCopySymlinkStrategy.cs
@@ -48,18 +48,15 @@ public override long EstimateDiskUsage(WorkspaceConfiguration configuration)
return 0;
long totalUsage = 0;
- foreach (var manifest in configuration.Manifests)
+ foreach (var file in configuration.GetWorkspaceUniqueFiles())
{
- foreach (var file in manifest.Files)
+ if (IsEssentialFile(file.RelativePath, file.Size))
{
- if (IsEssentialFile(file.RelativePath, file.Size))
- {
- totalUsage += file.Size;
- }
- else
- {
- totalUsage += LinkOverheadBytes;
- }
+ totalUsage += file.Size;
+ }
+ else
+ {
+ totalUsage += LinkOverheadBytes;
}
}
@@ -91,10 +88,10 @@ public override async Task PrepareAsync(
// Create workspace directory
Directory.CreateDirectory(workspacePath);
- // Deduplicate files by RelativePath - multiple manifests may contain the same file
- // include files where InstallTarget is Workspace.
- var allFiles = configuration.GetWorkspaceUniqueFiles().ToList();
- var totalFiles = allFiles.Count;
+ // Deduplicate files by RelativePath with priority ordering (higher priority content wins)
+ // ONLY include files where InstallTarget is Workspace.
+ var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles();
+ var totalFiles = prioritizedFiles.Count;
var processedFiles = 0;
long totalBytesProcessed = 0;
var estimatedTotalBytes = EstimateDiskUsage(configuration);
@@ -103,7 +100,7 @@ public override async Task PrepareAsync(
Logger.LogDebug("Processing {TotalFiles} files with estimated size {EstimatedSize} bytes", totalFiles, estimatedTotalBytes);
// Pre-classify files for reporting
- var essentialCount = allFiles.Count(f => IsEssentialFile(f.RelativePath, f.Size));
+ var essentialCount = prioritizedFiles.Count(p => IsEssentialFile(p.File.RelativePath, p.File.Size));
var nonEssentialCount = totalFiles - essentialCount;
Logger.LogDebug(
"Classified {EssentialCount} essential files (will copy) and {NonEssentialCount} non-essential files (will symlink)",
@@ -111,25 +108,38 @@ public override async Task PrepareAsync(
nonEssentialCount);
ReportProgress(progress, 0, totalFiles, "Initializing", string.Empty);
- // Process each manifest and its files to maintain manifest context for source path resolution
- foreach (var manifest in configuration.Manifests)
+ // Process prioritized files maintaining winning manifest context for source path resolution
+ foreach (var (file, manifest) in prioritizedFiles)
{
- foreach (var file in (manifest.Files ?? Enumerable.Empty()).Where(f => f.InstallTarget == ContentInstallTarget.Workspace))
- {
- cancellationToken.ThrowIfCancellationRequested();
- var destinationPath = Path.Combine(workspacePath, file.RelativePath);
- var isEssential = IsEssentialFile(file.RelativePath, file.Size);
+ cancellationToken.ThrowIfCancellationRequested();
+ var destinationPath = Path.Combine(workspacePath, file.RelativePath);
+ var isEssential = IsEssentialFile(file.RelativePath, file.Size);
- try
+ try
+ {
+ if (file.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(file.Hash))
{
- if (file.SourceType == ContentSourceType.ContentAddressable && !string.IsNullOrEmpty(file.Hash))
+ if (isEssential)
+ {
+ var success = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken);
+ if (!success)
+ {
+ throw new InvalidOperationException($"Failed to copy essential file from CAS: {file.RelativePath} (Hash: {file.Hash})");
+ }
+
+ copiedFiles++;
+ totalBytesProcessed += file.Size;
+ }
+ else
{
- if (isEssential)
+ var success = await FileOperations.LinkFromCasAsync(file.Hash, destinationPath, useHardLink: false, contentType: manifest.ContentType, cancellationToken: cancellationToken);
+ if (!success)
{
- var success = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken);
- if (!success)
+ Logger.LogWarning("CAS Link failed for {RelativePath}, attempting copy from CAS", file.RelativePath);
+ var copySuccess = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken);
+ if (!copySuccess)
{
- throw new InvalidOperationException($"Failed to copy essential file from CAS: {file.RelativePath} (Hash: {file.Hash})");
+ throw new InvalidOperationException($"Failed to link or copy file from CAS: {file.RelativePath} (Hash: {file.Hash})");
}
copiedFiles++;
@@ -137,94 +147,78 @@ public override async Task PrepareAsync(
}
else
{
- var success = await FileOperations.LinkFromCasAsync(file.Hash, destinationPath, useHardLink: false, contentType: manifest.ContentType, cancellationToken: cancellationToken);
- if (!success)
- {
- Logger.LogWarning("CAS Link failed for {RelativePath}, attempting copy from CAS", file.RelativePath);
- var copySuccess = await FileOperations.CopyFromCasAsync(file.Hash, destinationPath, contentType: manifest.ContentType, cancellationToken: cancellationToken);
- if (!copySuccess)
- {
- throw new InvalidOperationException($"Failed to link or copy file from CAS: {file.RelativePath} (Hash: {file.Hash})");
- }
+ symlinkedFiles++;
+ totalBytesProcessed += LinkOverheadBytes;
+ }
+ }
+ }
+ else
+ {
+ // Resolve source path supporting multi-source installations
+ var sourcePath = ResolveSourcePath(file, manifest, configuration);
+ if (!ValidateSourceFile(sourcePath, file.RelativePath))
+ {
+ continue;
+ }
- copiedFiles++;
- totalBytesProcessed += file.Size;
- }
- else
+ if (isEssential)
+ {
+ await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken);
+ copiedFiles++;
+ totalBytesProcessed += file.Size;
+ if (!string.IsNullOrEmpty(file.Hash))
+ {
+ var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, file.Hash, cancellationToken);
+ if (!hashValid)
{
- symlinkedFiles++;
- totalBytesProcessed += LinkOverheadBytes;
+ throw new InvalidOperationException($"Hash verification failed for essential file: {file.RelativePath}");
}
}
}
else
{
- // Resolve source path supporting multi-source installations
- var sourcePath = ResolveSourcePath(file, manifest, configuration);
- if (!ValidateSourceFile(sourcePath, file.RelativePath))
+ try
{
- continue;
- }
-
- if (isEssential)
- {
- await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken);
- copiedFiles++;
- totalBytesProcessed += file.Size;
- if (!string.IsNullOrEmpty(file.Hash))
- {
- var hashValid = await FileOperations.VerifyFileHashAsync(destinationPath, file.Hash, cancellationToken);
- if (!hashValid)
- {
- throw new InvalidOperationException($"Hash verification failed for essential file: {file.RelativePath}");
- }
- }
+ await FileOperations.CreateSymlinkAsync(destinationPath, sourcePath, allowFallback: false, cancellationToken);
+ symlinkedFiles++;
+ totalBytesProcessed += LinkOverheadBytes;
}
- else
+ catch (UnauthorizedAccessException) when (FileOperationsService.AreSameVolume(sourcePath, destinationPath))
{
+ // Fall back to hardlink on same volume when symlink fails due to lack of admin rights
+ Logger.LogWarning("Symlink creation failed (no admin rights), falling back to hardlink for {RelativePath}", file.RelativePath);
try
{
- await FileOperations.CreateSymlinkAsync(destinationPath, sourcePath, allowFallback: false, cancellationToken);
- symlinkedFiles++;
+ await FileOperations.CreateHardLinkAsync(destinationPath, sourcePath, cancellationToken);
+ symlinkedFiles++; // Still count as symlinked for reporting purposes
totalBytesProcessed += LinkOverheadBytes;
}
- catch (UnauthorizedAccessException) when (FileOperationsService.AreSameVolume(sourcePath, destinationPath))
+ catch (Exception hardLinkEx)
{
- // Fall back to hardlink on same volume when symlink fails due to lack of admin rights
- Logger.LogWarning("Symlink creation failed (no admin rights), falling back to hardlink for {RelativePath}", file.RelativePath);
- try
- {
- await FileOperations.CreateHardLinkAsync(destinationPath, sourcePath, cancellationToken);
- symlinkedFiles++; // Still count as symlinked for reporting purposes
- totalBytesProcessed += LinkOverheadBytes;
- }
- catch (Exception hardLinkEx)
- {
- Logger.LogError(hardLinkEx, "Hardlink fallback also failed for {RelativePath}, attempting copy", file.RelativePath);
- await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken);
- copiedFiles++;
- totalBytesProcessed += file.Size;
- }
+ Logger.LogError(hardLinkEx, "Hardlink fallback also failed for {RelativePath}, attempting copy", file.RelativePath);
+ await FileOperations.CopyFileAsync(sourcePath, destinationPath, cancellationToken);
+ copiedFiles++;
+ totalBytesProcessed += file.Size;
}
}
}
}
- catch (Exception ex)
- {
- var operation = isEssential ? "copy" : "create symlink for";
- Logger.LogError(
- ex,
- "Failed to {Operation} file {RelativePath} to {DestinationPath}",
- operation,
- file.RelativePath,
- destinationPath);
- throw new InvalidOperationException($"Failed to {operation} file {file.RelativePath}: {ex.Message}", ex);
- }
-
- processedFiles++;
- var currentOperation = isEssential ? "Copying essential file" : "Creating symlink";
- ReportProgress(progress, processedFiles, totalFiles, currentOperation, file.RelativePath);
}
+ catch (Exception ex)
+ {
+ var operation = isEssential ? "copy" : "create symlink for";
+ Logger.LogError(
+ ex,
+ "Failed to {Operation} file {RelativePath} to {DestinationPath}",
+ operation,
+ file.RelativePath,
+ destinationPath);
+ throw new InvalidOperationException($"Failed to {operation} file {file.RelativePath}: {ex.Message}", ex);
+ }
+
+ processedFiles++;
+ var currentOperation = isEssential ? "Copying essential file" : "Creating symlink";
+ ReportProgress(progress, processedFiles, totalFiles, currentOperation, file.RelativePath);
}
UpdateWorkspaceInfo(workspaceInfo, processedFiles, totalBytesProcessed, configuration);
diff --git a/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs
index e1a8e68b0..d8c11cd5b 100644
--- a/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs
+++ b/GenHub/GenHub/Features/Workspace/Strategies/SymlinkOnlyStrategy.cs
@@ -44,8 +44,11 @@ public override bool CanHandle(WorkspaceConfiguration configuration)
///
public override long EstimateDiskUsage(WorkspaceConfiguration configuration)
{
+ if (configuration?.Manifests is null || configuration.Manifests.Count == 0)
+ return 0;
+
// Symbolic links use minimal space - approximate 1KB per link for metadata
- return configuration.Manifests.SelectMany(m => m.Files).Count() * LinkOverheadBytes;
+ return configuration.GetWorkspaceUniqueFiles().Count() * LinkOverheadBytes;
}
///
@@ -80,8 +83,10 @@ public override async Task PrepareAsync(
// Create workspace directory
Directory.CreateDirectory(workspacePath);
- var allFiles = configuration.Manifests.SelectMany(m => m.Files).ToList();
- var totalFiles = allFiles.Count;
+ // Deduplicate files by RelativePath with priority ordering (higher priority content wins)
+ // ONLY include files where InstallTarget is Workspace.
+ var prioritizedFiles = configuration.GetPrioritizedWorkspaceFiles();
+ var totalFiles = prioritizedFiles.Count;
var processedFiles = 0;
Logger.LogDebug("Processing {TotalFiles} files in parallel", totalFiles);
@@ -105,16 +110,8 @@ public override async Task PrepareAsync(
degreeOfParallelism = Environment.ProcessorCount * 2;
}
- // Deduplicate files by RelativePath - multiple manifests may contain the same file
- // (e.g., GameClient and GameInstallation both contain the executable)
- // Group by path and take the first occurrence to avoid parallel creation conflicts
- // include files where InstallTarget is Workspace.
- var manifestFiles = configuration.GetWorkspaceUniqueFiles()
- .Select(f => new { Manifest = configuration.Manifests.First(m => m.Files.Contains(f)), File = f })
- .ToList();
-
await Parallel.ForEachAsync(
- manifestFiles,
+ prioritizedFiles,
new ParallelOptions
{
MaxDegreeOfParallelism = degreeOfParallelism,