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 @@ - - - - - - - - - - - - -