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