diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs
index 0e1249755..f33e89f27 100644
--- a/GenHub/GenHub.Core/Constants/FileTypes.cs
+++ b/GenHub/GenHub.Core/Constants/FileTypes.cs
@@ -84,4 +84,11 @@ public static class FileTypes
/// File extension for user data manifest files.
///
public const string UserDataManifestExtension = ".userdata.json";
+
+ ///
+ /// Filename used to store the source directory path mapping for a manifest's content.
+ /// This file is written inside the manifest's data directory and contains the path
+ /// to the original source directory (e.g., local installation folder).
+ ///
+ public const string SourcePathFileName = "source.path";
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
index 9a35655ea..a55134a1f 100644
--- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
+++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
@@ -161,38 +161,6 @@ public static class GenPatcherContentRegistry
Variants = ResolutionVariants,
},
- // Camera Modifications
- ["crgn"] = new GenPatcherContentMetadata
- {
- ContentCode = "crgn",
- DisplayName = "Camera Mod - Generals",
- Description = "Camera modification for Generals",
- ContentType = ContentType.Addon,
- TargetGame = GameType.Generals,
- Category = GenPatcherContentCategory.Camera,
- InstallTarget = ContentInstallTarget.Workspace,
- },
- ["crzh"] = new GenPatcherContentMetadata
- {
- ContentCode = "crzh",
- DisplayName = "Camera Mod - Zero Hour",
- Description = "Camera modification for Zero Hour",
- ContentType = ContentType.Addon,
- TargetGame = GameType.ZeroHour,
- Category = GenPatcherContentCategory.Camera,
- InstallTarget = ContentInstallTarget.Workspace,
- },
- ["dczh"] = new GenPatcherContentMetadata
- {
- ContentCode = "dczh",
- DisplayName = "D-Control - Zero Hour",
- Description = "D-Control camera for Zero Hour",
- ContentType = ContentType.Addon,
- TargetGame = GameType.ZeroHour,
- Category = GenPatcherContentCategory.Camera,
- InstallTarget = ContentInstallTarget.Workspace,
- },
-
// Hotkeys
["ewba"] = new GenPatcherContentMetadata
{
diff --git a/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs b/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs
index 88dd778ad..107290cb9 100644
--- a/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs
+++ b/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs
@@ -1,3 +1,4 @@
+using System;
using GenHub.Core.Models.Enums;
namespace GenHub.Core.Models.Workspace;
@@ -14,6 +15,7 @@ public static class ContentTypePriority
///
/// The content type.
/// Priority value (0-100).
+ /// Thrown when the content type should not be in a workspace.
public static int GetPriority(ContentType contentType)
{
return contentType switch
@@ -21,9 +23,26 @@ public static int GetPriority(ContentType contentType)
ContentType.Mod => 100, // Highest: User mods override everything
ContentType.Patch => 90, // Patches override base content
ContentType.GameClient => 50, // Community executables override official
+ ContentType.ModdingTool => 45, // Modding tools (between Addon and GameClient)
+ ContentType.Executable => 45, // Executables (between Addon and GameClient)
ContentType.Addon => 40, // Addons (maps, etc.)
+ ContentType.LanguagePack => 35, // Language packs
+ ContentType.MapPack => 30, // Map packs (between GameInstallation and Addon)
+ ContentType.Map => 30, // Individual maps
+ ContentType.Mission => 30, // Missions
+ ContentType.Skin => 20, // Skins
+ ContentType.Video => 20, // Videos
+ ContentType.Replay => 20, // Replays
+ ContentType.Screensaver => 20, // Screensavers
ContentType.GameInstallation => 10, // Lowest: Base game files
- _ => 0, // Unknown/undefined types
+
+ // These types should not be in workspaces
+ ContentType.ContentBundle => throw new ArgumentException($"ContentType {contentType} should not be used in workspace priority resolution"),
+ ContentType.PublisherReferral => throw new ArgumentException($"ContentType {contentType} should not be used in workspace priority resolution"),
+ ContentType.ContentReferral => throw new ArgumentException($"ContentType {contentType} should not be used in workspace priority resolution"),
+ ContentType.UnknownContentType => throw new ArgumentException($"ContentType {contentType} should not be used in workspace priority resolution"),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(contentType), contentType, $"ContentType '{contentType}' is not mapped in {nameof(ContentTypePriority)}. Add an explicit priority entry for this type."),
};
}
diff --git a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs
index 228473492..99db56429 100644
--- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs
+++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs
@@ -3,11 +3,15 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using GenHub.Common.ViewModels.Dialogs;
+using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Notifications;
using GenHub.Core.Messages;
@@ -416,4 +420,33 @@ partial void OnSelectedTabChanged(NavigationTab value)
SaveSelectedTab(value);
}
+
+ ///
+ /// Copies the application version to the clipboard.
+ ///
+ [RelayCommand]
+ private async Task CopyVersionToClipboard()
+ {
+ try
+ {
+ var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
+ var mainWindow = lifetime?.MainWindow;
+ var topLevel = mainWindow != null ? TopLevel.GetTopLevel(mainWindow) : null;
+
+ if (topLevel?.Clipboard != null)
+ {
+ await topLevel.Clipboard.SetTextAsync(AppConstants.FullDisplayVersion);
+ notificationService.ShowSuccess("Copied", "Version copied to clipboard.", 3000);
+ }
+ else
+ {
+ notificationService.ShowError("Error", "Clipboard not available.", 3000);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError(ex, "Failed to copy version to clipboard");
+ notificationService.ShowError("Error", "Failed to copy version to clipboard.", 3000);
+ }
+ }
}
diff --git a/GenHub/GenHub/Common/Views/MainView.axaml b/GenHub/GenHub/Common/Views/MainView.axaml
index 7866c0e14..71eb8f4d8 100644
--- a/GenHub/GenHub/Common/Views/MainView.axaml
+++ b/GenHub/GenHub/Common/Views/MainView.axaml
@@ -212,12 +212,27 @@
-
+
diff --git a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs
index 3fccea615..a677fb349 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs
@@ -524,14 +524,12 @@ private async Task> ReconcileBulkManifestR
}
else
{
- var error = $"Failed to update profile '{profile.Name}': {updateResult.FirstError}";
logger.LogWarning("Failed to update profile '{ProfileName}': {Error}", profile.Name, updateResult.FirstError);
failedProfiles.Add(profile.Name);
}
}
catch (Exception ex)
{
- var error = $"Error reconciling profile '{profile.Name}': {ex.Message}";
logger.LogError(ex, "Error reconciling profile '{ProfileName}': {Message}", profile.Name, ex.Message);
failedProfiles.Add(profile.Name);
}
@@ -608,14 +606,12 @@ private async Task> ReconcileManifestRemov
}
else
{
- var error = $"Failed to update profile '{profile.Name}': {updateResult.FirstError}";
logger.LogWarning("Failed to update profile '{ProfileName}': {Error}", profile.Name, updateResult.FirstError);
failedProfiles.Add(profile.Name);
}
}
catch (Exception ex)
{
- var error = $"Error removing manifest from profile '{profile.Name}': {ex.Message}";
logger.LogError(ex, "Error removing manifest from profile '{ProfileName}': {Message}", profile.Name, ex.Message);
failedProfiles.Add(profile.Name);
}
diff --git a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs
index 8476bb6a4..beeaa5156 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs
@@ -288,6 +288,13 @@ public async Task> StoreContentAsync(
var manifestJson = JsonSerializer.Serialize(updatedManifest, JsonOptions);
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
+ // Create source.path marker for CAS-stored content
+ // This prevents "Could not resolve source path" warnings when GetContentDirectoryAsync is called
+ var contentDir = Path.Combine(_storageRoot, DirectoryNames.Data, updatedManifest.Id.Value);
+ Directory.CreateDirectory(contentDir);
+ var sourcePathFile = Path.Combine(contentDir, FileTypes.SourcePathFileName);
+ await File.WriteAllTextAsync(sourcePathFile, "CAS-ONLY", cancellationToken);
+
_logger.LogInformation("Successfully stored content for manifest {ManifestId}", manifest.Id);
return OperationResult.CreateSuccess(updatedManifest);
}
@@ -408,7 +415,7 @@ public async Task> RemoveContentAsync(ManifestId manifestI
// Remove source.path mapping file if it exists
var contentDir = Path.Combine(_storageRoot, DirectoryNames.Data, manifestId.Value);
- var sourcePathFile = Path.Combine(contentDir, "source.path");
+ var sourcePathFile = Path.Combine(contentDir, FileTypes.SourcePathFileName);
FileOperationsService.DeleteFileIfExists(sourcePathFile);
// Clean up the data directory if empty
@@ -473,6 +480,11 @@ private async Task> StoreManifestOnlyAsync(
{
var manifestPath = GetManifestStoragePath(manifest.Id);
+ // Declare cleanup tracking variables outside try block so they're accessible in catch
+ bool contentDirCreatedByThisCall = false;
+ bool sourcePathWrittenByThisCall = false;
+ string? previousSourcePathContent = null;
+
try
{
// Validate manifest for security issues
@@ -498,10 +510,18 @@ private async Task> StoreManifestOnlyAsync(
if (!string.IsNullOrWhiteSpace(sourceDirectory) && Directory.Exists(sourceDirectory))
{
var contentDir = Path.Combine(_storageRoot, DirectoryNames.Data, manifest.Id.Value);
+ bool dirAlreadyExisted = Directory.Exists(contentDir);
Directory.CreateDirectory(contentDir);
+ contentDirCreatedByThisCall = !dirAlreadyExisted;
+
+ var sourcePathFile = Path.Combine(contentDir, FileTypes.SourcePathFileName);
+
+ // Backup existing source.path content before overwriting
+ if (File.Exists(sourcePathFile))
+ previousSourcePathContent = await File.ReadAllTextAsync(sourcePathFile, cancellationToken);
- var sourcePathFile = Path.Combine(contentDir, "source.path");
await File.WriteAllTextAsync(sourcePathFile, sourceDirectory, cancellationToken);
+ sourcePathWrittenByThisCall = true;
_logger.LogInformation(
"Created source path mapping for {ManifestId}: {SourcePath}",
@@ -534,6 +554,30 @@ private async Task> StoreManifestOnlyAsync(
{
FileOperationsService.DeleteFileIfExists(manifestPath);
+ // Clean up the content dir created for source.path mapping
+ var contentDir = Path.Combine(_storageRoot, DirectoryNames.Data, manifest.Id.Value);
+ if (contentDirCreatedByThisCall)
+ {
+ // We created this directory in the current call — safe to remove entirely.
+ if (Directory.Exists(contentDir))
+ Directory.Delete(contentDir, recursive: true);
+ }
+ else if (sourcePathWrittenByThisCall && Directory.Exists(contentDir))
+ {
+ // We overwrote an existing source.path file - restore it or delete if it was new
+ var sourcePathFile = Path.Combine(contentDir, FileTypes.SourcePathFileName);
+ if (previousSourcePathContent != null)
+ {
+ // Restore the previous content
+ await File.WriteAllTextAsync(sourcePathFile, previousSourcePathContent, CancellationToken.None);
+ }
+ else
+ {
+ // We created a new file, safe to delete
+ FileOperationsService.DeleteFileIfExists(sourcePathFile);
+ }
+ }
+
// Untrack manifest if we failed to save its metadata but had already tracked/refreshed references.
await _referenceTracker.UntrackManifestAsync(manifest.Id, CancellationToken.None);
}
diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestPool.cs b/GenHub/GenHub/Features/Manifest/ContentManifestPool.cs
index 763776256..f543794a6 100644
--- a/GenHub/GenHub/Features/Manifest/ContentManifestPool.cs
+++ b/GenHub/GenHub/Features/Manifest/ContentManifestPool.cs
@@ -294,12 +294,20 @@ public async Task> IsManifestAcquiredAsync(ManifestId mani
var contentDir = Path.Combine(storageService.GetContentStorageRoot(), DirectoryNames.Data, manifestId.Value);
// If a mapping file exists, return its value (this points to the original source directory)
- var mappingFile = Path.Combine(contentDir, "source.path");
+ var mappingFile = Path.Combine(contentDir, FileTypes.SourcePathFileName);
if (File.Exists(mappingFile))
{
var sourcePath = await File.ReadAllTextAsync(mappingFile, cancellationToken);
if (!string.IsNullOrWhiteSpace(sourcePath))
+ {
+ // Handle CAS-only content gracefully - return null without warnings
+ if (sourcePath.Trim().Equals("CAS-ONLY", StringComparison.OrdinalIgnoreCase))
+ {
+ return OperationResult.CreateSuccess(null);
+ }
+
return OperationResult.CreateSuccess(sourcePath);
+ }
}
var result = Directory.Exists(contentDir) ? contentDir : null;
diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml
index 9265280b6..719a8ae05 100644
--- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml
+++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml
@@ -409,31 +409,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -480,6 +455,23 @@
Command="{Binding OpenCasPoolDirectoryCommand}"
Classes="secondary-button" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
+
+
+
+
+
+
+
+
+
+
@@ -734,6 +726,14 @@
ToolTip.Tip="Browse available updates and subscribe to Pull Requests" />
+
+
+
+
+
+
diff --git a/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs b/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs
index 87b385bda..2cfb0a1a4 100644
--- a/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs
+++ b/GenHub/GenHub/Features/Workspace/Strategies/FullCopyStrategy.cs
@@ -142,10 +142,10 @@ await Parallel.ForEachAsync(
async (fileGroup, ct) =>
{
// For each destination path, process files in priority order (lowest to highest)
- // Priority: GameInstallation (0) < GameClient (1) < Mod (2)
+ // Priority: GameInstallation (10) < Addon (40) < GameClient (50) < Patch (90) < Mod (100)
// This ensures higher priority content overwrites lower priority
var orderedFiles = fileGroup
- .OrderBy(item => item.Manifest.ContentType)
+ .OrderBy(item => ContentTypePriority.GetPriority(item.Manifest.ContentType))
.ToList();
// Process all versions of this file in priority order