diff --git a/.github/scripts/inject-token.ps1 b/.github/scripts/inject-token.ps1 new file mode 100644 index 000000000..f41d16e12 --- /dev/null +++ b/.github/scripts/inject-token.ps1 @@ -0,0 +1,47 @@ +param( + [string]$Token +) + +if ([string]::IsNullOrEmpty($Token)) { + # For PRs from forks, secrets are not available. We use a dummy token to allow the build to pass. + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + Write-Host "Warning: No UPLOADTHING_TOKEN provided. Using dummy token for PR build." + $Token = "DUMMY_TOKEN_FOR_CI_ONLY" + } else { + Write-Error "No UPLOADTHING_TOKEN provided. Fails for Release builds." + exit 1 + } +} + +$constantsPath = "GenHub/GenHub.Core/Constants/ApiConstants.cs" +if (-not (Test-Path $constantsPath)) { + Write-Error "Could not find $constantsPath" + exit 1 +} + +$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 + +# Use regex replace to be robust against whitespace +# Pattern: byte[] data = []; // [PLACEHOLDER_DATA] +$content = [System.Text.RegularExpressions.Regex]::Replace($content, 'byte\[\]\s+data\s*=\s*\[\];\s*//\s*\[PLACEHOLDER_DATA\]', "byte[] data = [$dataStr];") +$content = [System.Text.RegularExpressions.Regex]::Replace($content, 'byte\[\]\s+key\s*=\s*\[\];\s*//\s*\[PLACEHOLDER_KEY\]', "byte[] key = [$keyStr];") + +if ($content -notmatch "0x") { + Write-Error "Token injection failed! Placeholders were not found or replaced." + exit 1 +} + +Set-Content $constantsPath $content +Write-Host "Successfully injected and obfuscated UPLOADTHING_TOKEN into ApiConstants.cs" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6cc166c4..837e9ae40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,11 @@ jobs: echo "VERSION=$version" >> $env:GITHUB_OUTPUT echo "CHANNEL=$channel" >> $env:GITHUB_OUTPUT + - name: Inject Secrets + shell: pwsh + run: | + ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" + - name: Build Projects shell: pwsh run: | @@ -142,6 +147,7 @@ jobs: Write-Host "Building Windows project" dotnet build "${{ env.WINDOWS_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} @buildProps + - name: Publish Windows App shell: pwsh run: | @@ -279,6 +285,11 @@ jobs: echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + - name: Inject Secrets + shell: pwsh + run: | + ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" + - name: Build Projects run: | BUILD_PROPS="-p:Version=${{ steps.buildinfo.outputs.VERSION }} -p:GitShortHash=${{ steps.buildinfo.outputs.SHORT_HASH }} -p:PullRequestNumber=${{ steps.buildinfo.outputs.PR_NUMBER }} -p:BuildChannel=${{ steps.buildinfo.outputs.CHANNEL }}" 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/.github/workflows/release.yml b/.github/workflows/release.yml index c1a46b68d..9cee9e205 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,11 @@ jobs: $version = "0.0.${{ github.run_number }}" echo "VERSION=$version" >> $env:GITHUB_OUTPUT + - name: Inject Secrets + shell: pwsh + run: | + ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" + - name: Publish Windows App shell: pwsh run: | @@ -68,7 +73,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 +116,11 @@ 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: | + ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" + - name: Publish Linux App run: | dotnet publish "${{ env.LINUX_PROJECT }}" \ @@ -136,7 +146,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 +204,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,12 +215,12 @@ jobs: prerelease: true body: | ## GenHub Alpha v${{ steps.info.outputs.VERSION }} - + ### 🆕 What's New **${{ steps.info.outputs.COUNT }} commits** included: ${{ steps.info.outputs.CHANGELOG }} ### 🔧 Assets - - **Installer:** `GenHub-win-Setup.exe` - - **Portable:** `GenHub-${{ steps.info.outputs.VERSION }}-win-portable.zip` + - **Installer:** [GenHub-win-Setup.exe](https://github.com/${{ github.repository }}/releases/download/v${{ steps.info.outputs.VERSION }}/GenHub-win-Setup.exe) + - **Portable:** [GenHub-${{ steps.info.outputs.VERSION }}-win-portable.zip](https://github.com/${{ github.repository }}/releases/download/v${{ steps.info.outputs.VERSION }}/GenHub-${{ steps.info.outputs.VERSION }}-win-portable.zip) files: final-assets/* diff --git a/GenHub/Directory.Build.props b/GenHub/Directory.Build.props index 12858a91b..d5d411692 100644 --- a/GenHub/Directory.Build.props +++ b/GenHub/Directory.Build.props @@ -4,9 +4,16 @@ 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 + + + <_NumericVersion Condition="$(Version.Contains('-'))">$(Version.Substring(0, $(Version.IndexOf('-')))) + <_NumericVersion Condition="!$(Version.Contains('-'))">$(Version) + $(_NumericVersion) + $(_NumericVersion) true @@ -15,15 +22,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/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 438ee388b..44fdd054d 100644 --- a/GenHub/GenHub.Core/Constants/ApiConstants.cs +++ b/GenHub/GenHub.Core/Constants/ApiConstants.cs @@ -59,6 +59,98 @@ 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 delete file URL. + /// + public const string UploadThingDeleteUrl = "https://api.uploadthing.com/v6/deleteFiles"; + + /// + /// 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/"; + + /// + /// 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 + + /// + /// 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"; + /// /// Format string for GitHub API Workflow Runs endpoint (owner, repo). /// @@ -70,4 +162,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/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 bc4c49b5e..414ce0d4b 100644 --- a/GenHub/GenHub.Core/Constants/FileTypes.cs +++ b/GenHub/GenHub.Core/Constants/FileTypes.cs @@ -35,6 +35,26 @@ public static class FileTypes /// 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"; + /// /// File extension for backup files. /// 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/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/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..634cbeec7 --- /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 upload bytes per period (10 MB). + /// + public const long MaxUploadBytesPerPeriod = 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/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/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/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/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/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/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/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/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/Models/Common/UploadHistoryItem.cs b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs new file mode 100644 index 000000000..d2b457c3b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs @@ -0,0 +1,16 @@ +using System; + +namespace GenHub.Core.Models.Common; + +/// +/// 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( + DateTime Timestamp, + long SizeBytes, + string Url, + string FileName); diff --git a/GenHub/GenHub.Core/Models/Common/UsageInfo.cs b/GenHub/GenHub.Core/Models/Common/UsageInfo.cs new file mode 100644 index 000000000..27053be1a --- /dev/null +++ b/GenHub/GenHub.Core/Models/Common/UsageInfo.cs @@ -0,0 +1,11 @@ +using System; + +namespace GenHub.Core.Models.Common; + +/// +/// Represents usage information for upload limits. +/// +/// 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); diff --git a/GenHub/GenHub.Core/Models/Enums/ContentType.cs b/GenHub/GenHub.Core/Models/Enums/ContentType.cs index df6ea3d79..e22bdfadb 100644 --- a/GenHub/GenHub.Core/Models/Enums/ContentType.cs +++ b/GenHub/GenHub.Core/Models/Enums/ContentType.cs @@ -21,9 +21,6 @@ public enum ContentType /// Major gameplay changes. Mod, - /// Major gameplay changes (alias for Mod). - Mods, - /// Balance/configuration changes. Patch, 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/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/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..7b3064444 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayFile.cs @@ -0,0 +1,52 @@ +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Tools.ReplayManager; + +/// +/// Represents a replay file on disk. +/// +public sealed class ReplayFile : IExportableFile +{ + /// + /// 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/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/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.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/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/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.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.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.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/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/Converters/IsSubscribedConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs index 1f6c65862..640e1cd85 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" }; @@ -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.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/GameProfiles/Services/ContentDisplayFormatterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ContentDisplayFormatterTests.cs index c8b49f6ad..58bc15357 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ContentDisplayFormatterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ContentDisplayFormatterTests.cs @@ -311,9 +311,9 @@ public void GetGameTypeDisplayName_ReturnsCorrectName(GameType gameType, bool us /// The expected display name. [Theory] [InlineData(ContentType.GameInstallation, "Game Installation")] - [InlineData(ContentType.GameClient, "Game Client")] - [InlineData(ContentType.Mod, "Modification")] - [InlineData(ContentType.MapPack, "Map Pack")] + [InlineData(ContentType.GameClient, "Executable")] + [InlineData(ContentType.Mod, "Mods")] + [InlineData(ContentType.MapPack, "Maps")] [InlineData(ContentType.Patch, "Patch")] public void GetContentTypeDisplayName_ReturnsCorrectName(ContentType contentType, string expected) { 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/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.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 9bbbe0421..604dd4651 100644 --- a/GenHub/GenHub.Windows/GenHub.Windows.csproj +++ b/GenHub/GenHub.Windows/GenHub.Windows.csproj @@ -13,6 +13,7 @@ + None @@ -28,6 +29,14 @@ + + + + .env + PreserveNewest + + + @@ -35,7 +44,7 @@ - + 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/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/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/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/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index e6aa2a6e2..2bf96c7f0 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -101,8 +101,8 @@ public async Task> DeliverContentAsync( { downloadProgress = new Progress(dp => { - // Map download progress (0-100) to the Downloading phase range (40-60%) - // We start at 40 (ProgressStepDownloading) and use 20% of the range for downloads + // Map download progress (0-100) to the Downloading phase range (40-65%) + // We start at 40 (ProgressStepDownloading) and use 25% of the range for downloads double downloadRange = 25.0; // 40% to 65% double fileProgressRange = downloadRange / totalFiles; double baseProgress = ContentConstants.ProgressStepDownloading + ((currentFileIndex - 1) * fileProgressRange); 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..c15efe532 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -25,16 +25,19 @@ namespace GenHub.Features.Content.Services.GitHub; public partial class GitHubResolver( IGitHubApiClient gitHubApiClient, IServiceProvider serviceProvider, - ILogger logger) : IContentResolver + ILogger logger) + : IContentResolver { + 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/ // (?[^/]+) -> owner // /(?[^/]+) -> repo // (?:/releases/tag/(?[^/]+))? -> optional tag - [GeneratedRegex( - ApiConstants.GitHubUrlRegexPattern, - RegexOptions.Compiled)] + [GeneratedRegex(ApiConstants.GitHubUrlRegexPattern, RegexOptions.Compiled)] private static partial Regex GitHubUrlRegex(); /// @@ -66,7 +69,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 +87,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 +98,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 +133,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 +151,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 +168,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 +181,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 +205,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 +269,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 +339,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 +364,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/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 @@ + + + + + + diff --git a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml.cs b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml.cs new file mode 100644 index 000000000..2f02265bc --- /dev/null +++ b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml.cs @@ -0,0 +1,117 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Features.Tools.MapManager.ViewModels; +using System; +using System.IO; +using System.Linq; + +namespace GenHub.Features.Tools.MapManager.Views; + +/// +/// Code-behind for MapManagerView. +/// +public partial class MapManagerView : UserControl +{ + private Border? _dragDropOverlay; + + /// + /// Initializes a new instance of the class. + /// + public MapManagerView() + { + InitializeComponent(); + AddHandler(DragDrop.DragOverEvent, DragOver); + AddHandler(DragDrop.DragLeaveEvent, DragLeave); + AddHandler(DragDrop.DropEvent, Drop); + + var dataGrid = this.Find("MapsGrid"); + if (dataGrid != null) + { + dataGrid.SelectionChanged += OnSelectionChanged; + } + } + + 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 Directory.Exists(path) || + path.EndsWith(".map", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + }); + + if (hasValidFiles) + { + e.DragEffects = DragDropEffects.Copy; + if (_dragDropOverlay != null) + { + _dragDropOverlay.IsVisible = true; + _dragDropOverlay.Opacity = 1.0; + } + + e.Handled = true; + } + else + { + e.DragEffects = DragDropEffects.None; + } + } + else + { + e.DragEffects = DragDropEffects.None; + } + } + + private void DragLeave(object? sender, RoutedEventArgs e) + { + if (_dragDropOverlay != null) + { + _dragDropOverlay.Opacity = 0.0; + _dragDropOverlay.IsVisible = false; + } + } + + 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 MapManagerViewModel vm) + { + var filePaths = files.Select(f => f.Path.LocalPath).ToList(); + await vm.ImportFilesAsync(filePaths); + } + + e.Handled = true; + } + } + + private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (sender is DataGrid dg && DataContext is MapManagerViewModel vm) + { + var selected = dg.SelectedItems.OfType().ToList(); + vm.UpdateSelectedMaps(selected); + } + } +} 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..1c801ee98 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayExportService.cs @@ -0,0 +1,125 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Services; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Tools.ReplayManager; +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.ReplayManager.Services; + +/// +/// Implementation of for exporting and sharing replays. +/// +public sealed class ReplayExportService( + IUploadThingService uploadThingService, + IZipValidationService zipValidationService, + ILogger logger) : IReplayExportService +{ + /// + public async Task UploadToUploadThingAsync( + IEnumerable replays, + IProgress? progress = null, + CancellationToken ct = default) + { + 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("ZIP validation failed for upload: {Error}", 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 ExportToZipAsync(replayList, tempZip, progress, ct); + if (createdZip == null) return null; + + zipToUpload = createdZip; + isTemporaryZip = true; + } + + if (new FileInfo(zipToUpload).Length > ReplayManagerConstants.MaxReplaySizeBytes) + { + logger.LogError("File exceeds size limit: {Path}", zipToUpload); + return null; + } + + return await uploadThingService.UploadFileAsync(zipToUpload, progress, ct); + } + catch (ArgumentException) + { + throw; // Bubble up validation errors + } + 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 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, "Failed to create ZIP: {Path}", destinationPath); + return null; + } + } +} 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/UrlParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs new file mode 100644 index 000000000..f7d74d459 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/UrlParserService.cs @@ -0,0 +1,138 @@ +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; + } + + // Check for raw Match ID (e.g., "151553") + if (long.TryParse(url, out _)) + { + return ReplaySource.GeneralsOnline; + } + + 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) + { + // If the URL is just a number, treat it as a match ID + if (long.TryParse(url, out long matchId)) + { + // Reconstruct: https://www.playgenerals.online/viewmatch?match=123 + // Note: ApiConstants.GeneralsOnlineViewMatchFragment is "playgenerals.online/viewmatch" + // We use GeneralsOnlineConstants.WebsiteUrl which is "https://www.playgenerals.online" + url = $"{GeneralsOnlineConstants.WebsiteUrl}/viewmatch?match={matchId}"; + } + + // 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..959b48090 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs @@ -0,0 +1,684 @@ +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.Common; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Features.Tools.ViewModels; +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 history and rate limit service. +/// The notification service. +/// The logger instance. +public partial class ReplayManagerViewModel( + IReplayDirectoryService directoryService, + IReplayImportService importService, + IReplayExportService exportService, + IUploadHistoryService uploadHistoryService, + 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 uploadHistoryService.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 uploadHistoryService.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 uploadHistoryService.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 file size limit + const long MaxReplayUploadSize = 10 * 1024 * 1024; // 10MB + if (totalSizeBytes > MaxReplayUploadSize) + { + 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([.. 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"; + 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(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(); + } +} \ No newline at end of file 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..b08e8f05a --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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 e98b9243a..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,17 +70,12 @@ 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; /// /// Gets the collection of installed tools. /// - public ObservableCollection InstalledTools { get; } = new(); + public ObservableCollection InstalledTools { get; } = []; /// /// Initializes the ViewModel by loading saved tools. @@ -153,13 +152,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 +204,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 { @@ -379,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. /// diff --git a/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs new file mode 100644 index 000000000..80f64ee35 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ViewModels/UploadHistoryItemViewModel.cs @@ -0,0 +1,106 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using GenHub.Core.Models.Common; + +namespace GenHub.Features.Tools.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/Views/ToolsView.axaml b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml index 5a12f110e..3c6c753c1 100644 --- a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml +++ b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml @@ -3,46 +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"> + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + +