From 63b2ba41b2a1cc07717cdd2db51aa9128ba1583e Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 31 Dec 2025 21:54:44 +0100 Subject: [PATCH 1/9] feat: add GitHub Pages deployment workflow and landing page assets --- .github/workflows/github-pages.yml | 53 +++++ Landing-page/assets/icon.png | Bin 0 -> 138001 bytes Landing-page/assets/logo.png | Bin 0 -> 9747 bytes Landing-page/index.html | 354 +++++++++++++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 .github/workflows/github-pages.yml create mode 100644 Landing-page/assets/icon.png create mode 100644 Landing-page/assets/logo.png create mode 100644 Landing-page/index.html diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml new file mode 100644 index 000000000..b5e4686fd --- /dev/null +++ b/.github/workflows/github-pages.yml @@ -0,0 +1,53 @@ +name: Deploy Landing Page to GitHub Pages + +on: + workflow_run: + workflows: ["GenHub Release"] + types: [completed] + workflow_dispatch: + +permissions: + contents: write + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v5 + + - id: release_info + env: + GH_TOKEN: ${{ github.token }} + run: | + LATEST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName') + + BUILD_NUM=$(echo "$LATEST_TAG" | cut -d'.' -f3) + DISPLAY_NAME="Alpha ${BUILD_NUM}" + + echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "display_name=$DISPLAY_NAME" >> $GITHUB_OUTPUT + + - run: | + mkdir -p ./public + cp Landing-page/index.html ./public/index.html + + if [ -d "Landing-page/assets" ]; then + cp -r Landing-page/assets ./public/assets + fi + + DOWNLOAD_URL="https://github.com/community-outpost/GenHub/releases/download/${{ steps.release_info.outputs.latest_tag }}/GenHub-win-Setup.exe" + + sed -i "s|VERSION_PLACEHOLDER|${{ steps.release_info.outputs.display_name }}|g" ./public/index.html + sed -i "s|URL_PLACEHOLDER|${DOWNLOAD_URL}|g" ./public/index.html + + - uses: actions/upload-pages-artifact@v3 + with: + path: ./public + + - uses: actions/deploy-pages@v4 diff --git a/Landing-page/assets/icon.png b/Landing-page/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cbf68e6bfff406330c0f0981c535efd9b7885ba3 GIT binary patch literal 138001 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 + + + + + + GeneralsHub - Universal C&C Launcher + + + + + + + + + + +

+ +
+ +
+
+
Latest Release: VERSION_PLACEHOLDER
+

Universal C&C Launcher

+

+ The ultimate tool for managing Command & Conquer: Generals and Zero Hour. + Organize mods, maps, and profiles in isolated, conflict-free workspaces. +

+ + +
+ +
+
+ 🎮 +

Profile Management

+

Create custom configurations combining base games with mods, patches, and maps. Switch between setups + instantly.

+
+ +
+ 🔍 +

Content Discovery

+

Automated discovery from GitHub, ModDB, CNC Labs, and local sources. Install mods with a single + click.

+
+ +
+ 📁 +

Isolated Workspaces

+

Run different profiles in completely isolated environments using symlinks or copies. No more file + conflicts.

+
+ +
+ 🌐 +

Cross-Platform

+

Native support for Windows and Linux. Manage your Generals ecosystem regardless of your OS.

+
+ +
+ 🔧 +

Smart Updates

+

Built-in update system powered by Velopack. Stay up to date with the latest features automatically. +

+
+ +
+ 🛡️ +

User Data Safety

+

Advanced user data management ensures your saves, replays, and maps are never lost when switching + profiles.

