diff --git a/.gitignore b/.gitignore
index 395b2bb14..7237ca5d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ docs/.vitepress/dist/
docs/.vitepress/cache/
# Build results
+build.lock
[Dd]ebug/
[Rr]elease/
@@ -173,3 +174,13 @@ _NCrunch*
# Velopack releases
releases/
Releases/
+.gitnexus
+
+# SampleCatalogs: machine-specific generated shortcuts. Never commit them.
+GenHub/GenHub/SampleCatalogs/register-genhub-scheme.reg
+GenHub/GenHub/SampleCatalogs/register-genhub-scheme.desktop
+GenHub/GenHub/SampleCatalogs/register-genhub-scheme.app/
+GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.url
+GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.desktop
+GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.command
+GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.webloc
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs
index ae8e591cd..c452f620d 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GenHub.Core.Constants;
@@ -10,6 +11,7 @@
using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.UserData;
using GenHub.Features.UserData.Services;
using Microsoft.Extensions.Logging;
using Moq;
@@ -824,4 +826,230 @@ public async Task InstallUserDataAsync_WhenBackupFails_AbortsInstallationToPreve
Assert.Contains("Failed to create safety backup", result.FirstError, StringComparison.OrdinalIgnoreCase);
}
}
+
+ ///
+ /// Verifies that when a profile is deactivated, another profile can install the same user data files without encountering a conflict.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task InstallUserDataAsync_WhenPriorOwnerProfileIsDeactivated_SucceedsWithoutConflictAsync()
+ {
+ // Arrange
+ var files = new List
+ {
+ new()
+ {
+ RelativePath = "Maps/Arabia v2/AdrianeMapSettings.ini",
+ Hash = "hash-map-settings",
+ Size = 500,
+ InstallTarget = ContentInstallTarget.UserDataDirectory,
+ },
+ };
+
+ // 1. Profile A installs the map pack
+ var installA = await _trackerService.InstallUserDataAsync(
+ "mappack-id",
+ "profile-a",
+ GameType.ZeroHour,
+ files,
+ "1.0",
+ "Map Pack",
+ CancellationToken.None);
+
+ Assert.True(installA.Success);
+
+ // 2. Profile A is deactivated
+ var deactivateA = await _trackerService.DeactivateProfileUserDataAsync("profile-a", CancellationToken.None);
+ Assert.True(deactivateA.Success);
+
+ // 3. Profile B installs the same map pack
+ var installB = await _trackerService.InstallUserDataAsync(
+ "mappack-id",
+ "profile-b",
+ GameType.ZeroHour,
+ files,
+ "1.0",
+ "Map Pack",
+ CancellationToken.None);
+
+ // Assert: Installation succeeds for profile B and ownership transfers
+ Assert.True(installB.Success);
+
+ var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "Arabia v2", "AdrianeMapSettings.ini");
+ Assert.True(File.Exists(targetPath));
+
+ var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None);
+ Assert.True(conflictResult.Success);
+ Assert.Equal("mappack-id_profile-b", conflictResult.Data);
+
+ var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName);
+ var indexJson = await File.ReadAllTextAsync(indexPath);
+ var index = JsonSerializer.Deserialize(indexJson);
+ Assert.NotNull(index);
+ Assert.True(index.FileToInstallationMap.TryGetValue(Path.GetFullPath(targetPath), out var ownerKey));
+ Assert.Equal("mappack-id_profile-b", ownerKey);
+ }
+
+ ///
+ /// Verifies that cleaning up an uninstalled or old profile does not delete files or prune mappings owned by a newer active profile.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CleanupProfileAsync_WhenPriorOwnerProfileCleanedUpAfterTransfer_PreservesNewOwnerFilesAndIndexMappingAsync()
+ {
+ // Arrange
+ var files = new List
+ {
+ new()
+ {
+ RelativePath = "Maps/TransferCheck/map.ini",
+ Hash = "hash-transfer-test",
+ Size = 300,
+ InstallTarget = ContentInstallTarget.UserDataDirectory,
+ },
+ };
+
+ // 1. Profile A installs the map pack
+ var installA = await _trackerService.InstallUserDataAsync(
+ "transfer-manifest",
+ "profile-a",
+ GameType.ZeroHour,
+ files,
+ "1.0",
+ "Transfer Test",
+ CancellationToken.None);
+ Assert.True(installA.Success);
+
+ // 2. Profile A is deactivated
+ var deactivateA = await _trackerService.DeactivateProfileUserDataAsync("profile-a", CancellationToken.None);
+ Assert.True(deactivateA.Success);
+
+ // 3. Profile B installs the same map pack
+ var installB = await _trackerService.InstallUserDataAsync(
+ "transfer-manifest",
+ "profile-b",
+ GameType.ZeroHour,
+ files,
+ "1.0",
+ "Transfer Test",
+ CancellationToken.None);
+ Assert.True(installB.Success);
+
+ var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "TransferCheck", "map.ini");
+ Assert.True(File.Exists(targetPath));
+
+ // 4. Profile A is cleaned up
+ var cleanupA = await _trackerService.CleanupProfileAsync("profile-a", CancellationToken.None);
+ Assert.True(cleanupA.Success);
+
+ // Assert: Profile B's file and index mapping remain intact
+ Assert.True(File.Exists(targetPath));
+
+ var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None);
+ Assert.True(conflictResult.Success);
+ Assert.Equal("transfer-manifest_profile-b", conflictResult.Data);
+
+ var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName);
+ var indexJson = await File.ReadAllTextAsync(indexPath);
+ var index = JsonSerializer.Deserialize(indexJson);
+ Assert.NotNull(index);
+ Assert.True(index.FileToInstallationMap.TryGetValue(Path.GetFullPath(targetPath), out var ownerKey));
+ Assert.Equal("transfer-manifest_profile-b", ownerKey);
+ }
+
+ ///
+ /// Verifies that when a file is temporarily missing on disk but its manifest is active, conflict checking still reports conflict.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CheckFileConflictAsync_WhenFileMissingOnDiskButManifestActive_ReportsConflictAsync()
+ {
+ // Arrange
+ var files = new List
+ {
+ new()
+ {
+ RelativePath = "Maps/TempMissing/map.ini",
+ Hash = "hash-missing-test",
+ Size = 100,
+ InstallTarget = ContentInstallTarget.UserDataDirectory,
+ },
+ };
+
+ var installResult = await _trackerService.InstallUserDataAsync(
+ "missing-test-manifest",
+ "profile-missing-test",
+ GameType.ZeroHour,
+ files,
+ "1.0",
+ "Missing Test",
+ CancellationToken.None);
+
+ Assert.True(installResult.Success);
+
+ var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "TempMissing", "map.ini");
+ Assert.True(File.Exists(targetPath));
+
+ // Temporarily delete the file from disk
+ File.Delete(targetPath);
+ Assert.False(File.Exists(targetPath));
+
+ // Act
+ var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None);
+
+ // Assert: Conflict is still reported because the owning manifest is active
+ Assert.True(conflictResult.Success);
+ Assert.Equal("missing-test-manifest_profile-missing-test", conflictResult.Data);
+ }
+
+ ///
+ /// Verifies that when a manifest is deactivated, CheckFileConflictAsync prunes the stale mapping and reports no conflict.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CheckFileConflictAsync_WhenManifestDeactivated_PrunesStaleMappingAndReturnsNoConflictAsync()
+ {
+ // Arrange
+ var files = new List
+ {
+ new()
+ {
+ RelativePath = "Maps/DeactivatedCheck/map.ini",
+ Hash = "hash-deact-test",
+ Size = 100,
+ InstallTarget = ContentInstallTarget.UserDataDirectory,
+ },
+ };
+
+ var installResult = await _trackerService.InstallUserDataAsync(
+ "deact-test-manifest",
+ "profile-deact-test",
+ GameType.ZeroHour,
+ files,
+ "1.0",
+ "Deact Test",
+ CancellationToken.None);
+
+ Assert.True(installResult.Success);
+
+ var targetPath = Path.Combine(_zeroHourDataDir, "Maps", "DeactivatedCheck", "map.ini");
+
+ // Deactivate the profile
+ var deactivateResult = await _trackerService.DeactivateProfileUserDataAsync("profile-deact-test", CancellationToken.None);
+ Assert.True(deactivateResult.Success);
+
+ // Act
+ var conflictResult = await _trackerService.CheckFileConflictAsync(targetPath, CancellationToken.None);
+
+ // Assert: No conflict reported and stale mapping is pruned
+ Assert.True(conflictResult.Success);
+ Assert.Null(conflictResult.Data);
+
+ // Verify index file persisted on disk no longer maps the path
+ var indexPath = Path.Combine(_appDataDir, DirectoryNames.UserData, FileTypes.UserDataIndexFileName);
+ var indexJson = await File.ReadAllTextAsync(indexPath);
+ var index = JsonSerializer.Deserialize(indexJson);
+ Assert.NotNull(index);
+ Assert.False(index.FileToInstallationMap.ContainsKey(Path.GetFullPath(targetPath)));
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
index 1e9085592..bdb971a03 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
@@ -96,7 +96,8 @@ public async Task ValidateAsync_WithProgressCallback_ReportsProgressAsync()
Assert.True(reportsList.Count > 0, "Expected progress reports to be generated");
// Find the final progress report (highest processed count)
- var finalProgress = reportsList.MaxBy(p => p.Processed)!;
+ var finalProgress = reportsList.MaxBy(p => p.Processed);
+ Assert.NotNull(finalProgress);
// Verify the final progress shows completion
Assert.Equal(finalProgress.Total, finalProgress.Processed);
@@ -390,7 +391,7 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefullyAsync
///
/// Custom progress implementation that captures reports synchronously.
///
- private class SynchronousProgress : IProgress
+ private sealed class SynchronousProgress : IProgress
{
private readonly List _reports = new();
private readonly object _lock = new();
diff --git a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs
index 03d643eff..e839380da 100644
--- a/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs
+++ b/GenHub/GenHub/Features/Storage/Services/CasPoolManager.cs
@@ -174,6 +174,17 @@ private static string NormalizeRoot(string? rootPath)
: Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath));
}
+ private static bool IsInsideApplicationDirectory(string rootPath)
+ {
+ var appBaseDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory));
+ var normalizedRootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath));
+
+ return normalizedRootPath.Equals(appBaseDirectory, PathHelper.PathComparison) ||
+ normalizedRootPath.StartsWith(
+ appBaseDirectory + Path.DirectorySeparatorChar,
+ PathHelper.PathComparison);
+ }
+
private void InitializePool(CasPoolType poolType)
{
// Double-check locking to ensure thread safety
@@ -306,15 +317,4 @@ private void RefreshLegacyInstallationPool(string activeInstallationRoot)
string.Join(", ", retainedRoots));
}
}
-
- private bool IsInsideApplicationDirectory(string rootPath)
- {
- var appBaseDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(AppContext.BaseDirectory));
- var normalizedRootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath));
-
- return normalizedRootPath.Equals(appBaseDirectory, PathHelper.PathComparison) ||
- normalizedRootPath.StartsWith(
- appBaseDirectory + Path.DirectorySeparatorChar,
- PathHelper.PathComparison);
- }
}
diff --git a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs
index 365c98a1f..19899f1f8 100644
--- a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs
+++ b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs
@@ -9,6 +9,7 @@
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
+using GenHub.Core.Models.UserData;
using Microsoft.Extensions.Logging;
namespace GenHub.Features.UserData.Services;
@@ -39,9 +40,7 @@ public async Task> PrepareProfileUserDataAsync(
{
// Filter to manifests with user data files
var userDataManifests = manifests
- .Where(m => m.Files.Any(f =>
- f.InstallTarget != ContentInstallTarget.Workspace &&
- f.InstallTarget != ContentInstallTarget.System))
+ .Where(HasProfileUserData)
.ToList();
if (userDataManifests.Count == 0)
@@ -89,7 +88,11 @@ public async Task> PrepareProfileUserDataAsync(
return OperationResult.CreateFailure(uninstallResult.Errors);
}
- await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken);
+ var reinstallResult = await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken);
+ if (!reinstallResult.Success)
+ {
+ return OperationResult.CreateFailure(reinstallResult);
+ }
}
else if (!existingResult.Data.IsActive)
{
@@ -99,7 +102,11 @@ public async Task> PrepareProfileUserDataAsync(
else
{
// New installation needed
- await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken);
+ var installResult = await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken);
+ if (!installResult.Success)
+ {
+ return OperationResult.CreateFailure(installResult);
+ }
}
}
@@ -120,6 +127,10 @@ public async Task> PrepareProfileUserDataAsync(
logger.LogInformation("[ProfileContentLinker] Successfully prepared user data for profile {ProfileId}", profileId);
return OperationResult.CreateSuccess(true);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
logger.LogError(ex, "[ProfileContentLinker] Failed to prepare user data for profile {ProfileId}", profileId);
@@ -180,7 +191,7 @@ public async Task> SwitchProfileUserDataAsync(
// Register this manifest's files for the new profile as well
// This ensures they are tracked and won't be deleted when switching FROM the new profile later
- await userDataTracker.InstallUserDataAsync(
+ var adoptResult = await userDataTracker.InstallUserDataAsync(
manifest.ManifestId,
newProfileId,
targetGame,
@@ -194,6 +205,11 @@ await userDataTracker.InstallUserDataAsync(
manifest.ManifestVersion,
manifest.ManifestName,
cancellationToken);
+
+ if (!adoptResult.Success)
+ {
+ logger.LogWarning("[ProfileContentLinker] Failed to adopt manifest {ManifestId} for profile {ProfileId}: {Error}", manifest.ManifestId, newProfileId, adoptResult.FirstError);
+ }
}
}
}
@@ -201,6 +217,10 @@ await userDataTracker.InstallUserDataAsync(
// Prepare new profile's user data
return await PrepareProfileUserDataAsync(newProfileId, newManifests, targetGame, cancellationToken);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
logger.LogError(ex, "[ProfileContentLinker] Failed to switch user data to profile {ProfileId}", newProfileId);
@@ -230,6 +250,10 @@ public async Task> CleanupDeletedProfileAsync(
var cleanupResult = await userDataTracker.CleanupProfileAsync(profileId, cancellationToken);
return cleanupResult;
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
logger.LogError(ex, "[ProfileContentLinker] Failed to cleanup profile {ProfileId}", profileId);
@@ -257,9 +281,7 @@ public async Task> UpdateProfileUserDataAsync(
// Filter to manifests with user data
var userDataManifests = newManifests
- .Where(m => m.Files.Any(f =>
- f.InstallTarget != ContentInstallTarget.Workspace &&
- f.InstallTarget != ContentInstallTarget.System))
+ .Where(HasProfileUserData)
.ToList();
var newManifestIds = userDataManifests.Select(m => m.Id.Value).ToHashSet();
@@ -286,11 +308,15 @@ public async Task> UpdateProfileUserDataAsync(
foreach (var manifest in toAdd)
{
logger.LogInformation("[ProfileContentLinker] Installing new content: {ManifestId}", manifest.Id.Value);
- await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken);
+ var installResult = await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken);
+ if (!installResult.Success)
+ {
+ return OperationResult.CreateFailure(installResult);
+ }
}
// Activate if this is the active profile
- bool shouldActivate;
+ bool shouldActivate = false;
lock (_activeProfileLock)
{
shouldActivate = _activeProfileId == profileId;
@@ -315,6 +341,10 @@ public async Task> UpdateProfileUserDataAsync(
? OperationResult.CreateFailure(uninstallErrors)
: OperationResult.CreateSuccess(true);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
catch (Exception ex)
{
logger.LogError(ex, "[ProfileContentLinker] Failed to update profile {ProfileId}", profileId);
@@ -342,24 +372,58 @@ public bool IsProfileActive(string profileId)
}
}
+ private static bool HasProfileUserData(ContentManifest manifest)
+ {
+ return GetUserDataFiles(manifest).Count > 0;
+ }
+
+ private static IReadOnlyList GetUserDataFiles(ContentManifest manifest)
+ {
+ return manifest.Files
+ .Where(file => file.InstallTarget != ContentInstallTarget.System &&
+ (file.InstallTarget != ContentInstallTarget.Workspace ||
+ manifest.ContentType is ContentType.Map or ContentType.MapPack))
+ .Select(file => (manifest.ContentType is ContentType.Map or ContentType.MapPack) &&
+ file.InstallTarget == ContentInstallTarget.Workspace
+ ? CreateUserMapsFile(file)
+ : file)
+ .ToList();
+ }
+
+ private static ManifestFile CreateUserMapsFile(ManifestFile file)
+ {
+ return new ManifestFile
+ {
+ RelativePath = file.RelativePath,
+ SourceType = file.SourceType,
+ InstallTarget = ContentInstallTarget.UserMapsDirectory,
+ Size = file.Size,
+ Hash = file.Hash,
+ Permissions = file.Permissions,
+ IsExecutable = file.IsExecutable,
+ DownloadUrl = file.DownloadUrl,
+ IsRequired = file.IsRequired,
+ SourcePath = file.SourcePath,
+ PatchSourceFile = file.PatchSourceFile,
+ PackageInfo = file.PackageInfo,
+ };
+ }
+
///
/// Installs user data files from a manifest for a specific profile.
///
- /// A task representing the asynchronous installation operation.
- private async Task InstallManifestUserDataAsync(
+ /// An operation result containing the installed user data manifest.
+ private async Task> InstallManifestUserDataAsync(
ContentManifest manifest,
string profileId,
GameType targetGame,
CancellationToken cancellationToken)
{
- var userDataFiles = manifest.Files
- .Where(f => f.InstallTarget != ContentInstallTarget.Workspace &&
- f.InstallTarget != ContentInstallTarget.System)
- .ToList();
+ var userDataFiles = GetUserDataFiles(manifest);
if (userDataFiles.Count == 0)
{
- return;
+ return OperationResult.CreateFailure("No user data files to install");
}
logger.LogDebug(
@@ -367,7 +431,7 @@ private async Task InstallManifestUserDataAsync(
userDataFiles.Count,
manifest.Id.Value);
- await userDataTracker.InstallUserDataAsync(
+ return await userDataTracker.InstallUserDataAsync(
manifest.Id.Value,
profileId,
targetGame,
diff --git a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs
index 1a5301ba6..1c53d4658 100644
--- a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs
+++ b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs
@@ -469,7 +469,7 @@ public async Task>> GetGameUserD
foreach (var file in manifestFiles)
{
var manifest = await LoadUserDataManifestFromFileAsync(file, cancellationToken);
- if (manifest is { TargetGame: var manifestGame } && manifestGame == targetGame)
+ if (manifest?.TargetGame == targetGame)
{
manifests.Add(manifest);
}
@@ -538,8 +538,7 @@ public async Task> VerifyInstallationAsync(
continue;
}
- if (!file.IsHardLink &&
- !await fileOperations.VerifyFileHashAsync(file.AbsolutePath, file.SourceHash, cancellationToken))
+ if (!file.IsHardLink && !await fileOperations.VerifyFileHashAsync(file.AbsolutePath, file.SourceHash, cancellationToken))
{
logger.LogWarning("[UserData] File hash mismatch: {Path}", file.AbsolutePath);
allValid = false;
@@ -563,22 +562,14 @@ public async Task> VerifyInstallationAsync(
string absolutePath,
CancellationToken cancellationToken = default)
{
+ await IndexLock.WaitAsync(cancellationToken);
try
{
- var index = await LoadIndexAsync(cancellationToken);
- var normalizedPath = Path.GetFullPath(absolutePath);
-
- if (index.FileToInstallationMap.TryGetValue(normalizedPath, out var installationKey))
- {
- return OperationResult.CreateSuccess(installationKey);
- }
-
- return OperationResult.CreateSuccess(null);
+ return await CheckFileConflictUnlockedAsync(absolutePath, cancellationToken);
}
- catch (Exception ex)
+ finally
{
- logger.LogError(ex, "[UserData] Failed to check file conflict for {Path}", absolutePath);
- return OperationResult.CreateFailure($"Failed to check file conflict: {ex.Message}");
+ IndexLock.Release();
}
}
@@ -1379,11 +1370,23 @@ private async Task CleanupInstalledFilesAsync(UserDataManifest manifest, C
{
var userDataBasePath = GetUserDataBasePath(manifest.TargetGame);
var allBackupsRestored = true;
+ var index = await LoadIndexUnlockedAsync(cancellationToken);
foreach (var file in manifest.InstalledFiles)
{
cancellationToken.ThrowIfCancellationRequested();
+ if (index.FileToInstallationMap.TryGetValue(file.AbsolutePath, out var currentOwnerKey) &&
+ currentOwnerKey != manifest.InstallationKey)
+ {
+ logger.LogDebug(
+ "[UserData] Skipping cleanup of {Path} for installation {Key}; currently owned by {OwnerKey}",
+ file.AbsolutePath,
+ manifest.InstallationKey,
+ currentOwnerKey);
+ continue;
+ }
+
var hasBackup = !string.IsNullOrEmpty(file.BackupPath) && File.Exists(file.BackupPath);
var backupRestored = false;
@@ -1637,11 +1640,32 @@ private async Task SaveIndexAsync(UserDataIndex index, CancellationToken cancell
if (index.FileToInstallationMap.TryGetValue(normalizedPath, out var installationKey))
{
- return OperationResult.CreateSuccess(installationKey);
+ var manifest = await LoadUserDataManifestByKeyAsync(installationKey, cancellationToken);
+ if (manifest != null && manifest.IsActive)
+ {
+ return OperationResult.CreateSuccess(installationKey);
+ }
+
+ var manifestFilePath = GetManifestFilePath(installationKey);
+ if (!File.Exists(manifestFilePath) || (manifest != null && !manifest.IsActive))
+ {
+ // Installation is inactive or manifest no longer exists; clean up stale index mapping and persist
+ index.FileToInstallationMap.Remove(normalizedPath);
+ await SaveIndexAsync(index, cancellationToken);
+ }
+ else
+ {
+ // Manifest file exists on disk but could not be read; retain conflict conservatively
+ return OperationResult.CreateSuccess(installationKey);
+ }
}
return OperationResult.CreateSuccess(null);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (Exception ex)
{
logger.LogError(ex, "[UserData] Failed to check file conflict for {Path}", absolutePath);
@@ -1675,9 +1699,9 @@ private async Task UpdateIndexUnlockedAsync(UserDataManifest manifest, bool isAd
}
// Update file mappings
- foreach (var file in manifest.InstalledFiles)
+ foreach (var path in manifest.InstalledFiles.Select(file => file.AbsolutePath))
{
- index.FileToInstallationMap[file.AbsolutePath] = key;
+ index.FileToInstallationMap[path] = key;
}
// Update profile mappings
@@ -1708,10 +1732,14 @@ private async Task UpdateIndexUnlockedAsync(UserDataManifest manifest, bool isAd
{
index.InstallationKeys.Remove(key);
- // Remove file mappings
- foreach (var file in manifest.InstalledFiles)
+ // Remove file mappings only if still mapped to this installation
+ foreach (var path in manifest.InstalledFiles.Select(file => file.AbsolutePath))
{
- index.FileToInstallationMap.Remove(file.AbsolutePath);
+ if (index.FileToInstallationMap.TryGetValue(path, out var mappedKey) &&
+ mappedKey == key)
+ {
+ index.FileToInstallationMap.Remove(path);
+ }
}
// Remove from profile mappings