+
+
+
+ + + + + + From 627d48d831ef686c09bef8244c6b7b13659ffb3d Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 1 Jan 2026 09:45:42 +0100 Subject: [PATCH 2/9] feat: Add Replay Manager tool with drag-and-drop functionality and file management - Implemented ReplayManagerView for managing replay files. - Added drag-and-drop support for importing replay files (.rep, .zip). - Enhanced ToolsViewModel to include Replay Manager services. - Updated UI styles for better visual consistency. - Introduced EnumToBoolConverter and EqualsToConverter for improved data binding. - Added documentation for Replay Manager features and usage. - Registered Replay Manager services in the dependency injection module. --- GenHub/Directory.Packages.props | 2 + GenHub/GenHub.Core/Constants/ApiConstants.cs | 36 + GenHub/GenHub.Core/Constants/ErrorMessages.cs | 62 ++ GenHub/GenHub.Core/Constants/FileTypes.cs | 20 + GenHub/GenHub.Core/Constants/LogMessages.cs | 72 ++ .../Constants/PlatformConstants.cs | 17 + .../GenHub.Core/Constants/RegexConstants.cs | 17 + .../Constants/ReplayManagerConstants.cs | 32 + GenHub/GenHub.Core/Constants/ToolConstants.cs | 53 ++ .../Interfaces/Tools/IToolRegistry.cs | 8 +- .../ReplayManager/IReplayDirectoryService.cs | 54 ++ .../ReplayManager/IReplayExportService.cs | 39 + .../ReplayManager/IReplayImportService.cs | 82 +++ .../ReplayManager/IUploadRateLimitService.cs | 55 ++ .../Tools/ReplayManager/IUrlParserService.cs | 33 + .../ReplayManager/IZipValidationService.cs | 16 + .../Tools/ReplayManager/UsageInfo.cs | 12 + .../Tools/ReplayManager/ImportResult.cs | 32 + .../Models/Tools/ReplayManager/ReplayFile.cs | 51 ++ .../Tools/ReplayManager/ReplayMetadata.cs | 27 + .../Tools/ReplayManager/ReplaySource.cs | 32 + .../Tools/ReplayManager/UploadHistoryItem.cs | 14 + .../GenHub.Core/Models/Tools/ToolMetadata.cs | 9 +- .../Services/Tools/ToolRegistry.cs | 12 +- .../GenHub.Core/Services/Tools/ToolService.cs | 51 +- GenHub/GenHub.Core/Utilities/ZipValidation.cs | 40 ++ .../Services/ToolSystemIntegrationTests.cs | 13 +- .../Workspace/WorkspaceStrategyBaseTests.cs | 8 +- .../Workspace/WorkspaceValidatorTests.cs | 32 +- .../SharedViewModelModuleTests.cs | 4 +- .../Models/Results/DetectionResultTests.cs | 2 +- .../WorkspaceCasIntegrationTests.cs | 10 +- .../WindowsInstallationDetector.cs | 5 +- GenHub/GenHub.Windows/GenHub.Windows.csproj | 1 + GenHub/GenHub.Windows/Program.cs | 17 +- GenHub/GenHub/App.axaml | 1 + .../GenHub/Common/ViewModels/MainViewModel.cs | 1 + .../ReplayManager/ReplayManagerToolPlugin.cs | 68 ++ .../Services/ReplayDirectoryService.cs | 141 ++++ .../Services/ReplayExportService.cs | 249 +++++++ .../Services/ReplayImportService.cs | 340 +++++++++ .../Services/UploadRateLimitService.cs | 253 +++++++ .../Services/UrlParserService.cs | 123 ++++ .../Services/ZipValidationService.cs | 73 ++ .../ViewModels/ReplayManagerViewModel.cs | 672 ++++++++++++++++++ .../ViewModels/UploadHistoryItemViewModel.cs | 106 +++ .../Views/ReplayManagerView.axaml | 286 ++++++++ .../Views/ReplayManagerView.axaml.cs | 163 +++++ .../Tools/ViewModels/ToolsViewModel.cs | 17 +- .../Features/Tools/Views/ToolsView.axaml | 144 ++-- GenHub/GenHub/GenHub.csproj | 1 + .../DependencyInjection/AppServices.cs | 6 +- .../ReplayManagerModule.cs | 38 + docs/.vitepress/config.js | 8 +- docs/dev/constants.md | 37 + docs/tools/replay-manager.md | 62 ++ 56 files changed, 3660 insertions(+), 99 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/ErrorMessages.cs create mode 100644 GenHub/GenHub.Core/Constants/LogMessages.cs create mode 100644 GenHub/GenHub.Core/Constants/PlatformConstants.cs create mode 100644 GenHub/GenHub.Core/Constants/RegexConstants.cs create mode 100644 GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs create mode 100644 GenHub/GenHub.Core/Constants/ToolConstants.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayDirectoryService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayImportService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IZipValidationService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/ReplayManager/ImportResult.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs create mode 100644 GenHub/GenHub.Core/Utilities/ZipValidation.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/ReplayManagerToolPlugin.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/UploadRateLimitService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ZipValidationService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml.cs create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/ReplayManagerModule.cs create mode 100644 docs/tools/replay-manager.md diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index 6c3670cbe..1be55ad77 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -11,6 +11,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/GenHub/GenHub.Core/Constants/ApiConstants.cs b/GenHub/GenHub.Core/Constants/ApiConstants.cs index d06432587..ed9596d50 100644 --- a/GenHub/GenHub.Core/Constants/ApiConstants.cs +++ b/GenHub/GenHub.Core/Constants/ApiConstants.cs @@ -59,6 +59,42 @@ public static class ApiConstants /// public const string GitHubApiRunArtifactsFormat = "https://api.github.com/repos/{0}/{1}/actions/runs/{2}/artifacts"; + // UploadThing + + /// + /// UploadThing API version. + /// + public const string UploadThingApiVersion = "7.7.4"; + + /// + /// UploadThing prepare upload URL. + /// + public const string UploadThingPrepareUrl = "https://api.uploadthing.com/v7/prepareUpload"; + + /// + /// UploadThing public file URL format. + /// + public const string UploadThingPublicUrlFormat = "https://utfs.io/f/{0}"; + + /// + /// UploadThing URL fragment for identification. + /// + public const string UploadThingUrlFragment = "utfs.io/f/"; + + // GenTool + + /// + /// GenTool data URL fragment for identification. + /// + public const string GenToolUrlFragment = "gentool.net/data/"; + + // Generals Online + + /// + /// Generals Online view match URL fragment. + /// + public const string GeneralsOnlineViewMatchFragment = "playgenerals.online/viewmatch"; + // User agents /// diff --git a/GenHub/GenHub.Core/Constants/ErrorMessages.cs b/GenHub/GenHub.Core/Constants/ErrorMessages.cs new file mode 100644 index 000000000..7e5371288 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ErrorMessages.cs @@ -0,0 +1,62 @@ +namespace GenHub.Core.Constants; + +/// +/// Error message constants. +/// +public static class ErrorMessages +{ + /// + /// Error message for missing UploadThing token. + /// + public const string UploadThingTokenMissing = "UploadThing V7 Token is missing. Ensure UPLOADTHING_TOKEN is in your .env file."; + + /// + /// Error message for ZIP validation failure. + /// + public const string ZipValidationFailed = "ZIP validation failed for upload: {Error}"; + + /// + /// Error message for file exceeding size limit. + /// + public const string FileExceedsSizeLimit = "File exceeds size limit: {Path}"; + + /// + /// Error message for failed prepare upload. + /// + public const string V7PrepareUploadFailed = "V7 PrepareUpload failed: {StatusCode} - {Error}"; + + /// + /// Error message for missing required fields in UploadThing response. + /// + public const string UploadThingMissingFields = "UploadThing V7 returned 200 OK but missing required fields. Response: {Response}"; + + /// + /// Error message for failed binary upload. + /// + public const string V7BinaryUploadFailed = "V7 PUT Binary Upload failed: {StatusCode} - {Error}"; + + /// + /// Error message for exception in UploadThing flow. + /// + public const string ExceptionInUploadThingFlow = "Exception in UploadThing V7 flow"; + + /// + /// Error message for could not extract download URL. + /// + public const string CouldNotExtractDownloadUrl = "Could not extract download URL from the provided source."; + + /// + /// Error message for download failed. + /// + public const string DownloadFailed = "Download failed."; + + /// + /// Error message for replay exceeding size. + /// + public const string ReplayExceedsMaxSize = "Replay file exceeds maximum size of 1 MB ({0:F1} KB)."; + + /// + /// Error message for failed to process ZIP. + /// + public const string FailedToProcessZip = "Failed to process ZIP: {0}"; +} diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs index 3e770a255..2abf048a6 100644 --- a/GenHub/GenHub.Core/Constants/FileTypes.cs +++ b/GenHub/GenHub.Core/Constants/FileTypes.cs @@ -34,4 +34,24 @@ public static class FileTypes /// Default settings file name. /// public const string SettingsFileName = "settings.json"; + + /// + /// File extension for replay files. + /// + public const string ReplayFileExtension = ".rep"; + + /// + /// File extension for ZIP files. + /// + public const string ZipFileExtension = ".zip"; + + /// + /// File extension pattern for replay files. + /// + public const string ReplayFilePattern = "*.rep"; + + /// + /// File extension pattern for ZIP files. + /// + public const string ZipFilePattern = "*.zip"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/LogMessages.cs b/GenHub/GenHub.Core/Constants/LogMessages.cs new file mode 100644 index 000000000..290e58e97 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/LogMessages.cs @@ -0,0 +1,72 @@ +namespace GenHub.Core.Constants; + +/// +/// Log message constants. +/// +public static class LogMessages +{ + /// + /// Log message for identifying URL source. + /// + public const string IdentifyingUrlSource = "Identifying source for URL: {Url}, Source: {Source}"; + + /// + /// Log message for failed URL extraction. + /// + public const string FailedToExtractDownloadUrl = "Failed to extract download URL from: {Url}"; + + /// + /// Log message for missing replay link on Generals Online. + /// + public const string CouldNotFindReplayLinkGeneralsOnline = "Could not find replay link on Generals Online page: {Url}"; + + /// + /// Log message for missing replay link on GenTool. + /// + public const string CouldNotFindReplayLinkGenTool = "Could not find replay link on GenTool page: {Url}"; + + /// + /// Log message for creating replay directory. + /// + public const string CreatingReplayDirectory = "Creating replay directory: {Path}"; + + /// + /// Log message for deleted replay. + /// + public const string DeletedReplay = "Deleted replay: {Path}"; + + /// + /// Log message for failed replay deletion. + /// + public const string FailedToDeleteReplay = "Failed to delete replay: {Path}"; + + /// + /// Log message for uploading to UploadThing. + /// + public const string UploadingToUploadThing = "Uploading to UploadThing V7: {Path}"; + + /// + /// Log message for successful UploadThing upload. + /// + public const string UploadThingSuccessful = "UploadThing V7 successful. Public URL: {Url}"; + + /// + /// Log message for failed ZIP creation. + /// + public const string FailedToCreateZip = "Failed to create ZIP: {Path}"; + + /// + /// Log message for detected ZIP file. + /// + public const string DetectedZipFile = "Detected ZIP file, extracting contents"; + + /// + /// Log message for failed import from ZIP. + /// + public const string FailedToImportFromZip = "Failed to import from ZIP: {Path}"; + + /// + /// Log message for failed stream import. + /// + public const string FailedToImportStream = "Failed to import stream for file: {FileName}"; +} diff --git a/GenHub/GenHub.Core/Constants/PlatformConstants.cs b/GenHub/GenHub.Core/Constants/PlatformConstants.cs new file mode 100644 index 000000000..e81581558 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/PlatformConstants.cs @@ -0,0 +1,17 @@ +namespace GenHub.Core.Constants; + +/// +/// Platform-specific constants. +/// +public static class PlatformConstants +{ + /// + /// Windows Explorer executable name. + /// + public const string WindowsExplorerExecutable = "explorer.exe"; + + /// + /// Windows Explorer select argument. + /// + public const string WindowsExplorerSelectArgument = "/select,\"{0}\""; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/RegexConstants.cs b/GenHub/GenHub.Core/Constants/RegexConstants.cs new file mode 100644 index 000000000..311312cd2 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/RegexConstants.cs @@ -0,0 +1,17 @@ +namespace GenHub.Core.Constants; + +/// +/// Regex pattern constants. +/// +public static class RegexConstants +{ + /// + /// Regex pattern for Generals Online replay URLs. + /// + public const string GeneralsOnlineReplayPattern = @"https://matchdata\.playgenerals\.online/[^""]+_replay\.rep"; + + /// + /// Regex pattern for GenTool replay links. + /// + public const string GenToolReplayPattern = @"href=""([^\""]+\.rep)"""; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs new file mode 100644 index 000000000..6e7dfb60b --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs @@ -0,0 +1,32 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the Replay Manager feature. +/// +public static class ReplayManagerConstants +{ + /// + /// Maximum size for a single replay file in bytes (1 MB). + /// + public const long MaxReplaySizeBytes = 1024 * 1024; + + /// + /// Maximum weekly upload bytes (10 MB). + /// + public const long MaxWeeklyUploadBytes = 10 * 1024 * 1024; + + /// + /// Prefix for temporary import files. + /// + public const string TempImportFilePrefix = "genhub_import_"; + + /// + /// Prefix for temporary share files. + /// + public const string TempShareFilePrefix = "genhub_share_"; + + /// + /// Default file name for imported replays. + /// + public const string DefaultImportedReplayFileName = "imported_replay.rep"; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ToolConstants.cs b/GenHub/GenHub.Core/Constants/ToolConstants.cs new file mode 100644 index 000000000..2967d13e0 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ToolConstants.cs @@ -0,0 +1,53 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for tool plugin metadata and configuration. +/// +public static class ToolConstants +{ + /// + /// Constants for the Replay Manager tool plugin. + /// + public static class ReplayManager + { + /// + /// The unique identifier for the Replay Manager tool. + /// + public const string Id = "genhub.tools.replaymanager"; + + /// + /// The display name for the Replay Manager tool. + /// + public const string Name = "Replay Manager"; + + /// + /// The version of the Replay Manager tool. + /// + public const string Version = "1.0.0"; + + /// + /// The author of the Replay Manager tool. + /// + public const string Author = "GenHub Team"; + + /// + /// The description of the Replay Manager tool. + /// + public const string Description = "Manage, import, and share replay files for Command & Conquer: Generals and Zero Hour."; + + /// + /// The icon path for the Replay Manager tool. + /// + public const string IconPath = "Assets/Icons/replay.png"; // Placeholder + + /// + /// Whether the Replay Manager tool is bundled with the application. + /// + public const bool IsBundled = true; + + /// + /// The tags associated with the Replay Manager tool. + /// + public static readonly string[] Tags = ["replays", "file-management", "sharing"]; + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs b/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs index 1dd03574a..5bf1804e7 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs @@ -19,12 +19,18 @@ public interface IToolRegistry IToolPlugin? GetToolById(string toolId); /// - /// Registers a new tool plugin. + /// Registers a new tool plugin with an assembly path (external tool). /// /// The tool plugin to register. /// The path to the tool assembly. void RegisterTool(IToolPlugin plugin, string assemblyPath); + /// + /// Registers a new built-in tool plugin. + /// + /// The tool plugin to register. + void RegisterTool(IToolPlugin plugin); + /// /// Unregisters a tool plugin by its ID. /// diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayDirectoryService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayDirectoryService.cs new file mode 100644 index 000000000..94f0ea046 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayDirectoryService.cs @@ -0,0 +1,54 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Manages replay directory operations. +/// +public interface IReplayDirectoryService +{ + /// + /// Gets the replay directory path for the specified game version. + /// + /// The game version. + /// The path to the replay directory. + string GetReplayDirectory(GameType version); + + /// + /// Ensures the replay directory exists, creating it if necessary. + /// + /// The game version. + void EnsureDirectoryExists(GameType version); + + /// + /// Gets all replay files for the specified game version. + /// + /// The game version. + /// Cancellation token. + /// A list of replay files. + Task> GetReplaysAsync(GameType version, CancellationToken ct = default); + + /// + /// Deletes the specified replay files (moves to Recycle Bin). + /// + /// The replays to delete. + /// Cancellation token. + /// True if deletion was successful. + Task DeleteReplaysAsync(IEnumerable replays, CancellationToken ct = default); + + /// + /// Opens the replay directory in Windows Explorer. + /// + /// The game version. + void OpenInExplorer(GameType version); + + /// + /// Reveals a specific file in Windows Explorer. + /// + /// The replay file to reveal. + void RevealInExplorer(ReplayFile replay); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs new file mode 100644 index 000000000..1705d5aed --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs @@ -0,0 +1,39 @@ +using GenHub.Core.Models.Tools.ReplayManager; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Handles exporting and sharing replays. +/// +public interface IReplayExportService +{ + /// + /// Uploads replays to UploadThing and returns the share URL. + /// + /// The replays to upload. + /// Progress reporter for upload updates. + /// Cancellation token. + /// The share URL if successful, otherwise null. + Task UploadToUploadThingAsync( + IEnumerable replays, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Creates a ZIP archive of the specified replays. + /// + /// The replays to export. + /// The destination ZIP file path. + /// Progress reporter for compression updates. + /// Cancellation token. + /// The path to the created ZIP file if successful, otherwise null. + Task ExportToZipAsync( + IEnumerable replays, + string destinationPath, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayImportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayImportService.cs new file mode 100644 index 000000000..1c5e540bd --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayImportService.cs @@ -0,0 +1,82 @@ +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Handles importing replays from various sources. +/// +public interface IReplayImportService +{ + /// + /// Maximum size for a single replay file in bytes (1 MB). + /// + public const long MaxReplaySizeBytes = ReplayManagerConstants.MaxReplaySizeBytes; + + /// + /// Imports a replay from a URL. + /// + /// The URL to import from. + /// The target game version. + /// Progress reporter for download updates. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromUrlAsync( + string url, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Imports replay files from local paths. + /// + /// The paths to the local files. + /// The target game version. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromFilesAsync( + IEnumerable filePaths, + GameType targetVersion, + CancellationToken ct = default); + + /// + /// Imports replays from a ZIP archive. + /// + /// The path to the ZIP archive. + /// The target game version. + /// Progress reporter for extraction updates. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromZipAsync( + string zipPath, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Validates a ZIP archive to ensure it contains only a single layer of replay files. + /// + /// The path to the ZIP archive. + /// A result indicating whether the ZIP is valid and any error message. + (bool IsValid, string? ErrorMessage) ValidateZip(string zipPath); + + /// + /// Imports replays from a stream (e.g., for drag-and-drop). + /// + /// The stream to read from. + /// The name of the file being imported. + /// The target game version. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromStreamAsync( + Stream stream, + string fileName, + GameType targetVersion, + CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs new file mode 100644 index 000000000..2e7ed43e7 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs @@ -0,0 +1,55 @@ +using GenHub.Core.Constants; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Manages upload rate limiting to prevent spam. +/// +public interface IUploadRateLimitService +{ + /// + /// Maximum upload size per week in bytes (10 MB). + /// + public const long MaxWeeklyUploadBytes = ReplayManagerConstants.MaxWeeklyUploadBytes; + + /// + /// Checks if an upload of the specified size is allowed. + /// + /// The size of the file to upload in bytes. + /// True if the upload is allowed, false if it would exceed the weekly limit. + Task CanUploadAsync(long fileSizeBytes); + + /// + /// Records a successful upload. + /// + /// The size of the uploaded file in bytes. + /// The public URL of the uploaded file. + /// The name of the uploaded file. + void RecordUpload(long fileSizeBytes, string url, string fileName); + + /// + /// Gets the current usage information. + /// + /// A containing the current usage information. + Task GetUsageInfoAsync(); + + /// + /// Gets the history of uploads. + /// + /// A list of upload history items. + Task> GetUploadHistoryAsync(); + + /// + /// Removes a specific upload history item by URL. + /// + /// The URL of the upload to remove. + /// A task representing the asynchronous operation. + Task RemoveHistoryItemAsync(string url); + + /// + /// Clears all upload history. + /// + /// A task representing the asynchronous operation. + Task ClearHistoryAsync(); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs new file mode 100644 index 000000000..917db1df0 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs @@ -0,0 +1,33 @@ +using GenHub.Core.Models.Tools.ReplayManager; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Parses and identifies replay source URLs. +/// +public interface IUrlParserService +{ + /// + /// Identifies the source of a replay URL. + /// + /// The URL to identify. + /// The identified source. + ReplaySource IdentifySource(string url); + + /// + /// Validates if the URL is a supported replay source. + /// + /// The URL to validate. + /// True if the URL is supported. + bool IsValidReplayUrl(string url); + + /// + /// Extracts the direct download URL from a source-specific URL. + /// + /// The source URL. + /// Cancellation token. + /// The direct download URL if successful, otherwise null. + Task GetDirectDownloadUrlAsync(string url, CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IZipValidationService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IZipValidationService.cs new file mode 100644 index 000000000..6c708765f --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IZipValidationService.cs @@ -0,0 +1,16 @@ +using System.IO; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Service for validating ZIP files. +/// +public interface IZipValidationService +{ + /// + /// Validates if the given file path points to a valid ZIP archive. + /// + /// The path to the ZIP file. + /// A tuple with validation result and error message if invalid. + (bool IsValid, string? ErrorMessage) ValidateZip(string zipPath); +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs new file mode 100644 index 000000000..601babec0 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs @@ -0,0 +1,12 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Core.Interfaces.Tools.ReplayManager; + +/// +/// Represents usage information for upload limits. +/// +/// The number of bytes used in the current week. +/// The maximum allowed bytes per week. +/// The date and time when the usage resets. +public readonly record struct UsageInfo(long UsedBytes, long LimitBytes, DateTime ResetDate); \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ImportResult.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ImportResult.cs new file mode 100644 index 000000000..5af28bfd0 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ImportResult.cs @@ -0,0 +1,32 @@ +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Result of an import operation. +/// +public sealed class ImportResult +{ + /// + /// Gets a value indicating whether the import was successful. + /// + public required bool Success { get; init; } + + /// + /// Gets the number of files successfully imported. + /// + public required int FilesImported { get; init; } + + /// + /// Gets the number of files skipped. + /// + public required int FilesSkipped { get; init; } + + /// + /// Gets the list of error messages. + /// + public IReadOnlyList Errors { get; init; } = []; + + /// + /// Gets the list of imported file paths. + /// + public IReadOnlyList ImportedFiles { get; init; } = []; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs new file mode 100644 index 000000000..d93f30b08 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs @@ -0,0 +1,51 @@ +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Represents a replay file on disk. +/// +public sealed class ReplayFile +{ + /// + /// Gets or sets the full path to the replay file. + /// + public required string FullPath { get; set; } + + /// + /// Gets or sets the file name. + /// + public required string FileName { get; set; } + + /// + /// Gets the file size in bytes. + /// + public required long SizeInBytes { get; init; } + + /// + /// Gets the last modified date/time. + /// + public required DateTime LastModified { get; init; } + + /// + /// Gets the game version this replay belongs to. + /// + public required GameType GameVersion { get; init; } + + /// + /// Gets or sets the replay metadata. + /// + public ReplayMetadata? Metadata { get; set; } + + /// + /// Gets the formatted file size string. + /// + public string FormattedSize => FormatFileSize(SizeInBytes); + + private static string FormatFileSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB", + }; +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs new file mode 100644 index 000000000..829018017 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs @@ -0,0 +1,27 @@ +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Placeholder for future replay parsing feature. +/// +public sealed class ReplayMetadata +{ + /// + /// Gets the map name. + /// + public string? MapName { get; init; } + + /// + /// Gets the list of players. + /// + public IReadOnlyList? Players { get; init; } + + /// + /// Gets the game duration. + /// + public TimeSpan? Duration { get; init; } + + /// + /// Gets the date the game was played. + /// + public DateTime? GameDate { get; init; } +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs new file mode 100644 index 000000000..74dedfe8b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs @@ -0,0 +1,32 @@ +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Identifies the source of a replay URL. +/// +public enum ReplaySource +{ + /// + /// Unknown source. + /// + Unknown, + + /// + /// UploadThing file hosting. + /// + UploadThing, + + /// + /// Generals Online community platform. + /// + GeneralsOnline, + + /// + /// GenTool community tool/website. + /// + GenTool, + + /// + /// Direct link to a .rep or .zip file. + /// + DirectLink, +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs new file mode 100644 index 000000000..797e6123d --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs @@ -0,0 +1,14 @@ +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Represents a single item in the upload history. +/// +/// The UTC timestamp of the upload. +/// The size of the uploaded file in bytes. +/// The public URL of the upload. +/// The name of the uploaded file. +public record UploadHistoryItem( + System.DateTime Timestamp, + long SizeBytes, + string Url, + string FileName); diff --git a/GenHub/GenHub.Core/Models/Tools/ToolMetadata.cs b/GenHub/GenHub.Core/Models/Tools/ToolMetadata.cs index 273f66818..924b34e69 100644 --- a/GenHub/GenHub.Core/Models/Tools/ToolMetadata.cs +++ b/GenHub/GenHub.Core/Models/Tools/ToolMetadata.cs @@ -35,8 +35,13 @@ public class ToolMetadata /// public string? IconPath { get; set; } + /// + /// Gets or sets a value indicating whether the tool is bundled with the application and cannot be removed. + /// + public bool IsBundled { get; set; } + /// /// Gets or sets the tags/categories for the tool. /// - public List Tags { get; set; } = new(); -} \ No newline at end of file + public List Tags { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs index d50a3edf2..180c828bc 100644 --- a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs +++ b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs @@ -14,7 +14,7 @@ public class ToolRegistry : IToolRegistry /// public IReadOnlyList GetAllTools() { - return _tools.Values.ToList(); + return [.. _tools.Values]; } /// @@ -38,6 +38,12 @@ public void RegisterTool(IToolPlugin plugin, string assemblyPath) _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath; } + /// + public void RegisterTool(IToolPlugin plugin) + { + _tools[plugin.Metadata.Id] = plugin; + } + /// public bool UnregisterTool(string toolId) { @@ -45,9 +51,9 @@ public bool UnregisterTool(string toolId) if (removed && plugin != null) { plugin.Dispose(); - _toolAssemblyPaths.TryRemove(toolId, out var path); + _ = _toolAssemblyPaths.TryRemove(toolId, out _); } return removed; } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Services/Tools/ToolService.cs b/GenHub/GenHub.Core/Services/Tools/ToolService.cs index fbf8a4db7..7b4dbfa0c 100644 --- a/GenHub/GenHub.Core/Services/Tools/ToolService.cs +++ b/GenHub/GenHub.Core/Services/Tools/ToolService.cs @@ -14,11 +14,13 @@ namespace GenHub.Core.Services.Tools; /// Plugin loader for loading tool plugins. /// Registry for managing tool plugins. /// Service for managing user settings. +/// Collection of built-in tool plugins. /// Logger for logging tool service activities. public class ToolService( IToolPluginLoader pluginLoader, IToolRegistry toolRegistry, IUserSettingsService userSettingsService, + IEnumerable builtInPlugins, ILogger logger) : IToolManager { @@ -53,7 +55,7 @@ public async Task> AddToolAsync(string assemblyPath userSettingsService.Update(settings => { - settings.InstalledToolAssemblyPaths ??= new List(); + settings.InstalledToolAssemblyPaths ??= []; if (!settings.InstalledToolAssemblyPaths.Contains(assemblyPath)) { settings.InstalledToolAssemblyPaths.Add(assemblyPath); @@ -89,8 +91,29 @@ public async Task>> LoadSavedToolsAsync() { try { + var loadedPlugins = new List(); + + // First, register all built-in plugins from DI + foreach (var builtInPlugin in builtInPlugins) + { + var existingTool = toolRegistry.GetToolById(builtInPlugin.Metadata.Id); + if (existingTool == null) + { + builtInPlugin.Metadata.IsBundled = true; + toolRegistry.RegisterTool(builtInPlugin); + loadedPlugins.Add(builtInPlugin); + logger.LogDebug("Registered built-in tool plugin: {PluginName}", builtInPlugin.Metadata.Name); + } + else + { + loadedPlugins.Add(existingTool); + logger.LogDebug("Built-in tool plugin {PluginName} already registered", builtInPlugin.Metadata.Name); + } + } + + // Then, load external plugins from saved paths var settings = userSettingsService.Get(); - var toolPaths = settings.InstalledToolAssemblyPaths ?? new List(); + var toolPaths = settings.InstalledToolAssemblyPaths ?? []; logger.LogInformation("Loading saved tool plugins. Found {Count} paths in settings.", toolPaths.Count); @@ -99,8 +122,6 @@ public async Task>> LoadSavedToolsAsync() logger.LogDebug("Tool paths: {Paths}", string.Join(", ", toolPaths)); } - var loadedPlugins = new List(); - foreach (var path in toolPaths) { logger.LogDebug("Processing tool path: {Path}", path); @@ -131,7 +152,11 @@ public async Task>> LoadSavedToolsAsync() } } - logger.LogInformation("Loaded {Count} tool plugins from saved settings.", loadedPlugins.Count); + logger.LogInformation( + "Loaded {Count} tool plugins ({BuiltIn} built-in, {External} external).", + loadedPlugins.Count, + builtInPlugins.Count(), + toolPaths.Count); return await Task.FromResult(OperationResult>.CreateSuccess(loadedPlugins)); } catch (Exception ex) @@ -146,10 +171,22 @@ public async Task> RemoveToolAsync(string toolId) { try { + var tool = toolRegistry.GetToolById(toolId); + if (tool == null) + { + return await Task.FromResult(OperationResult.CreateFailure("Tool not found.")); + } + + if (tool.Metadata.IsBundled) + { + logger.LogWarning("Attempted to remove bundled tool: {ToolName} ({ToolId})", tool.Metadata.Name, toolId); + return await Task.FromResult(OperationResult.CreateFailure("Bundled tools cannot be removed.")); + } + var assemblyPath = toolRegistry.GetToolAssemblyPath(toolId); if (assemblyPath == null) { - return await Task.FromResult(OperationResult.CreateFailure("Tool not found.")); + return await Task.FromResult(OperationResult.CreateFailure("Tool registration is incomplete (missing assembly path).")); } if (!toolRegistry.UnregisterTool(toolId)) @@ -173,4 +210,4 @@ public async Task> RemoveToolAsync(string toolId) return OperationResult.CreateFailure("An error occurred while removing the tool."); } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Utilities/ZipValidation.cs b/GenHub/GenHub.Core/Utilities/ZipValidation.cs new file mode 100644 index 000000000..3be059b33 --- /dev/null +++ b/GenHub/GenHub.Core/Utilities/ZipValidation.cs @@ -0,0 +1,40 @@ +using System.IO; + +namespace GenHub.Core.Utilities; + +/// +/// Utility methods for ZIP file validation. +/// +public static class ZipValidation +{ + /// + /// Validates if the given file path points to a valid ZIP archive by checking magic bytes. + /// + /// The path to the file to validate. + /// True if the file appears to be a valid ZIP archive. + public static bool IsValidZipFile(string filePath) + { + try + { + using var stream = File.OpenRead(filePath); + if (stream.Length < 4) + { + return false; + } + + var buffer = new byte[4]; + if (stream.Read(buffer, 0, 4) < 4) + { + return false; + } + + // Check for ZIP magic bytes: 50 4B 03 04 (local file header) or 50 4B 05 06 (end of central directory) + return (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x03 && buffer[3] == 0x04) || + (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x05 && buffer[3] == 0x06); + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ToolSystemIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ToolSystemIntegrationTests.cs index bf549f0bc..097d0b184 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ToolSystemIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ToolSystemIntegrationTests.cs @@ -18,8 +18,8 @@ public class ToolSystemIntegrationTests private readonly Mock _mockSettingsService; private readonly UserSettings _testSettings; private readonly IToolPluginLoader _pluginLoader; - private readonly IToolRegistry _registry; - private readonly IToolManager _toolService; + private readonly ToolRegistry _registry; + private readonly ToolService _toolService; /// /// Initializes a new instance of the class. @@ -32,7 +32,7 @@ public ToolSystemIntegrationTests() _testSettings = new UserSettings { - InstalledToolAssemblyPaths = new List(), + InstalledToolAssemblyPaths = [], }; _mockSettingsService.Setup(x => x.Get()).Returns(_testSettings); @@ -44,6 +44,7 @@ public ToolSystemIntegrationTests() _pluginLoader, _registry, _mockSettingsService.Object, + [], _mockServiceLogger.Object); } @@ -67,6 +68,7 @@ public async Task CompleteWorkflow_AddAndRemoveTool_WorksCorrectly() mockLoader.Object, _registry, _mockSettingsService.Object, + [], _mockServiceLogger.Object); Action? capturedUpdateAction = null; @@ -126,7 +128,7 @@ public async Task LoadSavedTools_LoadsMultipleToolsFromSettings() var plugin2 = new MockToolPlugin("test.tool2", "Test Tool 2", "1.0.0", "Author 2"); var plugin3 = new MockToolPlugin("test.tool3", "Test Tool 3", "1.0.0", "Author 3"); - _testSettings.InstalledToolAssemblyPaths = new List { path1, path2, path3 }; + _testSettings.InstalledToolAssemblyPaths = [path1, path2, path3]; var mockLoader = new Mock(); mockLoader.Setup(x => x.LoadPluginFromAssembly(path1)).Returns(plugin1); @@ -137,6 +139,7 @@ public async Task LoadSavedTools_LoadsMultipleToolsFromSettings() mockLoader.Object, _registry, _mockSettingsService.Object, + [], _mockServiceLogger.Object); // Act @@ -179,6 +182,7 @@ public async Task AddTool_PreventsDuplicateToolIds() mockLoader.Object, _registry, _mockSettingsService.Object, + [], _mockServiceLogger.Object); // Act @@ -220,6 +224,7 @@ public async Task ReplaceTool_ByRemovingAndAddingNewVersion() mockLoader.Object, _registry, _mockSettingsService.Object, + [], _mockServiceLogger.Object); // Act - Add first version diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceStrategyBaseTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceStrategyBaseTests.cs index cbed42349..97bfbfc8d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceStrategyBaseTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceStrategyBaseTests.cs @@ -128,8 +128,8 @@ public void UpdateWorkspaceInfo_WithExecutable_SetsExecutablePath() var config = new WorkspaceConfiguration { - Manifests = new List - { + Manifests = + [ new() { Files = @@ -138,7 +138,7 @@ public void UpdateWorkspaceInfo_WithExecutable_SetsExecutablePath() new() { RelativePath = "config.ini", Size = 500 }, ], }, - }, + ], GameClient = new GameClient { ExecutablePath = "generals.exe" }, }; @@ -195,6 +195,8 @@ public void Dispose() { Directory.Delete(_tempDir, true); } + + GC.SuppressFinalize(this); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceValidatorTests.cs index c4be81fad..e7473a7ab 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/WorkspaceValidatorTests.cs @@ -74,7 +74,7 @@ public async Task ValidateConfigurationAsync_MissingRequiredProperties_ReturnsEr Id = string.Empty, BaseInstallationPath = string.Empty, WorkspaceRootPath = string.Empty, - Manifests = new List { new() { Files = new List(), }, }, + Manifests = [new() { Files = [], }], }; // Act @@ -111,7 +111,7 @@ public async Task ValidateConfigurationAsync_EmptyManifest_ReturnsError() { // Arrange var config = CreateValidConfiguration(); - config.Manifests = new List { new() { Files = new List(), }, }; + config.Manifests = [new() { Files = [], }]; // Act var result = await _validator.ValidateConfigurationAsync(config); @@ -145,7 +145,7 @@ public async Task ValidatePrerequisitesAsync_AdminRequired_ValidatesCorrectly() Id = Path.GetFileName(_workspaceDir), BaseInstallationPath = _sourceDir, WorkspaceRootPath = Path.GetDirectoryName(_workspaceDir) ?? _workspaceDir, - Manifests = new List(), // Empty for this test + Manifests = [], // Empty for this test GameClient = new GameClient { Id = "test" }, Strategy = WorkspaceStrategy.FullCopy, }; @@ -183,7 +183,7 @@ public async Task ValidatePrerequisitesAsync_DifferentVolumes_ReturnsWarning() Id = Path.GetFileName(destPath), BaseInstallationPath = sourcePath, WorkspaceRootPath = Path.GetDirectoryName(destPath) ?? destPath, - Manifests = new List(), // Empty for this test + Manifests = [], // Empty for this test GameClient = new GameClient { Id = "test" }, Strategy = WorkspaceStrategy.HardLink, }; @@ -218,16 +218,16 @@ public async Task ValidatePrerequisitesAsync_InsufficientDiskSpace_ReturnsWarnin // Create a configuration with large files to trigger disk space warning var largeFileManifest = new ContentManifest { - Files = new List - { + Files = + [ new() { RelativePath = "huge.bin", Size = long.MaxValue / 2 }, - }, + ], }; var config = new WorkspaceConfiguration { Id = "test-workspace", - Manifests = new List { largeFileManifest }, + Manifests = [largeFileManifest], Strategy = WorkspaceStrategy.FullCopy, BaseInstallationPath = _sourceDir, WorkspaceRootPath = Path.GetDirectoryName(_workspaceDir) ?? _workspaceDir, @@ -260,6 +260,8 @@ public void Dispose() { Directory.Delete(_tempDir, true); } + + GC.SuppressFinalize(this); } /// @@ -271,21 +273,21 @@ private WorkspaceConfiguration CreateValidConfiguration() return new WorkspaceConfiguration { Id = "test-workspace", - Manifests = new List - { + Manifests = + [ new() { - Files = new List - { + Files = + [ new() { RelativePath = "generals.exe", Size = 1000000, IsExecutable = true }, new() { RelativePath = "config.ini", Size = 500 }, - }, + ], }, - }, + ], BaseInstallationPath = _sourceDir, WorkspaceRootPath = _workspaceDir, GameClient = new GameClient { Id = "test-version" }, Strategy = WorkspaceStrategy.FullCopy, }; } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs index 603c532b2..78f1ca7a9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs @@ -154,8 +154,8 @@ private static IConfigurationProviderService CreateMockConfigProvider() mock.Setup(x => x.GetLastSelectedTab()).Returns(NavigationTab.Home); mock.Setup(x => x.GetApplicationDataPath()).Returns(Path.Combine(Path.GetTempPath(), "GenHubTest", "Content")); mock.Setup(x => x.GetWorkspacePath()).Returns(Path.Combine(Path.GetTempPath(), "GenHubTest", "Workspace")); - mock.Setup(x => x.GetContentDirectories()).Returns(new List { Path.GetTempPath() }); - mock.Setup(x => x.GetGitHubDiscoveryRepositories()).Returns(new List { "test/repo" }); + mock.Setup(x => x.GetContentDirectories()).Returns([Path.GetTempPath()]); + mock.Setup(x => x.GetGitHubDiscoveryRepositories()).Returns(["test/repo"]); mock.Setup(x => x.GetCasConfiguration()).Returns(new GenHub.Core.Models.Storage.CasConfiguration()); mock.Setup(x => x.GetDownloadUserAgent()).Returns("TestAgent/1.0"); mock.Setup(x => x.GetDownloadTimeoutSeconds()).Returns(120); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Results/DetectionResultTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Results/DetectionResultTests.cs index 118ebc129..e99eae358 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Results/DetectionResultTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Results/DetectionResultTests.cs @@ -13,7 +13,7 @@ public class DetectionResultTests [Fact] public void Succeeded_SetsPropertiesCorrectly() { - var items = new List { "a", "b" }; + List items = ["a", "b"]; var elapsed = TimeSpan.FromSeconds(1); var result = DetectionResult.CreateSuccess(items, elapsed); Assert.True(result.Success); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs index 6a4a14977..a68311424 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/WorkspaceCasIntegrationTests.cs @@ -139,6 +139,8 @@ public void Dispose() { // Ignore cleanup errors } + + GC.SuppressFinalize(this); } /// @@ -172,8 +174,8 @@ public async Task PrepareWorkspace_WithCasContent_CreatesCorrectLinks() var manifest = new ContentManifest { Id = "1.0.genhub.mod.testmod", - Files = new List - { + Files = + [ new ManifestFile { RelativePath = "data/mymod.big", @@ -181,13 +183,13 @@ public async Task PrepareWorkspace_WithCasContent_CreatesCorrectLinks() SourceType = ContentSourceType.ContentAddressable, Size = 16, }, - }, + ], }; var config = new WorkspaceConfiguration { Id = "test-workspace", - Manifests = new List { manifest }, + Manifests = [manifest], Strategy = WorkspaceStrategy.SymlinkOnly, WorkspaceRootPath = _testWorkspacePath, BaseInstallationPath = _testWorkspacePath, diff --git a/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs b/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs index 0b03ff5ac..7ad9116c6 100644 --- a/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs +++ b/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs @@ -31,6 +31,8 @@ public class WindowsInstallationDetector(ILogger lo /// public bool CanDetectOnCurrentPlatform => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly GameInstallationType[] PriorityOrder = [GameInstallationType.Steam, GameInstallationType.EaApp, GameInstallationType.CDISO, GameInstallationType.Retail, GameInstallationType.TheFirstDecade]; + /// /// Scan for Windows platform installations and return them. /// @@ -192,8 +194,7 @@ private List DeduplicateInstallations(List i var deduplicated = new List(); // Define priority order: Steam > EA App > CDISO > Retail - var priorityOrder = new[] { GameInstallationType.Steam, GameInstallationType.EaApp, GameInstallationType.CDISO, GameInstallationType.Retail, GameInstallationType.TheFirstDecade }; - var orderedInstallations = installations.OrderBy(i => Array.IndexOf(priorityOrder, i.InstallationType)).ToList(); + var orderedInstallations = installations.OrderBy(i => Array.IndexOf(PriorityOrder, i.InstallationType)).ToList(); foreach (var installation in orderedInstallations) { diff --git a/GenHub/GenHub.Windows/GenHub.Windows.csproj b/GenHub/GenHub.Windows/GenHub.Windows.csproj index 778a8f5e7..be5610dce 100644 --- a/GenHub/GenHub.Windows/GenHub.Windows.csproj +++ b/GenHub/GenHub.Windows/GenHub.Windows.csproj @@ -13,6 +13,7 @@ + None diff --git a/GenHub/GenHub.Windows/Program.cs b/GenHub/GenHub.Windows/Program.cs index b6add0937..031e8108f 100644 --- a/GenHub/GenHub.Windows/Program.cs +++ b/GenHub/GenHub.Windows/Program.cs @@ -1,12 +1,13 @@ -using System; -using System.Linq; using Avalonia; +using DotNetEnv; using GenHub.Core.Constants; using GenHub.Core.Helpers; using GenHub.Infrastructure.DependencyInjection; using GenHub.Windows.Infrastructure.DependencyInjection; using GenHub.Windows.Infrastructure.SingleInstance; using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; using Microsoft.Extensions.Logging; using Velopack; @@ -32,6 +33,16 @@ public class Program [STAThread] public static void Main(string[] args) { + // Load environment variables (locally) + try + { + Env.TraversePath().Load(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to load environment variables: {ex}"); + } + // Initialize Velopack - must be first to handle install/update hooks VelopackApp.Build().Run(); @@ -132,4 +143,4 @@ public static AppBuilder BuildAvaloniaApp(IServiceProvider serviceProvider) .UsePlatformDetect() .WithInterFont() .LogToTrace(); -} \ No newline at end of file +} diff --git a/GenHub/GenHub/App.axaml b/GenHub/GenHub/App.axaml index 1179d942e..4f80e9d6e 100644 --- a/GenHub/GenHub/App.axaml +++ b/GenHub/GenHub/App.axaml @@ -4,6 +4,7 @@ x:Class="GenHub.App"> + diff --git a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs index 45dd9f55b..91c5f7e6e 100644 --- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs +++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs @@ -113,6 +113,7 @@ private static NavigationTab LoadInitialTab(IConfigurationProviderService config [ NavigationTab.GameProfiles, NavigationTab.Downloads, + NavigationTab.Tools, NavigationTab.Settings, ]; diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ReplayManagerToolPlugin.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ReplayManagerToolPlugin.cs new file mode 100644 index 000000000..3c8f8d61c --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ReplayManagerToolPlugin.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Tools; +using GenHub.Features.Tools.ReplayManager.ViewModels; +using GenHub.Features.Tools.ReplayManager.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace GenHub.Features.Tools.ReplayManager; + +/// +/// Tool plugin implementation for the Replay Manager. +/// +public sealed class ReplayManagerToolPlugin : IToolPlugin +{ + private ReplayManagerView? _view; + private IServiceProvider? _serviceProvider; + + /// + public ToolMetadata Metadata => new() + { + Id = ToolConstants.ReplayManager.Id, + Name = ToolConstants.ReplayManager.Name, + Version = ToolConstants.ReplayManager.Version, + Author = ToolConstants.ReplayManager.Author, + Description = ToolConstants.ReplayManager.Description, + Tags = [.. ToolConstants.ReplayManager.Tags], + IconPath = ToolConstants.ReplayManager.IconPath, + IsBundled = ToolConstants.ReplayManager.IsBundled, + }; + + /// + public Control CreateControl() + { + if (_view == null && _serviceProvider != null) + { + var viewModel = _serviceProvider.GetRequiredService(); + _view = new ReplayManagerView { DataContext = viewModel }; + + // Initialize the ViewModel to load replays + _ = viewModel.InitializeAsync(); + } + + return _view ?? (Control)new TextBlock { Text = "Error loading Replay Manager" }; + } + + /// + public void OnActivated(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + public void OnDeactivated() + { + // View and ViewModel state is preserved for now. + // Could call a reset or save method on ViewModel if needed. + } + + /// + public void Dispose() + { + _view = null; + _serviceProvider = null; + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs new file mode 100644 index 000000000..4c2878ed9 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayDirectoryService.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Implementation of for managing replay files on disk. +/// +/// +/// Initializes a new instance of the class. +/// +/// The logger instance. +public sealed class ReplayDirectoryService(ILogger logger) : IReplayDirectoryService +{ + /// + public string GetReplayDirectory(GameType version) + { + var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var gameDataFolder = version switch + { + GameType.Generals => GameSettingsConstants.FolderNames.Generals, + GameType.ZeroHour => GameSettingsConstants.FolderNames.ZeroHour, + _ => throw new ArgumentException("Unsupported game version", nameof(version)), + }; + + return Path.Combine(documents, gameDataFolder, GameSettingsConstants.FolderNames.Replays); + } + + /// + public void EnsureDirectoryExists(GameType version) + { + var path = GetReplayDirectory(version); + if (!Directory.Exists(path)) + { + logger.LogInformation(LogMessages.CreatingReplayDirectory, path); + Directory.CreateDirectory(path); + } + } + + /// + public async Task> GetReplaysAsync(GameType version, CancellationToken ct = default) + { + var directory = GetReplayDirectory(version); + if (!Directory.Exists(directory)) + { + return []; + } + + return await Task.Run( + () => + { + var files = Directory.GetFiles(directory, "*.*") + .Where(f => f.EndsWith(".rep", StringComparison.OrdinalIgnoreCase) || + f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + return files.Select(f => + { + var info = new FileInfo(f); + return new ReplayFile + { + FullPath = f, + FileName = Path.GetFileName(f), + SizeInBytes = info.Length, + LastModified = info.LastWriteTime, + GameVersion = version, + }; + }).OrderByDescending(r => r.LastModified).ToList(); + }, + ct); + } + + /// + public async Task DeleteReplaysAsync(IEnumerable replays, CancellationToken ct = default) + { + return await Task.Run( + () => + { + var success = true; + foreach (var replay in replays) + { + try + { + if (File.Exists(replay.FullPath)) + { + // In a real production app, we would use a library or platform-specific call + // to move to Recycle Bin. For now, we perform a standard delete. + // TODO: Implement Recycle Bin support for Windows + File.Delete(replay.FullPath); + logger.LogInformation(LogMessages.DeletedReplay, replay.FullPath); + } + } + catch (Exception ex) + { + logger.LogError(ex, LogMessages.FailedToDeleteReplay, replay.FullPath); + success = false; + } + } + + return success; + }, + ct); + } + + /// + public void OpenInExplorer(GameType version) + { + var path = GetReplayDirectory(version); + if (Directory.Exists(path)) + { + Process.Start(new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerExecutable, + Arguments = path, + UseShellExecute = true, + }); + } + } + + /// + public void RevealInExplorer(ReplayFile replay) + { + if (File.Exists(replay.FullPath)) + { + Process.Start(new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerExecutable, + Arguments = string.Format(PlatformConstants.WindowsExplorerSelectArgument, replay.FullPath), + UseShellExecute = true, + }); + } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs new file mode 100644 index 000000000..a2054dd5e --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs @@ -0,0 +1,249 @@ +using GenHub.Core.Constants; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Implementation of for exporting and sharing replays using UploadThing v7. +/// +public sealed class ReplayExportService( + HttpClient httpClient, + IZipValidationService zipValidationService, + ILogger logger) : IReplayExportService +{ + private const string UploadThingApiVersion = ApiConstants.UploadThingApiVersion; + + /// + public async Task UploadToUploadThingAsync( + IEnumerable replays, + IProgress? progress = null, + CancellationToken ct = default) + { + var token = Environment.GetEnvironmentVariable("UPLOADTHING_TOKEN") ?? + Environment.GetEnvironmentVariable("GENHUB_UPLOADTHING_TOKEN"); + + if (string.IsNullOrEmpty(token)) + { + logger.LogError(ErrorMessages.UploadThingTokenMissing); + return null; + } + + string? zipToUpload = null; + bool isTemporaryZip = false; + + try + { + var replayList = replays.ToList(); + if (replayList.Count == 0) return null; + + if (replayList.Count == 1 && replayList[0].FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + var (isValid, errorMessage) = zipValidationService.ValidateZip(replayList[0].FullPath); + if (!isValid) + { + logger.LogError(ErrorMessages.ZipValidationFailed, errorMessage); + throw new ArgumentException(errorMessage ?? "Invalid ZIP archive for upload."); + } + + zipToUpload = replayList[0].FullPath; + } + else + { + var tempZip = Path.Combine(Path.GetTempPath(), $"{ReplayManagerConstants.TempShareFilePrefix}{Guid.NewGuid()}.zip"); + var createdZip = await this.ExportToZipAsync(replayList, tempZip, progress, ct); + if (createdZip == null) return null; + + zipToUpload = createdZip; + isTemporaryZip = true; + } + + if (new FileInfo(zipToUpload).Length > ReplayManagerConstants.MaxReplaySizeBytes) + { + logger.LogError(ErrorMessages.FileExceedsSizeLimit, zipToUpload); + return null; + } + + return await this.PerformV7UploadAsync(zipToUpload, token, progress, ct); + } + catch (ArgumentException) + { + throw; // Bubble up validation errors + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to upload to UploadThing V7"); + return null; + } + finally + { + if (isTemporaryZip && !string.IsNullOrEmpty(zipToUpload) && File.Exists(zipToUpload)) + { + File.Delete(zipToUpload); + } + } + } + + /// + public async Task ExportToZipAsync(IEnumerable replays, string destinationPath, IProgress? progress = null, CancellationToken ct = default) + { + try + { + return await Task.Run( + () => + { + var replayList = replays.ToList(); + if (replayList.Count == 0) return null; + + using var zipFile = File.Create(destinationPath); + using var archive = new ZipArchive(zipFile, ZipArchiveMode.Create); + + int total = replayList.Count; + int count = 0; + + foreach (var replay in replayList) + { + count++; + progress?.Report((double)count / total * 0.4); + + if (!File.Exists(replay.FullPath)) continue; + archive.CreateEntryFromFile(replay.FullPath, replay.FileName); + } + + return destinationPath; + }, + ct); + } + catch (Exception ex) + { + logger.LogError(ex, LogMessages.FailedToCreateZip, destinationPath); + return null; + } + } + + private async Task PerformV7UploadAsync( + string filePath, + string token, + IProgress? progress = null, + CancellationToken ct = default) + { + logger.LogInformation(LogMessages.UploadingToUploadThing, filePath); + + try + { + var fileInfo = new FileInfo(filePath); + var fileName = Path.GetFileName(filePath); + + // Step 1: Prepare the upload + const string PrepareUrl = ApiConstants.UploadThingPrepareUrl; + + var requestPayload = new V7FileRequestDetail + { + FileName = fileName, + FileSize = fileInfo.Length, + ContentTypes = [ + "application/zip", + ], + }; + + var prepareRequest = new HttpRequestMessage(HttpMethod.Post, PrepareUrl); + prepareRequest.Headers.Add("x-uploadthing-api-key", token); + prepareRequest.Headers.Add("x-uploadthing-version", UploadThingApiVersion); + prepareRequest.Content = JsonContent.Create(requestPayload); + + var prepareResponse = await httpClient.SendAsync(prepareRequest, ct); + if (!prepareResponse.IsSuccessStatusCode) + { + var error = await prepareResponse.Content.ReadAsStringAsync(ct); + logger.LogError(ErrorMessages.V7PrepareUploadFailed, prepareResponse.StatusCode, error); + return null; + } + + var instruction = await prepareResponse.Content.ReadFromJsonAsync(cancellationToken: ct); + + if (instruction?.PresignedUrl == null || instruction?.Key == null) + { + var rawResponse = await prepareResponse.Content.ReadAsStringAsync(ct); + logger.LogError(ErrorMessages.UploadThingMissingFields, rawResponse); + return null; + } + + // Step 2: Upload binary via PUT + // CRITICAL FIX: The UploadThing docs specify using FormData (multipart/form-data) + // for the PUT request, not raw binary. + using var fileStream = File.OpenRead(filePath); // FIXED: Removed invalid buffer size argument + + // Create the multipart content + var multipartContent = new MultipartFormDataContent(); + + // Add the file to the form data with the name "file" + var fileContent = new StreamContent(fileStream); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"); + multipartContent.Add(fileContent, "file", fileName); + + var uploadRequest = new HttpRequestMessage(HttpMethod.Put, instruction.PresignedUrl) + { + Content = multipartContent, + }; + + // MultipartFormDataContent will automatically set the Content-Type with a boundary + uploadRequest.Headers.UserAgent.ParseAdd("GenHub/1.0"); + + progress?.Report(0.6); + + var uploadResponse = await httpClient.SendAsync(uploadRequest, ct); + if (!uploadResponse.IsSuccessStatusCode) + { + var uploadError = await uploadResponse.Content.ReadAsStringAsync(ct); + logger.LogError(ErrorMessages.V7BinaryUploadFailed, uploadResponse.StatusCode, uploadError); + return null; + } + + var publicFileUrl = string.Format(ApiConstants.UploadThingPublicUrlFormat, instruction.Key); + + logger.LogInformation(LogMessages.UploadThingSuccessful, publicFileUrl); + progress?.Report(1.0); + + return publicFileUrl; + } + catch (Exception ex) + { + logger.LogError(ex, ErrorMessages.ExceptionInUploadThingFlow); + return null; + } + } + + // --- V7 Request DTOs --- + private sealed class V7FileRequestDetail + { + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("fileSize")] + public long FileSize { get; set; } + + [JsonPropertyName("contentTypes")] + public List ContentTypes { get; set; } = []; + } + + // --- V7 Response DTO --- + private sealed class V7FileInstruction + { + [JsonPropertyName("url")] + public string? PresignedUrl { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs new file mode 100644 index 000000000..f92faca73 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs @@ -0,0 +1,340 @@ +using GenHub.Core.Constants; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Implementation of for importing replay files. +/// +public sealed class ReplayImportService( + IDownloadService downloadService, + IReplayDirectoryService directoryService, + IUrlParserService urlParserService, + IZipValidationService zipValidationService, + ILogger logger) : IReplayImportService +{ + /// + public async Task ImportFromUrlAsync( + string url, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default) + { + logger.LogInformation("Importing replay from URL: {Url}", url); + + try + { + var directUrl = await urlParserService.GetDirectDownloadUrlAsync(url, ct); + if (string.IsNullOrEmpty(directUrl)) + { + return new ImportResult + { + Success = false, + FilesImported = 0, + FilesSkipped = 0, + Errors = [ErrorMessages.CouldNotExtractDownloadUrl], + }; + } + + var tempPath = Path.Combine(Path.GetTempPath(), $"{ReplayManagerConstants.TempImportFilePrefix}{Guid.NewGuid()}.rep"); + try + { + var downloadProgress = progress != null ? new Progress(p => progress.Report(p.Percentage / 100.0)) : null; + var result = await downloadService.DownloadFileAsync(new Uri(directUrl), tempPath, progress: downloadProgress, cancellationToken: ct); + + if (!result.Success) + { + return new ImportResult + { + Success = false, + FilesImported = 0, + FilesSkipped = 0, + Errors = [ErrorMessages.DownloadFailed], + }; + } + + var info = new FileInfo(tempPath); + if (info.Length > ReplayManagerConstants.MaxReplaySizeBytes) + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + return new ImportResult + { + Success = false, + FilesImported = 0, + FilesSkipped = 0, + Errors = [string.Format(ErrorMessages.ReplayExceedsMaxSize, info.Length / 1024.0)], + }; + } + + // Detect if the downloaded file is a ZIP by checking magic bytes + if (IsZipFile(tempPath)) + { + logger.LogInformation(LogMessages.DetectedZipFile); + return await ImportFromZipAsync(tempPath, targetVersion, progress, ct); + } + + var importedFileName = GetFileNameFromUrl(directUrl); + using var stream = File.OpenRead(tempPath); + return await ImportFromStreamAsync(stream, importedFileName, targetVersion, ct); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to import from URL: {Url}", url); + return new ImportResult { Success = false, FilesImported = 0, FilesSkipped = 0, Errors = [ex.Message] }; + } + } + + /// + public async Task ImportFromFilesAsync( + IEnumerable filePaths, + GameType targetVersion, + CancellationToken ct = default) + { + var imported = new List(); + var errors = new List(); + int skipped = 0; + + foreach (var path in filePaths) + { + try + { + if (!File.Exists(path)) + { + continue; + } + + var isZip = path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + var info = new FileInfo(path); + + // Only enforce 1MB limit for individual .rep files, not for ZIP archives + if (!isZip && info.Length > ReplayManagerConstants.MaxReplaySizeBytes) + { + errors.Add($"File {Path.GetFileName(path)} skipped: exceeds 1 MB."); + skipped++; + continue; + } + + if (isZip) + { + var zipResult = await ImportFromZipAsync(path, targetVersion, null, ct); + imported.AddRange(zipResult.ImportedFiles); + errors.AddRange(zipResult.Errors); + skipped += zipResult.FilesSkipped; + continue; + } + + using var stream = File.OpenRead(path); + var result = await ImportFromStreamAsync(stream, Path.GetFileName(path), targetVersion, ct); + if (result.Success) + { + imported.AddRange(result.ImportedFiles); + } + else + { + errors.AddRange(result.Errors); + skipped++; + } + } + catch (Exception ex) + { + errors.Add($"Failed to import {Path.GetFileName(path)}: {ex.Message}"); + skipped++; + } + } + + return new ImportResult + { + Success = imported.Count > 0, + FilesImported = imported.Count, + FilesSkipped = skipped, + ImportedFiles = imported, + Errors = errors, + }; + } + + /// + public async Task ImportFromZipAsync( + string zipPath, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default) + { + var (isValid, errorMessage) = ValidateZip(zipPath); + if (!isValid) + { + logger.LogWarning("Import from ZIP failed validation: {Error}", errorMessage); + return new ImportResult + { + Success = false, + FilesImported = 0, + FilesSkipped = 0, + Errors = [errorMessage ?? "Invalid ZIP archive."], + }; + } + + var imported = new List(); + var errors = new List(); + int skipped = 0; + + try + { + using var archive = ZipFile.OpenRead(zipPath); + var entries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToList(); + int total = entries.Count; + int count = 0; + + foreach (var entry in entries) + { + count++; + progress?.Report((double)count / total); + + using var stream = entry.Open(); + var result = await ImportFromStreamAsync(stream, entry.Name, targetVersion, ct); + if (result.Success) + { + imported.AddRange(result.ImportedFiles); + } + else + { + errors.AddRange(result.Errors); + skipped++; + } + } + } + catch (Exception ex) + { + logger.LogError(ex, LogMessages.FailedToImportFromZip, zipPath); + errors.Add(string.Format(ErrorMessages.FailedToProcessZip, ex.Message)); + } + + return new ImportResult + { + Success = imported.Count > 0, + FilesImported = imported.Count, + FilesSkipped = skipped, + ImportedFiles = imported, + Errors = errors, + }; + } + + /// + public async Task ImportFromStreamAsync( + Stream stream, + string fileName, + GameType targetVersion, + CancellationToken ct = default) + { + try + { + directoryService.EnsureDirectoryExists(targetVersion); + var targetDir = directoryService.GetReplayDirectory(targetVersion); + + // Handle filename conflict + var targetPath = GetUniquePath(Path.Combine(targetDir, fileName)); + + using var fileStream = File.Create(targetPath); + await stream.CopyToAsync(fileStream, ct); + + return new ImportResult + { + Success = true, + FilesImported = 1, + FilesSkipped = 0, + ImportedFiles = [targetPath], + }; + } + catch (Exception ex) + { + logger.LogError(ex, LogMessages.FailedToImportStream, fileName); + return new ImportResult { Success = false, FilesImported = 0, FilesSkipped = 1, Errors = [ex.Message] }; + } + } + + /// + public (bool IsValid, string? ErrorMessage) ValidateZip(string zipPath) + { + return zipValidationService.ValidateZip(zipPath); + } + + private static bool IsZipFile(string filePath) + { + try + { + using var stream = File.OpenRead(filePath); + if (stream.Length < 4) + { + return false; + } + + var buffer = new byte[4]; + stream.Read(buffer, 0, 4); + + // Check for ZIP magic bytes: 50 4B 03 04 (local file header) or 50 4B 05 06 (end of central directory) + return (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x03 && buffer[3] == 0x04) || + (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x05 && buffer[3] == 0x06); + } + catch + { + return false; + } + } + + private static string GetUniquePath(string path) + { + if (!File.Exists(path)) + { + return path; + } + + var directory = Path.GetDirectoryName(path) ?? string.Empty; + var name = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + int count = 1; + + while (File.Exists(path)) + { + path = Path.Combine(directory, $"{name} ({count}){extension}"); + count++; + } + + return path; + } + + private static string GetFileNameFromUrl(string url) + { + try + { + var uri = new Uri(url); + var fileName = Path.GetFileName(uri.LocalPath); + return string.IsNullOrEmpty(fileName) ? ReplayManagerConstants.DefaultImportedReplayFileName : fileName; + } + catch + { + return "imported_replay.rep"; + } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/UploadRateLimitService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UploadRateLimitService.cs new file mode 100644 index 000000000..8379721df --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UploadRateLimitService.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Implementation of for tracking upload quotas. +/// +/// +/// Initializes a new instance of the class. +/// +/// Logger instance. +/// Application configuration service. +public sealed class UploadRateLimitService(ILogger logger, IAppConfiguration appConfig) : IUploadRateLimitService +{ + /// + /// Record of an upload for rate limiting purposes. + /// + internal sealed class UploadRecord + { + /// + /// Gets or sets the timestamp of the upload. + /// + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the size of the upload in bytes. + /// + public long SizeBytes { get; set; } + + /// + /// Gets or sets the public URL of the upload. + /// + public string? Url { get; set; } + + /// + /// Gets or sets the name of the uploaded file. + /// + public string? FileName { get; set; } + } + + private static readonly object _fileLock = new(); + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly string _historyFilePath = Path.Combine(appConfig.GetConfiguredDataPath(), "upload_history.json"); + private List? _cache; + + /// + public async Task CanUploadAsync(long fileSizeBytes) + { + var usage = await GetUsageInfoAsync(); + return usage.UsedBytes + fileSizeBytes <= usage.LimitBytes; + } + + /// + public void RecordUpload(long fileSizeBytes, string url, string fileName) + { + lock (_fileLock) + { + try + { + var history = LoadHistoryInternal(); + history.Add(new UploadRecord + { + Timestamp = DateTime.UtcNow, + SizeBytes = fileSizeBytes, + Url = url, + FileName = fileName, + }); + + SaveHistoryInternal(history); + _cache = history; // Update cache + _logger.LogInformation("Recorded upload of {Size} bytes. Total history: {Count} items.", fileSizeBytes, history.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record upload"); + } + } + } + + /// + public Task GetUsageInfoAsync() + { + var history = LoadHistoryInternal(); + var weekAgo = DateTime.UtcNow.AddDays(-7); + + // Calculate usage from the last 7 days + var recentUploads = history.Where(r => r.Timestamp >= weekAgo).ToList(); + var usedBytes = recentUploads.Sum(r => r.SizeBytes); + + // Reset date is 7 days from the oldest upload in the current rolling week + var oldestInWindow = recentUploads.OrderBy(r => r.Timestamp).FirstOrDefault(); + var resetDate = oldestInWindow != null + ? oldestInWindow.Timestamp.AddDays(7) + : DateTime.UtcNow; + + return Task.FromResult(new UsageInfo(usedBytes, IUploadRateLimitService.MaxWeeklyUploadBytes, resetDate)); + } + + /// + public Task> GetUploadHistoryAsync() + { + var history = LoadHistoryInternal(); + + // Return all history (up to 30 days as filtered in loading) + var items = history.Select(r => new UploadHistoryItem( + r.Timestamp, + r.SizeBytes, + r.Url ?? string.Empty, + r.FileName ?? "Unknown File")); + + return Task.FromResult(items); + } + + /// + public Task RemoveHistoryItemAsync(string url) + { + return Task.Run(() => + { + lock (_fileLock) + { + try + { + var history = LoadHistoryInternal(); + var removed = history.RemoveAll(r => r.Url == url); + + if (removed > 0) + { + SaveHistoryInternal(history); + _cache = history; // Update cache + _logger.LogInformation("Removed {Count} upload history item(s) with URL: {Url}", removed, url); + } + else + { + _logger.LogWarning("No upload history item found with URL: {Url}", url); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove upload history item with URL: {Url}", url); + } + } + }); + } + + /// + public Task ClearHistoryAsync() + { + return Task.Run(() => + { + lock (_fileLock) + { + try + { + _cache = []; + + if (File.Exists(_historyFilePath)) + { + File.Delete(_historyFilePath); + _logger.LogInformation("Cleared all upload history"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clear upload history"); + } + } + }); + } + + private List LoadHistoryInternal() + { + lock (_fileLock) + { + if (_cache != null) + { + return [.. _cache]; + } + + try + { + if (!File.Exists(_historyFilePath)) + { + _logger.LogDebug("Upload history file not found at {Path}, starting fresh.", _historyFilePath); + _cache = []; + return []; + } + + var json = File.ReadAllText(_historyFilePath); + if (string.IsNullOrWhiteSpace(json)) + { + _cache = []; + return []; + } + + var history = JsonSerializer.Deserialize>(json, _jsonOptions) ?? []; + + // Clean up old entries (older than 30 days) to keep file size small + var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); + _cache = [.. history.Where(r => r.Timestamp >= thirtyDaysAgo).OrderByDescending(r => r.Timestamp)]; + + _logger.LogDebug("Loaded {Count} upload records from history.", _cache.Count); + return [.. _cache]; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load upload history from {Path}. The file may be corrupt or inaccessible.", _historyFilePath); + + // Return empty but don't set cache to empty if we want to retry next time? + // Actually, if it's corrupt, returning empty might cause us to overwrite it with new data. + // We'll return a new list so the app continues working. + return []; + } + } + } + + private void SaveHistoryInternal(List history) + { + lock (_fileLock) + { + try + { + var directory = Path.GetDirectoryName(_historyFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(history, _jsonOptions); + File.WriteAllText(_historyFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save upload history to {Path}", _historyFilePath); + } + } + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs new file mode 100644 index 000000000..b4d0362ef --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs @@ -0,0 +1,123 @@ +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Service for parsing replay URLs and extracting direct download links. +/// +public sealed partial class UrlParserService(HttpClient httpClient, ILogger logger) : IUrlParserService +{ + /// + public ReplaySource IdentifySource(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return ReplaySource.Unknown; + } + + if (url.Contains(ApiConstants.UploadThingUrlFragment)) + { + return ReplaySource.UploadThing; + } + + if (url.Contains(ApiConstants.GeneralsOnlineViewMatchFragment)) + { + return ReplaySource.GeneralsOnline; + } + + if (url.Contains(ApiConstants.GenToolUrlFragment)) + { + return ReplaySource.GenTool; + } + + if (url.EndsWith(FileTypes.ReplayFileExtension, StringComparison.OrdinalIgnoreCase) || + url.EndsWith(FileTypes.ZipFileExtension, StringComparison.OrdinalIgnoreCase)) + { + return ReplaySource.DirectLink; + } + + return ReplaySource.Unknown; + } + + /// + public bool IsValidReplayUrl(string url) + { + return IdentifySource(url) != ReplaySource.Unknown; + } + + /// + public async Task GetDirectDownloadUrlAsync(string url, CancellationToken ct = default) + { + var source = IdentifySource(url); + logger.LogInformation(LogMessages.IdentifyingUrlSource, url, source); + + try + { + return source switch + { + ReplaySource.UploadThing => url, // UploadThing links are usually direct (utfs.io/f/...) + ReplaySource.DirectLink => url, + ReplaySource.GeneralsOnline => await ExtractGeneralsOnlineUrlAsync(url, ct), + ReplaySource.GenTool => await ExtractGenToolUrlAsync(url, ct), + _ => null, + }; + } + catch (Exception ex) + { + logger.LogError(ex, LogMessages.FailedToExtractDownloadUrl, url); + return null; + } + } + + [GeneratedRegex(RegexConstants.GeneralsOnlineReplayPattern)] + private static partial Regex GeneralsOnlineRegex(); + + [GeneratedRegex(RegexConstants.GenToolReplayPattern, RegexOptions.IgnoreCase)] + private static partial Regex GenToolRegex(); + + private async Task ExtractGeneralsOnlineUrlAsync(string url, CancellationToken ct) + { + // Example: https://www.playgenerals.online/viewmatch?match=354994 + // Search for a link matching *_replay.rep + var html = await httpClient.GetStringAsync(url, ct); + + // Regex to find matchdata link: https://matchdata.playgenerals.online/..._replay.rep + var match = GeneralsOnlineRegex().Match(html); + if (match.Success) + { + return match.Value; + } + + logger.LogWarning(LogMessages.CouldNotFindReplayLinkGeneralsOnline, url); + return null; + } + + private async Task ExtractGenToolUrlAsync(string url, CancellationToken ct) + { + var html = await httpClient.GetStringAsync(url, ct); + var match = GenToolRegex().Match(html); + if (match.Success) + { + var relativeUrl = match.Groups[1].Value; + if (Uri.IsWellFormedUriString(relativeUrl, UriKind.Absolute)) + { + return relativeUrl; + } + + var baseUri = new Uri(url); + var absoluteUri = new Uri(baseUri, relativeUrl); + return absoluteUri.ToString(); + } + + logger.LogWarning(LogMessages.CouldNotFindReplayLinkGenTool, url); + return null; + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ZipValidationService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ZipValidationService.cs new file mode 100644 index 000000000..af662f3b6 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ZipValidationService.cs @@ -0,0 +1,73 @@ +using GenHub.Core.Constants; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.ReplayManager.Services; + +/// +/// Implementation of for validating ZIP archives. +/// +public sealed class ZipValidationService(ILogger logger) : IZipValidationService +{ + /// + public (bool IsValid, string? ErrorMessage) ValidateZip(string zipPath) + { + try + { + if (!File.Exists(zipPath)) + { + return (false, "ZIP file does not exist."); + } + + using var archive = ZipFile.OpenRead(zipPath); + if (archive.Entries.Count == 0) + { + return (false, "ZIP archive is empty."); + } + + foreach (var entry in archive.Entries) + { + // Check for directories (Name is empty for directory entries) + if (string.IsNullOrEmpty(entry.Name)) + { + return (false, "ZIP contains directories. Only a single layer of files is allowed."); + } + + // Check for nested files (FullName should equal Name for root files) + // Normalize slashes just in case + var normalizedFullName = entry.FullName.Replace('\\', '/'); + if (normalizedFullName != entry.Name) + { + return (false, $"ZIP contains nested files ({entry.FullName}). Only a single layer of files is allowed."); + } + + // Check extension + if (!entry.Name.EndsWith(".rep", StringComparison.OrdinalIgnoreCase)) + { + return (false, $"ZIP contains non-replay file: {entry.Name}. Only .rep files are allowed."); + } + + // Check size + if (entry.Length > ReplayManagerConstants.MaxReplaySizeBytes) + { + return (false, $"File {entry.Name} in ZIP exceeds 1 MB limit."); + } + } + + return (true, null); + } + catch (InvalidDataException) + { + return (false, "The file is not a valid ZIP archive."); + } + catch (Exception ex) + { + logger.LogError(ex, "Error validating ZIP: {Path}", zipPath); + return (false, $"Validation error: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs new file mode 100644 index 000000000..a6d9ca9c7 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs @@ -0,0 +1,672 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.ReplayManager.ViewModels; + +/// +/// ViewModel for the Replay Manager tool. +/// +/// The directory service. +/// The import service. +/// The export service. +/// The upload rate limit service. +/// The notification service. +/// The logger instance. +public partial class ReplayManagerViewModel( + IReplayDirectoryService directoryService, + IReplayImportService importService, + IReplayExportService exportService, + IUploadRateLimitService uploadRateLimitService, + INotificationService notificationService, + ILogger logger) : ObservableObject +{ + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + return string.Concat(fileName.Where(c => !invalidChars.Contains(c))); + } + + [ObservableProperty] + private GameType selectedTab = GameType.ZeroHour; + + [ObservableProperty] + private string importUrl = string.Empty; + + [ObservableProperty] + private bool isBusy; + + [ObservableProperty] + private double progress; + + [ObservableProperty] + private string statusMessage = "Ready"; + + /// + /// The name of the ZIP file to export or upload. + /// + [ObservableProperty] + private string zipName = "replays.zip"; + + /// + /// Whether the upload history flyout is open. + /// + [ObservableProperty] + private bool isHistoryOpen; + + /// + /// Gets the list of upload history items. + /// + public ObservableCollection UploadHistory { get; } = []; + + /// + /// Toggles the upload history flyout. + /// + [RelayCommand] + private async Task ToggleHistoryAsync() + { + IsHistoryOpen = !IsHistoryOpen; + if (IsHistoryOpen) + { + await LoadHistoryAsync(); + } + } + + /// + /// Loads the upload history. + /// + private async Task LoadHistoryAsync() + { + try + { + var history = await uploadRateLimitService.GetUploadHistoryAsync(); + UploadHistory.Clear(); + + // Add items to collection + foreach (var item in history) + { + UploadHistory.Add(new UploadHistoryItemViewModel(item)); + } + + // Verify file existence for each item asynchronously + _ = Task.Run(async () => + { + using var httpClient = new System.Net.Http.HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(5); + + foreach (var viewModel in UploadHistory) + { + try + { + // Use head request to check if file exists without downloading it + var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, viewModel.Url); + var response = await httpClient.SendAsync(request); + + await Dispatcher.UIThread.InvokeAsync(() => + { + viewModel.FileExists = response.IsSuccessStatusCode; + viewModel.IsVerified = true; + }); + } + catch + { + // If request fails, assume file doesn't exist + await Dispatcher.UIThread.InvokeAsync(() => + { + viewModel.FileExists = false; + viewModel.IsVerified = true; + }); + } + } + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load upload history"); + } + } + + /// + /// Copies the URL to the clipboard. + /// + /// The URL to copy. + [RelayCommand] + private async Task CopyUrlAsync(string url) + { + if (string.IsNullOrEmpty(url)) return; + + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(url); + notificationService.ShowSuccess("Copied", "Link copied to clipboard!"); + } + } + + /// + /// Removes a specific upload history item. + /// + /// The history item to remove. + [RelayCommand] + private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) + { + try + { + await uploadRateLimitService.RemoveHistoryItemAsync(item.Url); + await LoadHistoryAsync(); + notificationService.ShowSuccess("Removed", "History item removed."); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to remove history item"); + notificationService.ShowError("Remove Failed", "Failed to remove history item."); + } + } + + /// + /// Clears all upload history. + /// + [RelayCommand] + private async Task ClearHistoryAsync() + { + try + { + await uploadRateLimitService.ClearHistoryAsync(); + await LoadHistoryAsync(); + notificationService.ShowSuccess("Cleared", "All upload history cleared."); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to clear history"); + notificationService.ShowError("Clear Failed", "Failed to clear history."); + } + } + + /// + /// Gets the list of replays for Generals. + /// + public ObservableCollection GeneralsReplays { get; } = []; + + /// + /// Gets the list of replays for Zero Hour. + /// + public ObservableCollection ZeroHourReplays { get; } = []; + + /// + /// Gets the list of currently selected replays. + /// + public ObservableCollection SelectedReplays { get; } = []; + + /// + /// Gets a value indicating whether any of the selected replays are ZIP archives. + /// + public bool HasSelectedZips => SelectedReplays.Any(r => r.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + + /// + /// Updates the collection of selected replays. + /// + /// The list of selected replays. + public void UpdateSelectedReplays(IEnumerable selected) + { + SelectedReplays.Clear(); + foreach (var r in selected) + { + SelectedReplays.Add(r); + } + + OnPropertyChanged(nameof(HasSelectedZips)); + DeleteSelectedCommand.NotifyCanExecuteChanged(); + ExportToZipCommand.NotifyCanExecuteChanged(); + UploadAndShareCommand.NotifyCanExecuteChanged(); + UncompressSelectedCommand.NotifyCanExecuteChanged(); + } + + /// + /// Gets the collection of all replays for the current tab. + /// + public ObservableCollection CurrentReplays { get; } = []; + + /// + /// Initializes the ViewModel by loading replays for the current tab. + /// + /// A task representing the asynchronous operation. + public async Task InitializeAsync() + { + await LoadReplaysAsync(); + } + + /// + /// Loads replays for the selected game version. + /// + /// A task representing the asynchronous operation. + [RelayCommand] + public async Task LoadReplaysAsync() + { + IsBusy = true; + StatusMessage = "Loading replays..."; + try + { + var replays = await directoryService.GetReplaysAsync(SelectedTab); + + // Marshall to UI thread for collection updates + await Dispatcher.UIThread.InvokeAsync(() => + { + // Update the appropriate collection + if (SelectedTab == GameType.Generals) + { + GeneralsReplays.Clear(); + foreach (var r in replays) + { + GeneralsReplays.Add(r); + } + } + else + { + ZeroHourReplays.Clear(); + foreach (var r in replays) + { + ZeroHourReplays.Add(r); + } + } + + // Update CurrentReplays by clearing and adding items (don't replace the reference!) + CurrentReplays.Clear(); + foreach (var r in replays) + { + CurrentReplays.Add(r); + } + }); + + StatusMessage = $"Loaded {replays.Count} replays."; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load replays"); + notificationService.ShowError("Load Error", "Failed to load replays."); + StatusMessage = "Error loading replays."; + } + finally + { + IsBusy = false; + } + } + + /// + /// Imports files from the specified paths. + /// + /// The paths of the files to import. + /// A task representing the asynchronous operation. + public async Task ImportFilesAsync(System.Collections.Generic.IEnumerable filePaths) + { + IsBusy = true; + StatusMessage = "Importing files..."; + try + { + var result = await importService.ImportFromFilesAsync(filePaths, SelectedTab); + if (result.Success) + { + notificationService.ShowSuccess("Import Complete", $"Imported {result.FilesImported} file(s)."); + StatusMessage = $"Imported {result.FilesImported} file(s)."; + } + else + { + var errorMsg = result.Errors.Any() ? string.Join("\n", result.Errors) : "No files were imported."; + notificationService.ShowError("Import Failed", errorMsg); + StatusMessage = "Import failed."; + } + + await LoadReplaysAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Import from files failed"); + notificationService.ShowError("Import Error", ex.Message); + StatusMessage = "Import error."; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task ImportFromUrlAsync() + { + if (string.IsNullOrWhiteSpace(ImportUrl)) + { + return; + } + + IsBusy = true; + StatusMessage = "Importing from URL..."; + Progress = 0; + + try + { + var result = await importService.ImportFromUrlAsync(ImportUrl, SelectedTab, new Progress(p => Progress = p)); + if (result.Success) + { + notificationService.ShowSuccess("Import Complete", $"Imported {result.FilesImported} file(s) from URL."); + StatusMessage = $"Successfully imported {result.FilesImported} file(s)."; + ImportUrl = string.Empty; + await LoadReplaysAsync(); + } + else + { + var errorMsg = string.Join(" ", result.Errors); + notificationService.ShowError("Import Failed", errorMsg); + StatusMessage = $"Import failed: {errorMsg}"; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Import failed"); + notificationService.ShowError("Import Error", ex.Message); + StatusMessage = "Import error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private async Task BrowseAndImportAsync() + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var topLevel = TopLevel.GetTopLevel(lifetime?.MainWindow); + if (topLevel == null) + { + return; + } + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Select Replays to Import", + AllowMultiple = true, + FileTypeFilter = + [ + new FilePickerFileType("Replays and ZIPs") { Patterns = ["*.rep", "*.zip"] }, + ], + }); + + if (files.Any()) + { + await ImportFilesAsync(files.Select(f => f.Path.LocalPath)); + } + } + + [RelayCommand] + private async Task DeleteSelectedAsync() + { + if (!SelectedReplays.Any()) + { + return; + } + + IsBusy = true; + StatusMessage = "Deleting replays..."; + int count = SelectedReplays.Count; + var result = await directoryService.DeleteReplaysAsync([.. SelectedReplays], CancellationToken.None); + if (result) + { + notificationService.ShowSuccess("Deleted", $"Deleted {count} replays."); + StatusMessage = "Deleted successfully."; + } + else + { + notificationService.ShowError("Delete Failed", "Could not delete selected replays."); + StatusMessage = "Deletion error."; + } + + SelectedReplays.Clear(); + await LoadReplaysAsync(); + IsBusy = false; + } + + [RelayCommand] + private async Task ExportToZipAsync() + { + if (!SelectedReplays.Any()) + { + return; + } + + IsBusy = true; + StatusMessage = "Creating ZIP..."; + Progress = 0; + + try + { + var directory = directoryService.GetReplayDirectory(SelectedTab); + var safeZipName = SanitizeFileName(ZipName); + if (!safeZipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + safeZipName += ".zip"; + + var destinationPath = Path.Combine(directory, safeZipName); + + // Handle filename conflict by appending (1), (2), etc. + if (File.Exists(destinationPath)) + { + var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; + var nameOnly = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + int count = 1; + while (File.Exists(destinationPath)) + { + destinationPath = Path.Combine(dir, $"{nameOnly} ({count}){ext}"); + count++; + } + } + + var result = await exportService.ExportToZipAsync([.. SelectedReplays], destinationPath, new Progress(p => Progress = p)); + if (result != null) + { + notificationService.ShowSuccess("Zip Created", $"Created {Path.GetFileName(result)} in replay folder."); + StatusMessage = "ZIP created successfully."; + + // Reload replays to show the new ZIP + await LoadReplaysAsync(); + + // Reveal in Explorer + try + { + System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{result}\""); + } + catch + { + /* Ignore explorer errors */ + } + } + else + { + notificationService.ShowError("Zip Failed", "Failed to create ZIP archive."); + StatusMessage = "ZIP creation failed."; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to export ZIP directly"); + notificationService.ShowError("Export Error", ex.Message); + StatusMessage = "Export error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private async Task UploadAndShareAsync() + { + if (!SelectedReplays.Any()) + { + return; + } + + // Calculate total size of selected replays + long totalSizeBytes = SelectedReplays.Sum(r => new FileInfo(r.FullPath).Length); + + // Check rate limit + var isAllowed = await uploadRateLimitService.CanUploadAsync(totalSizeBytes); + if (!isAllowed) + { + var usage = await uploadRateLimitService.GetUsageInfoAsync(); + var limit = usage.LimitBytes; + var resetDate = usage.ResetDate; + var resetDateLocal = resetDate.ToLocalTime(); + notificationService.ShowError( + "Rate Limit Exceeded", + $"You have reached your 10MB weekly upload limit. Resets on {resetDateLocal:g}."); + StatusMessage = $"Upload limit reached. Resets {resetDateLocal:g}."; + return; + } + + IsBusy = true; + StatusMessage = "Uploading to cloud (UploadThing)..."; + Progress = 0; + + try + { + var url = await exportService.UploadToUploadThingAsync([.. SelectedReplays], new Progress(p => Progress = p)); + if (url != null) + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(url); + } + + // Record successful upload + var fileName = SelectedReplays.Count == 1 ? SelectedReplays[0].FileName : "replays.zip"; + uploadRateLimitService.RecordUpload(totalSizeBytes, url, fileName); + + // Refresh history if open + if (IsHistoryOpen) + { + await LoadHistoryAsync(); + } + + StatusMessage = "Uploaded! Link copied to clipboard."; + notificationService.ShowSuccess("Upload Complete", "Link copied to clipboard!"); + } + else + { + StatusMessage = "Upload failed. Check API key in .env or file size limits."; + notificationService.ShowError("Upload Failed", "Check API key or file sizes (Max 20MB total, 1MB per file)."); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Upload failed"); + notificationService.ShowError("Upload Error", ex.Message); + StatusMessage = "Upload error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private void OpenFolder() + { + directoryService.OpenInExplorer(SelectedTab); + } + + [RelayCommand] + private void RevealFile(ReplayFile replay) + { + directoryService.RevealInExplorer(replay); + } + + [RelayCommand] + private async Task UncompressSelectedAsync() + { + var zipFiles = SelectedReplays + .Where(r => r.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (zipFiles.Count == 0) return; + + IsBusy = true; + StatusMessage = "Uncompressing ZIP(s)..."; + int totalImported = 0; + + try + { + var errorMessages = new List(); + foreach (var zip in zipFiles) + { + var result = await importService.ImportFromZipAsync(zip.FullPath, SelectedTab); + if (result.Success) + { + totalImported += result.FilesImported; + } + + if (result.Errors.Any()) + { + errorMessages.AddRange(result.Errors); + } + } + + if (totalImported > 0) + { + notificationService.ShowSuccess("Uncompress Complete", $"Extracted {totalImported} replays from selected ZIP(s)."); + StatusMessage = $"Extracted {totalImported} replay(s)."; + } + + if (errorMessages.Count > 0) + { + notificationService.ShowWarning("Uncompress Warning", string.Join("\n", errorMessages.Take(5))); + } + + await LoadReplaysAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to uncompress selected ZIP files"); + notificationService.ShowError("Uncompress Error", ex.Message); + StatusMessage = "Uncompress error."; + } + finally + { + IsBusy = false; + } + } + + partial void OnSelectedTabChanged(GameType value) + { + // Update CurrentReplays to show the correct collection's items + CurrentReplays.Clear(); + var sourceCollection = value == GameType.Generals ? GeneralsReplays : ZeroHourReplays; + foreach (var replay in sourceCollection) + { + CurrentReplays.Add(replay); + } + + // Load replays for the new tab + _ = LoadReplaysAsync(); + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs new file mode 100644 index 000000000..bcd0ef70e --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs @@ -0,0 +1,106 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Features.Tools.ReplayManager.ViewModels; + +/// +/// ViewModel for a single upload history item. +/// +/// +/// Initializes a new instance of the class. +/// +/// The upload history item. +public partial class UploadHistoryItemViewModel(UploadHistoryItem item) : ObservableObject +{ + private readonly UploadHistoryItem _item = item; + + /// + /// Gets the filename. + /// + public string FileName => _item.FileName; + + /// + /// Gets the URL. + /// + public string Url => _item.Url; + + /// + /// Gets the formatted timestamp display. + /// + public string TimestampDisplay => GetTimeAgo(_item.Timestamp); + + /// + /// Gets the formatted size display. + /// + public string SizeDisplay => FormatSize(_item.SizeBytes); + + /// + /// Gets or sets a value indicating whether the file existence has been verified. + /// + [ObservableProperty] + private bool isVerified; + + /// + /// Gets or sets a value indicating whether the file exists in storage. + /// + [ObservableProperty] + private bool fileExists; + + /// + /// Gets a value indicating whether the upload is still active (file exists in storage). + /// + public bool IsActive => IsVerified ? FileExists : (DateTime.UtcNow - _item.Timestamp).TotalDays < 14; + + /// + /// Gets the status color based on activity. + /// + public string StatusColor => IsActive ? "#4CAF50" : "#888888"; + + private static string GetTimeAgo(DateTime timestamp) + { + var span = DateTime.UtcNow - timestamp; + if (span.TotalDays > 1) + { + return $"{(int)span.TotalDays}d ago"; + } + + if (span.TotalHours > 1) + { + return $"{(int)span.TotalHours}h ago"; + } + + if (span.TotalMinutes > 1) + { + return $"{(int)span.TotalMinutes}m ago"; + } + + return "Just now"; + } + + private static string FormatSize(long bytes) + { + string[] sizes = ["B", "KB", "MB", "GB", "TB"]; + double len = bytes; + int order = 0; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len /= 1024; + } + + return $"{len:0.##} {sizes[order]}"; + } + + partial void OnFileExistsChanged(bool value) + { + OnPropertyChanged(nameof(IsActive)); + OnPropertyChanged(nameof(StatusColor)); + } + + partial void OnIsVerifiedChanged(bool value) + { + OnPropertyChanged(nameof(IsActive)); + OnPropertyChanged(nameof(StatusColor)); + } +} diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml new file mode 100644 index 000000000..e5cee4ab8 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml.cs new file mode 100644 index 000000000..55f9ce52b --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml.cs @@ -0,0 +1,163 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Features.Tools.ReplayManager.ViewModels; +using System; +using System.IO; +using System.Linq; + +namespace GenHub.Features.Tools.ReplayManager.Views; + +/// +/// Interaction logic for . +/// +public partial class ReplayManagerView : UserControl +{ + private Border? _dragDropOverlay; + + /// + /// Initializes a new instance of the class. + /// + public ReplayManagerView() + { + InitializeComponent(); + AddHandler(DragDrop.DragOverEvent, DragOver); + AddHandler(DragDrop.DragLeaveEvent, DragLeave); + AddHandler(DragDrop.DropEvent, Drop); + + var dataGrid = this.Find("ReplaysGrid"); + if (dataGrid != null) + { + dataGrid.SelectionChanged += OnSelectionChanged; + + // CellEditEnded is handled via XAML, but can also be attached here if needed. + } + } + + /// + /// Handles the cell edit ended event for the data grid. + /// + /// The sender of the event. + /// The event arguments. + public void OnCellEditEnded(object? sender, DataGridCellEditEndedEventArgs e) + { + if (e.EditAction == DataGridEditAction.Commit && e.Row.DataContext is ReplayFile replay) + { + // The FileName property is updated by the binding before this event fires. + // replay.FullPath contains the original path. + var oldPath = replay.FullPath; + var directory = Path.GetDirectoryName(oldPath); + if (directory == null) return; + + var newFileName = replay.FileName; + + // Ensure .rep extension if missing? + if (!newFileName.EndsWith(".rep", StringComparison.OrdinalIgnoreCase)) + { + newFileName += ".rep"; + } + + var newPath = Path.Combine(directory, newFileName); + + if (string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + File.Move(oldPath, newPath); + replay.FullPath = newPath; + replay.FileName = newFileName; // Ensure case or extension is normalized + } + catch (IOException) + { + // File exists or other IO error + replay.FileName = Path.GetFileName(oldPath); + } + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + _dragDropOverlay = this.Find("DragDropOverlay"); + } + + private void DragOver(object? sender, DragEventArgs e) + { + if (e.Data.Contains(DataFormats.Files)) + { + var files = e.Data.GetFiles(); + bool hasValidFiles = files != null && files.Any(f => + { + var path = f.Path.LocalPath; + return path.EndsWith(".rep", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + }); + + if (hasValidFiles) + { + e.DragEffects = DragDropEffects.Copy; + if (_dragDropOverlay != null) + { + _dragDropOverlay.IsVisible = true; + _dragDropOverlay.Opacity = 1.0; + } + } + else + { + e.DragEffects = DragDropEffects.None; + } + } + else + { + e.DragEffects = DragDropEffects.None; + } + } + + private void DragLeave(object? sender, RoutedEventArgs e) + { + if (_dragDropOverlay != null) + { + _dragDropOverlay.Opacity = 0.0; + + // We use a small delay or just wait for transition? IsVisible=false breaks transition if immediate. + // But IsVisible=false is needed when fully hidden to not block input. + // Actually hit test is off, so it should be fine. + // Better to hide IsVisible after transition or just leave it visible but Opacity 0? + // Let's just set Opacity 0 for now. + } + } + + private async void Drop(object? sender, DragEventArgs e) + { + if (_dragDropOverlay != null) + { + _dragDropOverlay.Opacity = 0.0; + _dragDropOverlay.IsVisible = false; + } + + if (e.Data.Contains(DataFormats.Files)) + { + var files = e.Data.GetFiles(); + if (files != null && DataContext is ReplayManagerViewModel vm) + { + var filePaths = files.Select(f => f.Path.LocalPath).ToList(); + await vm.ImportFilesAsync(filePaths); + } + } + } + + private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (sender is DataGrid dg && DataContext is ReplayManagerViewModel vm) + { + var selected = dg.SelectedItems.OfType().ToList(); + vm.UpdateSelectedReplays(selected); + } + } +} diff --git a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs index e98b9243a..be1ea3b3a 100644 --- a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs @@ -76,7 +76,7 @@ public partial class ToolsViewModel(IToolManager toolService, ILogger /// Gets the collection of installed tools. /// - public ObservableCollection InstalledTools { get; } = new(); + public ObservableCollection InstalledTools { get; } = []; /// /// Initializes the ViewModel by loading saved tools. @@ -153,13 +153,13 @@ private async Task AddToolAsync() { Title = "Select Tool Plugin Assembly", AllowMultiple = false, - FileTypeFilter = new[] - { + FileTypeFilter = + [ new FilePickerFileType("Tool Plugin Assembly") { - Patterns = new[] { "*.dll" }, + Patterns = ["*.dll"], }, - }, + ], }); if (files.Count > 0) @@ -205,6 +205,11 @@ private async Task RemoveToolAsync(IToolPlugin? tool = null) { var toolToRemove = tool ?? SelectedTool; if (toolToRemove == null) return; + if (toolToRemove.Metadata.IsBundled) + { + ShowStatusMessage($"✗ Tool '{toolToRemove.Metadata.Name}' is a bundled tool and cannot be removed.", error: true); + return; + } try { @@ -435,4 +440,4 @@ private async void ShowStatusMessage(string message, bool success = false, bool // Timer was cancelled, ignore } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml index 5a12f110e..097a50377 100644 --- a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml +++ b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml @@ -9,36 +9,47 @@ + + + + + + + + + + + + + @@ -244,23 +295,27 @@ SelectedItem="{Binding SelectedTool}" Background="Transparent" BorderThickness="0" + Padding="0" IsVisible="{Binding HasTools}"> - - + + + Classes="tool-version" + IsVisible="{Binding !$parent[UserControl].((vm:ToolsViewModel)DataContext).IsSidebarCollapsed}" /> @@ -290,11 +346,15 @@ - @@ -308,7 +368,7 @@ Margin="0,0,0,20" IsVisible="{Binding SelectedTool, Converter={x:Static ObjectConverters.IsNotNull}}"> @@ -318,11 +378,11 @@ VerticalAlignment="Center"> - + - + @@ -341,10 +401,8 @@ - - - + @@ -377,7 +435,7 @@ - @@ -386,7 +444,7 @@ - @@ -394,7 +452,7 @@ - @@ -403,7 +461,7 @@ - @@ -412,7 +470,7 @@ - diff --git a/GenHub/GenHub/GenHub.csproj b/GenHub/GenHub/GenHub.csproj index bdd80c8ff..5bd8a8f69 100644 --- a/GenHub/GenHub/GenHub.csproj +++ b/GenHub/GenHub/GenHub.csproj @@ -12,6 +12,7 @@ + None diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs index 3f0465c11..d712b33ac 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs @@ -1,5 +1,6 @@ -using System; +using GenHub.Features.Tools.ReplayManager; using Microsoft.Extensions.DependencyInjection; +using System; namespace GenHub.Infrastructure.DependencyInjection; @@ -42,6 +43,7 @@ public static IServiceCollection ConfigureApplicationServices( // Register Tools services services.AddToolsServices(); + services.AddReplayManagerServices(); // Register Notification services services.AddNotificationModule(); @@ -55,4 +57,4 @@ public static IServiceCollection ConfigureApplicationServices( return services; } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ReplayManagerModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ReplayManagerModule.cs new file mode 100644 index 000000000..a84882b4b --- /dev/null +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ReplayManagerModule.cs @@ -0,0 +1,38 @@ +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Features.Tools.ReplayManager; +using GenHub.Features.Tools.ReplayManager.Services; +using GenHub.Features.Tools.ReplayManager.ViewModels; +using Microsoft.Extensions.DependencyInjection; + +namespace GenHub.Infrastructure.DependencyInjection; + +/// +/// Dependency injection module for the Replay Manager tool. +/// +public static class ReplayManagerModule +{ + /// + /// Adds Replay Manager services to the service collection. + /// + /// The service collection. + /// The updated service collection. + public static IServiceCollection AddReplayManagerServices(this IServiceCollection services) + { + // Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ViewModel (Singleton to persist state across tool activations) + services.AddSingleton(); + + // Tool Plugin (Registered as a singleton IToolPlugin) + services.AddSingleton(); + + return services; + } +} diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 1f490cb91..52acc2a86 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -7,7 +7,7 @@ export default withMermaid( description: 'C&C Launcher Documentation', base: process.env.NODE_ENV === 'production' || - process.env.GITHUB_ACTIONS === 'true' + process.env.GITHUB_ACTIONS === 'true' ? '/wiki/' : '/', @@ -91,6 +91,12 @@ export default withMermaid( { text: 'Manifest Creation', link: '/FlowCharts/Manifest-Creation-Flow' }, { text: 'Complete User Flow', link: '/FlowCharts/Complete-User-Flow' } ] + }, + { + text: 'Tools', + items: [ + { text: 'Replay Manager', link: '/tools/replay-manager' } + ] } ], diff --git a/docs/dev/constants.md b/docs/dev/constants.md index ecf45566e..fd8b3cffa 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -1203,6 +1203,43 @@ Constants for The Super Hackers content discovery and manifest creation. - `PublisherPrefix`: Publisher prefix string (`"thesuperhackers"`) - `PublisherDisplayName`: Display name for the publisher (`"The Super Hackers"`) - `VersionDelimiter`: Character used to separate components in version strings (`':'`) +## ToolConstants Class + +Constants for tool plugin metadata and configuration. + +### ReplayManager Subclass + +Constants specific to the Replay Manager tool plugin. + +| Constant | Value | Description | +| ------------ | ------------------------------------------ | ------------------------------------------------ | +| `Id` | `"genhub.tools.replaymanager"` | Unique identifier for the Replay Manager tool | +| `Name` | `"Replay Manager"` | Display name for the Replay Manager tool | +| `Version` | `"1.0.0"` | Version of the Replay Manager tool | +| `Author` | `"GenHub Team"` | Author of the Replay Manager tool | +| `Description`| `"Manage, import, and share replay files for Command & Conquer: Generals and Zero Hour."` | Description of the Replay Manager tool | +| `Tags` | `["replays", "file-management", "sharing"]`| Tags associated with the Replay Manager tool | +| `IconPath` | `"Assets/Icons/replay.png"` | Icon path for the Replay Manager tool (placeholder) | +| `IsBundled` | `true` | Whether the tool is bundled with the application | + +### Usage Example + +```csharp +using GenHub.Core.Constants; + +// Create tool metadata using constants +var metadata = new ToolMetadata +{ + Id = ToolConstants.ReplayManager.Id, + Name = ToolConstants.ReplayManager.Name, + Version = ToolConstants.ReplayManager.Version, + Author = ToolConstants.ReplayManager.Author, + Description = ToolConstants.ReplayManager.Description, + Tags = ToolConstants.ReplayManager.Tags, + IconPath = ToolConstants.ReplayManager.IconPath, + IsBundled = ToolConstants.ReplayManager.IsBundled, +}; +``` --- diff --git a/docs/tools/replay-manager.md b/docs/tools/replay-manager.md new file mode 100644 index 000000000..6e031b127 --- /dev/null +++ b/docs/tools/replay-manager.md @@ -0,0 +1,62 @@ +# Replay Manager + +The Replay Manager is a built-in tool in GenHub that allows you to manage, import, and share your Command & Conquer: Generals and Zero Hour replay files with ease. + +## Features + +- **Unified View**: See all your replays for both Generals and Zero Hour in one place. +- **Easy Import**: Import replays directly from URLs or by dragging and dropping files. +- **Cloud Sharing**: Share your best matches instantly via UploadThing. +- **Local Export**: Bundle multiple replays into a ZIP archive for local storage or manual sharing. +- **Conflict Resolution**: Automatically handles duplicate filenames during import. + +## Getting Started + +To access the Replay Manager: +1. Open GenHub. +2. Navigate to the **TOOLS** tab. +3. Select **Replay Manager** from the sidebar. + +## Importing Replays + +### From URL +You can import replays from various sources by pasting the link into the import bar: +- **UploadThing**: Direct links from other GenHub users. +- **Generals Online**: Match view URLs. +- **GenTool**: Directory URLs from the GenTool data repository. +- **Direct Links**: Any URL ending in `.rep` or `.zip`. + +### Drag and Drop +Simply drag one or more `.rep` or `.zip` files from your computer and drop them anywhere on the Replay Manager window to import them. + +## Sharing Replays + +1. Select the replays you want to share from the list. +2. Click **Upload & Share**. +3. Once the upload is complete, the download link will be copied to your clipboard automatically. + +> [!IMPORTANT] +> **Size Limit**: Each individual replay or ZIP file must be under **1 MB**. +> **Privacy**: Shared replays are maintained for up to 14 days or until storage is full. + +## Storage Policy + +Replays imported or shared via GenHub are subject to the following policies: +- Files are maintained in our cloud storage for **14 days**. +- Older files may be removed automatically to make room for new ones. +- Only `.rep` files are allowed within shared archives. + +## Architecture + +The Replay Manager is built on a modular service architecture: + +- **`IReplayDirectoryService`**: Manages replay directory operations and file system access. +- **`IReplayImportService`**: Handles importing replays from URLs, local files, and ZIP archives. +- **`IReplayExportService`**: Manages exporting and cloud sharing via UploadThing. +- **`IUploadRateLimitService`**: Enforces weekly upload quotas and tracks upload history. +- **`IUrlParserService`**: Identifies and validates replay source URLs *(coming soon)*. + +## Upcoming Features + +- **Enhanced URL Parser**: Improved support for additional replay sources and better URL validation. +- **Replay Metadata Viewer**: View detailed match information directly in GenHub. From f6d8d053ac146578cf3ea2bbb1f466798ef76f95 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Fri, 2 Jan 2026 08:32:01 +0100 Subject: [PATCH 3/9] feat(tools): enhance map/replay managers and refine tools sidebar ui --- .github/workflows/release.yml | 56 +- GenHub/GenHub.Core/Constants/ApiConstants.cs | 58 +- .../Constants/MapManagerConstants.cs | 102 ++ .../Constants/ReplayManagerConstants.cs | 4 +- .../Interfaces/Common/IExportableFile.cs | 17 + .../Common/IUploadHistoryService.cs | 56 ++ .../Services/IUploadThingService.cs | 31 + .../Tools/MapManager/IMapDirectoryService.cs | 63 ++ .../Tools/MapManager/IMapExportService.cs | 39 + .../Tools/MapManager/IMapImportService.cs | 81 ++ .../Tools/MapManager/IMapPackService.cs | 83 ++ .../ReplayManager/IUploadRateLimitService.cs | 55 -- .../UploadHistoryItem.cs | 6 +- .../Common}/UsageInfo.cs | 11 +- .../Models/Tools/MapManager/ImportResult.cs | 29 + .../Models/Tools/MapManager/MapFile.cs | 102 ++ .../Models/Tools/MapManager/MapPack.cs | 46 + .../Models/Tools/MapManager/MapSource.cs | 22 + .../Models/Tools/ReplayManager/ReplayFile.cs | 3 +- .../GenHub.Core/Models/Tools/UploadRecord.cs | 32 + .../GameInstallations/LutrisInstallation.cs | 59 +- .../Converters/FileSizeConverterTests.cs | 2 +- .../Content/GitHubContentProviderTests.cs | 4 +- .../Validation/FileSystemValidatorTests.cs | 2 +- .../AppUpdate/Services/SimpleHttpServer.cs | 4 +- .../Views/UpdateNotificationWindow.axaml.cs | 4 +- .../ContentDeliverers/FileSystemDeliverer.cs | 4 +- .../FileSystemDiscoverer.cs | 2 + .../CNCLabsContentProvider.cs | 43 +- .../LocalFileSystemContentProvider.cs | 50 +- .../ContentProviders/ModDBContentProvider.cs | 44 +- .../Content/Services/ContentStorageService.cs | 13 +- .../Content/Services/ContentValidator.cs | 20 +- .../Services/GitHub/GitHubContentProvider.cs | 17 +- .../Content/Services/GitHub/GitHubResolver.cs | 70 +- .../Content/Services/Helpers/CNCLabsHelper.cs | 4 +- .../Services/MemoryDynamicContentCache.cs | 4 +- .../GameClientDetectionOrchestrator.cs | 2 +- .../GameClients/GameClientDetector.cs | 2 +- .../GameInstallationDetectionOrchestrator.cs | 4 +- .../Services/DependencyResolver.cs | 10 +- .../Services/ProfileEditorFacade.cs | 4 +- .../ViewModels/GitHubTokenDialogViewModel.cs | 1 + .../Manifest/ContentManifestBuilder.cs | 9 +- .../Services/NotificationService.cs | 5 +- .../NotificationManagerViewModel.cs | 4 +- .../Tools/MapManager/MapManagerToolPlugin.cs | 67 ++ .../Services/MapDirectoryService.cs | 383 ++++++++ .../MapManager/Services/MapExportService.cs | 164 ++++ .../MapManager/Services/MapImportService.cs | 516 ++++++++++ .../MapManager/Services/MapNameParser.cs | 141 +++ .../MapManager/Services/MapPackService.cs | 362 +++++++ .../Tools/MapManager/Services/TgaParser.cs | 298 ++++++ .../ViewModels/MapManagerViewModel.cs | 919 ++++++++++++++++++ .../MapManager/Views/MapManagerView.axaml | 408 ++++++++ .../MapManager/Views/MapManagerView.axaml.cs | 117 +++ .../Services/ReplayExportService.cs | 172 +--- .../Services/UploadRateLimitService.cs | 253 ----- .../ViewModels/ReplayManagerViewModel.cs | 44 +- .../Views/ReplayManagerView.axaml | 75 +- .../Tools/Services/UploadHistoryService.cs | 308 ++++++ .../Tools/Services/UploadThingService.cs | 196 ++++ .../Tools/ViewModels/ToolsViewModel.cs | 28 +- .../ViewModels/UploadHistoryItemViewModel.cs | 4 +- .../Features/Tools/Views/ToolsView.axaml | 539 +++------- .../Features/Tools/Views/ToolsView.axaml.cs | 16 + .../Validation/FileSystemValidator.cs | 24 +- .../Workspace/Strategies/FullCopyStrategy.cs | 2 +- .../Workspace/Strategies/HardLinkStrategy.cs | 2 +- .../Strategies/SymlinkOnlyStrategy.cs | 4 +- .../Features/Workspace/WorkspaceValidator.cs | 4 +- .../Converters/AssetCountConverter.cs | 28 + .../Converters/BoolToTypeConverter.cs | 28 + .../Converters/FileSizeConverter.cs | 3 +- .../InvertedBoolToVisibilityConverter.cs | 2 +- .../Converters/MapTypeDisplayConverter.cs | 71 ++ .../Converters/NavigationTabConverter.cs | 2 +- .../Converters/ObjectToBoolConverter.cs | 34 + .../Converters/StringToIntConverter.cs | 2 +- .../TabIndexToVisibilityConverter.cs | 2 +- .../DependencyInjection/AppServices.cs | 2 + .../DependencyInjection/MapManagerModule.cs | 43 + .../ReplayManagerModule.cs | 2 +- .../DependencyInjection/UploadThingModule.cs | 23 + .../Infrastructure/Imaging/TgaImageParser.cs | 273 ++++++ docs/dev/constants.md | 89 ++ docs/dev/uploading-api.md | 87 ++ docs/tools/map-manager.md | 73 ++ 88 files changed, 5914 insertions(+), 1179 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/MapManagerConstants.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Common/IExportableFile.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapDirectoryService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapImportService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapPackService.cs delete mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs rename GenHub/GenHub.Core/Models/{Tools/ReplayManager => Common}/UploadHistoryItem.cs (84%) rename GenHub/GenHub.Core/{Interfaces/Tools/ReplayManager => Models/Common}/UsageInfo.cs (53%) create mode 100644 GenHub/GenHub.Core/Models/Tools/MapManager/ImportResult.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/MapManager/MapFile.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/MapManager/MapPack.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/MapManager/MapSource.cs create mode 100644 GenHub/GenHub.Core/Models/Tools/UploadRecord.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/MapManagerToolPlugin.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Services/MapNameParser.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Services/MapPackService.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Services/TgaParser.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml create mode 100644 GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml.cs delete mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/UploadRateLimitService.cs create mode 100644 GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs create mode 100644 GenHub/GenHub/Features/Tools/Services/UploadThingService.cs rename GenHub/GenHub/Features/Tools/{ReplayManager => }/ViewModels/UploadHistoryItemViewModel.cs (96%) create mode 100644 GenHub/GenHub/Infrastructure/Converters/AssetCountConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToTypeConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/MapTypeDisplayConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ObjectToBoolConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/MapManagerModule.cs create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/UploadThingModule.cs create mode 100644 GenHub/GenHub/Infrastructure/Imaging/TgaImageParser.cs create mode 100644 docs/dev/uploading-api.md create mode 100644 docs/tools/map-manager.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1a46b68d..8b3aa5c6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,30 @@ jobs: $version = "0.0.${{ github.run_number }}" echo "VERSION=$version" >> $env:GITHUB_OUTPUT + - name: Inject Secrets + shell: pwsh + run: | + $constantsPath = "GenHub/GenHub.Core/Constants/ApiConstants.cs" + $token = "${{ secrets.UPLOADTHING_TOKEN }}" + if ([string]::IsNullOrEmpty($token)) { throw "UPLOADTHING_TOKEN secret is not set in GitHub Settings" } + + $tokenBytes = [System.Text.Encoding]::UTF8.GetBytes($token) + $key = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($key) + + $obfuscated = New-Object byte[] $tokenBytes.Length + for ($i = 0; $i -lt $tokenBytes.Length; $i++) { + $obfuscated[$i] = $tokenBytes[$i] -bxor $key[$i % $key.Length] + } + + $dataStr = ($obfuscated | ForEach-Object { "0x{0:x2}" -f $_ }) -join ", " + $keyStr = ($key | ForEach-Object { "0x{0:x2}" -f $_ }) -join ", " + + $content = Get-Content $constantsPath -Raw + $content = $content -replace 'byte\[\] data = \[\]; // \[PLACEHOLDER_DATA\]', "byte[] data = [$dataStr];" + $content = $content -replace 'byte\[\] key = \[\]; // \[PLACEHOLDER_KEY\]', "byte[] key = [$keyStr];" + Set-Content $constantsPath $content + - name: Publish Windows App shell: pwsh run: | @@ -68,7 +92,7 @@ jobs: --packAuthors "Community Outpost" ` --icon GenHub/GenHub/Assets/Icons/generalshub.ico ` --outputDir velopack-release-windows - + # Rename metadata to prevent collisions with Linux Rename-Item -Path "velopack-release-windows\releases.json" -NewName "releases.win.json" -ErrorAction SilentlyContinue Rename-Item -Path "velopack-release-windows\assets.json" -NewName "assets.win.json" -ErrorAction SilentlyContinue @@ -111,6 +135,30 @@ jobs: - name: Install Linux Dependencies run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev + - name: Inject Secrets + shell: pwsh + run: | + $constantsPath = "GenHub/GenHub.Core/Constants/ApiConstants.cs" + $token = "${{ secrets.UPLOADTHING_TOKEN }}" + if ([string]::IsNullOrEmpty($token)) { throw "UPLOADTHING_TOKEN secret is not set in GitHub Settings" } + + $tokenBytes = [System.Text.Encoding]::UTF8.GetBytes($token) + $key = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($key) + + $obfuscated = New-Object byte[] $tokenBytes.Length + for ($i = 0; $i -lt $tokenBytes.Length; $i++) { + $obfuscated[$i] = $tokenBytes[$i] -bxor $key[$i % $key.Length] + } + + $dataStr = ($obfuscated | ForEach-Object { "0x{0:x2}" -f $_ }) -join ", " + $keyStr = ($key | ForEach-Object { "0x{0:x2}" -f $_ }) -join ", " + + $content = Get-Content $constantsPath -Raw + $content = $content -replace 'byte\[\] data = \[\]; // \[PLACEHOLDER_DATA\]', "byte[] data = [$dataStr];" + $content = $content -replace 'byte\[\] key = \[\]; // \[PLACEHOLDER_KEY\]', "byte[] key = [$keyStr];" + Set-Content $constantsPath $content + - name: Publish Linux App run: | dotnet publish "${{ env.LINUX_PROJECT }}" \ @@ -136,7 +184,7 @@ jobs: --packAuthors "Community Outpost" \ --icon GenHub/GenHub/Assets/Icons/generalshub-icon.png \ --outputDir velopack-release-linux - + # Rename metadata to prevent collisions with Windows mv velopack-release-linux/releases.json velopack-release-linux/releases.linux.json || true mv velopack-release-linux/assets.json velopack-release-linux/assets.linux.json || true @@ -194,7 +242,7 @@ jobs: find artifacts/linux-release -type f \( -name "*.nupkg" -o -name "*.json" \) -exec cp {} final-assets/ \; # 3. Windows Portable (Specific match) cp artifacts/windows-portable/*.zip final-assets/ - + ls -lh final-assets/ - name: Create Release @@ -205,7 +253,7 @@ jobs: prerelease: true body: | ## GenHub Alpha v${{ steps.info.outputs.VERSION }} - + ### 🆕 What's New **${{ steps.info.outputs.COUNT }} commits** included: ${{ steps.info.outputs.CHANGELOG }} diff --git a/GenHub/GenHub.Core/Constants/ApiConstants.cs b/GenHub/GenHub.Core/Constants/ApiConstants.cs index ed9596d50..04e7671a6 100644 --- a/GenHub/GenHub.Core/Constants/ApiConstants.cs +++ b/GenHub/GenHub.Core/Constants/ApiConstants.cs @@ -71,6 +71,11 @@ public static class ApiConstants /// public const string UploadThingPrepareUrl = "https://api.uploadthing.com/v7/prepareUpload"; + /// + /// UploadThing delete file URL. + /// + public const string UploadThingDeleteUrl = "https://api.uploadthing.com/v6/deleteFiles"; + /// /// UploadThing public file URL format. /// @@ -81,6 +86,57 @@ public static class ApiConstants /// public const string UploadThingUrlFragment = "utfs.io/f/"; + /// + /// UploadThing token environment variable. + /// + public const string UploadThingTokenEnvVar = "UPLOADTHING_TOKEN"; + + /// + /// Alternative UploadThing token environment variable. + /// + public const string UploadThingTokenEnvVarAlt = "GENHUB_UPLOADTHING_TOKEN"; + + /// + /// Gets the default UploadThing token injected at build time (Obfuscated). + /// + public static string BuildTimeUploadThingToken + { + get + { + // This is a simple XOR obfuscation to prevent the raw token from appearing in strings/debuggers. + // The actual values are injected during the GitHub Actions build process. + byte[] data = []; // [PLACEHOLDER_DATA] + byte[] key = []; // [PLACEHOLDER_KEY] + + if (data.Length == 0 || key.Length == 0) return string.Empty; + + var result = new byte[data.Length]; + for (int i = 0; i < data.Length; i++) + { + result[i] = (byte)(data[i] ^ key[i % key.Length]); + } + + return System.Text.Encoding.UTF8.GetString(result); + } + } + + /// + /// UploadThing API key header. + /// + public const string UploadThingApiKeyHeader = "x-uploadthing-api-key"; + + /// + /// UploadThing version header. + /// + public const string UploadThingVersionHeader = "x-uploadthing-version"; + + // Media Types + + /// + /// Media type for ZIP files. + /// + public const string MediaTypeZip = "application/zip"; + // GenTool /// @@ -101,4 +157,4 @@ public static class ApiConstants /// Gets the default user agent string for HTTP requests. /// public static string DefaultUserAgent => $"{AppConstants.AppName}/{AppConstants.AppVersion}"; -} +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/MapManagerConstants.cs b/GenHub/GenHub.Core/Constants/MapManagerConstants.cs new file mode 100644 index 000000000..b0521d02d --- /dev/null +++ b/GenHub/GenHub.Core/Constants/MapManagerConstants.cs @@ -0,0 +1,102 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the Map Manager feature. +/// +public static class MapManagerConstants +{ + /// + /// Maximum file size for individual maps in bytes (10 MB). + /// + public const long MaxMapSizeBytes = 10 * 1024 * 1024; + + /// + /// Number of days for rate limit reset period. + /// + public const int RateLimitDays = 3; + + /// + /// Maximum upload size in bytes per period (100 MB). + /// + public const long MaxUploadBytesPerPeriod = 100 * 1024 * 1024; + + /// + /// Maximum width for map thumbnails in pixels. + /// + public const int ThumbnailMaxWidth = 128; + + /// + /// Maximum height for map thumbnails in pixels. + /// + public const int ThumbnailMaxHeight = 128; + + /// + /// Default thumbnail filename to look for in map directories. + /// + public const string DefaultThumbnailName = "map.tga"; + + /// + /// Maximum directory nesting depth for maps (1 level). + /// + public const int MaxDirectoryDepth = 1; + + /// + /// Directory name for Generals data. + /// + public const string GeneralsDataDirectoryName = "Command and Conquer Generals Data"; + + /// + /// Directory name for Zero Hour data. + /// + public const string ZeroHourDataDirectoryName = "Command and Conquer Generals Zero Hour Data"; + + /// + /// Subdirectory name where maps are stored. + /// + public const string MapsSubdirectoryName = "Maps"; + + /// + /// Subdirectory name where MapPacks are stored. + /// + public const string MapPacksSubdirectoryName = "mappacks"; + + /// + /// File pattern for map files. + /// + public const string MapFilePattern = "*.map"; + + /// + /// File pattern for ZIP files. + /// + public const string ZipFilePattern = "*.zip"; + + /// + /// Default name for exported ZIP files. + /// + public const string DefaultZipName = "maps"; + + /// + /// Tool identifier for Map Manager. + /// + public const string ToolId = "map-manager"; + + /// + /// Tool display name for Map Manager. + /// + public const string ToolName = "Map Manager"; + + /// + /// Tool description for Map Manager. + /// + public const string ToolDescription = "Manage, import, and share custom maps. Create MapPacks for easy profile switching."; + + /// + /// Allowed file extensions for map packages. + /// + public static readonly string[] AllowedExtensions = [".map", ".tga", ".ini", ".str", ".txt"]; + + /// + /// Image file extensions that can be used as thumbnails. + /// + public static readonly string[] ImageExtensions = [".tga"]; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs index 6e7dfb60b..634cbeec7 100644 --- a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs @@ -11,9 +11,9 @@ public static class ReplayManagerConstants public const long MaxReplaySizeBytes = 1024 * 1024; /// - /// Maximum weekly upload bytes (10 MB). + /// Maximum upload bytes per period (10 MB). /// - public const long MaxWeeklyUploadBytes = 10 * 1024 * 1024; + public const long MaxUploadBytesPerPeriod = 10 * 1024 * 1024; /// /// Prefix for temporary import files. diff --git a/GenHub/GenHub.Core/Interfaces/Common/IExportableFile.cs b/GenHub/GenHub.Core/Interfaces/Common/IExportableFile.cs new file mode 100644 index 000000000..b50921971 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/IExportableFile.cs @@ -0,0 +1,17 @@ +namespace GenHub.Core.Interfaces.Common; + +/// +/// Interface for files that can be exported or uploaded. +/// +public interface IExportableFile +{ + /// + /// Gets the file name. + /// + string FileName { get; } + + /// + /// Gets the full path to the file. + /// + string FullPath { get; } +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs new file mode 100644 index 000000000..3bfca422a --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Tools; + +namespace GenHub.Core.Interfaces.Common; + +/// +/// Interface for managing upload history. +/// +public interface IUploadHistoryService +{ + /// + /// Gets the maximum upload bytes per period. + /// + long MaxUploadBytesPerPeriod { get; } + + /// + /// Checks if an upload of the specified size is allowed. + /// + /// The file size in bytes. + /// A task representing the asynchronous operation, with a boolean indicating if the upload is allowed. + Task CanUploadAsync(long fileSizeBytes); + + /// + /// Gets the usage info. + /// + /// A task representing the asynchronous operation, with the usage info. + Task GetUsageInfoAsync(); + + /// + /// Records an upload. + /// + /// The file size in bytes. + /// The URL. + /// The file name. + void RecordUpload(long fileSizeBytes, string url, string fileName); + + /// + /// Gets the upload history. + /// + /// A task representing the asynchronous operation, with the history items. + Task> GetUploadHistoryAsync(); + + /// + /// Removes a history item. + /// + /// The URL. + /// A task representing the asynchronous operation. + Task RemoveHistoryItemAsync(string url); + + /// + /// Clears the history. + /// + /// A task representing the asynchronous operation. + Task ClearHistoryAsync(); +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs b/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs new file mode 100644 index 000000000..a0adb1174 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Services; + +/// +/// Service for uploading files to UploadThing cloud storage. +/// +public interface IUploadThingService +{ + /// + /// Uploads a file to UploadThing and returns the public URL. + /// + /// The absolute path to the file to upload. + /// Optional progress reporter (0.0 to 1.0). + /// Cancellation token. + /// The public URL if successful, otherwise null. + Task UploadFileAsync( + string filePath, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Deletes a file from UploadThing. + /// + /// The key of the file to delete. + /// Cancellation token. + /// True if the deletion was successful, otherwise false. + Task DeleteFileAsync(string fileKey, CancellationToken ct = default); +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapDirectoryService.cs b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapDirectoryService.cs new file mode 100644 index 000000000..74f7b00a3 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapDirectoryService.cs @@ -0,0 +1,63 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.MapManager; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.MapManager; + +/// +/// Manages map directory operations. +/// +public interface IMapDirectoryService +{ + /// + /// Gets the map directory path for the specified game version. + /// + /// The game version. + /// The path to the map directory. + string GetMapDirectory(GameType version); + + /// + /// Ensures the map directory exists, creating it if necessary. + /// + /// The game version. + void EnsureDirectoryExists(GameType version); + + /// + /// Gets all map files for the specified game version. + /// + /// The game version. + /// Cancellation token. + /// A list of map files. + Task> GetMapsAsync(GameType version, CancellationToken ct = default); + + /// + /// Deletes the specified map files (moves to Recycle Bin). + /// + /// The maps to delete. + /// Cancellation token. + /// True if deletion was successful. + Task DeleteMapsAsync(IEnumerable maps, CancellationToken ct = default); + + /// + /// Opens the map directory in Windows Explorer. + /// + /// The game version. + void OpenInExplorer(GameType version); + + /// + /// Reveals a specific file in Windows Explorer. + /// + /// The map file to reveal. + void RevealInExplorer(MapFile map); + + /// + /// Renames a map, including its parent directory if applicable. + /// + /// The map to rename. + /// The new name (without extension). + /// Cancellation token. + /// True if successful, false otherwise. + Task RenameMapAsync(MapFile map, string newName, CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs new file mode 100644 index 000000000..53c23aeb4 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs @@ -0,0 +1,39 @@ +using GenHub.Core.Models.Tools.MapManager; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.MapManager; + +/// +/// Handles exporting and sharing maps. +/// +public interface IMapExportService +{ + /// + /// Uploads maps to UploadThing and returns the share URL. + /// + /// The maps to upload. + /// Progress reporter for upload updates. + /// Cancellation token. + /// The share URL if successful, otherwise null. + Task UploadToUploadThingAsync( + IEnumerable maps, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Creates a ZIP archive of the specified maps. + /// + /// The maps to export. + /// The destination ZIP file path. + /// Progress reporter for compression updates. + /// Cancellation token. + /// The path to the created ZIP file if successful, otherwise null. + Task ExportToZipAsync( + IEnumerable maps, + string destinationPath, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapImportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapImportService.cs new file mode 100644 index 000000000..23668e736 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapImportService.cs @@ -0,0 +1,81 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.MapManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.MapManager; + +/// +/// Handles importing maps from various sources. +/// +public interface IMapImportService +{ + /// + /// Maximum allowed size for a single map file (10 MB). + /// + public const long MaxMapSizeBytes = 10 * 1024 * 1024; // 10 MB + + /// + /// Imports a map from a URL. + /// + /// The URL to import from. + /// The target game version. + /// Progress reporter for download updates. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromUrlAsync( + string url, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Imports map files from local paths. + /// + /// The paths to the local files. + /// The target game version. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromFilesAsync( + IEnumerable filePaths, + GameType targetVersion, + CancellationToken ct = default); + + /// + /// Imports maps from a ZIP archive. + /// + /// The path to the ZIP archive. + /// The target game version. + /// Progress reporter for extraction updates. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromZipAsync( + string zipPath, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Validates a ZIP archive to ensure it contains only map files. + /// + /// The path to the ZIP archive. + /// A result indicating whether the ZIP is valid and any error message. + (bool IsValid, string? ErrorMessage) ValidateZip(string zipPath); + + /// + /// Imports maps from a stream (e.g., for drag-and-drop). + /// + /// The stream to read from. + /// The name of the file being imported. + /// The target game version. + /// Cancellation token. + /// The result of the import operation. + Task ImportFromStreamAsync( + Stream stream, + string fileName, + GameType targetVersion, + CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapPackService.cs b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapPackService.cs new file mode 100644 index 000000000..597f741ee --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapPackService.cs @@ -0,0 +1,83 @@ +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.MapManager; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Core.Interfaces.Tools.MapManager; + +/// +/// Manages MapPacks - collections of maps associated with profiles. +/// +public interface IMapPackService +{ + /// + /// Creates a new MapPack from selected maps. + /// + /// The name of the MapPack. + /// Optional profile ID to associate with. + /// List of map file paths to include. + /// The created MapPack. + Task CreateMapPackAsync(string name, Guid? profileId, IEnumerable mapFilePaths); + + /// + /// Creates a new MapPack manifest using the Content Addressable Storage system. + /// + /// The name of the MapPack. + /// The target game. + /// The maps to include. + /// Progress repoter. + /// Cancellation token. + /// The operation result with the created manifest. + Task> CreateCasMapPackAsync( + string name, + GameType targetGame, + IEnumerable selectedMaps, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Gets all available MapPacks. + /// + /// List of all MapPacks. + Task> GetAllMapPacksAsync(); + + /// + /// Gets MapPacks associated with a specific profile. + /// + /// The profile ID. + /// List of MapPacks for the profile. + Task> GetMapPacksForProfileAsync(Guid profileId); + + /// + /// Loads a MapPack by copying its maps to the game directory. + /// + /// The MapPack ID. + /// True if successful. + Task LoadMapPackAsync(ManifestId mapPackId); + + /// + /// Unloads a MapPack by removing its maps from the game directory. + /// + /// The MapPack ID. + /// True if successful. + Task UnloadMapPackAsync(ManifestId mapPackId); + + /// + /// Deletes a MapPack. + /// + /// The MapPack ID. + /// True if successful. + Task DeleteMapPackAsync(ManifestId mapPackId); + + /// + /// Updates an existing MapPack. + /// + /// The updated MapPack. + /// True if successful. + Task UpdateMapPackAsync(MapPack mapPack); +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs deleted file mode 100644 index 2e7ed43e7..000000000 --- a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUploadRateLimitService.cs +++ /dev/null @@ -1,55 +0,0 @@ -using GenHub.Core.Constants; -using GenHub.Core.Models.Tools.ReplayManager; - -namespace GenHub.Core.Interfaces.Tools.ReplayManager; - -/// -/// Manages upload rate limiting to prevent spam. -/// -public interface IUploadRateLimitService -{ - /// - /// Maximum upload size per week in bytes (10 MB). - /// - public const long MaxWeeklyUploadBytes = ReplayManagerConstants.MaxWeeklyUploadBytes; - - /// - /// Checks if an upload of the specified size is allowed. - /// - /// The size of the file to upload in bytes. - /// True if the upload is allowed, false if it would exceed the weekly limit. - Task CanUploadAsync(long fileSizeBytes); - - /// - /// Records a successful upload. - /// - /// The size of the uploaded file in bytes. - /// The public URL of the uploaded file. - /// The name of the uploaded file. - void RecordUpload(long fileSizeBytes, string url, string fileName); - - /// - /// Gets the current usage information. - /// - /// A containing the current usage information. - Task GetUsageInfoAsync(); - - /// - /// Gets the history of uploads. - /// - /// A list of upload history items. - Task> GetUploadHistoryAsync(); - - /// - /// Removes a specific upload history item by URL. - /// - /// The URL of the upload to remove. - /// A task representing the asynchronous operation. - Task RemoveHistoryItemAsync(string url); - - /// - /// Clears all upload history. - /// - /// A task representing the asynchronous operation. - Task ClearHistoryAsync(); -} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs similarity index 84% rename from GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs rename to GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs index 797e6123d..d2b457c3b 100644 --- a/GenHub/GenHub.Core/Models/Tools/ReplayManager/UploadHistoryItem.cs +++ b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs @@ -1,4 +1,6 @@ -namespace GenHub.Core.Models.Tools.ReplayManager; +using System; + +namespace GenHub.Core.Models.Common; /// /// Represents a single item in the upload history. @@ -8,7 +10,7 @@ namespace GenHub.Core.Models.Tools.ReplayManager; /// The public URL of the upload. /// The name of the uploaded file. public record UploadHistoryItem( - System.DateTime Timestamp, + DateTime Timestamp, long SizeBytes, string Url, string FileName); diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs b/GenHub/GenHub.Core/Models/Common/UsageInfo.cs similarity index 53% rename from GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs rename to GenHub/GenHub.Core/Models/Common/UsageInfo.cs index 601babec0..27053be1a 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/UsageInfo.cs +++ b/GenHub/GenHub.Core/Models/Common/UsageInfo.cs @@ -1,12 +1,11 @@ -using GenHub.Core.Models.Enums; -using GenHub.Core.Models.Tools.ReplayManager; +using System; -namespace GenHub.Core.Interfaces.Tools.ReplayManager; +namespace GenHub.Core.Models.Common; /// /// Represents usage information for upload limits. /// -/// The number of bytes used in the current week. -/// The maximum allowed bytes per week. +/// The number of bytes used in the current period. +/// The maximum allowed bytes per period. /// The date and time when the usage resets. -public readonly record struct UsageInfo(long UsedBytes, long LimitBytes, DateTime ResetDate); \ No newline at end of file +public readonly record struct UsageInfo(long UsedBytes, long LimitBytes, DateTime ResetDate); diff --git a/GenHub/GenHub.Core/Models/Tools/MapManager/ImportResult.cs b/GenHub/GenHub.Core/Models/Tools/MapManager/ImportResult.cs new file mode 100644 index 000000000..1f3216aff --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/MapManager/ImportResult.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace GenHub.Core.Models.Tools.MapManager; + +/// +/// Result of a map import operation. +/// +public sealed class ImportResult +{ + /// + /// Gets or sets a value indicating whether the import was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the number of files imported. + /// + public int FilesImported { get; set; } + + /// + /// Gets or sets the list of error messages. + /// + public List Errors { get; set; } = []; + + /// + /// Gets the list of imported map files. + /// + public List ImportedMaps { get; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Tools/MapManager/MapFile.cs b/GenHub/GenHub.Core/Models/Tools/MapManager/MapFile.cs new file mode 100644 index 000000000..f17fcc615 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/MapManager/MapFile.cs @@ -0,0 +1,102 @@ +using Avalonia.Media.Imaging; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Enums; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace GenHub.Core.Models.Tools.MapManager; + +/// +/// Represents a map file with its metadata and associated assets. +/// +public class MapFile : INotifyPropertyChanged +{ + private Bitmap? _thumbnailBitmap; + + /// + /// Event for property change notifications. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Gets or sets the file name of the map. + /// + public required string FileName { get; set; } + + /// + /// Gets or sets the full path to the map file. + /// + public required string FullPath { get; set; } + + /// + /// Gets or sets the size of the map file in bytes (includes all assets if directory-based). + /// + public required long SizeBytes { get; set; } + + /// + /// Gets or sets the game type (Generals or Zero Hour). + /// + public required GameType GameType { get; set; } + + /// + /// Gets or sets the last modified timestamp. + /// + public required DateTime LastModified { get; set; } + + /// + /// Gets or sets the directory name containing this map (null for root-level maps). + /// + public string? DirectoryName { get; set; } + + /// + /// Gets or sets a value indicating whether this map is stored in a directory with assets. + /// All maps should be directory-based after migration. + /// + public bool IsDirectory { get; set; } + + /// + /// Gets or sets the list of asset file paths associated with this map (.tga, .ini, .str, .txt). + /// + public List AssetFiles { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the map directory is expanded in the UI. + /// + public bool IsExpanded { get; set; } + + /// + /// Gets or sets the display name for this map (parsed from file or directory). + /// + public string? DisplayName { get; set; } + + /// + /// Gets or sets the path to the thumbnail image file (.tga). + /// + public string? ThumbnailPath { get; set; } + + /// + /// Gets or sets the cached thumbnail bitmap for UI display. + /// + public Bitmap? ThumbnailBitmap + { + get => _thumbnailBitmap; + set + { + if (_thumbnailBitmap != value) + { + _thumbnailBitmap = value; + OnPropertyChanged(); + } + } + } + + /// + /// Notifies listeners that a property value has changed. + /// + /// Name of the property. + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Tools/MapManager/MapPack.cs b/GenHub/GenHub.Core/Models/Tools/MapManager/MapPack.cs new file mode 100644 index 000000000..d2cb95215 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/MapManager/MapPack.cs @@ -0,0 +1,46 @@ +using GenHub.Core.Models.Manifest; +using System; +using System.Collections.Generic; + +namespace GenHub.Core.Models.Tools.MapManager; + +/// +/// Represents a collection of maps that can be loaded/unloaded for a profile. +/// +public sealed class MapPack +{ + /// + /// Gets or sets the unique identifier for this MapPack. + /// + public ManifestId Id { get; set; } + + /// + /// Gets or sets the name of the MapPack. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the description of the MapPack. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the profile ID this MapPack is associated with. + /// + public Guid? ProfileId { get; set; } + + /// + /// Gets or sets the list of map file paths included in this pack. + /// + public List MapFilePaths { get; set; } = []; + + /// + /// Gets or sets the creation date. + /// + public DateTime CreatedDate { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets a value indicating whether this MapPack is currently loaded. + /// + public bool IsLoaded { get; set; } +} diff --git a/GenHub/GenHub.Core/Models/Tools/MapManager/MapSource.cs b/GenHub/GenHub.Core/Models/Tools/MapManager/MapSource.cs new file mode 100644 index 000000000..3a64c0bbe --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/MapManager/MapSource.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Tools.MapManager; + +/// +/// Identifies the source of a map URL. +/// +public enum MapSource +{ + /// + /// Unknown source. + /// + Unknown, + + /// + /// UploadThing file hosting. + /// + UploadThing, + + /// + /// Direct link to a .map or .zip file. + /// + DirectLink, +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs index d93f30b08..7b3064444 100644 --- a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Enums; namespace GenHub.Core.Models.Tools.ReplayManager; @@ -5,7 +6,7 @@ namespace GenHub.Core.Models.Tools.ReplayManager; /// /// Represents a replay file on disk. /// -public sealed class ReplayFile +public sealed class ReplayFile : IExportableFile { /// /// Gets or sets the full path to the replay file. diff --git a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs new file mode 100644 index 000000000..30733b0c5 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs @@ -0,0 +1,32 @@ +namespace GenHub.Core.Models.Tools; + +/// +/// Record of an upload for rate limiting purposes. +/// +public sealed class UploadRecord +{ + /// + /// Gets or sets the timestamp of the upload. + /// + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the size of the upload in bytes. + /// + public long SizeBytes { get; set; } + + /// + /// Gets or sets the public URL of the upload. + /// + public string? Url { get; set; } + + /// + /// Gets or sets the name of the uploaded file. + /// + public string? FileName { get; set; } + + /// + /// Gets or sets a value indicating whether this item is queued for deletion. + /// + public bool IsPendingDeletion { get; set; } +} \ No newline at end of file diff --git a/GenHub/GenHub.Linux/GameInstallations/LutrisInstallation.cs b/GenHub/GenHub.Linux/GameInstallations/LutrisInstallation.cs index 3dffb734d..4ab988af3 100644 --- a/GenHub/GenHub.Linux/GameInstallations/LutrisInstallation.cs +++ b/GenHub/GenHub.Linux/GameInstallations/LutrisInstallation.cs @@ -17,10 +17,13 @@ namespace GenHub.Linux.GameInstallations; /// /// Lutris installation detector and manager for Linux. /// -public class LutrisInstallation(ILogger? logger = null) : IGameInstallation +public partial class LutrisInstallation(ILogger? logger = null) : IGameInstallation { - private readonly Regex lutrisVersionRegex = new Regex(@"^lutris-([\d\.]*)$"); - private readonly Regex lutrisGamesRegex = new Regex(@"\[[\s\S]*\]"); + [GeneratedRegex(@"^lutris-([\d\.]*)$")] + private static partial Regex LutrisVersionRegex(); + + [GeneratedRegex(@"\[[\s\S]*\]")] + private static partial Regex LutrisGamesRegex(); /// /// Initializes a new instance of the class. @@ -58,7 +61,7 @@ public LutrisInstallation(bool fetch, ILogger? logger = null public string ZeroHourPath { get; private set; } = string.Empty; /// - public List AvailableGameClients { get; private set; } = new(); + public List AvailableGameClients { get; private set; } = []; /// /// Gets a value indicating whether Lutris is installed successfully. @@ -149,18 +152,20 @@ public void PopulateGameClients(IEnumerable clients) AvailableGameClients.AddRange(clients); } - private bool TryLutris(string installationPath, out string lutrisVersion) + private static bool TryLutris(string installationPath, out string lutrisVersion) { lutrisVersion = string.Empty; - var process = new Process(); - process.StartInfo = new ProcessStartInfo() + var process = new Process { - WindowStyle = ProcessWindowStyle.Hidden, - FileName = installationPath, - Arguments = "-v", - RedirectStandardOutput = true, - RedirectStandardError = false, - WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + FileName = installationPath, + Arguments = "-v", + RedirectStandardOutput = true, + RedirectStandardError = false, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + }, }; if (!process.Start()) @@ -173,8 +178,8 @@ private bool TryLutris(string installationPath, out string lutrisVersion) continue; // check for lutris, if installed version is printed - var match = lutrisVersionRegex.Match(item); - if (match is { Success: true, Groups.Count: > 1 }) + var match = LutrisVersionRegex().Match(item); + if (match.Success && match.Groups.Count > 1) lutrisVersion = match.Groups[1].Value; return true; @@ -183,25 +188,27 @@ private bool TryLutris(string installationPath, out string lutrisVersion) return false; } - private bool TryLutrisHasZH(string installationPath, out string directory) + private static bool TryLutrisHasZH(string installationPath, out string directory) { directory = string.Empty; - var process = new Process(); - process.StartInfo = new ProcessStartInfo() + var process = new Process { - WindowStyle = ProcessWindowStyle.Hidden, - FileName = installationPath, - ArgumentList = { "-l", "-j" }, - RedirectStandardOutput = true, - RedirectStandardError = false, - WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + FileName = installationPath, + ArgumentList = { "-l", "-j" }, + RedirectStandardOutput = true, + RedirectStandardError = false, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + }, }; if (!process.Start()) return false; process.WaitForExit(); var output = process.StandardOutput.ReadToEnd(); - var jsonOutput = lutrisGamesRegex.Match(output).Value; + var jsonOutput = LutrisGamesRegex().Match(output).Value; // check for games on lutris, it's a json array var jsonOutputParsed = JsonSerializer.Deserialize>(jsonOutput); @@ -219,4 +226,4 @@ private bool TryLutrisHasZH(string installationPath, out string directory) directory = gameListFiltered.Directory; return true; } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/FileSizeConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/FileSizeConverterTests.cs index 91f39b5fd..18527039d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/FileSizeConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/FileSizeConverterTests.cs @@ -116,7 +116,7 @@ public void Convert_HandlesNonLongValues() [Fact] public void ConvertBack_ThrowsNotImplementedException() { - // Act & Assert + // Use the specific exception type to ensure the test is precise Assert.Throws(() => _converter.ConvertBack("1 KB", typeof(long), null, CultureInfo.InvariantCulture)); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index 9194795dd..07630fd6f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -6,7 +6,7 @@ using GenHub.Core.Models.Results; using GenHub.Core.Models.Results.Content; using GenHub.Core.Models.Validation; -using GenHub.Features.Content.Services.ContentProviders; +using GenHub.Features.Content.Services.GitHub; using Microsoft.Extensions.Logging; using Moq; @@ -132,4 +132,4 @@ public async Task PrepareContentAsync_CallsDelivererAndValidator_Successfully() _delivererMock.Verify(d => d.CanDeliver(It.IsAny()), Times.AtLeastOnce()); _delivererMock.Verify(d => d.DeliverContentAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once()); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs index fa4898b8d..f4fc7f283 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs @@ -99,7 +99,7 @@ public TestFileSystemValidator(ILogger logger) /// Cancellation token. /// List of validation issues. public new Task> ValidateDirectoriesAsync(string basePath, IEnumerable requiredDirectories, CancellationToken cancellationToken) - => base.ValidateDirectoriesAsync(basePath, requiredDirectories, cancellationToken); + => FileSystemValidator.ValidateDirectoriesAsync(basePath, requiredDirectories, cancellationToken); /// /// Exposes base ValidateFilesAsync for testing. diff --git a/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs b/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs index e96b6bd3e..1b0398ffa 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs @@ -51,7 +51,7 @@ public SimpleHttpServer(string nupkgPath, string releasesPath, int port, ILogger Port = port; // Generate a random secret token to prevent other local processes from hijacking the server - _secretToken = Guid.NewGuid().ToString("N").Substring(0, SecretTokenLength); + _secretToken = Guid.NewGuid().ToString("N")[..SecretTokenLength]; _listener = new HttpListener(); _listener.Prefixes.Add($"http://localhost:{Port}/{_secretToken}/"); @@ -203,4 +203,4 @@ private async Task ProcessRequestAsync(HttpListenerContext context) _logger.LogError(ex, "Error processing HTTP request"); } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs index df35b6f93..5fd0a2bf2 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs @@ -53,7 +53,7 @@ public static async Task ShowAsync(Window parent) /// A representing the asynchronous operation. public async Task InitializeAsync() { - if (DataContext is UpdateNotificationViewModel viewModel) + if (DataContext is UpdateNotificationViewModel) { // Add any initialization logic here await Task.CompletedTask; @@ -81,4 +81,4 @@ private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e) { BeginMoveDrag(e); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 251d77865..653528237 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs @@ -107,7 +107,7 @@ public async Task> DeliverContentAsync( downloadService, configProvider); - if (!int.TryParse(packageManifest.Version, out int manifestVersionInt)) + if (!int.TryParse(packageManifest.Version, out var manifestVersionInt)) { logger.LogError("Invalid manifest version format: {Version}", packageManifest.Version); return OperationResult.CreateFailure("Invalid manifest version format"); @@ -166,7 +166,7 @@ await manifestBuilder.AddContentAddressableFileAsync( } // Add required directories - manifestBuilder.AddRequiredDirectories([.. packageManifest.RequiredDirectories]); + manifestBuilder.AddRequiredDirectories([..packageManifest.RequiredDirectories]); // Add installation instructions if present if (packageManifest.InstallationInstructions != null) diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs index 96c9e5989..578acc069 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs @@ -161,5 +161,7 @@ private void InitializeContentDirectories() { var userDefinedDirs = _configurationProvider.GetContentDirectories(); _contentDirectories.AddRange(userDefinedDirs.Where(Directory.Exists)); + + _logger.LogInformation("FileSystemDiscoverer initialized with {Count} directories", _contentDirectories.Count); } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs index 13a835985..4c166bb66 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs @@ -16,35 +16,22 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// CNC Labs content provider that orchestrates discovery→resolution→delivery pipeline /// for CNC Labs-hosted content. /// -public class CNCLabsContentProvider : BaseContentProvider +public class CNCLabsContentProvider( + IEnumerable discoverers, + IEnumerable resolvers, + IEnumerable deliverers, + ILogger logger, + IContentValidator contentValidator) + : BaseContentProvider(contentValidator, logger) { - private readonly IContentDiscoverer _cncLabsDiscoverer; - private readonly IContentResolver _cncLabsResolver; - private readonly IContentDeliverer _httpDeliverer; - - /// - /// Initializes a new instance of the class. - /// - /// Available content discoverers. - /// Available content resolvers. - /// Available content deliverers. - /// The logger instance. - /// The content validator. - public CNCLabsContentProvider( - IEnumerable discoverers, - IEnumerable resolvers, - IEnumerable deliverers, - ILogger logger, - IContentValidator contentValidator) - : base(contentValidator, logger) - { - _cncLabsDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.CNCLabsDiscoverer, StringComparison.OrdinalIgnoreCase) == true) - ?? throw new ArgumentException("CNC Labs discoverer not found", nameof(discoverers)); - _cncLabsResolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(ContentSourceNames.CNCLabsResolverId, StringComparison.OrdinalIgnoreCase) == true) - ?? throw new ArgumentException("CNC Labs resolver not found", nameof(resolvers)); - _httpDeliverer = deliverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.HttpDeliverer, StringComparison.OrdinalIgnoreCase) == true) - ?? throw new ArgumentException("HTTP deliverer not found", nameof(deliverers)); - } + private readonly IContentDiscoverer _cncLabsDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.CNCLabsDiscoverer, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new ArgumentException("CNC Labs discoverer not found", nameof(discoverers)); + + private readonly IContentResolver _cncLabsResolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(ContentSourceNames.CNCLabsResolverId, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new ArgumentException("CNC Labs resolver not found", nameof(resolvers)); + + private readonly IContentDeliverer _httpDeliverer = deliverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.HttpDeliverer, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new ArgumentException("HTTP deliverer not found", nameof(deliverers)); /// public override string SourceName => "CNC Labs"; diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs index 6b147d184..16300c566 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs @@ -18,39 +18,25 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// Local file system provider that uses FileSystemDiscoverer for content discovery. /// This eliminates duplication with ManifestDiscoveryService. /// -public class LocalFileSystemContentProvider : BaseContentProvider +public class LocalFileSystemContentProvider( + IEnumerable discoverers, + IEnumerable resolvers, + IEnumerable deliverers, + ILogger logger, + IContentValidator contentValidator, + IConfigurationProviderService configurationProvider) + : BaseContentProvider(contentValidator, logger) { - private readonly IContentDiscoverer _fileSystemDiscoverer; - private readonly IContentResolver _localResolver; - private readonly IContentDeliverer _fileSystemDeliverer; - private readonly IConfigurationProviderService _configurationProvider; - - /// - /// Initializes a new instance of the class. - /// - /// Available content discoverers. - /// Available content resolvers. - /// Available content deliverers. - /// The logger instance. - /// The content validator. - /// The configuration provider. - public LocalFileSystemContentProvider( - IEnumerable discoverers, - IEnumerable resolvers, - IEnumerable deliverers, - ILogger logger, - IContentValidator contentValidator, - IConfigurationProviderService configurationProvider) - : base(contentValidator, logger) - { - _fileSystemDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDiscoverer, StringComparison.OrdinalIgnoreCase) == true) - ?? throw new InvalidOperationException("No FileSystem discoverer found"); - _localResolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(ContentSourceNames.LocalResolverId, StringComparison.OrdinalIgnoreCase) == true) - ?? throw new InvalidOperationException("No Local resolver found"); - _fileSystemDeliverer = deliverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDeliverer, StringComparison.OrdinalIgnoreCase) == true) - ?? throw new InvalidOperationException("No FileSystem deliverer found"); - _configurationProvider = configurationProvider; - } + private readonly IContentDiscoverer _fileSystemDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDiscoverer, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new InvalidOperationException("No FileSystem discoverer found"); + + private readonly IContentResolver _localResolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(ContentSourceNames.LocalResolverId, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new InvalidOperationException("No Local resolver found"); + + private readonly IContentDeliverer _fileSystemDeliverer = deliverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDeliverer, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new InvalidOperationException("No FileSystem deliverer found"); + + private readonly IConfigurationProviderService _configurationProvider = configurationProvider; /// public override string SourceName => "LocalFileSystem"; diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs index 505e0b2a0..8595f0d79 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs @@ -16,40 +16,22 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// ModDB content provider that orchestrates discovery→resolution→delivery pipeline /// for ModDB-hosted content. /// -public class ModDBContentProvider : BaseContentProvider +public class ModDBContentProvider( + IEnumerable discoverers, + IEnumerable resolvers, + IEnumerable deliverers, + ILogger logger, + IContentValidator contentValidator) + : BaseContentProvider(contentValidator, logger) { - private readonly IContentDiscoverer _moddbDiscoverer; - private readonly IContentResolver _moddbResolver; - private readonly IContentDeliverer _httpDeliverer; - - /// - /// Initializes a new instance of the class. - /// - /// Available content discoverers. - /// Available content resolvers. - /// Available content deliverers. - /// The logger instance. - /// The content validator. - public ModDBContentProvider( - IEnumerable discoverers, - IEnumerable resolvers, - IEnumerable deliverers, - ILogger logger, - IContentValidator contentValidator) - : base(contentValidator, logger) - { - _moddbDiscoverer = discoverers?.FirstOrDefault(d => - string.Equals(d.SourceName, ContentSourceNames.ModDBDiscoverer, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException("ModDB discoverer not found"); + private readonly IContentDiscoverer _moddbDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.ModDBDiscoverer, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new ArgumentException("ModDB discoverer not found", nameof(discoverers)); - _moddbResolver = resolvers?.FirstOrDefault(r => - string.Equals(r.ResolverId, ContentSourceNames.ModDBResolverId, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException("ModDB resolver not found"); + private readonly IContentResolver _moddbResolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(ContentSourceNames.ModDBResolverId, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new ArgumentException("ModDB resolver not found", nameof(resolvers)); - _httpDeliverer = deliverers?.FirstOrDefault(d => - string.Equals(d.SourceName, ContentSourceNames.HttpDeliverer, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException("HTTP deliverer not found"); - } + private readonly IContentDeliverer _httpDeliverer = deliverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.HttpDeliverer, StringComparison.OrdinalIgnoreCase) == true) + ?? throw new ArgumentException("HTTP deliverer not found", nameof(deliverers)); /// public override string SourceName => "ModDB"; diff --git a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs index d17622263..f27b426b8 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs @@ -100,7 +100,12 @@ public async Task> StoreContentAsync( // Check if source directory is on a potentially invalid or removable drive bool isInvalidDrive = IsInvalidOrRemovableDrive(sourceDirectory); - if (isInvalidDrive) + + // MapPacks and other local content might be created in temp directories on "invalid" drives (e.g. RAM disks) + // We should allow storage if it's a MapPack to ensure it persists after temp cleanup. + bool forceStorage = manifest.ContentType == ContentType.MapPack; + + if (isInvalidDrive && !forceStorage) { _logger.LogWarning("Source directory {SourceDirectory} is on an invalid or removable drive, storing metadata only", sourceDirectory); return await StoreManifestOnlyAsync(manifest, cancellationToken); @@ -334,6 +339,12 @@ private static bool RequiresPhysicalStorage(ContentManifest manifest) return false; } + // MapPacks created locally MUST be stored in CAS because the source (temp dir) will be deleted + if (manifest.ContentType == ContentType.MapPack) + { + return true; + } + // GameClient content typically references external installations - no storage needed (old behavior) // Only store physically for GitHub content that requires it if (manifest.ContentType == ContentType.GameClient) diff --git a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs index 1a9443c40..92711ffcc 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs @@ -61,10 +61,7 @@ public async Task ValidateAllAsync(string contentPath, Content throw new ArgumentException("Content path cannot be null or empty.", nameof(contentPath)); } - if (manifest == null) - { - throw new ArgumentNullException(nameof(manifest)); - } + ArgumentNullException.ThrowIfNull(manifest); var issues = new List(); @@ -104,10 +101,7 @@ public async Task ValidateContentIntegrityAsync(string content throw new ArgumentException("Content path cannot be null or empty.", nameof(contentPath)); } - if (manifest == null) - { - throw new ArgumentNullException(nameof(manifest)); - } + ArgumentNullException.ThrowIfNull(manifest); var issues = new List(); var totalFiles = manifest.Files.Count; @@ -192,10 +186,7 @@ public async Task DetectExtraneousFilesAsync(string contentPat throw new ArgumentException("Content path cannot be null or empty.", nameof(contentPath)); } - if (manifest == null) - { - throw new ArgumentNullException(nameof(manifest)); - } + ArgumentNullException.ThrowIfNull(manifest); var issues = new List(); @@ -280,10 +271,7 @@ await Task.Run( private static List ValidateManifestStructure(ContentManifest manifest) { - if (manifest == null) - { - throw new ArgumentNullException(nameof(manifest)); - } + ArgumentNullException.ThrowIfNull(manifest); var issues = new List(); diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs index 17a23f2c2..4f9358e22 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs @@ -10,21 +10,22 @@ using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; +using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; -namespace GenHub.Features.Content.Services.ContentProviders; +namespace GenHub.Features.Content.Services.GitHub; /// /// GitHub content provider that orchestrates discovery→resolution→delivery pipeline /// for GitHub-hosted content (releases, repositories). /// public class GitHubContentProvider( - IEnumerable discoverers, - IEnumerable resolvers, - IEnumerable deliverers, - ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IEnumerable discoverers, + IEnumerable resolvers, + IEnumerable deliverers, + ILogger logger, + IContentValidator contentValidator) + : BaseContentProvider(contentValidator, logger) { /// public override string SourceName => "GitHub"; @@ -144,4 +145,4 @@ protected override async Task> PrepareContentIn return OperationResult.CreateFailure($"GitHub content preparation failed: {ex.Message}"); } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs index 1dafe0a6d..121706549 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -22,19 +22,34 @@ namespace GenHub.Features.Content.Services.GitHub; /// /// Resolves a discovered GitHub release into a full ContentManifest. /// -public partial class GitHubResolver( - IGitHubApiClient gitHubApiClient, - IServiceProvider serviceProvider, - ILogger logger) : IContentResolver +public partial class GitHubResolver : IContentResolver { + private readonly IGitHubApiClient _gitHubApiClient; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// API client for GitHub interactions. + /// Service provider to resolve factories and helpers. + /// Logger instance. + public GitHubResolver( + IGitHubApiClient gitHubApiClient, + IServiceProvider serviceProvider, + ILogger logger) + { + _gitHubApiClient = gitHubApiClient ?? throw new ArgumentNullException(nameof(gitHubApiClient)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + // Regex breakdown: // ^https://github\.com/ // (?[^/]+) -> owner // /(?[^/]+) -> repo // (?:/releases/tag/(?[^/]+))? -> optional tag - [GeneratedRegex( - ApiConstants.GitHubUrlRegexPattern, - RegexOptions.Compiled)] + [GeneratedRegex(ApiConstants.GitHubUrlRegexPattern, RegexOptions.Compiled)] private static partial Regex GitHubUrlRegex(); /// @@ -66,7 +81,7 @@ public async Task> ResolveAsync( // Check if this is a SINGLE ASSET selection (from multi-asset split) if (discoveredItem.ResolverMetadata.TryGetValue("asset-name", out var assetName)) { - logger.LogInformation( + _logger.LogInformation( "Resolving single asset: {AssetName} from {Owner}/{Repo}:{Tag}", assetName, owner, @@ -84,7 +99,7 @@ public async Task> ResolveAsync( // Check if this is a SINGLE RELEASE ASSET selection (legacy path) if (discoveredItem.Data is GitHubArtifact singleAsset && singleAsset.IsRelease) { - logger.LogInformation( + _logger.LogInformation( "Resolving single release asset: {AssetName} from {Owner}/{Repo}:{Tag}", singleAsset.Name, owner, @@ -95,14 +110,14 @@ public async Task> ResolveAsync( } // Otherwise, fetch the full release and include all assets - logger.LogInformation("Resolving full release: {Owner}/{Repo}:{Tag}", owner, repo, tag); + _logger.LogInformation("Resolving full release: {Owner}/{Repo}:{Tag}", owner, repo, tag); var release = string.IsNullOrEmpty(tag) - ? await gitHubApiClient.GetLatestReleaseAsync( + ? await _gitHubApiClient.GetLatestReleaseAsync( owner, repo, cancellationToken) - : await gitHubApiClient.GetReleaseByTagAsync( + : await _gitHubApiClient.GetReleaseByTagAsync( owner, repo, tag, @@ -130,7 +145,7 @@ public async Task> ResolveAsync( var publisherType = DeterminePublisherType(owner); // Create a new manifest builder for each resolve operation to ensure clean state - var manifestBuilder = serviceProvider.GetRequiredService(); + var manifestBuilder = _serviceProvider.GetRequiredService(); var manifest = manifestBuilder .WithBasicInfo( @@ -148,15 +163,15 @@ public async Task> ResolveAsync( changelogUrl: release.HtmlUrl ?? string.Empty) .WithInstallationInstructions(WorkspaceStrategy.HybridCopySymlink); - // Add files from GitHub assets + // Validate assets collection if (release.Assets == null || release.Assets.Count == 0) { - logger.LogWarning("No assets found for release {Owner}/{Repo}:{Tag}", owner, repo, release.TagName); + _logger.LogWarning("No assets found for release {Owner}/{Repo}:{Tag}", owner, repo, release.TagName); return OperationResult.CreateSuccess(manifest.Build()); } // Add files from GitHub assets - logger.LogInformation( + _logger.LogInformation( "Adding {AssetCount} assets from release {Owner}/{Repo}:{Tag}", release.Assets.Count, owner, @@ -165,7 +180,7 @@ public async Task> ResolveAsync( foreach (var asset in release.Assets) { - logger.LogDebug( + _logger.LogDebug( "Adding asset: {AssetName} ({AssetUrl})", asset.Name, asset.BrowserDownloadUrl); @@ -178,12 +193,12 @@ await manifest.AddRemoteFileAsync( } var builtManifest = manifest.Build(); - logger.LogInformation("GitHubResolver: Built manifest with ID: {ManifestId}", builtManifest.Id); + _logger.LogInformation("GitHubResolver: Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } catch (Exception ex) { - logger.LogError(ex, "Failed to resolve GitHub release for {ItemName}", discoveredItem.Name); + _logger.LogError(ex, "Failed to resolve GitHub release for {ItemName}", discoveredItem.Name); return OperationResult.CreateFailure($"Resolution failed: {ex.Message}"); } } @@ -202,10 +217,6 @@ private static string DeterminePublisherType(string owner) return "thesuperhackers"; } - // Future: Add more publisher detection logic - // if (repo.Contains("generalsonline", StringComparison.OrdinalIgnoreCase)) - // return "generalsonline"; - // Default to generic GitHub publisher return "github"; } @@ -270,6 +281,11 @@ private static string ExtractAssetVariant(string assetName) return nameWithoutExt.Replace("_", " ").Replace("-", " ").Trim(); } + private static (ContentType Type, bool IsInferred) InferContentType(string repo, string? releaseName) + { + return GitHubInferenceHelper.InferContentType(repo, releaseName); + } + private static GitHubUrlParseResult ParseGitHubUrl(string url) { if (string.IsNullOrWhiteSpace(url)) @@ -335,7 +351,7 @@ private async Task> ResolveSingleAssetAsync( var publisherType = DeterminePublisherType(owner); // Create a new manifest builder for each resolve operation to ensure clean state - var manifestBuilder = serviceProvider.GetRequiredService(); + var manifestBuilder = _serviceProvider.GetRequiredService(); var manifest = manifestBuilder .WithBasicInfo( @@ -360,15 +376,15 @@ await manifest.AddRemoteFileAsync( ContentSourceType.RemoteDownload, isExecutable: GitHubInferenceHelper.IsExecutableFile(asset.Name)); - logger.LogInformation("Successfully resolved single release asset: {AssetName}", asset.Name); + _logger.LogInformation("Successfully resolved single release asset: {AssetName}", asset.Name); var builtManifest = manifest.Build(); - logger.LogInformation("GitHubResolver (Single Asset): Built manifest with ID: {ManifestId}", builtManifest.Id); + _logger.LogInformation("GitHubResolver (Single Asset): Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } catch (Exception ex) { - logger.LogError(ex, "Failed to resolve single release asset: {AssetName}", asset.Name); + _logger.LogError(ex, "Failed to resolve single release asset: {AssetName}", asset.Name); return OperationResult.CreateFailure($"Failed to resolve asset: {ex.Message}"); } } diff --git a/GenHub/GenHub/Features/Content/Services/Helpers/CNCLabsHelper.cs b/GenHub/GenHub/Features/Content/Services/Helpers/CNCLabsHelper.cs index 8e953b915..0d9e50142 100644 --- a/GenHub/GenHub/Features/Content/Services/Helpers/CNCLabsHelper.cs +++ b/GenHub/GenHub/Features/Content/Services/Helpers/CNCLabsHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using System.Linq; using System.Net; @@ -301,4 +301,4 @@ private static bool SupportsFilters(ContentType? contentType) // Only Maps and Missions support tag/player filtering return contentType == ContentType.Map || contentType == ContentType.Mission; } -} +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Content/Services/MemoryDynamicContentCache.cs b/GenHub/GenHub/Features/Content/Services/MemoryDynamicContentCache.cs index 639018f33..0fce64e6d 100644 --- a/GenHub/GenHub/Features/Content/Services/MemoryDynamicContentCache.cs +++ b/GenHub/GenHub/Features/Content/Services/MemoryDynamicContentCache.cs @@ -15,7 +15,7 @@ namespace GenHub.Features.Content.Services; /// public class MemoryDynamicContentCache(IMemoryCache memoryCache) : IDynamicContentCache { - private static readonly List _keys = new(); + private static readonly List _keys = []; private readonly IMemoryCache _memoryCache = memoryCache; /// @@ -59,7 +59,7 @@ public Task InvalidateAsync(string pattern, CancellationToken cancellationToken lock (_keys) { - keysToRemove = _keys.Where(k => regex.IsMatch(k)).ToList(); + keysToRemove = [.._keys.Where(k => regex.IsMatch(k))]; } foreach (var key in keysToRemove) diff --git a/GenHub/GenHub/Features/GameClients/GameClientDetectionOrchestrator.cs b/GenHub/GenHub/Features/GameClients/GameClientDetectionOrchestrator.cs index 5457959dc..847828b1b 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientDetectionOrchestrator.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientDetectionOrchestrator.cs @@ -132,6 +132,6 @@ public async Task> GetDetectedClientsAsync( { logger.LogDebug("Getting detected clients"); var result = await DetectAllClientsAsync(cancellationToken); - return result.Success ? [.. result.Items] : []; + return result.Success ? [..result.Items] : []; } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs index 2ff7f5b6b..7c910c37c 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs @@ -865,4 +865,4 @@ private List CreateGameClientsFromManifests( return gameClients; } -} +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/GameInstallations/GameInstallationDetectionOrchestrator.cs b/GenHub/GenHub/Features/GameInstallations/GameInstallationDetectionOrchestrator.cs index 0c422ca88..e2bd4d8ea 100644 --- a/GenHub/GenHub/Features/GameInstallations/GameInstallationDetectionOrchestrator.cs +++ b/GenHub/GenHub/Features/GameInstallations/GameInstallationDetectionOrchestrator.cs @@ -72,7 +72,7 @@ public async Task> DetectAllInstallationsAsync detectorCount, sw.ElapsedMilliseconds); - return errors.Any() + return errors.Count > 0 ? DetectionResult.CreateFailure(string.Join("; ", errors)) : DetectionResult.CreateSuccess(allGameInstallations, sw.Elapsed); } @@ -82,6 +82,6 @@ public async Task> GetDetectedInstallationsAsync( CancellationToken cancellationToken = default) { var result = await DetectAllInstallationsAsync(cancellationToken); - return result.Success ? result.Items.ToList() : new List(); + return result.Success ? [..result.Items] : []; } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs b/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs index 39cde030c..7cffd1500 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs @@ -89,7 +89,7 @@ public async Task> ResolveDependenciesAsync(IEnumerable } } - if (missingContentIds.Any()) + if (missingContentIds.Count > 0) { throw new InvalidOperationException($"Missing or invalid content IDs: {string.Join(", ", missingContentIds)}"); } @@ -181,16 +181,16 @@ public async Task ResolveDependenciesWithManifestsAs } } - if (missingContentIds.Any()) + if (missingContentIds.Count > 0) { return DependencyResolutionResult.CreateFailure($"Missing or invalid content IDs: {string.Join(", ", missingContentIds)}"); } - if (warnings.Any()) + if (warnings.Count > 0) { - return DependencyResolutionResult.CreateSuccessWithWarnings(resolvedIds.ToList(), resolvedManifests, missingContentIds, warnings); + return DependencyResolutionResult.CreateSuccessWithWarnings([..resolvedIds], resolvedManifests, missingContentIds, warnings); } - return DependencyResolutionResult.CreateSuccess(resolvedIds.ToList(), resolvedManifests, missingContentIds); + return DependencyResolutionResult.CreateSuccess([..resolvedIds], resolvedManifests, missingContentIds); } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileEditorFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileEditorFacade.cs index c05d36e83..61106556e 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileEditorFacade.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileEditorFacade.cs @@ -117,8 +117,8 @@ public async Task> UpdateProfileWithWorkspac return ProfileOperationResult.CreateFailure(string.Join(", ", resolutionResult.Errors)); } - workspaceConfig.Manifests = [.. resolutionResult.ResolvedManifests]; - profile.EnabledContentIds = [.. resolutionResult.ResolvedContentIds]; + workspaceConfig.Manifests = [..resolutionResult.ResolvedManifests]; + profile.EnabledContentIds = [..resolutionResult.ResolvedContentIds]; // Resolve source paths for all manifests var manifestSourcePaths = new Dictionary(); diff --git a/GenHub/GenHub/Features/GitHub/ViewModels/GitHubTokenDialogViewModel.cs b/GenHub/GenHub/Features/GitHub/ViewModels/GitHubTokenDialogViewModel.cs index 3fae0fa8a..f5e23c0fe 100644 --- a/GenHub/GenHub/Features/GitHub/ViewModels/GitHubTokenDialogViewModel.cs +++ b/GenHub/GenHub/Features/GitHub/ViewModels/GitHubTokenDialogViewModel.cs @@ -70,6 +70,7 @@ public void SetToken(string token) public void Dispose() { _secureToken?.Dispose(); + GC.SuppressFinalize(this); } /// diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index 4d112b082..fe24a3a1f 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -781,8 +781,15 @@ private static string NormalizePublisherName(string? input) /// /// The relative path of the file. /// The determined installation target. - private static ContentInstallTarget DetermineInstallTarget(string relativePath) + private ContentInstallTarget DetermineInstallTarget(string relativePath) { + // If this is a Map or MapPack, all files should go to the UserMapsDirectory + // to comply with userdata.md and ensure proper linking by IProfileContentLinker. + if (_manifest.ContentType == ContentType.Map || _manifest.ContentType == ContentType.MapPack) + { + return ContentInstallTarget.UserMapsDirectory; + } + var extension = Path.GetExtension(relativePath).ToLowerInvariant(); if (extension == ".map" || diff --git a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs index a5350b1bb..fbe52ec28 100644 --- a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs +++ b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs @@ -92,10 +92,7 @@ public void Show(NotificationMessage notification) return; } - if (notification == null) - { - throw new ArgumentNullException(nameof(notification)); - } + ArgumentNullException.ThrowIfNull(notification); logger.LogDebug( "Showing {Type} notification: {Title}", diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs index 95aa1dd81..2637c7758 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs @@ -20,7 +20,7 @@ public class NotificationManagerViewModel : ViewModelBase, IDisposable private readonly IDisposable _notificationSubscription; private readonly IDisposable _dismissSubscription; private readonly IDisposable _dismissAllSubscription; - private readonly object _lock = new object(); + private readonly object _lock = new(); private bool _disposed; /// @@ -43,7 +43,7 @@ public NotificationManagerViewModel( _logger = logger; _itemLogger = itemLogger; - ActiveNotifications = new ObservableCollection(); + ActiveNotifications = []; _notificationSubscription = _notificationService.Notifications.Subscribe(HandleNotificationReceived); _dismissSubscription = _notificationService.DismissRequests.Subscribe(HandleDismissRequest); diff --git a/GenHub/GenHub/Features/Tools/MapManager/MapManagerToolPlugin.cs b/GenHub/GenHub/Features/Tools/MapManager/MapManagerToolPlugin.cs new file mode 100644 index 000000000..9ef2f9c0e --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/MapManagerToolPlugin.cs @@ -0,0 +1,67 @@ +using Avalonia.Controls; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Tools; +using GenHub.Features.Tools.MapManager.ViewModels; +using GenHub.Features.Tools.MapManager.Views; +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace GenHub.Features.Tools.MapManager; + +/// +/// Tool plugin for Map Manager. +/// +public sealed class MapManagerToolPlugin : IToolPlugin +{ + private MapManagerView? _view; + private IServiceProvider? _serviceProvider; + + /// + public ToolMetadata Metadata => new() + { + Id = MapManagerConstants.ToolId, + Name = MapManagerConstants.ToolName, + Version = "1.0.0", + Author = AppConstants.AppName, + Description = MapManagerConstants.ToolDescription, + IconPath = "🗺️", + IsBundled = true, + Tags = ["Content Management"], + }; + + /// + public Control CreateControl() + { + if (_view == null && _serviceProvider != null) + { + var viewModel = _serviceProvider.GetRequiredService(); + _view = new MapManagerView { DataContext = viewModel }; + + // Initialize the ViewModel to load maps + _ = viewModel.InitializeAsync(); + } + + return _view ?? (Control)new TextBlock { Text = "Error loading Map Manager" }; + } + + /// + public void OnActivated(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + public void OnDeactivated() + { + // View and ViewModel state is preserved for now. + // Could call a reset or save method on ViewModel if needed. + } + + /// + public void Dispose() + { + _view = null; + _serviceProvider = null; + } +} diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs new file mode 100644 index 000000000..75cb4b591 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapDirectoryService.cs @@ -0,0 +1,383 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Infrastructure.Imaging; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.MapManager.Services; + +/// +/// Implementation of for managing map directories. +/// +public sealed class MapDirectoryService( + MapNameParser mapNameParser, + ILogger logger) : IMapDirectoryService +{ + private const string GeneralsMapFolder = MapManagerConstants.GeneralsDataDirectoryName; + private const string ZeroHourMapFolder = MapManagerConstants.ZeroHourDataDirectoryName; + private const string MapSubfolder = MapManagerConstants.MapsSubdirectoryName; + + /// + public string GetMapDirectory(GameType version) + { + var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var gameFolder = version == GameType.Generals ? GeneralsMapFolder : ZeroHourMapFolder; + return Path.Combine(documentsPath, gameFolder, MapSubfolder); + } + + /// + public void EnsureDirectoryExists(GameType version) + { + var directory = GetMapDirectory(version); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + logger.LogInformation("Created map directory: {Directory}", directory); + } + } + + /// + public async Task> GetMapsAsync(GameType version, CancellationToken ct = default) + { + var directory = GetMapDirectory(version); + EnsureDirectoryExists(version); + + return await Task.Run( + () => + { + var mapFiles = new List(); + var processedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Find all .map files recursively + var allMapFiles = Directory.GetFiles(directory, MapManagerConstants.MapFilePattern, SearchOption.AllDirectories); + + foreach (var mapFilePath in allMapFiles) + { + try + { + var fileInfo = new FileInfo(mapFilePath); + var parentDir = fileInfo.Directory?.FullName ?? string.Empty; + + // Check if this .map file is in a subdirectory + var isInSubdirectory = !string.Equals(parentDir, directory, StringComparison.OrdinalIgnoreCase); + + if (isInSubdirectory && !processedDirectories.Contains(parentDir)) + { + // This is a directory-based map - process the entire directory + processedDirectories.Add(parentDir); + + var dirInfo = new DirectoryInfo(parentDir); + var allFilesInDir = dirInfo.GetFiles(); + var mapFilesInDir = allFilesInDir.Where(f => f.Extension.Equals(Path.GetExtension(MapManagerConstants.MapFilePattern), StringComparison.OrdinalIgnoreCase)).ToList(); + + if (mapFilesInDir.Count == 0) + continue; + + // Use the first .map file as the primary + var primaryMap = mapFilesInDir[0]; + var assetFiles = allFilesInDir + .Where(f => !f.Extension.Equals(Path.GetExtension(MapManagerConstants.MapFilePattern), StringComparison.OrdinalIgnoreCase)) + .Where(f => IsValidAssetFile(f.Extension)) + .Select(f => f.FullName) + .ToList(); + + var totalSize = allFilesInDir.Sum(f => f.Length); + + // Find thumbnail TGA file + var thumbnailPath = FindThumbnail(allFilesInDir); + + // Parse display name + var displayName = mapNameParser.ParseMapName(primaryMap.FullName); + + mapFiles.Add(new MapFile + { + FileName = primaryMap.Name, + FullPath = primaryMap.FullName, + SizeBytes = totalSize, + GameType = version, + LastModified = dirInfo.LastWriteTime, + DirectoryName = dirInfo.Name, + IsDirectory = true, + AssetFiles = assetFiles, + IsExpanded = false, + DisplayName = displayName, + ThumbnailPath = thumbnailPath, + ThumbnailBitmap = null, // Loaded lazily in ViewModel + }); + } + else if (!isInSubdirectory) + { + // This is a standalone .map file in the root Maps directory + var displayName = mapNameParser.ParseMapName(fileInfo.FullName); + + mapFiles.Add(new MapFile + { + FileName = fileInfo.Name, + FullPath = fileInfo.FullName, + SizeBytes = fileInfo.Length, + GameType = version, + LastModified = fileInfo.LastWriteTime, + DirectoryName = null, + IsDirectory = false, + AssetFiles = new List(), + IsExpanded = false, + DisplayName = displayName, + ThumbnailPath = null, + ThumbnailBitmap = null, + }); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to read map file: {File}", mapFilePath); + } + } + + // Also scan for ZIP files in the root directory + try + { + var zipFiles = Directory.GetFiles(directory, MapManagerConstants.ZipFilePattern, SearchOption.TopDirectoryOnly); + foreach (var zipPath in zipFiles) + { + try + { + var fileInfo = new FileInfo(zipPath); + mapFiles.Add(new MapFile + { + FileName = fileInfo.Name, + FullPath = fileInfo.FullName, + SizeBytes = fileInfo.Length, + GameType = version, + LastModified = fileInfo.LastWriteTime, + DirectoryName = null, + IsDirectory = false, + AssetFiles = new List(), + IsExpanded = false, + DisplayName = fileInfo.Name, // Use filename for ZIPs + ThumbnailPath = null, + ThumbnailBitmap = null, + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to read zip file: {File}", zipPath); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to scan for zip files"); + } + + logger.LogDebug("Found {Count} maps for {GameType}", mapFiles.Count, version); + return mapFiles; + }, + ct); + } + + /// + public async Task DeleteMapsAsync(IEnumerable maps, CancellationToken ct = default) + { + return await Task.Run( + () => + { + try + { + foreach (var map in maps) + { + if (ct.IsCancellationRequested) + { + break; + } + + if (map.IsDirectory) + { + // Delete the entire directory + var dirPath = Path.GetDirectoryName(map.FullPath); + if (!string.IsNullOrEmpty(dirPath) && Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, true); + logger.LogInformation("Deleted map directory: {DirectoryName}", map.DirectoryName); + } + } + else + { + // Delete standalone file + if (File.Exists(map.FullPath)) + { + File.Delete(map.FullPath); + logger.LogInformation("Deleted map: {FileName}", map.FileName); + } + } + } + + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete maps"); + return false; + } + }, + ct); + } + + /// + public void OpenInExplorer(GameType version) + { + var directory = GetMapDirectory(version); + EnsureDirectoryExists(version); + + try + { + System.Diagnostics.Process.Start("explorer.exe", directory); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to open map directory in Explorer: {Directory}", directory); + } + } + + /// + public void RevealInExplorer(MapFile map) + { + try + { + System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{map.FullPath}\""); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to reveal map in Explorer: {FileName}", map.FileName); + } + } + + /// + public async Task RenameMapAsync(MapFile map, string newName, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(newName)) + { + return false; + } + + // Validate name for illegal characters + var invalidChars = Path.GetInvalidFileNameChars(); + if (newName.Any(c => invalidChars.Contains(c))) + { + logger.LogWarning("Invalid characters in map name: {Name}", newName); + return false; + } + + return await Task.Run( + () => + { + try + { + if (map.IsDirectory) + { + // Rename both directory and .map file + var currentDirPath = Path.GetDirectoryName(map.FullPath); + if (string.IsNullOrEmpty(currentDirPath)) + { + return false; + } + + var parentPath = Path.GetDirectoryName(currentDirPath); + if (string.IsNullOrEmpty(parentPath)) + { + return false; + } + + var newDirPath = Path.Combine(parentPath, newName); + + // Check if target directory already exists + if (Directory.Exists(newDirPath)) + { + logger.LogWarning("Target directory already exists: {Path}", newDirPath); + return false; + } + + // First rename the .map file inside the directory + var newMapFileName = newName + ".map"; + var newMapFilePath = Path.Combine(currentDirPath, newMapFileName); + + if (!string.Equals(map.FullPath, newMapFilePath, StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(newMapFilePath)) + { + logger.LogWarning("Target map file already exists: {Path}", newMapFilePath); + return false; + } + + File.Move(map.FullPath, newMapFilePath); + } + + // Then rename the directory + Directory.Move(currentDirPath, newDirPath); + + logger.LogInformation("Renamed map directory from {OldName} to {NewName}", map.DirectoryName, newName); + return true; + } + else + { + // Rename standalone .map file + var directory = Path.GetDirectoryName(map.FullPath); + if (string.IsNullOrEmpty(directory)) + { + return false; + } + + var newFileName = newName + ".map"; + var newFilePath = Path.Combine(directory, newFileName); + + if (File.Exists(newFilePath)) + { + logger.LogWarning("Target file already exists: {Path}", newFilePath); + return false; + } + + File.Move(map.FullPath, newFilePath); + logger.LogInformation("Renamed map from {OldName} to {NewName}", map.FileName, newFileName); + return true; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to rename map: {FileName}", map.FileName); + return false; + } + }, + ct); + } + + /// + /// Finds the best thumbnail file in a map directory. + /// + /// Files in the map directory. + /// Path to the thumbnail file, or null if none found. + private static string? FindThumbnail(FileInfo[] files) + { + // Priority: map.tga > any .tga file + var mapTga = files.FirstOrDefault(f => f.Name.Equals(MapManagerConstants.DefaultThumbnailName, StringComparison.OrdinalIgnoreCase)); + if (mapTga != null) + { + return mapTga.FullName; + } + + var anyTga = files.FirstOrDefault(f => f.Extension.Equals(".tga", StringComparison.OrdinalIgnoreCase)); + return anyTga?.FullName; + } + + private static bool IsValidAssetFile(string extension) + { + var validExtensions = new[] { ".tga", ".ini", ".str", ".txt" }; + return validExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs new file mode 100644 index 000000000..4c47f8503 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapExportService.cs @@ -0,0 +1,164 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Services; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Tools.MapManager; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.MapManager.Services; + +/// +/// Implementation of for exporting and sharing maps. +/// +public sealed class MapExportService( + IUploadThingService uploadThingService, + IMapImportService importService, + ILogger logger) : IMapExportService +{ + /// + /// Maximum total upload size. + /// + private const long MaxTotalUploadBytes = MapManagerConstants.MaxUploadBytesPerPeriod; + + /// + public async Task UploadToUploadThingAsync( + IEnumerable maps, + IProgress? progress = null, + CancellationToken ct = default) + { + string? zipToUpload = null; + bool isTemporaryZip = false; + + try + { + var mapList = maps.ToList(); + if (mapList.Count == 0) return null; + + if (mapList.Count == 1 && mapList[0].FileName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase)) + { + var (isValid, errorMessage) = importService.ValidateZip(mapList[0].FullPath); + if (!isValid) + { + logger.LogError("ZIP validation failed for upload: {Error}", errorMessage); + throw new ArgumentException(errorMessage ?? "Invalid ZIP archive for upload."); + } + + zipToUpload = mapList[0].FullPath; + } + else + { + var tempZip = Path.Combine(Path.GetTempPath(), $"genhub_maps_{Guid.NewGuid()}.zip"); + var createdZip = await ExportToZipAsync(mapList, tempZip, progress, ct); + if (createdZip == null) return null; + + zipToUpload = createdZip; + isTemporaryZip = true; + } + + if (new FileInfo(zipToUpload).Length > MaxTotalUploadBytes) + { + logger.LogError("File exceeds size limit: {Path}", zipToUpload); + return null; + } + + return await uploadThingService.UploadFileAsync(zipToUpload, progress, ct); + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to upload to UploadThing"); + return null; + } + finally + { + if (isTemporaryZip && !string.IsNullOrEmpty(zipToUpload) && File.Exists(zipToUpload)) + { + File.Delete(zipToUpload); + } + } + } + + /// + public async Task ExportToZipAsync( + IEnumerable maps, + string destinationPath, + IProgress? progress = null, + CancellationToken ct = default) + { + try + { + return await Task.Run( + () => + { + var mapList = maps.ToList(); + if (mapList.Count == 0) return null; + + using var zipFile = File.Create(destinationPath); + using var archive = new ZipArchive(zipFile, ZipArchiveMode.Create); + + int total = mapList.Count; + int count = 0; + + foreach (var map in mapList) + { + count++; + progress?.Report((double)count / total * 0.4); + + if (map.IsDirectory) + { + // Add directory-based map with all its assets + var dirPath = Path.GetDirectoryName(map.FullPath); + if (string.IsNullOrEmpty(dirPath) || !Directory.Exists(dirPath)) + continue; + + var dirInfo = new DirectoryInfo(dirPath); + var dirName = dirInfo.Name; + + // Add the .map file + if (File.Exists(map.FullPath)) + { + var entryName = $"{dirName}/{Path.GetFileName(map.FullPath)}"; + archive.CreateEntryFromFile(map.FullPath, entryName); + } + + // Add all asset files + foreach (var assetPath in map.AssetFiles) + { + if (File.Exists(assetPath)) + { + var entryName = $"{dirName}/{Path.GetFileName(assetPath)}"; + archive.CreateEntryFromFile(assetPath, entryName); + } + } + } + else + { + // Add standalone .map file wrapped in a directory + if (!File.Exists(map.FullPath)) continue; + + var mapName = Path.GetFileNameWithoutExtension(map.FileName); + var entryName = $"{mapName}/{map.FileName}"; + archive.CreateEntryFromFile(map.FullPath, entryName); + } + } + + return destinationPath; + }, + ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create ZIP: {Path}", destinationPath); + return null; + } + } +} diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs new file mode 100644 index 000000000..7b3b82da9 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs @@ -0,0 +1,516 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.MapManager; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.MapManager.Services; + +/// +/// Implementation of for importing maps. +/// +public sealed class MapImportService( + IMapDirectoryService directoryService, + HttpClient httpClient, + MapNameParser mapNameParser, + ILogger logger) : IMapImportService +{ + private static readonly char[] PathSeparators = ['/', '\'']; + + /// + public async Task ImportFromUrlAsync( + string url, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default) + { + var result = new ImportResult(); + + try + { + logger.LogInformation("Importing map from URL: {Url}", url); + + var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + var fileName = GetFileNameFromUrl(url, response); + var tempPath = Path.Combine(Path.GetTempPath(), fileName); + + await using (var fileStream = File.Create(tempPath)) + await using (var httpStream = await response.Content.ReadAsStreamAsync(ct)) + { + await httpStream.CopyToAsync(fileStream, ct); + } + + // Detect file type by magic bytes + bool isZip = false; + try + { + using var stream = File.OpenRead(tempPath); + var buffer = new byte[4]; + if (await stream.ReadAsync(buffer.AsMemory(0, 4), ct) == 4) + { + // ZIP signature: 50 4B 03 04 + if (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x03 && buffer[3] == 0x04) + { + isZip = true; + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to detect file type for {File}", tempPath); + } + + if (isZip || fileName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase)) + { + // Ensure extension is .zip for the import service if it was detected by magic bytes but has wrong extension + if (!tempPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + var newPath = tempPath + ".zip"; + if (File.Exists(newPath)) File.Delete(newPath); + File.Move(tempPath, newPath); + tempPath = newPath; + } + + result = await ImportFromZipAsync(tempPath, targetVersion, progress, ct); + } + else + { + // Assume it's a map file (or text-based map file) + // Ensure extension is .map so ImportFromFilesAsync picks it up + if (!tempPath.EndsWith(".map", StringComparison.OrdinalIgnoreCase)) + { + var newPath = tempPath + ".map"; + if (File.Exists(newPath)) File.Delete(newPath); + File.Move(tempPath, newPath); + tempPath = newPath; + } + + result = await ImportFromFilesAsync([tempPath], targetVersion, ct); + } + + // Cleanup temp file + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to import from URL: {Url}", url); + result.Errors.Add($"Import failed: {ex.Message}"); + } + + result.Success = result.FilesImported > 0; + return result; + } + + /// + public async Task ImportFromFilesAsync( + IEnumerable filePaths, + GameType targetVersion, + CancellationToken ct = default) + { + var result = new ImportResult(); + var targetDir = directoryService.GetMapDirectory(targetVersion); + directoryService.EnsureDirectoryExists(targetVersion); + + // Expand directories + var expandedPaths = new List(); + foreach (var path in filePaths) + { + if (Directory.Exists(path)) + { + try + { + expandedPaths.AddRange(Directory.GetFiles(path, "*", SearchOption.AllDirectories)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to expand directory: {Path}", path); + } + } + else + { + expandedPaths.Add(path); + } + } + + foreach (var filePath in expandedPaths) + { + try + { + if (filePath.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase)) + { + var zipResult = await ImportFromZipAsync(filePath, targetVersion, null, ct); + result.FilesImported += zipResult.FilesImported; + result.Errors.AddRange(zipResult.Errors); + result.ImportedMaps.AddRange(zipResult.ImportedMaps); + continue; + } + + if (!filePath.EndsWith(Path.GetExtension(MapManagerConstants.MapFilePattern), StringComparison.OrdinalIgnoreCase)) + { + result.Errors.Add($"Skipped non-map file: {Path.GetFileName(filePath)}"); + continue; + } + + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length > IMapImportService.MaxMapSizeBytes) + { + result.Errors.Add($"File too large: {fileInfo.Name} ({fileInfo.Length / 1024 / 1024}MB)"); + continue; + } + + // Create a directory for the map (all maps must be in directories) + var mapName = Path.GetFileNameWithoutExtension(fileInfo.Name); + var mapDirPath = GetUniqueDirectoryPath(Path.Combine(targetDir, mapName)); + Directory.CreateDirectory(mapDirPath); + + var destPath = Path.Combine(mapDirPath, fileInfo.Name); + File.Copy(filePath, destPath, false); + + result.FilesImported++; + logger.LogInformation("Imported map to directory: {DirectoryName}/{FileName}", mapName, fileInfo.Name); + + // Create MapFile object + var displayName = mapNameParser.ParseMapName(destPath); + var mapFile = new MapFile + { + FileName = fileInfo.Name, + FullPath = destPath, + SizeBytes = fileInfo.Length, + GameType = targetVersion, + LastModified = File.GetLastWriteTime(destPath), + DirectoryName = Path.GetFileName(mapDirPath), + IsDirectory = true, // We forced it into a directory + AssetFiles = [], // Single file import has no assets + DisplayName = displayName, + ThumbnailPath = null, + ThumbnailBitmap = null, + }; + result.ImportedMaps.Add(mapFile); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to import file: {FilePath}", filePath); + result.Errors.Add($"Failed to import {Path.GetFileName(filePath)}: {ex.Message}"); + } + } + + result.Success = result.FilesImported > 0; + return result; + } + + /// + public async Task ImportFromZipAsync( + string zipPath, + GameType targetVersion, + IProgress? progress = null, + CancellationToken ct = default) + { + return await Task.Run( + () => + { + var result = new ImportResult(); + var (isValid, errorMessage) = ValidateZip(zipPath); + + if (!isValid) + { + result.Errors.Add(errorMessage ?? "Invalid ZIP file"); + return result; + } + + var targetDir = directoryService.GetMapDirectory(targetVersion); + directoryService.EnsureDirectoryExists(targetVersion); + + try + { + using var archive = ZipFile.OpenRead(zipPath); + var allEntries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToList(); + + // Group entries by their parent directory (if any) + var entriesByDirectory = allEntries + .GroupBy(e => + { + var parts = e.FullName.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 1 ? parts[0] : string.Empty; + }) + .ToDictionary(g => g.Key, g => g.ToList()); + + int totalMaps = 0; + int processedMaps = 0; + + // Count total maps for progress + foreach (var group in entriesByDirectory) + { + totalMaps += group.Value.Count(e => e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase)); + } + + foreach (var (directoryName, entries) in entriesByDirectory) + { + var mapEntries = entries.Where(e => e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase)).ToList(); + if (mapEntries.Count == 0) + continue; + + foreach (var mapEntry in mapEntries) + { + if (mapEntry.Length > IMapImportService.MaxMapSizeBytes) + { + result.Errors.Add($"Map too large: {mapEntry.Name}"); + continue; + } + + // Determine the directory name for this map + string mapDirName; + if (string.IsNullOrEmpty(directoryName)) + { + // Root-level map - create a directory using the map name + mapDirName = Path.GetFileNameWithoutExtension(mapEntry.Name); + } + else + { + // Directory-based map - use the existing directory name + mapDirName = directoryName; + } + + var mapDirPath = GetUniqueDirectoryPath(Path.Combine(targetDir, mapDirName)); + Directory.CreateDirectory(mapDirPath); + + // Extract the .map file + var mapDestPath = Path.Combine(mapDirPath, mapEntry.Name); + mapEntry.ExtractToFile(mapDestPath, false); + + var assetFiles = new List(); + string? thumbnailPath = null; + + // Extract related asset files from the same directory in the ZIP + if (!string.IsNullOrEmpty(directoryName)) + { + var assetEntries = entries.Where(e => + !e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase) && + MapManagerConstants.AllowedExtensions.Contains(Path.GetExtension(e.Name), StringComparer.OrdinalIgnoreCase)); + + foreach (var assetEntry in assetEntries) + { + var assetDestPath = Path.Combine(mapDirPath, assetEntry.Name); + if (!File.Exists(assetDestPath)) + { + assetEntry.ExtractToFile(assetDestPath, false); + } + + assetFiles.Add(assetDestPath); + + // Check for thumbnail + if (assetEntry.Name.Equals(MapManagerConstants.DefaultThumbnailName, StringComparison.OrdinalIgnoreCase) || + (thumbnailPath == null && assetEntry.Name.EndsWith(".tga", StringComparison.OrdinalIgnoreCase))) + { + thumbnailPath = assetDestPath; + } + } + } + + var totalSize = new FileInfo(mapDestPath).Length + assetFiles.Sum(f => new FileInfo(f).Length); + + result.FilesImported++; + processedMaps++; + progress?.Report((double)processedMaps / totalMaps); + logger.LogInformation("Extracted map to directory: {DirectoryName}/{FileName}", mapDirName, mapEntry.Name); + + // Create MapFile object + var displayName = mapNameParser.ParseMapName(mapDestPath); + var mapFile = new MapFile + { + FileName = mapEntry.Name, + FullPath = mapDestPath, + SizeBytes = totalSize, + GameType = targetVersion, + LastModified = DateTime.Now, + DirectoryName = Path.GetFileName(mapDirPath), + IsDirectory = true, + AssetFiles = assetFiles, + DisplayName = displayName, + ThumbnailPath = thumbnailPath, + ThumbnailBitmap = null, + }; + result.ImportedMaps.Add(mapFile); + } + } + + progress?.Report(1.0); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to import from ZIP: {ZipPath}", zipPath); + result.Errors.Add($"ZIP extraction failed: {ex.Message}"); + } + + result.Success = result.FilesImported > 0; + return result; + }, + ct); + } + + /// + public (bool IsValid, string? ErrorMessage) ValidateZip(string zipPath) + { + try + { + using var archive = ZipFile.OpenRead(zipPath); + var entries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToList(); + + if (entries.Count == 0) + { + return (false, "ZIP file is empty"); + } + + var allowedExtensions = MapManagerConstants.AllowedExtensions; + var directoriesWithMaps = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + // Calculate nesting depth + var separatorCount = entry.FullName.Count(c => c == '/' || c == '\\'); + + // Allow files at root (depth 0) or in one subdirectory (depth 1) + if (separatorCount > 1) + { + return (false, "ZIP contains nested directories beyond 1 level. Only flat archives or 1-level deep directories are supported."); + } + + // Validate file extension + var extension = Path.GetExtension(entry.Name); + if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + return (false, $"ZIP contains invalid file type: {extension}. Only .map, .tga, .ini, .str, and .txt files are allowed."); + } + + // Track which directories contain .map files + if (extension.Equals(Path.GetExtension(MapManagerConstants.MapFilePattern), StringComparison.OrdinalIgnoreCase)) + { + if (separatorCount == 1) + { + // Extract directory name from path like "MapName/MapName.map" + var dirName = entry.FullName.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries)[0]; + directoriesWithMaps.Add(dirName); + } + else + { + // Root-level .map file + directoriesWithMaps.Add(string.Empty); + } + } + } + + // Verify that every subdirectory contains at least one .map file + var allDirectories = entries + .Where(e => e.FullName.Contains('/') || e.FullName.Contains('\\')) + .Select(e => e.FullName.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries)[0]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var dir in allDirectories) + { + if (!directoriesWithMaps.Contains(dir)) + { + return (false, $"Directory '{dir}' does not contain a .map file. Each directory must have at least one .map file."); + } + } + + return (true, null); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to validate ZIP: {ZipPath}", zipPath); + return (false, $"Failed to read ZIP file: {ex.Message}"); + } + } + + /// + public async Task ImportFromStreamAsync( + Stream stream, + string fileName, + GameType targetVersion, + CancellationToken ct = default) + { + var tempPath = Path.Combine(Path.GetTempPath(), fileName); + + try + { + await using (var fileStream = File.Create(tempPath)) + { + await stream.CopyToAsync(fileStream, ct); + } + + return await ImportFromFilesAsync([tempPath], targetVersion, ct); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private static string GetFileNameFromUrl(string url, HttpResponseMessage response) + { + if (response.Content.Headers.ContentDisposition?.FileName != null) + { + return response.Content.Headers.ContentDisposition.FileName.Trim('"'); + } + + var uri = new Uri(url); + return Path.GetFileName(uri.LocalPath); + } + + private static string GetUniqueFilePath(string path) + { + if (!File.Exists(path)) + { + return path; + } + + var directory = Path.GetDirectoryName(path) ?? string.Empty; + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + int counter = 1; + + while (File.Exists(path)) + { + path = Path.Combine(directory, $"{fileNameWithoutExt} ({counter}){extension}"); + counter++; + } + + return path; + } + + private static string GetUniqueDirectoryPath(string path) + { + if (!Directory.Exists(path)) + { + return path; + } + + var parentDirectory = Path.GetDirectoryName(path) ?? string.Empty; + var dirName = Path.GetFileName(path); + int counter = 1; + + while (Directory.Exists(path)) + { + path = Path.Combine(parentDirectory, $"{dirName} ({counter})"); + counter++; + } + + return path; + } +} diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapNameParser.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapNameParser.cs new file mode 100644 index 000000000..616b05e8a --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapNameParser.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.MapManager.Services; + +/// +/// Service for parsing map display names from .map files and directories. +/// +public class MapNameParser(ILogger logger) +{ + /// + /// Parses the display name for a map from its file path. + /// + /// Path to the .map file. + /// The parsed display name. + public string ParseMapName(string mapFilePath) + { + var nameFromFile = TryParseFromMapFile(mapFilePath); + if (!string.IsNullOrWhiteSpace(nameFromFile)) + { + return nameFromFile; + } + + var nameFromDirectory = FallbackToDirectoryName(mapFilePath); + if (!string.IsNullOrWhiteSpace(nameFromDirectory)) + { + return nameFromDirectory; + } + + return Path.GetFileNameWithoutExtension(mapFilePath); + } + + private static string CleanMapName(string name) + { + name = name.Replace('_', ' '); + name = name.Replace('-', ' '); + + while (name.Contains(" ", StringComparison.Ordinal)) + { + name = name.Replace(" ", " ", StringComparison.Ordinal); + } + + return name.Trim(); + } + + /// + /// Attempts to parse the map name from the .map file contents. + /// + /// Path to the .map file. + /// The map name if found, otherwise null. + private string? TryParseFromMapFile(string mapFilePath) + { + try + { + if (!File.Exists(mapFilePath)) + { + return null; + } + + using var reader = new StreamReader(mapFilePath, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string? line; + var inMapSection = false; + + while ((line = reader.ReadLine()) != null) + { + var trimmedLine = line.Trim(); + + if (trimmedLine.Equals("Map", StringComparison.OrdinalIgnoreCase)) + { + inMapSection = true; + continue; + } + + if (inMapSection && trimmedLine.StartsWith("End", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + if (inMapSection && trimmedLine.StartsWith("displayName", StringComparison.OrdinalIgnoreCase)) + { + var parts = trimmedLine.Split('=', 2); + if (parts.Length == 2) + { + var displayName = parts[1].Trim().Trim('"', '\''); + if (!string.IsNullOrWhiteSpace(displayName)) + { + logger.LogDebug("Parsed map name from file: {Name}", displayName); + return displayName; + } + } + } + } + + return null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to parse map name from file: {Path}", mapFilePath); + return null; + } + } + + /// + /// Falls back to using the directory name as the map name. + /// + /// Path to the .map file. + /// The cleaned directory name, or null if not in a subdirectory. + private string? FallbackToDirectoryName(string mapFilePath) + { + try + { + var directory = Path.GetDirectoryName(mapFilePath); + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + var directoryName = Path.GetFileName(directory); + if (string.IsNullOrWhiteSpace(directoryName)) + { + return null; + } + + if (directoryName.Equals("Maps", StringComparison.OrdinalIgnoreCase) || + directoryName.Contains("Command and Conquer", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + logger.LogDebug("Using directory name as map name: {Name}", directoryName); + return CleanMapName(directoryName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to get directory name for: {Path}", mapFilePath); + return null; + } + } +} diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapPackService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapPackService.cs new file mode 100644 index 000000000..b2a9721d3 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapPackService.cs @@ -0,0 +1,362 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.MapManager; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.MapManager.Services; + +/// +/// Implementation of for managing MapPacks. +/// NOTE: MapPacks store metadata only. The actual map file activation is handled +/// by the userdata system (IProfileContentLinker) when profiles are launched. +/// +public sealed class MapPackService : IMapPackService +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private static void CopyDirectory(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + foreach (var file in Directory.GetFiles(sourceDir)) + { + var destFile = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, destFile, true); + } + + foreach (var subManager in Directory.GetDirectories(sourceDir)) + { + var destSub = Path.Combine(destDir, Path.GetFileName(subManager)); + CopyDirectory(subManager, destSub); + } + } + + private readonly IAppConfiguration _appConfig; + private readonly ILocalContentService _localContentService; + private readonly IContentManifestPool _manifestPool; + private readonly ILogger _logger; + private readonly string _mapPacksDirectory; + private readonly object _fileLock = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The application configuration. + /// Provides operations for local content manifest creation. + /// Pool for content manifests and CAS operations. + /// Logger instance. + public MapPackService( + IAppConfiguration appConfig, + ILocalContentService localContentService, + IContentManifestPool manifestPool, + ILogger logger) + { + _appConfig = appConfig ?? throw new ArgumentNullException(nameof(appConfig)); + _localContentService = localContentService ?? throw new ArgumentNullException(nameof(localContentService)); + _manifestPool = manifestPool ?? throw new ArgumentNullException(nameof(manifestPool)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _mapPacksDirectory = Path.Combine(_appConfig.GetConfiguredDataPath(), MapManagerConstants.MapPacksSubdirectoryName); + } + + /// + public Task CreateMapPackAsync( + string name, + Guid? profileId, + IEnumerable mapFilePaths) + { + // Legacy method - we should probably discourage its use or map it to CAS + var mapPack = new MapPack + { + Id = ManifestId.Create($"1.0.local.mappack.{name.ToLowerInvariant().Replace(" ", "-")}"), + Name = name, + ProfileId = profileId, + MapFilePaths = mapFilePaths.ToList(), + CreatedDate = DateTime.UtcNow, + IsLoaded = false, + }; + + SaveMapPack(mapPack); + _logger.LogInformation("Created Legacy MapPack: {Name} with {Count} maps", name, mapPack.MapFilePaths.Count); + + return Task.FromResult(mapPack); + } + + /// + public async Task> CreateCasMapPackAsync( + string name, + GameType targetGame, + IEnumerable selectedMaps, + IProgress? progress = null, + CancellationToken ct = default) + { + // Create a temporary directory + var tempDir = Path.Combine(Path.GetTempPath(), "GenHub_MapPack_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + + try + { + // Copy each map directory to the temp directory + foreach (var map in selectedMaps) + { + var sourcePath = map.FullPath; + if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + { + _logger.LogWarning("Skipping map {Map} because file not found at {Path}", map.FileName, sourcePath); + continue; + } + + if (map.IsDirectory) + { + var sourceDir = Path.GetDirectoryName(sourcePath); + if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir)) + { + _logger.LogWarning("Skipping map {Map} because directory not found", map.FileName); + continue; + } + + var dirName = new DirectoryInfo(sourceDir).Name; + var destDir = Path.Combine(tempDir, dirName); + + // Copy directory recursively + CopyDirectory(sourceDir, destDir); + } + else + { + // Standalone map file - create a directory for it as per game requirements + // Maps/MapName/MapName.map + var mapNameWithoutExt = Path.GetFileNameWithoutExtension(map.FileName); + var destDir = Path.Combine(tempDir, mapNameWithoutExt); + Directory.CreateDirectory(destDir); + File.Copy(sourcePath, Path.Combine(destDir, map.FileName), true); + } + } + + // Create manifest using LocalContentService + // The ContentManifestBuilder now automatically sets InstallTarget to UserMapsDirectory + // for ContentType.MapPack, complying with userdata.md. + var result = await _localContentService.CreateLocalContentManifestAsync( + tempDir, + name, + ContentType.MapPack, + targetGame, + progress, + ct); + + return result; + } + finally + { + // Cleanup temp + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clean up temp directory {Dir}", tempDir); + } + } + } + + /// + public async Task> GetAllMapPacksAsync() + { + var result = await _manifestPool.GetAllManifestsAsync(); + var mapPacks = new List(); + + if (result.Success) + { + mapPacks.AddRange(result.Data + .Where(m => m.ContentType == ContentType.MapPack) + .Select(m => new MapPack + { + Id = m.Id, + Name = m.Name, + MapFilePaths = m.Files.Select(f => f.RelativePath).ToList(), + CreatedDate = m.Metadata.ReleaseDate, + IsLoaded = false, // Managed by Profile system + })); + } + + // Also load legacy JSON MapPacks if any exist + EnsureDirectoryExists(); + lock (_fileLock) + { + var files = Directory.GetFiles(_mapPacksDirectory, FileTypes.JsonFilePattern); + foreach (var file in files) + { + try + { + var json = File.ReadAllText(file); + var mapPack = JsonSerializer.Deserialize(json, _jsonOptions); + if (mapPack != null && !mapPacks.Any(p => p.Id == mapPack.Id)) + { + mapPacks.Add(mapPack); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load legacy MapPack from {File}", file); + } + } + } + + return mapPacks; + } + + /// + public async Task> GetMapPacksForProfileAsync(Guid profileId) + { + var allPacks = await GetAllMapPacksAsync(); + return allPacks.Where(p => p.ProfileId == profileId).ToList(); + } + + /// + public Task LoadMapPackAsync(ManifestId mapPackId) + { + lock (_fileLock) + { + var mapPack = LoadMapPackById(mapPackId); + if (mapPack == null) + { + _logger.LogWarning("MapPack not found: {Id}", mapPackId); + return Task.FromResult(false); + } + + // Mark as loaded - the actual map activation is handled by the userdata system + // when the profile is launched/switched + mapPack.IsLoaded = true; + SaveMapPack(mapPack); + + _logger.LogInformation("Loaded MapPack: {Name}", mapPack.Name); + return Task.FromResult(true); + } + } + + /// + public Task UnloadMapPackAsync(ManifestId mapPackId) + { + lock (_fileLock) + { + var mapPack = LoadMapPackById(mapPackId); + if (mapPack == null) + { + _logger.LogWarning("MapPack not found: {Id}", mapPackId); + return Task.FromResult(false); + } + + // Mark as unloaded - the userdata system will handle removing maps + // on the next profile switch + mapPack.IsLoaded = false; + SaveMapPack(mapPack); + + _logger.LogInformation("Unloaded MapPack: {Name}", mapPack.Name); + return Task.FromResult(true); + } + } + + /// + public async Task DeleteMapPackAsync(ManifestId mapPackId) + { + // Try to delete from CAS pool first + var casResult = await _manifestPool.RemoveManifestAsync(mapPackId); + + lock (_fileLock) + { + var filePath = GetMapPackFilePath(mapPackId); + if (File.Exists(filePath)) + { + File.Delete(filePath); + _logger.LogInformation("Deleted legacy MapPack: {Id}", mapPackId); + return true; + } + } + + if (casResult.Success && casResult.Data) + { + _logger.LogInformation("Deleted CAS MapPack: {Id}", mapPackId); + return true; + } + + _logger.LogWarning("MapPack not found for deletion: {Id}", mapPackId); + return false; + } + + /// + public Task UpdateMapPackAsync(MapPack mapPack) + { + lock (_fileLock) + { + SaveMapPack(mapPack); + _logger.LogInformation("Updated MapPack: {Name}", mapPack.Name); + return Task.FromResult(true); + } + } + + private void SaveMapPack(MapPack mapPack) + { + EnsureDirectoryExists(); + + lock (_fileLock) + { + var filePath = GetMapPackFilePath(mapPack.Id); + var json = JsonSerializer.Serialize(mapPack, _jsonOptions); + File.WriteAllText(filePath, json); + } + } + + private MapPack? LoadMapPackById(ManifestId id) + { + var filePath = GetMapPackFilePath(id); + if (!File.Exists(filePath)) + { + return null; + } + + try + { + var json = File.ReadAllText(filePath); + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load MapPack: {Id}", id); + return null; + } + } + + private string GetMapPackFilePath(ManifestId id) + { + // Sanitize ID for filename + var safeId = id.ToString().Replace(".", "_").Replace(":", "_"); + return Path.Combine(_mapPacksDirectory, $"{safeId}{FileTypes.JsonFileExtension}"); + } + + private void EnsureDirectoryExists() + { + if (!Directory.Exists(_mapPacksDirectory)) + { + Directory.CreateDirectory(_mapPacksDirectory); + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/TgaParser.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/TgaParser.cs new file mode 100644 index 000000000..00da32dd3 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/TgaParser.cs @@ -0,0 +1,298 @@ +using Avalonia.Media.Imaging; +using Microsoft.Extensions.Logging; +using System; +using System.IO; + +namespace GenHub.Features.Tools.MapManager.Services; + +/// +/// Service for parsing TGA (Truevision Graphics Adapter) image files. +/// Supports uncompressed and RLE-compressed 24-bit and 32-bit TGA images. +/// +public class TgaParser(ILogger logger) +{ + /// + /// Loads a TGA file and converts it to an Avalonia Bitmap. + /// + /// Path to the TGA file. + /// A Bitmap if successful, null otherwise. + public Bitmap? LoadTga(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + logger.LogWarning("TGA file not found: {Path}", filePath); + return null; + } + + var fileBytes = File.ReadAllBytes(filePath); + return ParseTga(fileBytes, filePath); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load TGA file: {Path}", filePath); + return null; + } + } + + /// + /// Decodes uncompressed TGA pixel data. + /// + private static byte[]? DecodeUncompressed(byte[] data, int offset, int width, int height, int bytesPerPixel) + { + int expectedSize = width * height * bytesPerPixel; + if (data.Length < offset + expectedSize) + { + return null; + } + + var result = new byte[expectedSize]; + Array.Copy(data, offset, result, 0, expectedSize); + return result; + } + + /// + /// Decodes RLE-compressed TGA pixel data. + /// + private static byte[] DecodeRle(byte[] data, int offset, int width, int height, int bytesPerPixel) + { + int totalPixels = width * height; + var result = new byte[totalPixels * bytesPerPixel]; + int resultIndex = 0; + int dataIndex = offset; + + while (resultIndex < result.Length && dataIndex < data.Length) + { + int packetHeader = data[dataIndex++]; + int packetCount = (packetHeader & 0x7F) + 1; + + if ((packetHeader & 0x80) != 0) + { + // RLE packet - one pixel repeated + if (dataIndex + bytesPerPixel > data.Length) + break; + + for (int i = 0; i < packetCount && resultIndex < result.Length; i++) + { + for (int j = 0; j < bytesPerPixel; j++) + { + result[resultIndex++] = data[dataIndex + j]; + } + } + + dataIndex += bytesPerPixel; + } + else + { + // Raw packet - consecutive pixels + int bytesToCopy = packetCount * bytesPerPixel; + if (dataIndex + bytesToCopy > data.Length) + break; + + for (int i = 0; i < bytesToCopy && resultIndex < result.Length; i++) + { + result[resultIndex++] = data[dataIndex++]; + } + } + } + + return result; + } + + /// + /// Converts BGR(A) pixel data to RGBA format and handles vertical flip. + /// + private static byte[] ConvertToRgba(byte[] bgrData, int width, int height, int bytesPerPixel, bool topToBottom) + { + var rgba = new byte[width * height * 4]; + int srcIndex = 0; + + for (int y = 0; y < height; y++) + { + int destY = topToBottom ? y : (height - 1 - y); + int destRowStart = destY * width * 4; + + for (int x = 0; x < width; x++) + { + int destIndex = destRowStart + (x * 4); + + // TGA stores as BGR(A) + byte b = bgrData[srcIndex++]; + byte g = bgrData[srcIndex++]; + byte r = bgrData[srcIndex++]; + byte a = bytesPerPixel == 4 ? bgrData[srcIndex++] : (byte)255; + + // Convert to RGBA + rgba[destIndex] = r; + rgba[destIndex + 1] = g; + rgba[destIndex + 2] = b; + rgba[destIndex + 3] = a; + } + } + + return rgba; + } + + /// + /// Creates an Avalonia Bitmap from RGBA pixel data. + /// + private static Bitmap? CreateBitmap(byte[] rgbaData, int width, int height) + { + try + { + using var stream = new MemoryStream(); + + // Write a simple BMP header for RGBA data + WriteBmpHeader(stream, width, height); + WriteBmpPixelData(stream, rgbaData, width, height); + + stream.Position = 0; + return new Bitmap(stream); + } + catch + { + return null; + } + } + + /// + /// Writes a BMP file header to the stream. + /// + private static void WriteBmpHeader(Stream stream, int width, int height) + { + using var writer = new BinaryWriter(stream); + + int rowStride = width * 4; + int pixelDataSize = rowStride * height; + int fileSize = 54 + pixelDataSize; + + // BMP File Header (14 bytes) + writer.Write((byte)'B'); + writer.Write((byte)'M'); + writer.Write(fileSize); + writer.Write((short)0); // Reserved + writer.Write((short)0); // Reserved + writer.Write(54); // Pixel data offset + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + writer.Write(40); // Header size + writer.Write(width); + writer.Write(height); + writer.Write((short)1); // Color planes + writer.Write((short)32); // Bits per pixel + writer.Write(0); // Compression (BI_RGB) + writer.Write(pixelDataSize); + writer.Write(2835); // Horizontal resolution (72 DPI) + writer.Write(2835); // Vertical resolution (72 DPI) + writer.Write(0); // Colors in palette + writer.Write(0); // Important colors + } + + /// + /// Writes RGBA pixel data as BGRA to the BMP stream. + /// + private static void WriteBmpPixelData(Stream stream, byte[] rgbaData, int width, int height) + { + using var writer = new BinaryWriter(stream); + + // BMP stores rows bottom-to-top, BGRA + for (int y = height - 1; y >= 0; y--) + { + int rowStart = y * width * 4; + for (int x = 0; x < width; x++) + { + int i = rowStart + (x * 4); + byte r = rgbaData[i]; + byte g = rgbaData[i + 1]; + byte b = rgbaData[i + 2]; + byte a = rgbaData[i + 3]; + + // Write as BGRA + writer.Write(b); + writer.Write(g); + writer.Write(r); + writer.Write(a); + } + } + } + + private Bitmap? ParseTga(byte[] data, string sourcePath) + { + if (data.Length < 18) + { + logger.LogWarning("TGA file too small: {Path}", sourcePath); + return null; + } + + // TGA Header + int idLength = data[0]; + int colorMapType = data[1]; + int imageType = data[2]; + + // Image specification + int width = data[12] | (data[13] << 8); + int height = data[14] | (data[15] << 8); + int bitsPerPixel = data[16]; + int imageDescriptor = data[17]; + + // Validate image type + // Type 2 = Uncompressed True-color + // Type 10 = RLE compressed True-color + if (imageType != 2 && imageType != 10) + { + logger.LogWarning("Unsupported TGA image type {Type}: {Path}", imageType, sourcePath); + return null; + } + + // Validate color map type (should be 0 for true-color) + if (colorMapType != 0) + { + logger.LogWarning("Color-mapped TGA not supported: {Path}", sourcePath); + return null; + } + + // Validate bits per pixel + if (bitsPerPixel != 24 && bitsPerPixel != 32) + { + logger.LogWarning("Unsupported TGA bit depth {Depth}: {Path}", bitsPerPixel, sourcePath); + return null; + } + + int bytesPerPixel = bitsPerPixel / 8; + int headerEnd = 18 + idLength; + + if (data.Length < headerEnd) + { + logger.LogWarning("TGA file header incomplete: {Path}", sourcePath); + return null; + } + + // Origin flag (bit 5 of image descriptor): 0 = bottom-left, 1 = top-left + bool topToBottom = (imageDescriptor & 0x20) != 0; + + byte[]? pixelData; + + if (imageType == 2) + { + // Uncompressed + pixelData = DecodeUncompressed(data, headerEnd, width, height, bytesPerPixel); + } + else + { + // RLE compressed + pixelData = DecodeRle(data, headerEnd, width, height, bytesPerPixel); + } + + if (pixelData == null) + { + logger.LogWarning("Failed to decode TGA pixel data: {Path}", sourcePath); + return null; + } + + // Convert BGR(A) to RGBA for Avalonia + var rgbaData = ConvertToRgba(pixelData, width, height, bytesPerPixel, topToBottom); + + return CreateBitmap(rgbaData, width, height); + } +} diff --git a/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs b/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs new file mode 100644 index 000000000..7d17fc0fc --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs @@ -0,0 +1,919 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Features.Tools.ViewModels; +using GenHub.Infrastructure.Imaging; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.MapManager.ViewModels; + +/// +/// ViewModel for the Map Manager tool. +/// +public partial class MapManagerViewModel : ObservableObject +{ + private readonly IMapDirectoryService _directoryService; + private readonly IMapImportService _importService; + private readonly IMapExportService _exportService; + private readonly IMapPackService _mapPackService; + private readonly IUploadHistoryService _uploadHistoryService; + private readonly INotificationService _notificationService; + private readonly TgaImageParser _tgaImageParser; + private readonly ILogger _logger; + private readonly DispatcherTimer _searchTimer; + + /// + /// Initializes a new instance of the class. + /// + /// The map directory service. + /// The map import service. + /// The map export service. + /// The map pack service. + /// The upload history service. + /// The notification service. + /// The TGA image parser. + /// The logger. + public MapManagerViewModel( + IMapDirectoryService directoryService, + IMapImportService importService, + IMapExportService exportService, + IMapPackService mapPackService, + IUploadHistoryService uploadHistoryService, + INotificationService notificationService, + TgaImageParser tgaImageParser, + ILogger logger) + { + _directoryService = directoryService; + _importService = importService; + _exportService = exportService; + _mapPackService = mapPackService; + _uploadHistoryService = uploadHistoryService; + _notificationService = notificationService; + _tgaImageParser = tgaImageParser; + _logger = logger; + + _searchTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(300), + }; + _searchTimer.Tick += (s, e) => + { + _searchTimer.Stop(); + ApplyFilter(); + }; + } + + [ObservableProperty] + private GameType selectedTab = GameType.ZeroHour; + + [ObservableProperty] + private string importUrl = string.Empty; + + [ObservableProperty] + private bool isBusy; + + [ObservableProperty] + private double progress; + + [ObservableProperty] + private string statusMessage = "Ready"; + + [ObservableProperty] + private string searchText = string.Empty; + + /// + /// The name of the ZIP file to export or upload. + /// + [ObservableProperty] + private string zipName = MapManagerConstants.DefaultZipName; + + partial void OnZipNameChanged(string value) + { + if (string.IsNullOrWhiteSpace(value)) return; + + var ext = Path.GetExtension(MapManagerConstants.ZipFilePattern).Replace("*", ""); + if (value.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + { + ZipName = value[..^ext.Length]; + } + } + + /// + /// Whether the MapPack panel is open. + /// + [ObservableProperty] + private bool isMapPackPanelOpen; + + partial void OnSearchTextChanged(string value) + { + _searchTimer.Stop(); + _searchTimer.Start(); + } + + partial void OnSelectedTabChanged(GameType value) + { + _ = LoadMapsAsync(); + } + + private void ApplyFilter() + { + var source = SelectedTab == GameType.Generals ? GeneralsMaps : ZeroHourMaps; + var filtered = string.IsNullOrWhiteSpace(SearchText) + ? (IEnumerable)source + : source.Where(m => (m.DisplayName?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false) || + (m.DirectoryName?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false)); + + // Replace the collection to avoid multiple notifications + CurrentMaps = new ObservableCollection(filtered); + } + + /// + /// Name for new MapPack. + /// + [ObservableProperty] + private string newMapPackName = string.Empty; + + /// + /// Gets the list of maps for Generals. + /// + public List GeneralsMaps { get; } = []; + + /// + /// Gets the list of maps for Zero Hour. + /// + public List ZeroHourMaps { get; } = []; + + /// + /// Gets the list of currently selected maps. + /// + /// + /// Gets or sets the list of currently selected maps. + /// + [ObservableProperty] + private ObservableCollection selectedMaps = []; + + /// + /// Gets or sets the collection of all maps for the current tab. + /// + [ObservableProperty] + private ObservableCollection currentMaps = []; + + /// + /// Gets the list of available MapPacks. + /// + public ObservableCollection MapPacks { get; } = []; + + /// + /// Gets the upload history. + /// + public ObservableCollection UploadHistory { get; } = []; + + /// + /// Gets or sets whether the upload history popup is open. + /// + [ObservableProperty] + private bool isHistoryOpen; + + /// + /// Gets a value indicating whether any of the selected maps are ZIP archives or directory-based. + /// + public bool HasSelectedZips => SelectedMaps.Any(m => + m.FileName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase)); + + /// + /// Updates the collection of selected maps. + /// + /// The selected maps. + public void UpdateSelectedMaps(IEnumerable selected) + { + // Replace the collection to avoid multiple notifications + SelectedMaps = new ObservableCollection(selected); + + OnPropertyChanged(nameof(HasSelectedZips)); + DeleteSelectedCommand.NotifyCanExecuteChanged(); + UncompressSelectedCommand.NotifyCanExecuteChanged(); + ExportToZipCommand.NotifyCanExecuteChanged(); + UploadAndShareCommand.NotifyCanExecuteChanged(); + CreateMapPackCommand.NotifyCanExecuteChanged(); + } + + /// + /// Initializes the ViewModel by loading maps for the current tab. + /// + /// A representing the asynchronous operation. + public async Task InitializeAsync() + { + await LoadMapsAsync(); + await LoadMapPacksAsync(); + } + + /// + /// Loads maps for the selected game version. + /// + /// A representing the asynchronous operation. + [RelayCommand] + public async Task LoadMapsAsync() + { + IsBusy = true; + StatusMessage = "Loading maps..."; + try + { + var maps = await _directoryService.GetMapsAsync(SelectedTab); + + if (SelectedTab == GameType.Generals) + { + GeneralsMaps.Clear(); + GeneralsMaps.AddRange(maps); + } + else + { + ZeroHourMaps.Clear(); + ZeroHourMaps.AddRange(maps); + } + + ApplyFilter(); + + StatusMessage = $"Loaded {maps.Count} maps."; + + // Load thumbnails in background to avoid UI hang + _ = Task.Run(() => + { + foreach (var map in maps) + { + if (map.ThumbnailPath != null && map.ThumbnailBitmap == null) + { + try + { + var bitmap = _tgaImageParser.LoadTgaThumbnail(map.ThumbnailPath); + if (bitmap != null) + { + // Update on UI thread if needed, but MapFile.ThumbnailBitmap + // now handles notification and Avalonia is thread-safe for Bitmap assignment + map.ThumbnailBitmap = bitmap; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load thumbnail for {Map}", map.FileName); + } + } + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load maps"); + _notificationService.ShowError("Load Error", "Failed to load maps."); + StatusMessage = "Error loading maps."; + } + finally + { + IsBusy = false; + } + } + + /// + /// Imports files from the specified paths. + /// + /// The paths of the files to import. + /// A representing the asynchronous operation. + public async Task ImportFilesAsync(IEnumerable filePaths) + { + IsBusy = true; + StatusMessage = "Importing files..."; + try + { + var result = await _importService.ImportFromFilesAsync(filePaths, SelectedTab); + if (result.Success) + { + _notificationService.ShowSuccess("Import Complete", $"Imported {result.FilesImported} file(s)."); + StatusMessage = $"Imported {result.FilesImported} file(s)."; + } + else + { + var errorMsg = result.Errors.Count > 0 ? string.Join("\n", result.Errors) : "No files were imported."; + _notificationService.ShowError("Import Failed", errorMsg); + StatusMessage = "Import failed."; + } + + await LoadMapsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Import from files failed"); + _notificationService.ShowError("Import Error", ex.Message); + StatusMessage = "Import error."; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task ImportFromUrlAsync() + { + if (string.IsNullOrWhiteSpace(ImportUrl)) + { + return; + } + + IsBusy = true; + StatusMessage = "Importing from URL..."; + Progress = 0; + + try + { + var result = await _importService.ImportFromUrlAsync(ImportUrl, SelectedTab, new Progress(p => Progress = p)); + if (result.Success) + { + _notificationService.ShowSuccess("Import Complete", $"Imported {result.FilesImported} file(s) from URL."); + StatusMessage = $"Successfully imported {result.FilesImported} file(s)."; + ImportUrl = string.Empty; + await LoadMapsAsync(); + } + else + { + var errorMsg = string.Join(" ", result.Errors); + _notificationService.ShowError("Import Failed", errorMsg); + StatusMessage = $"Import failed: {errorMsg}"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Import failed"); + _notificationService.ShowError("Import Error", ex.Message); + StatusMessage = "Import error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private async Task BrowseAndImportAsync() + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var topLevel = TopLevel.GetTopLevel(lifetime?.MainWindow); + if (topLevel == null) + { + return; + } + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Select Maps to Import", + AllowMultiple = true, + FileTypeFilter = + [ + new FilePickerFileType("Maps and ZIPs") { Patterns = [MapManagerConstants.MapFilePattern, MapManagerConstants.ZipFilePattern] }, + ], + }); + + if (files.Any()) + { + await ImportFilesAsync(files.Select(f => f.Path.LocalPath)); + } + } + + [RelayCommand] + private async Task DeleteSelectedAsync() + { + if (!SelectedMaps.Any()) + { + return; + } + + IsBusy = true; + StatusMessage = "Deleting maps..."; + + // Capture selected maps before clearing + var mapsToDelete = SelectedMaps.ToList(); + int count = mapsToDelete.Count; + + var result = await _directoryService.DeleteMapsAsync(mapsToDelete); + if (result) + { + // Remove from local lists to avoid full reload + foreach (var map in mapsToDelete) + { + GeneralsMaps.Remove(map); + ZeroHourMaps.Remove(map); + } + + ApplyFilter(); + SelectedMaps.Clear(); + + _notificationService.ShowSuccess("Deleted", $"Deleted {count} maps."); + StatusMessage = "Deleted successfully."; + } + else + { + _notificationService.ShowError("Delete Failed", "Could not delete selected maps."); + StatusMessage = "Deletion error."; + } + + IsBusy = false; + } + + [RelayCommand] + private async Task ExportToZipAsync() + { + if (!SelectedMaps.Any()) + { + return; + } + + IsBusy = true; + StatusMessage = "Creating ZIP..."; + Progress = 0; + + try + { + var directory = _directoryService.GetMapDirectory(SelectedTab); + var safeZipName = ZipName.EndsWith(Path.GetExtension(MapManagerConstants.ZipFilePattern), StringComparison.OrdinalIgnoreCase) + ? ZipName + : ZipName + Path.GetExtension(MapManagerConstants.ZipFilePattern); + + var destinationPath = Path.Combine(directory, safeZipName); + + if (File.Exists(destinationPath)) + { + var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; + var nameOnly = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + int count = 1; + while (File.Exists(destinationPath)) + { + destinationPath = Path.Combine(dir, $"{nameOnly} ({count}){ext}"); + count++; + } + } + + var result = await _exportService.ExportToZipAsync([.. SelectedMaps], destinationPath, new Progress(p => Progress = p)); + if (result != null) + { + _notificationService.ShowSuccess("Zip Created", $"Created {Path.GetFileName(result)} in map folder."); + StatusMessage = "ZIP created successfully."; + + await LoadMapsAsync(); + + try + { + System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{result}\""); + } + catch + { + /* Ignore explorer errors */ + } + } + else + { + _notificationService.ShowError("Zip Failed", "Failed to create ZIP archive."); + StatusMessage = "ZIP creation failed."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export ZIP directly"); + _notificationService.ShowError("Export Error", ex.Message); + StatusMessage = "Export error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand(CanExecute = nameof(HasSelectedZips))] + private async Task UncompressSelectedAsync() + { + if (!SelectedMaps.Any()) return; + + IsBusy = true; + Progress = 0; + StatusMessage = "Uncompressing..."; + + try + { + var zips = SelectedMaps.Where(m => m.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)).ToList(); + int successCount = 0; + int failCount = 0; + + foreach (var zip in zips) + { + var result = await _importService.ImportFromZipAsync(zip.FullPath, SelectedTab, new Progress(p => Progress = p)); + if (result.Success) + { + successCount++; + } + else + { + failCount++; + _logger.LogWarning("Failed to uncompress {Zip}: {Error}", zip.FileName, string.Join(", ", result.Errors)); + } + } + + if (successCount > 0) + { + _notificationService.ShowSuccess("Uncompress Complete", $"Successfully uncompressed {successCount} ZIP(s)."); + await LoadMapsAsync(); + } + + if (failCount > 0) + { + _notificationService.ShowError("Uncompress Failed", $"Failed to uncompress {failCount} ZIP(s)."); + } + + StatusMessage = "Uncompress complete."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to uncompress selected maps"); + _notificationService.ShowError("Uncompress Error", ex.Message); + StatusMessage = "Uncompress error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private async Task UploadAndShareAsync() + { + if (!SelectedMaps.Any()) + { + return; + } + + // Calculate total size of selected maps + // Calculate total size of selected maps + long totalSizeBytes = SelectedMaps.Sum(r => new FileInfo(r.FullPath).Length); + + // Check file size limit (10MB max per file/batch typically, but user said "File too large. Maximum upload size is 10MB") + // Note: The UI says "max 10MB per file". But usually there is a total limit too if zipped. + // Let's enforce the 10MB limit based on the total size if it's a ZIP, or per file? + // If multiple files are selected, they are zipped. The ZIP must be < 10MB? + // Start simple: If total > 10MB, warn. + if (totalSizeBytes > MapManagerConstants.MaxMapSizeBytes) + { + _notificationService.ShowError( + "File Too Large", + "File too large. Maximum upload size is 10MB."); + StatusMessage = "Upload too large (Max 10MB)."; + return; + } + + // Check rate limit + var isAllowed = await _uploadHistoryService.CanUploadAsync(totalSizeBytes); + if (!isAllowed) + { + var usage = await _uploadHistoryService.GetUsageInfoAsync(); + var resetDateLocal = usage.ResetDate.ToLocalTime(); + _notificationService.ShowError( + "Rate Limit Exceeded", + "Upload limit exceeded for the current 3-day period. Please remove items from your Upload History to free up quota immediately."); + StatusMessage = $"Limited reached. Resets {resetDateLocal:g}."; + return; + } + + IsBusy = true; + StatusMessage = "Uploading to cloud (UploadThing)..."; + Progress = 0; + + try + { + var url = await _exportService.UploadToUploadThingAsync([.. SelectedMaps], new Progress(p => Progress = p)); + if (url != null) + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(url); + } + + // Record successful upload + var fileName = SelectedMaps.Count == 1 ? SelectedMaps[0].FileName : "maps.zip"; + _uploadHistoryService.RecordUpload(totalSizeBytes, url, fileName); + + // Refresh history if open + if (IsHistoryOpen) + { + await LoadHistoryAsync(); + } + + StatusMessage = "Uploaded! Link copied to clipboard."; + _notificationService.ShowSuccess("Upload Complete", "Link copied to clipboard!"); + } + else + { + StatusMessage = "Upload failed. Check API key."; + _notificationService.ShowError("Upload Failed", "Upload failed. Please check your API key and internet connection."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Upload failed"); + _notificationService.ShowError("Upload Error", ex.Message); + StatusMessage = "Upload error."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private void OpenFolder() + { + _directoryService.OpenInExplorer(SelectedTab); + } + + [RelayCommand] + private void RevealFile(MapFile map) + { + _directoryService.RevealInExplorer(map); + } + + // MapPack Commands + [RelayCommand] + private void ToggleMapPackPanel() + { + IsMapPackPanelOpen = !IsMapPackPanelOpen; + } + + [RelayCommand] + private async Task LoadMapPacksAsync() + { + try + { + var packs = await _mapPackService.GetAllMapPacksAsync(); + MapPacks.Clear(); + foreach (var pack in packs) + { + MapPacks.Add(pack); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load MapPacks"); + } + } + + [RelayCommand] + private async Task CreateMapPackAsync() + { + if (string.IsNullOrWhiteSpace(NewMapPackName) || !SelectedMaps.Any()) + { + _notificationService.ShowWarning("Invalid Input", "Please provide a name and select maps."); + return; + } + + IsBusy = true; + StatusMessage = "Creating MapPack..."; + + try + { + var result = await _mapPackService.CreateCasMapPackAsync( + NewMapPackName, + SelectedTab, // Use current tab's game type + SelectedMaps, + new Progress(p => Progress = p.Percentage / 100.0)); + + if (result.Success) + { + _notificationService.ShowSuccess("MapPack Created", $"Created '{NewMapPackName}'. Enable it in your Profile."); + StatusMessage = "MapPack created successfully."; + + await LoadMapPacksAsync(); + + NewMapPackName = string.Empty; + IsMapPackPanelOpen = false; // Close modal on success + } + else + { + var error = result.FirstError ?? "Unknown error"; + _notificationService.ShowError("Creation Failed", error); + StatusMessage = "Creation failed."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create MapPack"); + _notificationService.ShowError("Creation Failed", ex.Message); + StatusMessage = "Creation failed."; + } + finally + { + IsBusy = false; + Progress = 0; + } + } + + [RelayCommand] + private async Task LoadMapPackAsync(MapPack mapPack) + { + try + { + var success = await _mapPackService.LoadMapPackAsync(mapPack.Id); + if (success) + { + mapPack.IsLoaded = true; + _notificationService.ShowSuccess("MapPack Loaded", $"Loaded '{mapPack.Name}'. Maps will be available on next profile launch."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load MapPack"); + _notificationService.ShowError("Load Failed", ex.Message); + } + } + + [RelayCommand] + private async Task UnloadMapPackAsync(MapPack mapPack) + { + try + { + var success = await _mapPackService.UnloadMapPackAsync(mapPack.Id); + if (success) + { + mapPack.IsLoaded = false; + _notificationService.ShowSuccess("MapPack Unloaded", $"Unloaded '{mapPack.Name}'."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to unload MapPack"); + _notificationService.ShowError("Unload Failed", ex.Message); + } + } + + [RelayCommand] + private async Task DeleteMapPackAsync(MapPack mapPack) + { + try + { + var success = await _mapPackService.DeleteMapPackAsync(mapPack.Id); + if (success) + { + MapPacks.Remove(mapPack); + _notificationService.ShowSuccess("MapPack Deleted", $"Deleted '{mapPack.Name}'."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete MapPack"); + _notificationService.ShowError("Delete Failed", ex.Message); + } + } + + // History Commands + [RelayCommand] + private void ToggleHistory() + { + IsHistoryOpen = !IsHistoryOpen; + if (IsHistoryOpen) + { + _ = LoadHistoryAsync(); + } + } + + [RelayCommand] + private async Task LoadHistoryAsync() + { + try + { + var history = await _uploadHistoryService.GetUploadHistoryAsync(); + UploadHistory.Clear(); + + foreach (var item in history) + { + UploadHistory.Add(new UploadHistoryItemViewModel(item)); + } + + // Verify file existence + _ = Task.Run(async () => + { + using var httpClient = new System.Net.Http.HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(5); + + foreach (var viewModel in UploadHistory) + { + try + { + var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, viewModel.Url); + var response = await httpClient.SendAsync(request); + + await Dispatcher.UIThread.InvokeAsync(() => + { + viewModel.FileExists = response.IsSuccessStatusCode; + viewModel.IsVerified = true; + }); + } + catch + { + await Dispatcher.UIThread.InvokeAsync(() => + { + viewModel.FileExists = false; + viewModel.IsVerified = true; + }); + } + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load upload history"); + } + } + + [RelayCommand] + private async Task CopyUrlAsync(string url) + { + try + { + var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + var clipboard = lifetime?.MainWindow?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(url); + _notificationService.ShowSuccess("Copied", "Link copied to clipboard."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to copy URL"); + } + } + + [RelayCommand] + private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) + { + try + { + await _uploadHistoryService.RemoveHistoryItemAsync(item.Url); + await LoadHistoryAsync(); + _notificationService.ShowSuccess("Removed", "History item removed."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove history item"); + } + } + + [RelayCommand] + private async Task ClearHistoryAsync() + { + try + { + await _uploadHistoryService.ClearHistoryAsync(); + await LoadHistoryAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clear history"); + } + } + + [RelayCommand] + private void CreateCasMapPack() + { + if (!SelectedMaps.Any()) + { + _notificationService.ShowWarning("Selection Required", "Please select at least one map."); + return; + } + + if (!IsMapPackPanelOpen) + { + IsMapPackPanelOpen = true; + _notificationService.ShowInfo("Create MapPack", "Enter a name and description in the panel, then click Create."); + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml new file mode 100644 index 000000000..aa0a7ef3a --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml @@ -0,0 +1,408 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + - + + + ToolTip.Tip="Copy the download link for this replay."> @@ -223,7 +224,7 @@ Command="{Binding $parent[UserControl].((vm:ReplayManagerViewModel)DataContext).RemoveHistoryItemCommand}" CommandParameter="{Binding}" Padding="6,4" Margin="4,0,0,0" VerticalAlignment="Center" - ToolTip.Tip="Remove from history"> + ToolTip.Tip="Remove this item from your local history check."> @@ -243,7 +244,7 @@ HorizontalAlignment="Stretch" Margin="0,8,0,0" IsVisible="{Binding UploadHistory.Count}" - ToolTip.Tip="Clear all upload history"> + ToolTip.Tip="Clear all items from your upload history."> @@ -259,7 +260,7 @@ + FontSize="11" Opacity="0.4" HorizontalAlignment="Left" Margin="0,8,0,0" TextWrapping="Wrap" MaxWidth="600"/> diff --git a/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs b/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs new file mode 100644 index 000000000..945e1faa4 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Services; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Tools; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Tools.Services; + +/// +/// Implementation of for tracking upload quotas. +/// +/// +/// Initializes a new instance of the class. +/// +/// Logger instance. +/// Application configuration service. +/// UploadThing service. +public sealed class UploadHistoryService( + ILogger logger, + IAppConfiguration appConfig, + IUploadThingService uploadThing) : IUploadHistoryService +{ + private const int RateLimitDays = 3; + private const int HistoryRetentionDays = 30; + + private static readonly object FileLock = new(); + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly string _historyFilePath = Path.Combine(appConfig.GetConfiguredDataPath(), "upload_history.json"); + + // Trigger cleanup on service instantiation (fire-and-forget) + private readonly Task _cleanupTask = Task.Run(async () => await ProcessPendingDeletionsAsync()); + private List? _cache; + + private static Task ProcessPendingDeletionsAsync() + { + return Task.CompletedTask; + } + + private static string? ExtractKeyFromUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + try + { + var uri = new Uri(url); + return uri.Segments.Last(); + } + catch + { + // Fallback for simple string logic if Uri fails + var lastSlash = url.LastIndexOf('/'); + return lastSlash >= 0 && lastSlash < url.Length - 1 ? url[(lastSlash + 1)..] : null; + } + } + + /// + public long MaxUploadBytesPerPeriod => MapManagerConstants.MaxUploadBytesPerPeriod; + + /// + public async Task CanUploadAsync(long fileSizeBytes) + { + var usage = await GetUsageInfoAsync(); + return usage.UsedBytes + fileSizeBytes <= usage.LimitBytes; + } + + /// + public void RecordUpload(long fileSizeBytes, string url, string fileName) + { + lock (FileLock) + { + try + { + var history = LoadHistoryInternal(); + history.Add(new UploadRecord + { + Timestamp = DateTime.UtcNow, + SizeBytes = fileSizeBytes, + Url = url, + FileName = fileName, + }); + + SaveHistoryInternal(history); + _cache = history; // Update cache + _logger.LogInformation("Recorded upload of {Size} bytes. Total history: {Count} items.", fileSizeBytes, history.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record upload"); + } + } + } + + /// + public Task GetUsageInfoAsync() + { + var history = LoadHistoryInternal(); + var periodStart = DateTime.UtcNow.AddDays(-RateLimitDays); + + // Include items even if pending deletion, as they still occupy quota until confirmed deleted + var recentUploads = history.Where(r => r.Timestamp >= periodStart).ToList(); + var usedBytes = recentUploads.Sum(r => r.SizeBytes); + + // Reset date is when the oldest upload in the current window expires + var oldestInWindow = recentUploads.OrderBy(r => r.Timestamp).FirstOrDefault(); + var resetDate = oldestInWindow != null + ? oldestInWindow.Timestamp.AddDays(RateLimitDays) + : DateTime.UtcNow; + + return Task.FromResult(new UsageInfo(usedBytes, MaxUploadBytesPerPeriod, resetDate)); + } + + /// + public Task> GetUploadHistoryAsync() + { + var history = LoadHistoryInternal(); + + // Return history, EXCLUDING pending deletions so user sees effective state + var items = history + .Where(r => !r.IsPendingDeletion) + .Select(r => new UploadHistoryItem( + r.Timestamp, + r.SizeBytes, + r.Url ?? string.Empty, + r.FileName ?? "Unknown File")); + + return Task.FromResult(items); + } + + /// + public async Task RemoveHistoryItemAsync(string url) + { + List history; + lock (FileLock) + { + history = LoadHistoryInternal(); + var item = history.FirstOrDefault(r => r.Url == url); + if (item != null && !item.IsPendingDeletion) + { + item.IsPendingDeletion = true; // Mark as pending + SaveHistoryInternal(history); // Persist state + _cache = history; + } + } + + // Attempt immediate deletion + await TryDeleteUrlAsync(url); + } + + /// + public async Task ClearHistoryAsync() + { + List history; + lock (FileLock) + { + history = LoadHistoryInternal(); + if (history.Count == 0 || history.All(x => x.IsPendingDeletion)) return; + + foreach (var item in history) + { + item.IsPendingDeletion = true; + } + + SaveHistoryInternal(history); + _cache = history; + } + + // Attempt deletion of all pending items + await ProcessPendingDeletionsAsync(); + } + + private async Task TryDeleteUrlAsync(string url) + { + var key = ExtractKeyFromUrl(url); + if (string.IsNullOrEmpty(key)) + { + _logger.LogError("Could not extract file key from URL: {Url}", url); + + // Even if invalid, we might want to just remove it from history? + // For now, keep it pending to avoid quota exploit via malformed URLs if that were possible. + // But practically, if we can't delete it, it's stuck. Let's remove it if invalid format. + RemoveFromHistoryPermanent(url); + return; + } + + var success = await uploadThing.DeleteFileAsync(key); + if (success) + { + RemoveFromHistoryPermanent(url); + _logger.LogInformation("Successfully deleted and removed history for: {Url}", url); + } + else + { + _logger.LogWarning("Failed to delete {Url}. Item remains in Pending Deletion state.", url); + } + } + + private void RemoveFromHistoryPermanent(string url) + { + lock (FileLock) + { + var history = LoadHistoryInternal(); + var removed = history.RemoveAll(r => r.Url == url); + if (removed > 0) + { + SaveHistoryInternal(history); + _cache = history; + } + } + } + + private async Task RunCleanupAsync() + { + List snapshot; + lock (FileLock) + { + var history = LoadHistoryInternal(); + snapshot = history.Where(x => x.IsPendingDeletion).ToList(); + } + + foreach (var item in snapshot) + { + if (item.Url != null) + { + await TryDeleteUrlAsync(item.Url); + } + } + } + + private List LoadHistoryInternal() + { + lock (FileLock) + { + if (_cache != null) + { + return new List(_cache); + } + + try + { + if (!File.Exists(_historyFilePath)) + { + _cache = new List(); + return new List(); + } + + var json = File.ReadAllText(_historyFilePath); + if (string.IsNullOrWhiteSpace(json)) + { + _cache = new List(); + return new List(); + } + + var history = JsonSerializer.Deserialize>(json, JsonOptions) ?? new List(); + + // Clean up old entries (expired retention) + var retentionCutoff = DateTime.UtcNow.AddDays(-HistoryRetentionDays); + + // Also remove items that were pending deletion and are very old? No, we keep trying. + // Filter logic: Keep if New enough OR (IsPendingDeletion AND New enough?) + // If it's pending deletion and 30 days old, maybe just give up? + // Let's stick to standard retention. If it's old, it falls off history anyway. + _cache = history.Where(r => r.Timestamp >= retentionCutoff).OrderByDescending(r => r.Timestamp).ToList(); + + return new List(_cache); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load upload history."); + return []; + } + } + } + + private void SaveHistoryInternal(List history) + { + lock (FileLock) + { + try + { + var directory = Path.GetDirectoryName(_historyFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(history, JsonOptions); + File.WriteAllText(_historyFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save upload history"); + } + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs b/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs new file mode 100644 index 000000000..7afc2a252 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs @@ -0,0 +1,196 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Services; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace GenHub.Features.Tools.Services; + +/// +/// Implementation of for uploading files to UploadThing cloud storage using V7 API. +/// +public sealed class UploadThingService( + HttpClient httpClient, + ILogger logger) : IUploadThingService +{ + /// + public async Task UploadFileAsync( + string filePath, + IProgress? progress = null, + CancellationToken ct = default) + { + var token = Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVar) ?? + Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVarAlt); + + // Fallback to build-time injected token if no env var is found + if (string.IsNullOrEmpty(token)) + { + token = ApiConstants.BuildTimeUploadThingToken; + } + + if (string.IsNullOrEmpty(token)) + { + logger.LogError("UploadThing V7 Token is missing. Ensure UPLOADTHING_TOKEN is set."); + return null; + } + + if (!File.Exists(filePath)) + { + logger.LogError("File to upload does not exist: {Path}", filePath); + return null; + } + + logger.LogInformation("Uploading to UploadThing V7: {Path}", filePath); + + try + { + var fileInfo = new FileInfo(filePath); + var fileName = Path.GetFileName(filePath); + + // Step 1: Prepare the upload + var requestPayload = new V7FileRequestDetail + { + FileName = fileName, + FileSize = fileInfo.Length, + ContentTypes = [ApiConstants.MediaTypeZip], + }; + + var prepareRequest = new HttpRequestMessage(HttpMethod.Post, ApiConstants.UploadThingPrepareUrl); + prepareRequest.Headers.Add(ApiConstants.UploadThingApiKeyHeader, token); + prepareRequest.Headers.Add(ApiConstants.UploadThingVersionHeader, ApiConstants.UploadThingApiVersion); + prepareRequest.Content = JsonContent.Create(requestPayload); + + var prepareResponse = await httpClient.SendAsync(prepareRequest, ct); + if (!prepareResponse.IsSuccessStatusCode) + { + var error = await prepareResponse.Content.ReadAsStringAsync(ct); + logger.LogError("V7 PrepareUpload failed: {Status} - {Error}", prepareResponse.StatusCode, error); + return null; + } + + var instruction = await prepareResponse.Content.ReadFromJsonAsync(cancellationToken: ct); + + if (instruction?.PresignedUrl == null || instruction?.Key == null) + { + var rawResponse = await prepareResponse.Content.ReadAsStringAsync(ct); + logger.LogError("UploadThing V7 returned 200 OK but missing required fields. Response: {Response}", rawResponse); + return null; + } + + // Step 2: Upload binary via PUT with multipart/form-data + using var fileStream = File.OpenRead(filePath); + var multipartContent = new MultipartFormDataContent(); + var fileContent = new StreamContent(fileStream); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(ApiConstants.MediaTypeZip); + multipartContent.Add(fileContent, "file", fileName); + + var uploadRequest = new HttpRequestMessage(HttpMethod.Put, instruction.PresignedUrl) + { + Content = multipartContent, + }; + uploadRequest.Headers.UserAgent.ParseAdd(ApiConstants.DefaultUserAgent); + + progress?.Report(0.6); + + var uploadResponse = await httpClient.SendAsync(uploadRequest, ct); + if (!uploadResponse.IsSuccessStatusCode) + { + var uploadError = await uploadResponse.Content.ReadAsStringAsync(ct); + logger.LogError("V7 PUT Binary Upload failed: {Status} - {Error}", uploadResponse.StatusCode, uploadError); + return null; + } + + var publicFileUrl = string.Format(ApiConstants.UploadThingPublicUrlFormat, instruction.Key); + + logger.LogInformation("UploadThing V7 successful. Public URL: {Url}", publicFileUrl); + progress?.Report(1.0); + + return publicFileUrl; + } + catch (Exception ex) + { + logger.LogError(ex, "Exception in UploadThing V7 flow"); + return null; + } + } + + /// + public async Task DeleteFileAsync(string fileKey, CancellationToken ct = default) + { + var token = Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVar) ?? + Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVarAlt); + + // Fallback to build-time injected token if no env var is found + if (string.IsNullOrEmpty(token)) + { + token = ApiConstants.BuildTimeUploadThingToken; + } + + if (string.IsNullOrEmpty(token)) + { + logger.LogError("UploadThing Token is missing."); + return false; + } + + try + { + var requestPayload = new V6DeleteRequest { FileKeys = [fileKey] }; + var request = new HttpRequestMessage(HttpMethod.Post, ApiConstants.UploadThingDeleteUrl); + request.Headers.Add(ApiConstants.UploadThingApiKeyHeader, token); + + request.Content = JsonContent.Create(requestPayload); + + var response = await httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(ct); + logger.LogError("UploadThing Delete failed: {Status} - {Error}", response.StatusCode, error); + return false; + } + + logger.LogInformation("Deleted file from UploadThing: {Key}", fileKey); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Exception deleting file from UploadThing"); + return false; + } + } + + // --- V7 Request DTOs --- + private sealed class V7FileRequestDetail + { + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("fileSize")] + public long FileSize { get; set; } + + [JsonPropertyName("contentTypes")] + public List ContentTypes { get; set; } = []; + } + + // --- V7 Response DTO --- + private sealed class V7FileInstruction + { + [JsonPropertyName("url")] + public string? PresignedUrl { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + } + + // --- V6 Delete DTO --- + private sealed class V6DeleteRequest + { + [JsonPropertyName("fileKeys")] + public List FileKeys { get; set; } = []; + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs index be1ea3b3a..a1ef9cd2c 100644 --- a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs @@ -55,10 +55,14 @@ public partial class ToolsViewModel(IToolManager toolService, ILogger IsPaneOpen = true; + + [RelayCommand] + private void ClosePane() => IsPaneOpen = false; [ObservableProperty] private bool _isDetailsDialogOpen = false; @@ -66,11 +70,6 @@ public partial class ToolsViewModel(IToolManager toolService, ILogger - /// Gets the tooltip text for the sidebar toggle button. - /// - public string SidebarToggleTooltip => IsSidebarCollapsed ? "Expand Sidebar" : "Collapse Sidebar"; - private System.Threading.CancellationTokenSource? _statusHideCts; /// @@ -384,17 +383,6 @@ private void SetStatusType(bool success = false, bool error = false, bool info = IsStatusInfo = info; } - /// - /// Toggles the sidebar collapsed state. - /// - [RelayCommand] - private void ToggleSidebar() - { - IsSidebarCollapsed = !IsSidebarCollapsed; - SidebarWidth = IsSidebarCollapsed ? 50 : 300; - OnPropertyChanged(nameof(SidebarToggleTooltip)); - } - /// /// Shows the details dialog for a specific tool. /// @@ -440,4 +428,4 @@ private async void ShowStatusMessage(string message, bool success = false, bool // Timer was cancelled, ignore } } -} +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs similarity index 96% rename from GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs rename to GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs index bcd0ef70e..80f64ee35 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/UploadHistoryItemViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs @@ -1,8 +1,8 @@ using System; using CommunityToolkit.Mvvm.ComponentModel; -using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Models.Common; -namespace GenHub.Features.Tools.ReplayManager.ViewModels; +namespace GenHub.Features.Tools.ViewModels; /// /// ViewModel for a single upload history item. diff --git a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml index 097a50377..3c6c753c1 100644 --- a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml +++ b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml @@ -3,57 +3,49 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:GenHub.Features.Tools.ViewModels" + xmlns:interfaces="clr-namespace:GenHub.Core.Interfaces.Tools;assembly=GenHub.Core" + xmlns:converters="clr-namespace:GenHub.Infrastructure.Converters" mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700" x:Class="GenHub.Features.Tools.Views.ToolsView" - x:DataType="vm:ToolsViewModel"> + x:DataType="vm:ToolsViewModel" + x:Name="Root"> + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + public const string ZipFilePattern = "*.zip"; + + /// /// File extension for backup files. /// public const string BackupExtension = ".ghbak"; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs index 1f6c65862..2306b9d29 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs @@ -32,7 +32,7 @@ public void Convert_ReturnsTrue_WhenPrMatches() Title = "PR 123", BranchName = "feature/123", Author = "user", - State = "open" + State = "open", }; var subscribedPr = new PullRequestInfo { @@ -40,7 +40,7 @@ public void Convert_ReturnsTrue_WhenPrMatches() Title = "PR 123", BranchName = "feature/123", Author = "user", - State = "open" + State = "open", }; var values = new List { pr, subscribedPr, "some-branch" }; @@ -64,7 +64,7 @@ public void Convert_ReturnsFalse_WhenPrDoesNotMatch() Title = "PR 123", BranchName = "feature/123", Author = "user", - State = "open" + State = "open", }; var subscribedPr = new PullRequestInfo { @@ -72,7 +72,7 @@ public void Convert_ReturnsFalse_WhenPrDoesNotMatch() Title = "PR 456", BranchName = "feature/456", Author = "user", - State = "open" + State = "open", }; var values = new List { pr, subscribedPr, "some-branch" }; diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 1a33fc53a..ff162bafa 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -1176,6 +1176,7 @@ private static async Task DownloadFileWithProgressAsync( totalRead += read; var currentTime = stopwatch.ElapsedMilliseconds; + // Report every 500ms if (currentTime - lastReportTime >= 500 || !isMoreToRead) { diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml index 7332bc469..fc4a522ba 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml @@ -88,7 +88,7 @@ - + diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs index 121706549..c15efe532 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -22,27 +22,15 @@ namespace GenHub.Features.Content.Services.GitHub; /// /// Resolves a discovered GitHub release into a full ContentManifest. /// -public partial class GitHubResolver : IContentResolver +public partial class GitHubResolver( + IGitHubApiClient gitHubApiClient, + IServiceProvider serviceProvider, + ILogger logger) + : IContentResolver { - private readonly IGitHubApiClient _gitHubApiClient; - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// API client for GitHub interactions. - /// Service provider to resolve factories and helpers. - /// Logger instance. - public GitHubResolver( - IGitHubApiClient gitHubApiClient, - IServiceProvider serviceProvider, - ILogger logger) - { - _gitHubApiClient = gitHubApiClient ?? throw new ArgumentNullException(nameof(gitHubApiClient)); - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + private readonly IGitHubApiClient _gitHubApiClient = gitHubApiClient ?? throw new ArgumentNullException(nameof(gitHubApiClient)); + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Regex breakdown: // ^https://github\.com/ diff --git a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml index aa0a7ef3a..c530da3be 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml +++ b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml @@ -18,6 +18,8 @@ + + @@ -405,4 +407,3 @@ - diff --git a/GenHub/GenHub/Infrastructure/Converters/EnumToBoolConverter.cs b/GenHub/GenHub/Infrastructure/Converters/EnumToBoolConverter.cs new file mode 100644 index 000000000..7272e1a37 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/EnumToBoolConverter.cs @@ -0,0 +1,24 @@ +using Avalonia.Data; +using Avalonia.Data.Converters; +using System; +using System.Globalization; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converter to convert Enum values to Boolean for RadioButtons. +/// +public class EnumToBoolConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value?.Equals(parameter); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is bool b && b ? parameter : BindingOperations.DoNothing; + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs b/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs index c84f7d189..0427165d4 100644 --- a/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs +++ b/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs @@ -33,9 +33,17 @@ public class IsSubscribedConverter : IMultiValueConverter return false; } - /// - public object?[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) + + /// + /// Not implemented. + /// + /// The value to convert back. + /// The target types. + /// The converter parameter. + /// The culture info. + /// An empty array. + public object?[] ConvertBack(object? _, Type[] __, object? ___, CultureInfo ____) { - return Array.Empty(); + return []; } } From c9079b6deb0fbf4ba9383a12cbb2abe90534cf67 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Thu, 8 Jan 2026 20:01:07 +0100 Subject: [PATCH 8/9] fix: resolve build version missmatch and the add local content DragDrop not working admin --- GenHub/Directory.Build.props | 13 +- .../Extensions/ContentTypeExtensions.cs | 10 +- .../Content/ILocalContentService.cs | 7 + .../Models/Results/OperationResult.cs | 48 +-- .../Models/Results/OperationResultOfT.cs | 72 ++++ .../Services/Content/LocalContentService.cs | 30 ++ .../GenHub.Core/Utilities/PrivilegeHelpers.cs | 41 ++ .../GenHub.ProxyLauncher.csproj | 1 + .../Converters/IsSubscribedConverterTests.cs | 13 - GenHub/GenHub.Windows/GenHub.Windows.csproj | 2 +- .../Downloads/Views/PublisherCardView.axaml | 2 +- .../ViewModels/AddLocalContentViewModel.cs | 53 ++- .../GameProfileSettingsViewModel.cs | 396 +++++++++++------- .../Views/AddLocalContentWindow.axaml | 12 +- .../Views/AddLocalContentWindow.axaml.cs | 25 +- .../Views/GameProfileSettingsWindow.axaml | 75 +--- .../Services/UrlParserService.cs | 15 + .../Views/ReplayManagerView.axaml | 5 +- GenHub/GenHub/GenHub.csproj | 1 + .../Converters/IsSubscribedConverter.cs | 13 - .../Interop/AdminDragDropFix.cs | 323 ++++++++++++++ .../Providers/communityoutpost.provider.json | 2 +- build-release.ps1 | 2 +- docs/dev/constants.md | 9 + 24 files changed, 862 insertions(+), 308 deletions(-) create mode 100644 GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs create mode 100644 GenHub/GenHub.Core/Utilities/PrivilegeHelpers.cs create mode 100644 GenHub/GenHub/Infrastructure/Interop/AdminDragDropFix.cs diff --git a/GenHub/Directory.Build.props b/GenHub/Directory.Build.props index 12858a91b..51855b42d 100644 --- a/GenHub/Directory.Build.props +++ b/GenHub/Directory.Build.props @@ -4,9 +4,9 @@ Alpha versioning: 0.0.X format CI will override with: 0.0.{runNumber} or 0.0.{runNumber}-pr{prNumber} --> - 0.0.1 - 0.0.1.0 - 0.0.1.0 + 0.0.1 + $(Version) + $(Version) true @@ -15,15 +15,16 @@ Build info for CI - these are overridden by CI workflow via MSBuild properties: dotnet build -p:GitShortHash=abc1234 -p:PullRequestNumber=42 -p:BuildChannel=PR --> - - - Dev + + + Dev $(Version)+$(GitShortHash) + $(Version) diff --git a/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs b/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs index abd5b25b2..e57a228ee 100644 --- a/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/ContentTypeExtensions.cs @@ -17,17 +17,18 @@ public static string GetDisplayName(this ContentType contentType) return contentType switch { ContentType.GameInstallation => "Game Installation", - ContentType.GameClient => "Game Client", - ContentType.Mod => "Modification", + ContentType.GameClient => "Executable", + ContentType.Mod => "Mods", ContentType.Patch => "Patch", - ContentType.Addon => "Add-on", - ContentType.MapPack => "Map Pack", + ContentType.Addon => "Addons", + ContentType.MapPack => "Maps", ContentType.Map => "Map", ContentType.Mission => "Mission", ContentType.LanguagePack => "Language Pack", ContentType.ContentBundle => "Content Bundle", ContentType.PublisherReferral => "Publisher Referral", ContentType.ContentReferral => "Content Referral", + ContentType.ModdingTool => "Tools", _ => contentType.ToString(), }; } @@ -54,6 +55,7 @@ public static string ToManifestIdString(this ContentType contentType) ContentType.ContentReferral => "contentreferral", ContentType.Mission => "mission", ContentType.Map => "map", + ContentType.ModdingTool => "moddingtool", ContentType.UnknownContentType => "unknown", _ => "unknown", }; diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs index c5a8f1b09..9818bbdde 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs @@ -43,6 +43,13 @@ Task> AddLocalContentAsync( ContentType contentType, GameType targetGame); + /// + /// Deletes local content by removing its manifest and potentially deleting files. + /// + /// The manifest ID of the content to delete. + /// A result indicating success or failure. + Task DeleteLocalContentAsync(string manifestId); + /// /// Gets the allowed content types for local content creation. /// diff --git a/GenHub/GenHub.Core/Models/Results/OperationResult.cs b/GenHub/GenHub.Core/Models/Results/OperationResult.cs index 3ff6ec087..82c3f9270 100644 --- a/GenHub/GenHub.Core/Models/Results/OperationResult.cs +++ b/GenHub/GenHub.Core/Models/Results/OperationResult.cs @@ -2,68 +2,46 @@ namespace GenHub.Core.Models.Results; -/// Represents the result of an operation, including success/failure, data, and errors. -/// The type of data returned by the operation. -public class OperationResult : ResultBase +/// Represents the result of an operation without return data. +public class OperationResult : ResultBase { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Whether the operation succeeded. - /// The data returned by the operation. /// The errors, if any. /// The elapsed time. - protected OperationResult(bool success, T? data, IEnumerable? errors = null, TimeSpan elapsed = default) + protected OperationResult(bool success, IEnumerable? errors = null, TimeSpan elapsed = default) : base(success, errors, elapsed) { - Data = data; } - /// Gets the data returned by the operation. - [NotNullIfNotNull("Success")] - public T? Data { get; } - - /// Gets a value indicating whether the operation was successful. - [MemberNotNullWhen(true, nameof(Data))] - public new bool Success => base.Success; - /// Creates a successful operation result. - /// The data returned by the operation. /// The elapsed time. - /// A successful . - public static OperationResult CreateSuccess(T data, TimeSpan elapsed = default) + /// A successful . + public static OperationResult CreateSuccess(TimeSpan elapsed = default) { - return new OperationResult(true, data, null, elapsed); + return new OperationResult(true, null, elapsed); } /// Creates a failed operation result with a single error message. /// The error message. /// The elapsed time. - /// A failed . - public static OperationResult CreateFailure(string error, TimeSpan elapsed = default) + /// A failed . + public static OperationResult CreateFailure(string error, TimeSpan elapsed = default) { - return new OperationResult(false, default, new[] { error }, elapsed); + return new OperationResult(false, [error], elapsed); } /// Creates a failed operation result with multiple error messages. /// The error messages. /// The elapsed time. - /// A failed . - public static OperationResult CreateFailure(IEnumerable errors, TimeSpan elapsed = default) + /// A failed . + public static OperationResult CreateFailure(IEnumerable errors, TimeSpan elapsed = default) { ArgumentNullException.ThrowIfNull(errors, nameof(errors)); if (!errors.Any()) throw new ArgumentException("Errors collection cannot be empty.", nameof(errors)); - return new OperationResult(false, default, errors, elapsed); - } - - /// Creates a failed operation result from another result, copying its errors. - /// The source result to copy errors from. - /// The elapsed time. - /// A failed with copied errors. - public static OperationResult CreateFailure(ResultBase result, TimeSpan elapsed = default) - { - ArgumentNullException.ThrowIfNull(result, nameof(result)); - return new OperationResult(false, default, result.Errors ?? Enumerable.Empty(), elapsed); + return new OperationResult(false, errors, elapsed); } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs b/GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs new file mode 100644 index 000000000..e936cb58d --- /dev/null +++ b/GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs @@ -0,0 +1,72 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GenHub.Core.Models.Results; + +// File name intentionally matches generic type param T style, avoiding rename friction +#pragma warning disable SA1649 // File name should match first type name + +/// Represents the result of an operation, including success/failure, data, and errors. +/// The type of data returned by the operation. +public class OperationResult : ResultBase +{ + /// + /// Initializes a new instance of the class. + /// + /// Whether the operation succeeded. + /// The data returned by the operation. + /// The errors, if any. + /// The elapsed time. + protected OperationResult(bool success, T? data, IEnumerable? errors = null, TimeSpan elapsed = default) + : base(success, errors, elapsed) + { + Data = data; + } + + /// Gets the data returned by the operation. + [NotNullIfNotNull(nameof(Success))] + public T? Data { get; } + + /// Gets a value indicating whether the operation was successful. + [MemberNotNullWhen(true, nameof(Data))] + public new bool Success => base.Success; + + /// Creates a successful operation result. + /// The data returned by the operation. + /// The elapsed time. + /// A successful . + public static OperationResult CreateSuccess(T data, TimeSpan elapsed = default) + { + return new OperationResult(true, data, null, elapsed); + } + + /// Creates a failed operation result with a single error message. + /// The error message. + /// The elapsed time. + /// A failed . + public static OperationResult CreateFailure(string error, TimeSpan elapsed = default) + { + return new OperationResult(false, default, [error], elapsed); + } + + /// Creates a failed operation result with multiple error messages. + /// The error messages. + /// The elapsed time. + /// A failed . + public static OperationResult CreateFailure(IEnumerable errors, TimeSpan elapsed = default) + { + ArgumentNullException.ThrowIfNull(errors, nameof(errors)); + if (!errors.Any()) + throw new ArgumentException("Errors collection cannot be empty.", nameof(errors)); + return new OperationResult(false, default, errors, elapsed); + } + + /// Creates a failed operation result from another result, copying its errors. + /// The source result to copy errors from. + /// The elapsed time. + /// A failed with copied errors. + public static OperationResult CreateFailure(ResultBase result, TimeSpan elapsed = default) + { + ArgumentNullException.ThrowIfNull(result, nameof(result)); + return new OperationResult(false, default, result.Errors ?? [], elapsed); + } +} diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs index dca858fd7..6721e1941 100644 --- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs +++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs @@ -141,6 +141,36 @@ public Task> AddLocalContentAsync( return CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame); } + /// + public async Task DeleteLocalContentAsync(string manifestId) + { + try + { + logger.LogInformation("Deleting local content with manifest ID '{ManifestId}'", manifestId); + + // Deleting local content involves: + // 1. Removing the manifest from the storage/pool + // 2. Potentially removing the local files if they were managed/copied by GenHub (via CAS) + + // For now, we primarily just remove the content from the storage service + var result = await contentStorageService.RemoveContentAsync(ManifestId.Create(manifestId)); + + if (!result.Success) + { + logger.LogWarning("Failed to delete local content '{ManifestId}': {Error}", manifestId, result.FirstError); + return OperationResult.CreateFailure(result.FirstError ?? "Unknown error occurred during deletion"); + } + + logger.LogInformation("Successfully deleted local content '{ManifestId}'", manifestId); + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error deleting local content '{ManifestId}'", manifestId); + return OperationResult.CreateFailure($"Failed to delete content: {ex.Message}"); + } + } + /// /// Sanitizes a name for use in a manifest ID. /// diff --git a/GenHub/GenHub.Core/Utilities/PrivilegeHelpers.cs b/GenHub/GenHub.Core/Utilities/PrivilegeHelpers.cs new file mode 100644 index 000000000..bf9e794fc --- /dev/null +++ b/GenHub/GenHub.Core/Utilities/PrivilegeHelpers.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace GenHub.Core.Utilities; + +/// +/// Helper methods for checking process privileges. +/// +public static class PrivilegeHelpers +{ + private static bool? _isAdministrator; + + /// + /// Gets a value indicating whether the current process is running as Administrator. + /// + public static bool IsAdministrator + { + get + { + if (_isAdministrator.HasValue) + { + return _isAdministrator.Value; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + _isAdministrator = principal.IsInRole(WindowsBuiltInRole.Administrator); + } + else + { + // On non-Windows platforms, we assume false for now or implement specific checks if needed. + // For this specific issue (Windows UIPI), we only care about Windows Admin. + _isAdministrator = false; + } + + return _isAdministrator.Value; + } + } +} diff --git a/GenHub/GenHub.ProxyLauncher/GenHub.ProxyLauncher.csproj b/GenHub/GenHub.ProxyLauncher/GenHub.ProxyLauncher.csproj index d27b94b58..b940c1ed2 100644 --- a/GenHub/GenHub.ProxyLauncher/GenHub.ProxyLauncher.csproj +++ b/GenHub/GenHub.ProxyLauncher/GenHub.ProxyLauncher.csproj @@ -9,6 +9,7 @@ true false embedded + true diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs index 2306b9d29..640e1cd85 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs @@ -116,17 +116,4 @@ public void Convert_ReturnsFalse_WhenValuesCountTooLow() // Assert Assert.False((bool?)result); } - - /// - /// Verifies that ConvertBack returns an empty array. - /// - [Fact] - public void ConvertBack_ReturnsEmptyArray() - { - // Act - var result = _converter.ConvertBack(true, new[] { typeof(object), typeof(object), typeof(object) }, null, CultureInfo.InvariantCulture); - - // Assert - Assert.Empty(result); - } } diff --git a/GenHub/GenHub.Windows/GenHub.Windows.csproj b/GenHub/GenHub.Windows/GenHub.Windows.csproj index 9b808339a..604dd4651 100644 --- a/GenHub/GenHub.Windows/GenHub.Windows.csproj +++ b/GenHub/GenHub.Windows/GenHub.Windows.csproj @@ -44,7 +44,7 @@ - + diff --git a/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml b/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml index 26b73eeee..ccb9878e5 100644 --- a/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml +++ b/GenHub/GenHub/Features/Downloads/Views/PublisherCardView.axaml @@ -190,7 +190,7 @@ - @@ -180,7 +180,7 @@ - + @@ -221,30 +221,34 @@ +