diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323290891..93b5f3ea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -264,11 +264,6 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Linux Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Cache NuGet Packages uses: actions/cache@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89588dd19..8d424e2c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -234,9 +234,6 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Linux Dependencies - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Extract Build Info id: buildinfo run: | diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs index e7a543023..5ee0581e1 100644 --- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs @@ -10,6 +10,21 @@ public static class AppUpdateConstants /// public const int MaxHttpRetries = 3; + /// + /// Index for the Update tab in update notification views. + /// + public const int UpdateTabIndex = 0; + + /// + /// Index for the Browse Builds tab in update notification views. + /// + public const int BrowseBuildsTabIndex = 1; + + /// + /// Maximum valid tab index in update notification views. + /// + public const int MaxTabIndex = 1; + /// /// Velopack directory name. /// @@ -153,6 +168,101 @@ public static class AppUpdateConstants "3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" + "Update available: v{1}"; + /// + /// Update available notification title for release channel. + /// + public const string UpdateAvailableNotificationTitle = "Update Available"; + + /// + /// Update available notification title for branch subscriptions. + /// + public const string BranchUpdateAvailableNotificationTitle = "Branch Update Available"; + + /// + /// Update available notification title for PR subscriptions. + /// + public const string PrUpdateAvailableNotificationTitle = "PR Update Available"; + + /// + /// Update action button text. + /// + public const string UpdateAction = "Update"; + + /// + /// Title for the update in progress notification. + /// + public const string UpdatingAppNotificationTitle = "Updating GenHub"; + + /// + /// Starting update progress message. + /// + public const string UpdateStartingMessage = "Starting update..."; + + /// + /// Title for update failed notification. + /// + public const string UpdateFailedNotificationTitle = "Update Failed"; + + /// + /// Update failed notification body format string ({0}: error message). + /// + public const string UpdateFailedNotificationFormat = "Failed to install update: {0}"; + + /// + /// View updates action button text. + /// + public const string ViewUpdatesAction = "View Updates"; + + /// + /// Release update notification body format string ({0}: version). + /// + public const string ReleaseUpdateNotificationFormat = "A new version ({0}) is available."; + + /// + /// Branch update notification body format string ({0}: version, {1}: branch name). + /// + public const string BranchUpdateNotificationFormat = "A new build ({0}) is available on branch '{1}'."; + + /// + /// PR update notification body format string ({0}: version, {1}: PR number). + /// + public const string PrUpdateNotificationFormat = "A new build ({0}) is available for PR #{1}."; + + /// + /// Sort option: sort by last updated date descending. + /// + public const string SortOptionLastUpdated = "Last Updated"; + + /// + /// Sort option: sort by pull request number descending. + /// + public const string SortOptionPrNumberDesc = "PR Number (Highest)"; + + /// + /// Sort option: sort by pull request number ascending. + /// + public const string SortOptionPrNumberAsc = "PR Number (Lowest)"; + + /// + /// Default interval in minutes for periodic update checks (30 minutes). + /// + public const int DefaultPeriodicUpdateCheckIntervalMinutes = 30; + + /// + /// Minimum interval in minutes for periodic update checks (5 minutes). + /// + public const int MinPeriodicUpdateCheckIntervalMinutes = 5; + + /// + /// Maximum interval in minutes for periodic update checks (10080 minutes / 7 days). + /// + public const int MaxPeriodicUpdateCheckIntervalMinutes = 10080; + + /// + /// Increment step in minutes for periodic update check interval setting (5 minutes). + /// + public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5; + /// /// Delay before exit after applying update (5 seconds). /// diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs index 4b0821443..30cd69c4f 100644 --- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs @@ -1,8 +1,13 @@ namespace GenHub.Core.Constants; /// -/// Constants for command line arguments and URI schemes. +/// Constants for command line arguments and the genhub:// URI scheme. /// +/// +/// Subscription links use genhub://subscribe?url=<absolute-url>. +/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share +/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time. +/// public static class CommandLineConstants { /// @@ -16,22 +21,27 @@ public static class CommandLineConstants public const string LaunchProfileInlinePrefix = "--launch-profile="; /// - /// URI scheme used for protocol handling. + /// Scheme name for custom protocol registration. /// - public const string UriScheme = "genhub://"; + public const string SchemeName = "genhub"; /// - /// Command for subscribing to a catalog via URI. + /// Custom URI scheme registered so OS/browser links can open GenHub. + /// + public const string UriScheme = SchemeName + "://"; + + /// + /// URI path segment for content subscription (genhub://subscribe?url=...). /// public const string SubscribeCommand = "subscribe"; /// - /// Full prefix for subscription URI. + /// Full prefix for subscription URIs (genhub://subscribe). /// public const string SubscribeUriPrefix = UriScheme + SubscribeCommand; /// - /// Query parameter name for the catalog URL in a subscription URI. + /// Query parameter carrying the absolute URL of a catalog (or future provider definition). /// public const string SubscribeUrlParam = "?url="; } diff --git a/GenHub/GenHub.Core/Constants/ContentConstants.cs b/GenHub/GenHub.Core/Constants/ContentConstants.cs index 0bc3c907c..96d6a6db0 100644 --- a/GenHub/GenHub.Core/Constants/ContentConstants.cs +++ b/GenHub/GenHub.Core/Constants/ContentConstants.cs @@ -94,4 +94,31 @@ public static class ContentConstants /// Maximum allowed size for the content catalog in bytes (10 MB). /// public const long MaxCatalogSizeBytes = 10 * ConversionConstants.BytesPerMegabyte; + + /// + /// Shared resolver/display metadata key for map player counts. + /// Builtin and catalog publishers should set this so download cards can render a consistent badge. + /// + public const string PlayerCountMetadataKey = "playerCount"; + + /// + /// Shared resolver/display metadata key for content categories (AOA, Compstomp, ModDB category, etc.). + /// + public const string CategoryMetadataKey = "category"; + + /// + /// Display metadata key for a comma-separated list of included/required content names + /// (e.g. catalog ContentBundle dependencies resolved to friendly titles). + /// + public const string IncludesSummaryMetadataKey = "includesSummary"; + + /// + /// Number of recent releases and addons to eagerly preload extended details for. + /// + public const int PreloadRecentItemsLimit = 5; + + /// + /// Maximum concurrent background requests when preloading recent item details. + /// + public const int PreloadConcurrencyLimit = 3; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs index 47097cc18..19b341d80 100644 --- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs +++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs @@ -59,4 +59,14 @@ public static class DirectoryNames /// Directory for storing tool workspaces. /// public const string ToolWorkspaces = "ToolWorkspaces"; + + /// + /// Directory for persistent Playwright browser profiles (cookies/storage for bot-protected sites). + /// + public const string BrowserProfiles = "BrowserProfiles"; + + /// + /// Directory for the app-owned Playwright Chromium runtime (not the system Chrome/Edge install). + /// + public const string BrowserRuntime = "BrowserRuntime"; } diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs index 1a66630f8..4096fd317 100644 --- a/GenHub/GenHub.Core/Constants/IpcCommands.cs +++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs @@ -11,7 +11,8 @@ public static class IpcCommands public const string LaunchProfilePrefix = "launch-profile:"; /// - /// Command prefix used to subscribe to a catalog via IPC. + /// Command prefix used to forward a subscribe URL to the primary instance + /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=.... /// public const string SubscribePrefix = "subscribe:"; } diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs index 2deed935c..f8b9c8d3d 100644 --- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs @@ -72,6 +72,12 @@ public static class ModDBConstants /// ModDB website URL. public const string PublisherWebsite = BaseUrl; + /// + /// On-disk Playwright browser profile name used to persist the Cloudflare clearance cookie so + /// the user only solves the bot challenge once per session (and across restarts until expiry). + /// + public const string BrowserProfileName = "moddb"; + /// Short description for publisher card display. public const string ShortDescription = "Community mods, maps, and content from ModDB"; @@ -184,6 +190,29 @@ public static class ModDBConstants /// Value for filter parameter when enabled. public const string FilterEnabledValue = "t"; + // ===== Sort Values ===== + + /// Sort: Date descending (newest first). + public const string SortDateDesc = "date-desc"; + + /// Sort: Date ascending (oldest first). + public const string SortDateAsc = "date-asc"; + + /// Sort: Visits / Popularity descending. + public const string SortVisitDesc = "visit-desc"; + + /// Sort: Rating descending. + public const string SortRatingDesc = "rating-desc"; + + /// Sort: Name ascending (A-Z). + public const string SortNameAsc = "name-asc"; + + /// Sort: Name descending (Z-A). + public const string SortNameDesc = "name-desc"; + + /// Default sort value for ModDB searches and listings (newest first). + public const string DefaultSort = SortDateDesc; + // ===== Category Values ===== // Downloads Section - Releases @@ -356,6 +385,34 @@ public static class ModDBConstants /// Metadata key for original category. public const string OriginalCategoryMetadataKey = "moddbCategory"; + /// Metadata key for identifying if content is a mod. + public const string IsModMetadataKey = "IsMod"; + + /// Metadata key for parent mod URL. + public const string ParentModUrlMetadataKey = "ParentModUrl"; + + // ===== Playwright / Scraping Constants ===== + + /// Default timeout for page navigation (ms). + public const int DefaultGotoTimeout = 30000; + + /// + /// Default timeout for waiting for a selector (ms). ModDB sits behind Cloudflare; the headed + /// browser persistent profile usually receives the clearance cookie after verification, but 15 s gives a safe margin + /// for manual challenge solves before the scraper parses whatever it has. + /// + public const int DefaultSelectorTimeout = 15000; + + /// + /// How long (ms) the listing scrape waits for the user to solve a Cloudflare challenge in the + /// visible browser before giving up. Long enough for a manual "I am not a robot" click; the + /// page stays open after the deadline so the user can finish and retry. + /// + public const int VerificationWaitTimeoutMs = 120000; + + /// Selector for content items in listing pages (Fallback). + public const string DefaultListItemSelector = "div.row.rowcontent, div.table tr"; + // ===== Error Messages ===== /// Error message for invalid URL. diff --git a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs index 55ab7fa3c..4f61403a9 100644 --- a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs @@ -17,6 +17,9 @@ public static class ModDBParserConstants /// Selector for developer/publisher links. public const string DeveloperSelector = "a[href*='/members/'], a[href*='/company/']"; + /// Selector for the profile/mod info box that carries the real developer name. + public const string DeveloperProfileSelector = "#modsinfo a[href*='/members/'], #modsinfo a[href*='/company/'], .sidecolumn a[href*='/members/'], .sidecolumn a[href*='/company/']"; + /// Selector for release date. public const string ReleaseDateSelector = "time[datetime], .date, .released"; @@ -59,7 +62,7 @@ public static class ModDBParserConstants public const string FileMetadataValueSelector = "td:last-child"; /// Selector for the main download button on file pages. - public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/']"; + public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/'], a[href*='/addons/start/']"; /// Selector for download size on the button. public const string DownloadSizeSelector = ".download .size, .downloadbutton .size"; @@ -87,10 +90,13 @@ public static class ModDBParserConstants // ===== Description/Summary Selectors ===== /// Selector for full description content. - public const string FullDescriptionSelector = "#articlebrowse, .summary .content, .description .content, .modtext"; + public const string FullDescriptionSelector = "#downloaddescription, #downloadsummary, #articlebrowse .articlebody, .articlebody, #modsummary, .modtext, #profile .description, #description, #articlebrowse, .summary .content, .description .content"; + + /// Selector for the file-page body copy (not the breadcrumb .summary trail). + public const string FileDescriptionSelector = "#downloaddescription, #downloadsummary"; - /// Selector for truncated summary. - public const string SummarySelector = ".summary p, .description p"; + /// Selector for summary or description container. + public const string SummarySelector = ".description, .rubric, p[itemprop='description']"; // ===== Legacy File Selectors ===== @@ -98,28 +104,31 @@ public static class ModDBParserConstants public const string FilesTableSelector = "table.filelist, .table.files, #files"; /// Selector for individual file rows. - public const string FileRowSelector = "tr.file, .row.file, .file"; + public const string FileRowSelector = "tr.file, .row.file, .file, .row.rowcontent"; /// Selector for file name. - public const string FileNameSelector = "h5, h4, .name, .title"; + public const string FileNameSelector = "h4 a, h5 a, h3 a, .heading a, .title a, a.title, .name a, h5, h4, .name, .title"; /// Selector for file version. public const string FileVersionSelector = ".version, .ver"; /// Selector for file size. - public const string FileSizeSelector = ".size, .filesize"; + public const string FileSizeSelector = ".size, .filesize, .filesizes, span.size"; + + /// Selector for file subheading or metadata row. + public const string FileSubheadingSelector = ".subheading, span.subheading, .meta, .details, .info, p.summary, .summary"; /// Selector for file upload date. - public const string FileDateSelector = "time[datetime], .date, .uploaded"; + public const string FileDateSelector = "time[datetime], .date, .uploaded, time"; /// Selector for file category. - public const string FileCategorySelector = ".category, .type"; + public const string FileCategorySelector = ".category, .type, span.category"; /// Selector for file uploader. - public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/']"; + public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/'], a[href*='/company/']"; /// Selector for file download link (robust). - public const string FileDownloadSelector = "a.button.download, a[href*='/downloads/start/'], .download a"; + public const string FileDownloadSelector = "a.button, a.buttonlarge, a.download, a.btn, a[href*='/downloads/start/'], a[href*='/addons/start/'], a[href*='/downloads/'], a[href*='/addons/'], .download a, .actions a"; /// Selector for file MD5 hash. public const string FileMd5Selector = ".md5, .hash"; @@ -130,22 +139,41 @@ public static class ModDBParserConstants // ===== Videos Section Selectors ===== /// Selector for embedded video iframes. - public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='vimeo'], iframe[src*='youtu.be']"; + public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='youtube-nocookie'], iframe[src*='youtu.be'], iframe[src*='vimeo'], iframe[src*='dailymotion'], iframe[src*='moddb.com/media/iframe'], iframe[src*='moddb.com/media/embed'], iframe[src*='moddb.com/videos/iframe'], iframe[src*='moddb.com/videos/embed']"; + + /// Selector for video gallery containers and items. + public const string VideoGallerySelector = "#videobox, #videosbrowse, #mediabrowse, .mediarow, .mediabox"; + + /// Selector for video links. + public const string VideoLinkSelector = "a[href*='/videos/'], a[href*='youtube.com/watch'], a[href*='youtu.be/'], a[href*='vimeo.com/']"; /// Selector for video thumbnails. - public const string VideoThumbnailSelector = ".thumbnail img, .preview img"; + public const string VideoThumbnailSelector = ".thumbnail img, .preview img, img"; /// Selector for video titles. - public const string VideoTitleSelector = ".title, h3, h4"; + public const string VideoTitleSelector = ".title, h3, h4, h5, .caption"; + + /// Selector for recommendation and related content sections. + public const string RecommendationsSelector = "#recommendations, .recommendations, #related, .related, #similar, .similar, #fansalsoviewed, .fansalsoviewed, .youmayalso, [class*='recommend'], [id*='recommend'], [class*='similar'], [id*='similar']"; // ===== Images Section Selectors ===== /// Selector for image gallery container. - public const string ImageGallerySelector = ".mediarow, .screenshot, .imagebox, .gallery"; + public const string ImageGallerySelector = "#imagebox, #mediaimage, #imagebrowse, #mediabrowse, .mediarow"; + + /// + /// Selector for gallery images only. Deliberately excludes a blanket + /// img[src*='media.moddb.com'] match, which previously pulled game icons, member + /// avatars, and file-page chrome into the Media tab. + /// + public const string GalleryImageSelector = "#imagebox img, #mediaimage img, #imagebrowse img, #mediabrowse img, .mediarow img, .media .holder img, #downloadsummary img, #downloaddescription img, .preview img, a[href*='/mods/'][href*='/images/'] img"; /// Selector for individual images. public const string ImageSelector = "img"; + /// Sidebar/profile containers whose images are icons and avatars, not gallery media. + public const string ImageSidebarSelector = "#modsinfo, #downloadsprofilemenu, #profile, .sidecolumn, aside"; + /// Selector for image thumbnails. public const string ImageThumbnailSelector = ".thumbnail img, .thumb img"; @@ -197,20 +225,24 @@ public static class ModDBParserConstants // ===== Comments Section Selectors ===== - /// Selector for comments container. - public const string CommentsSelector = ".comment, .post, .comments"; + /// Selector for comments container. Do not use #commentform — that is the composer. + public const string CommentsSelector = "#commentsbrowse"; - /// Selector for individual comment rows. - public const string CommentRowSelector = ".comment, .post"; + /// + /// Selector for posted comment rows. Requires the exact rowcomment class so the + /// composer rows (rowcommentguest, rowcommentsummary, rowcommentemail) + /// and #commentform are not treated as comments. + /// + public const string CommentRowSelector = ".row.rowcomment, .rowcomment"; /// Selector for comment authors. - public const string CommentAuthorSelector = ".author, .username, a[href*='/members/']"; + public const string CommentAuthorSelector = ".author, .username, .heading a, a[href*='/members/']"; - /// Selector for comment content. - public const string CommentContentSelector = ".content, .body, .text"; + /// Selector for comment content. Avoids bare p which matches login chrome and CSS blobs. + public const string CommentContentSelector = ":scope > .commentbody, .commentbody, p.comment"; /// Selector for comment dates. - public const string CommentDateSelector = "time[datetime], .date"; + public const string CommentDateSelector = "time[datetime], time, .date, .datetime, span.subheading"; /// Selector for comment karma/votes. public const string CommentKarmaSelector = ".karma, .votes, .goodkarma, .badkarma"; @@ -245,4 +277,100 @@ public static class ModDBParserConstants /// Pattern for games URLs. public const string GamesUrlPattern = "/games/"; + + // ===== Mod Detail Page Selectors ===== + + /// Selector for the downloads section on mod pages. + public const string DownloadsSectionSelector = "#downloads, .downloads, .files"; + + /// Selector for the addons section on mod pages. + public const string AddonsSectionSelector = "#addons, .addons"; + + /// Selector for the tabs/navigation on mod pages. + public const string TabsSelector = ".tabs, .navigation, nav"; + + /// Selector for individual tab links. + public const string TabLinkSelector = "a[href*='/downloads'], a[href*='/addons']"; + + // ===== Metadata Keys (Internal/Normalized) ===== + + /// Metadata key for filename. + public const string MetadataFilename = "filename"; + + /// Alternative metadata key for filename. + public const string MetadataFileNameAlt = "file name"; + + /// Alternative metadata key for file. + public const string MetadataFileAlt = "file"; + + /// Metadata key for size. + public const string MetadataSize = "size"; + + /// Alternative metadata key for size. + public const string MetadataFileSizeAlt = "file size"; + + /// Metadata key for uploader. + public const string MetadataUploader = "uploader"; + + /// Alternative metadata key for uploaded by. + public const string MetadataUploadedBy = "uploaded by"; + + /// Alternative metadata key for author. + public const string MetadataAuthor = "author"; + + /// Metadata key for category. + public const string MetadataCategory = "category"; + + /// Alternative metadata key for file category. + public const string MetadataFileCategory = "file category"; + + /// Alternative metadata key for type. + public const string MetadataType = "type"; + + /// Metadata key for MD5 hash. + public const string MetadataMd5Hash = "md5 hash"; + + /// Metadata key for MD5 hash (alternative). + public const string MetadataMd5HashAlt = "md5hash"; + + /// Alternative metadata key for MD5 checksum. + public const string MetadataMd5Checksum = "md5 checksum"; + + /// Alternative metadata key for MD5. + public const string MetadataMd5 = "md5"; + + /// Alternative metadata key for hash. + public const string MetadataHash = "hash"; + + /// Alternative metadata key for checksum. + public const string MetadataChecksum = "checksum"; + + /// Metadata key for total downloads. + public const string MetadataTotalDownloads = "total downloads"; + + /// Alternative metadata key for download count. + public const string MetadataDownloadCount = "download count"; + + /// Metadata key for added date. + public const string MetadataAdded = "added"; + + /// Metadata key for updated date. + public const string MetadataUpdated = "updated"; + + // ===== Additional Selectors ===== + + /// Selector for fallback titles (h1, h2, etc). + public const string FallbackTitleSelector = "h2 a, h1 a, h2, h1"; + + /// Selector for file detail page title heading outside the global headerbox. + public const string FilePageTitleSelector = ".midcolumn h2, .columncenter h2, #downloadsfiles h2, #downloadsinfo h2, #downloads h2, .heading h2, .title h2, h2.title, .midcolumn h3, .heading h3"; + + /// Selector for file detail page preview images. + public const string FilePreviewImagesSelector = "#downloadmedia img, #downloadsmedia img, #preview img, #media img, .mediagallery img, .imagebox img, #imagebox img, #downloaddescription img, #downloadsummary img, a[href*='/images/'] img, .previewholder img, .media .holder img"; + + /// Selector for file description container elements. + public const string FileDescriptionContainerSelector = "#downloaddescription, #downloadsummary, #description, .description, .articlebody, #profiletotal"; + + /// Regex pattern for extracting parent mod path. + public const string ParentModPathRegex = @"(/mods/[^/]+)/(?:downloads|addons)/"; } diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs index b15606e59..d5d3dffce 100644 --- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs +++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs @@ -60,6 +60,21 @@ public static class SuperHackersConstants /// public const string GeneralsGameCodeRepo = "GeneralsGameCode"; + /// + /// GitHub owner for Generals game patch 2. + /// + public const string GeneralsGamePatch2Owner = "TheSuperHackers"; + + /// + /// GitHub repo for Generals game patch 2. + /// + public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2"; + + /// + /// Display name for Generals game patch 2. + /// + public const string GeneralsGamePatch2DisplayName = "Community Patch 2"; + // ===== Service Configuration ===== /// diff --git a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs new file mode 100644 index 000000000..b44ff9c53 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace GenHub.Core.Helpers; + +/// +/// Helper class for application update version comparison and parsing. +/// +public static partial class AppUpdateVersionHelper +{ + /// + /// Extracts the workflow run number from a version string (e.g., "0.0.641-pr241" -> 641). + /// Returns 0 for plain semantic versions without CI run markers. + /// + /// The version string to extract the run number from. + /// The extracted run number, or 0 if extraction fails or not a CI build. + public static int ExtractRunNumber(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return 0; + } + + var match = CiRunNumberRegex().Match(version); + if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber) && runNumber > 0) + { + return runNumber; + } + + var ciMatch = CiMarkerRegex().Match(version); + if (ciMatch.Success && int.TryParse(ciMatch.Groups[1].Value, out var ciRunNumber) && ciRunNumber > 0) + { + return ciRunNumber; + } + + return 0; + } + + /// + /// Checks whether an available artifact version is newer than the currently installed version. + /// + /// The new artifact version string. + /// The current version string. + /// True if newVersion is newer than currentVersion; otherwise false. + public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion) + { + if (string.IsNullOrWhiteSpace(newVersion)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(currentVersion)) + { + return true; + } + + var newVersionBase = newVersion.Split('+')[0].Trim(); + var currentVersionBase = currentVersion.Split('+')[0].Trim(); + + var newRun = ExtractRunNumber(newVersionBase); + var currentRun = ExtractRunNumber(currentVersionBase); + + if (newRun > 0 && currentRun > 0) + { + return newRun > currentRun; + } + + if (newRun == 0 && currentRun > 0) + { + return false; + } + + if (newRun > 0 && currentRun == 0) + { + return true; + } + + var newClean = newVersionBase.Split('-')[0]; + var currentClean = currentVersionBase.Split('-')[0]; + if (Version.TryParse(newClean, out var newVer) && Version.TryParse(currentClean, out var currentVer)) + { + return newVer > currentVer; + } + + return false; + } + + /// + /// Regex for extracting workflow run number from a 0.0.X CI version string. + /// Matches patterns like "0.0.1282-pr265", "0.0.1282-main", "0.0.1282". + /// + [GeneratedRegex(@"^0\.0\.(\d+)(?:-[a-zA-Z0-9_.-]+)?$", RegexOptions.IgnoreCase)] + private static partial Regex CiRunNumberRegex(); + + /// + /// Regex for extracting workflow run number from a -ci.X marker. + /// + [GeneratedRegex(@"-ci\.(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex CiMarkerRegex(); +} diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs index f6b570af0..d5c595d36 100644 --- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs +++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs @@ -15,9 +15,9 @@ public static class CommandLineParser /// The extracted profile identifier if present; otherwise, null. public static string? ExtractProfileId(string[] args) { - for (var i = 0; i < args.Length; i++) + for (int i = 0; i < args.Length; i++) { - var arg = args[i]; + string arg = args[i]; if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { @@ -34,23 +34,48 @@ public static class CommandLineParser } /// - /// Extracts a subscription URL from command line arguments. - /// Supports the URI scheme format: genhub://subscribe?url=<url>. + /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument. /// + /// + /// The returned value is the url query value only (not the genhub:// wrapper). + /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider + /// Definition URL without changing this parser. + /// /// The command line arguments. - /// The extracted catalog URL if present; otherwise, null. + /// The decoded absolute URL if present; otherwise, null. public static string? ExtractSubscriptionUrl(string[] args) { - foreach (var arg in args) + foreach (string arg in args) { if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase)) { - // Simple parsing for ?url=... - var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); + string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..]; + if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal)) + { + continue; + } + + int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); if (queryStart != -1) { - var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; - return Uri.UnescapeDataString(url).Trim('"'); + string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; + string unescaped = Uri.UnescapeDataString(url) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim('"', '\'', ' ', '\t'); + + if (string.IsNullOrWhiteSpace(unescaped)) + { + return null; + } + + if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return unescaped; + } + + return null; } } } diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs new file mode 100644 index 000000000..b19f11d94 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; + +namespace GenHub.Core.Helpers; + +/// +/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities, +/// and normalizing text descriptions for display across the application. +/// +public static partial class HtmlTextHelper +{ + /// + /// Converts an HTML snippet or formatted description into clean, normalized plain text: + /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks. + /// - Strips all remaining HTML tags. + /// - Decodes HTML entities (e.g., &amp;, &quot;, &gt;, &nbsp;). + /// - Normalizes whitespace and excessive blank lines. + /// - Uses the platform newline format. + /// + /// The raw HTML or formatted text string to normalize. + /// Normalized plain text, or empty string if input is null or whitespace. + public static string NormalizeHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + // 0. Remove script and style elements along with their contents + var text = ScriptTagRegex().Replace(html, string.Empty); + text = StyleTagRegex().Replace(text, string.Empty); + + // 1. Convert
tags to newline + text = BrTagRegex().Replace(text, "\n"); + + // 2. Convert paragraph closing tags to double newline for paragraph separation + text = ParagraphCloseTagRegex().Replace(text, "\n\n"); + + // 3. Convert other block-level closing tags and
tags to newline + text = BlockCloseTagRegex().Replace(text, "\n"); + + // 4. Strip all remaining HTML/XML tags + text = HtmlTagRegex().Replace(text, string.Empty); + + // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.) + text = WebUtility.HtmlDecode(text); + + // 6. Normalize non-breaking spaces and line endings + text = text.Replace('\u00A0', ' ') + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + + // 7. Clean trailing whitespace on lines and collapse excess blank lines + text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n"); + text = ExcessBlankLinesRegex().Replace(text, "\n\n"); + + // 8. Trim and unify with environment newline + text = text.Trim(); + text = text.Replace("\n", Environment.NewLine); + + return text; + } + + /// + /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags, + /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis. + /// + /// The input HTML or text string. + /// Optional maximum character length including ellipsis. + /// A single-line plain text summary. + public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null) + { + if (string.IsNullOrWhiteSpace(htmlOrText)) + { + return string.Empty; + } + + // Strip HTML if tags exist, decode entities, and normalize + var text = NormalizeHtml(htmlOrText); + + // Collapse all newlines, tabs, and multiple spaces into a single space + text = MultiWhitespaceRegex().Replace(text, " ").Trim(); + + if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value) + { + return TruncateWithEllipsis(text, maxLength.Value); + } + + return text; + } + + /// + /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated. + /// + /// The text to truncate. + /// The maximum allowed length (including the ellipsis). + /// The truncated text with an ellipsis if it exceeded maxLength, or the original text. + public static string TruncateWithEllipsis(string? text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text) || maxLength <= 0) + { + return string.Empty; + } + + if (text.Length <= maxLength) + { + return text; + } + + if (maxLength <= 3) + { + return text[..maxLength]; + } + + return string.Concat(text.AsSpan(0, maxLength - 3), "..."); + } + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ScriptTagRegex(); + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex StyleTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BrTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ParagraphCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BlockCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.CultureInvariant)] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)] + private static partial Regex TrailingWhitespaceBeforeNewlineRegex(); + + [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)] + private static partial Regex ExcessBlankLinesRegex(); + + [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)] + private static partial Regex MultiWhitespaceRegex(); +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs index d97f2f8cf..93a3536b0 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs @@ -64,6 +64,18 @@ public interface IConfigurationProviderService /// True if auto-check is enabled; otherwise, false. bool GetAutoCheckForUpdatesOnStartup(); + /// + /// Gets whether to automatically check for updates periodically. + /// + /// True if periodic auto-check is enabled; otherwise, false. + bool GetAutoCheckForUpdatesPeriodically(); + + /// + /// Gets the interval in minutes for periodic update checks. + /// + /// The update check interval in minutes. + int GetPeriodicUpdateCheckIntervalMinutes(); + /// /// Gets whether detailed logging is enabled. /// diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs index 84a4d3773..9b26f26b2 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs @@ -20,6 +20,7 @@ public interface ILocalContentService /// Optional original source path of the content. /// Optional progress reporter for tracking manifest creation. /// Cancellation token. + /// Optional relative path of the main executable entry point. /// A result containing the created manifest or errors. Task> CreateLocalContentManifestAsync( string directoryPath, @@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync( GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? entryPoint = null); /// /// Adds local content by creating and storing a manifest. @@ -66,6 +68,7 @@ Task> AddLocalContentAsync( /// Optional original source path of the content. /// Optional progress reporter. /// Cancellation token. + /// Optional relative path of the main executable entry point. /// A result containing the updated manifest. Task> UpdateLocalContentManifestAsync( string existingManifestId, @@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync( GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? entryPoint = null); /// /// Gets the allowed content types for local content creation. diff --git a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs index 34a88df40..55800dd3e 100644 --- a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs +++ b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs @@ -28,6 +28,11 @@ public interface INotificationService /// IObservable NotificationHistory { get; } + /// + /// Gets the observable stream of notification update requests. + /// + IObservable<(Guid Id, string? Title, string Message)> UpdateRequests { get; } + /// /// Shows an informational notification. /// @@ -70,6 +75,14 @@ public interface INotificationService /// The notification to show. void Show(NotificationMessage notification); + /// + /// Updates the message and optionally the title of an active notification. + /// + /// The ID of the notification to update. + /// The new message content. + /// Optional new title. If null, the existing title is preserved. + void Update(Guid notificationId, string message, string? title = null); + /// /// Dismisses a specific notification. /// diff --git a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs index 136018297..2d75645fe 100644 --- a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs +++ b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Models.Parsers; namespace GenHub.Core.Interfaces.Parsers; @@ -36,4 +41,56 @@ public interface IWebPageParser /// Cancellation token. /// A parsed web page with all extracted content sections. Task ParseAsync(string url, string html, CancellationToken cancellationToken = default); + + /// + /// Parses a specific file or item detail page. + /// Default implementation delegates to . + /// + /// The detail page URL. + /// Cancellation token. + /// A parsed web page containing the detailed file information. + Task ParseFileDetailAsync(string url, CancellationToken cancellationToken = default) + => ParseAsync(url, cancellationToken); + + /// + /// Parses multiple file or item detail pages in a batch. + /// Default implementation delegates to . + /// + /// The detail page URLs to parse. + /// Cancellation token. + /// A dictionary mapping each URL to its parsed web page result. + async Task> ParseFileDetailsManyAsync( + IReadOnlyList urls, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(urls); + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var page = await ParseFileDetailAsync(url, cancellationToken); + results[url] = page; + } + catch (HttpRequestException) + { + // soft failure per url in batch + } + catch (IOException) + { + // soft failure per url in batch + } + catch (InvalidOperationException) + { + // soft failure per url in batch + } + catch (FormatException) + { + // soft failure per url in batch + } + } + + return results; + } } diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs index f2007576a..df73f9827 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs @@ -6,6 +6,7 @@ using GenHub.Core.Models.Results; using Microsoft.Playwright; using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -25,6 +26,31 @@ public interface IPlaywrightService /// A new IPage instance. Task CreatePageAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default); + /// + /// Creates a page in a persistent, headed browser context whose cookies and storage survive + /// across calls. Use this for bot-protected sites (e.g. ModDB's Cloudflare): the user solves + /// the challenge once, the resulting clearance cookie is persisted to disk, and subsequent + /// pages in the same session (and across app restarts, until the cookie expires) load without + /// another challenge. A real browser window is shown while the challenge is pending. + /// + /// The on-disk profile name (scoped under the app data browser-profile root). + /// Cancellation token. + /// A new in the persistent context. + Task CreatePersistentPageAsync(string profileName, CancellationToken cancellationToken = default); + + /// + /// Closes a page from and shuts down the headed Chromium + /// window when no active pages remain. Prefer this over page.CloseAsync alone so + /// callers do not leave an about:blank window open after a successful ModDB scrape. + /// + /// The persistent-context page to close. + /// + /// When , leaves the page open (e.g. so the user can finish a Cloudflare + /// challenge) without closing the browser. + /// + /// A task representing the asynchronous operation. + Task ClosePersistentPageAsync(IPage page, bool keepOpen = false); + /// /// Fetches HTML content from a URL using Playwright. /// @@ -41,6 +67,35 @@ public interface IPlaywrightService /// A parsed AngleSharp IDocument. Task FetchAndParseAsync(string url, CancellationToken cancellationToken = default); + /// + /// Fetches and parses a web page in a persistent, headed browser context whose cookies survive + /// across calls. Use this for bot-protected URLs (e.g. ModDB) so the Cloudflare clearance cookie + /// obtained from a single manual challenge solve is reused. + /// + /// The on-disk profile name (scoped under the app data browser-profile root). + /// The URL to fetch and parse. + /// Cancellation token. + /// A parsed AngleSharp IDocument. + Task FetchAndParsePersistentAsync(string profileName, string url, CancellationToken cancellationToken = default); + + /// + /// Fetches and parses multiple URLs in one persistent headed page — open once, navigate each + /// URL in order, then close. Use this for ModDB section sweeps so Chromium does not spawn a + /// new window per section (and so concurrent NewPage/Close races cannot tear down the context + /// mid-navigation). + /// + /// The on-disk profile name (scoped under the app data browser-profile root). + /// URLs to fetch in order. Duplicates are fetched once; order of first occurrence is kept. + /// Cancellation token. + /// + /// A map of URL → parsed document for every URL that loaded successfully. Failed URLs are omitted; + /// callers should treat a missing key as a soft failure for that section. + /// + Task> FetchAndParsePersistentManyAsync( + string profileName, + IReadOnlyList urls, + CancellationToken cancellationToken = default); + /// /// Downloads a file using Playwright to handle complex scenarios (like anti-bot protections). /// @@ -48,4 +103,27 @@ public interface IPlaywrightService /// Cancellation token. /// A DownloadResult indicating success or failure. Task DownloadFileAsync(DownloadConfiguration configuration, CancellationToken cancellationToken = default); + + /// + /// Executes an operation within a scoped persistent browser context session. + /// The persistent browser window stays open for the duration of the operation and closes + /// immediately when the operation completes, avoiding multiple window launches and idle delays. + /// + /// The return type of the operation. + /// The on-disk profile name. + /// The asynchronous operation to execute. + /// Cancellation token. + /// The result of the operation. + Task ExecuteInPersistentContextAsync( + string profileName, + Func> operation, + CancellationToken cancellationToken = default); + + /// + /// Asynchronously pre-warms the Playwright driver runtime in the background so subsequent + /// browser operations launch with minimal latency. + /// + /// Cancellation token. + /// A task representing the background warmup operation. + Task WarmupAsync(CancellationToken cancellationToken = default); } diff --git a/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs new file mode 100644 index 000000000..e199ff5b3 --- /dev/null +++ b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs @@ -0,0 +1,12 @@ +namespace GenHub.Core.Messages; + +/// +/// Message sent when update settings have changed. +/// +/// Whether to check for updates on startup. +/// Whether to check for updates periodically. +/// Interval in minutes between periodic update checks. +public record UpdateSettingsChangedMessage( + bool AutoCheckForUpdatesOnStartup, + bool AutoCheckForUpdatesPeriodically, + int PeriodicUpdateCheckIntervalMinutes); diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs index a8d2a9693..e9fc4bb56 100644 --- a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs +++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs @@ -50,6 +50,11 @@ public record PullRequestInfo /// public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}"; + /// + /// Gets the display title formatted with the PR number (e.g., "#123 - PR Title"). + /// + public string DisplayTitle => $"#{Number} - {Title}"; + /// /// Gets a value indicating whether this PR is still open. /// diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index 4b77fe175..c33263307 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -39,6 +39,12 @@ public class UserSettings /// Gets or sets a value indicating whether to automatically check for updates on startup. public bool AutoCheckForUpdatesOnStartup { get; set; } = true; + /// Gets or sets a value indicating whether to automatically check for updates periodically. + public bool AutoCheckForUpdatesPeriodically { get; set; } = true; + + /// Gets or sets the interval in minutes between periodic update checks. + public int PeriodicUpdateCheckIntervalMinutes { get; set; } = GenHub.Core.Constants.AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + /// Gets or sets the timestamp of the last update check in ISO 8601 format. public string? LastUpdateCheckTimestamp { get; set; } @@ -151,6 +157,8 @@ public UserSettings Clone() MaxConcurrentDownloads = MaxConcurrentDownloads, AllowBackgroundDownloads = AllowBackgroundDownloads, AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup, + AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically, + PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes, LastUpdateCheckTimestamp = LastUpdateCheckTimestamp, EnableDetailedLogging = EnableDetailedLogging, DefaultWorkspaceStrategy = DefaultWorkspaceStrategy, diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs index 83d681312..7a93f17cf 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs @@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest; public sealed class ManifestIdJsonConverter : JsonConverter { /// - public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138 { var s = reader.GetString() ?? string.Empty; return ManifestId.Create(s); diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs index ddb22794a..fc609db20 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs @@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint( files); } - private static bool PathsMatch(string left, string right) => + /// + /// Determines whether two relative file paths match, normalizing directory separators and leading slashes. + /// + /// The first relative path. + /// The second relative path. + /// true if the paths match; otherwise, false. + public static bool PathsMatch(string left, string right) => string.Equals( left.Replace('\\', '/').TrimStart('/'), right.Replace('\\', '/').TrimStart('/'), diff --git a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs index 347634147..979071455 100644 --- a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs +++ b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs @@ -37,4 +37,4 @@ public record MapDetails( string? FileType = null, float? Rating = null, string? RefererUrl = null, - List? AdditionalFiles = null); + List? AdditionalFiles = null); diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs index cf2fc5920..c0f1e9a1f 100644 --- a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs +++ b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs @@ -19,8 +19,8 @@ public static ContentType MapCategory(string? categoryCode) // Releases (Mods) "2" => ContentType.Mod, // Full Version "3" => ContentType.Mod, // Demo - "4" => ContentType.Patch, // Patch - "28" => ContentType.Patch, // Script + "4" => ContentType.Mod, // Patch (mod release/update) + "28" => ContentType.Mod, // Script (mod script/release) "29" => ContentType.Addon, // Trainer // Media @@ -60,11 +60,11 @@ public static ContentType MapCategory(string? categoryCode) "131" => ContentType.Addon, // Model Pack // Addons - Skins - "112" => ContentType.Skin, // Player Skin - "133" => ContentType.Skin, // Prop Skin - "113" => ContentType.Skin, // Vehicle Skin - "114" => ContentType.Skin, // Weapon Skin - "134" => ContentType.Skin, // Skin Pack + "112" => ContentType.Addon, // Player Skin + "133" => ContentType.Addon, // Prop Skin + "113" => ContentType.Addon, // Vehicle Skin + "114" => ContentType.Addon, // Weapon Skin + "134" => ContentType.Addon, // Skin Pack // Addons - Audio "117" => ContentType.Addon, // Music @@ -75,8 +75,8 @@ public static ContentType MapCategory(string? categoryCode) // Addons - Graphics "124" => ContentType.Addon, // Decal "136" => ContentType.Addon, // Effects GFX - "125" => ContentType.Skin, // GUI - "126" => ContentType.Skin, // HUD + "125" => ContentType.Addon, // GUI + "126" => ContentType.Addon, // HUD "128" => ContentType.Addon, // Sprite "129" => ContentType.Addon, // Texture @@ -103,10 +103,15 @@ public static ContentType MapCategoryByName(string? categoryName) { var s when s.Contains("full version") => ContentType.Mod, var s when s.Contains("demo") => ContentType.Mod, - var s when s.Contains("patch") => ContentType.Patch, - var s when s.Contains("script") => ContentType.Patch, + var s when s.Contains("patch") => ContentType.Mod, + var s when s.Contains("script") => ContentType.Mod, var s when s.Contains("trainer") => ContentType.Addon, + var s when s.Contains("tool") => ContentType.ModdingTool, + var s when s.Contains("sdk") => ContentType.ModdingTool, + var s when s.Contains("ide") => ContentType.ModdingTool, + var s when s.Contains("source code") => ContentType.ModdingTool, + var s when s.Contains("trailer") => ContentType.Video, var s when s.Contains("movie") => ContentType.Video, var s when s.Contains("video") => ContentType.Video, @@ -116,17 +121,12 @@ var s when s.Contains("singleplayer map") => ContentType.Map, var s when s.Contains("map") => ContentType.Map, var s when s.Contains("prefab") => ContentType.Map, - var s when s.Contains("skin") => ContentType.Skin, - var s when s.Contains("gui") => ContentType.Skin, - var s when s.Contains("hud") => ContentType.Skin, + var s when s.Contains("skin") => ContentType.Addon, + var s when s.Contains("gui") => ContentType.Addon, + var s when s.Contains("hud") => ContentType.Addon, var s when s.Contains("language") => ContentType.LanguagePack, - var s when s.Contains("tool") => ContentType.ModdingTool, - var s when s.Contains("sdk") => ContentType.ModdingTool, - var s when s.Contains("ide") => ContentType.ModdingTool, - var s when s.Contains("source code") => ContentType.ModdingTool, - _ => ContentType.Addon, }; } diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs index a977a5f08..88232974f 100644 --- a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs +++ b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using GenHub.Core.Constants; + namespace GenHub.Core.Models.ModDB; /// @@ -21,7 +25,7 @@ public class ModDBFilter public string? Licence { get; set; } /// Gets or sets the sort parameter. - public string? Sort { get; set; } + public string? Sort { get; set; } = ModDBConstants.DefaultSort; /// Gets or sets the page number (1-based). public int Page { get; set; } = 1; diff --git a/GenHub/GenHub.Core/Models/Parsers/Comment.cs b/GenHub/GenHub.Core/Models/Parsers/Comment.cs index 645e1fd5a..dd1532318 100644 --- a/GenHub/GenHub.Core/Models/Parsers/Comment.cs +++ b/GenHub/GenHub.Core/Models/Parsers/Comment.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace GenHub.Core.Models.Parsers; /// @@ -8,9 +11,13 @@ namespace GenHub.Core.Models.Parsers; /// The comment date (optional). /// The karma/vote score (optional). /// Whether the comment is from the content creator (optional). +/// Indentation depth level for reply threads (optional). +/// Child replies to this comment (optional). public record Comment( string? Author = null, string? Content = null, DateTime? Date = null, int? Karma = null, - bool? IsCreator = null) : ContentSection(SectionType.Comment, "Comment"); + bool? IsCreator = null, + int IndentLevel = 0, + IReadOnlyList? Replies = null) : ContentSection(SectionType.Comment, "Comment"); diff --git a/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs new file mode 100644 index 000000000..0df14e67a --- /dev/null +++ b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs @@ -0,0 +1,42 @@ +namespace GenHub.Core.Models.Parsers; + +/// +/// Represents a downloadable file extracted from a web page. +/// +/// The file name. +/// The file version (optional). +/// File size in bytes (optional). +/// Human-readable file size (optional). +/// The upload date (optional). +/// The file category (optional). +/// The uploader name (optional). +/// The download URL (optional). +/// The MD5 hash of the file (optional). +/// Number of comments (optional). +/// The thumbnail image URL (optional). +/// Number of downloads (optional). +/// The file section type (Downloads or Addons). +/// The release date (optional, may differ from upload date). +/// The web page details URL (optional). +/// The full description or release notes (optional). +/// List of preview image URLs (optional). +/// The actual file archive name (optional). +public record DownloadableFile( + string Name, + string? Version = null, + long? SizeBytes = null, + string? SizeDisplay = null, + DateTime? UploadDate = null, + string? Category = null, + string? Uploader = null, + string? DownloadUrl = null, + string? Md5Hash = null, + int? CommentCount = null, + string? ThumbnailUrl = null, + int? DownloadCount = null, + FileSectionType FileSectionType = FileSectionType.Downloads, + DateTime? ReleaseDate = null, + string? DetailsUrl = null, + string? Description = null, + System.Collections.Generic.IReadOnlyList? PreviewImages = null, + string? Filename = null) : ContentSection(SectionType.File, Name); diff --git a/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs new file mode 100644 index 000000000..9fddb1afb --- /dev/null +++ b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs @@ -0,0 +1,13 @@ +namespace GenHub.Core.Models.Parsers; + +/// +/// Represents the type of file section, distinguishing between main releases and addon files. +/// +public enum FileSectionType +{ + /// Files from the main releases/downloads section. + Downloads, + + /// Files from the addons section. + Addons, +} diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs index 24058e7d9..04375f0ea 100644 --- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs +++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs @@ -15,7 +15,8 @@ public class JsonWorkspaceStrategyConverter : JsonConverter /// [SuppressMessage("Maintainability", "CS-R1138:Inappropriate ordering of parameters", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")] [SuppressMessage("DeepSource", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")] - public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + [SuppressMessage("csharp", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")] + public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138 { if (reader.TokenType == JsonTokenType.Number) { diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs index 8f544eec5..2912aa9a5 100644 --- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs +++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs @@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? entryPoint = null) { try { @@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs var manifest = builder.Build(); manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath; + if (!string.IsNullOrWhiteSpace(entryPoint)) + { + var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/'); + + var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == "..")) + { + return OperationResult.CreateFailure( + $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..')."); + } + + var matchedFile = manifest.Files.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint)); + + if (matchedFile == null) + { + return OperationResult.CreateFailure( + $"Entry point '{entryPoint}' was not found among the files in the directory."); + } + + manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/'); + } + // Auto-add GameInstallation dependency for GameClient content types // This ensures auto-resolution logic works correctly for locally added clients if (contentType == ContentType.GameClient) @@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? entryPoint = null) { try { // 1. Create the new manifest/content // We do this FIRST to ensure the new content is valid before deleting the old one - var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken); + var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint); if (!createResult.Success) { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs index 49836a2dc..7cd571b77 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -549,6 +549,58 @@ public void GetAutoCheckForUpdatesOnStartup_ReturnsUserSetting(bool userValue) Assert.Equal(userValue, result); } + /// + /// Verifies that GetAutoCheckForUpdatesPeriodically returns user setting when explicitly set. + /// + /// The value to set for AutoCheckForUpdatesPeriodically in user settings. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetAutoCheckForUpdatesPeriodically_ReturnsUserSetting(bool userValue) + { + // Arrange + var userSettings = new UserSettings { AutoCheckForUpdatesPeriodically = userValue }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetAutoCheckForUpdatesPeriodically(); + + // Assert + Assert.Equal(userValue, result); + } + + /// + /// Verifies that GetPeriodicUpdateCheckIntervalMinutes returns user setting when explicitly set. + /// + /// The interval to set in user settings. + /// The expected clamped interval. + [Theory] + [InlineData(60, 60)] + [InlineData(0, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes)] + [InlineData(20000, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)] + public void GetPeriodicUpdateCheckIntervalMinutes_ReturnsUserSetting(int intervalMinutes, int expectedMinutes) + { + // Arrange + var userSettings = new UserSettings { PeriodicUpdateCheckIntervalMinutes = intervalMinutes }; + if (intervalMinutes > 0) + { + userSettings.MarkAsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes)); + } + + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetPeriodicUpdateCheckIntervalMinutes(); + + // Assert + Assert.Equal(expectedMinutes, result); + } + /// /// Verifies that GetEnableDetailedLogging returns user setting when explicitly set. /// @@ -784,7 +836,8 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults() // Assert Assert.Contains("TheSuperHackers/GeneralsGameCode", result); - Assert.Single(result); + Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result); + Assert.Equal(2, result.Count); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 9b37967c3..1e105e2ef 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs @@ -66,6 +66,8 @@ public void Get_WhenNoFileExists_ReturnsDefaultUserSettings() Assert.Equal(DownloadDefaults.MaxConcurrentDownloads, settings.MaxConcurrentDownloads); Assert.True(settings.AllowBackgroundDownloads); Assert.True(settings.AutoCheckForUpdatesOnStartup); + Assert.True(settings.AutoCheckForUpdatesPeriodically); + Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, settings.PeriodicUpdateCheckIntervalMinutes); Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, settings.DefaultWorkspaceStrategy); } @@ -364,6 +366,25 @@ public void UpdateSettings_EnableDetailedLogging_CanBeSetAndRetrieved(bool enabl Assert.Equal(enableLogging, currentSettings.EnableDetailedLogging); } + /// + /// Verifies that periodic update settings can be set and retrieved correctly. + /// + [Fact] + public void UpdateSettings_PeriodicUpdateSettings_CanBeSetAndRetrieved() + { + var service = CreateService(); + + service.Update(settings => + { + settings.AutoCheckForUpdatesPeriodically = false; + settings.PeriodicUpdateCheckIntervalMinutes = 15; + }); + var currentSettings = service.Get(); + + Assert.False(currentSettings.AutoCheckForUpdatesPeriodically); + Assert.Equal(15, currentSettings.PeriodicUpdateCheckIntervalMinutes); + } + private static IAppConfiguration CreateAppConfigMock() { var appConfig = new Mock(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs new file mode 100644 index 000000000..be8cebf08 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs @@ -0,0 +1,95 @@ +using System; +using GenHub.Core.Constants; +using Xunit; + +namespace GenHub.Tests.Core.Constants; + +/// +/// Unit tests for . +/// +public class AppUpdateConstantsTests +{ + /// + /// Tests that tab index constants have expected values. + /// + [Fact] + public void TabIndex_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(0, AppUpdateConstants.UpdateTabIndex); + Assert.Equal(1, AppUpdateConstants.BrowseBuildsTabIndex); + Assert.Equal(1, AppUpdateConstants.MaxTabIndex); + } + + /// + /// Tests that platform and artifact prefix constants have expected values. + /// + [Fact] + public void ArtifactAndPlatform_Constants_ShouldHaveExpectedValues() + { + Assert.Equal("velopack", AppUpdateConstants.VelopackDirectory); + Assert.Equal("genhub-velopack-windows-", AppUpdateConstants.ArtifactPrefixWindows); + Assert.Equal("genhub-velopack-linux-", AppUpdateConstants.ArtifactPrefixLinux); + Assert.Equal("GenHub-Release", AppUpdateConstants.ArtifactNameRelease); + Assert.Equal("windows", AppUpdateConstants.PlatformWindows); + Assert.Equal("linux", AppUpdateConstants.PlatformLinux); + } + + /// + /// Tests that periodic update check interval constants have expected values. + /// + [Fact] + public void PeriodicUpdateCheckInterval_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(30, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(5, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(10080, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(5, AppUpdateConstants.PeriodicUpdateCheckIntervalIncrementMinutes); + Assert.True(AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes); + Assert.True(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Tests that timespan constants have expected durations. + /// + [Fact] + public void TimeSpan_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(TimeSpan.FromSeconds(5), AppUpdateConstants.PostUpdateExitDelay); + Assert.Equal(TimeSpan.FromHours(1), AppUpdateConstants.CacheDuration); + Assert.Equal(3, AppUpdateConstants.MaxHttpRetries); + } + + /// + /// Tests that notification title and format constants are non-empty strings. + /// + [Fact] + public void NotificationAndFormat_Constants_ShouldBeValid() + { + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdatingAppNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateFailedNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAction)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ViewUpdatesAction)); + Assert.Contains("{0}", AppUpdateConstants.ReleaseUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.UpdateFailedNotificationFormat); + } + + /// + /// Tests that sort option constants are distinct non-empty strings. + /// + [Fact] + public void SortOption_Constants_ShouldBeDistinctAndNonEmpty() + { + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionLastUpdated)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberDesc)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberAsc)); + Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc); + Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs index fed2373b8..b4ea11d25 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs @@ -1,8 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.AppUpdate.ViewModels; using Microsoft.Extensions.Logging; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels; @@ -23,7 +30,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn .ReturnsAsync((Velopack.UpdateInfo?)null); var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( mockVelopack.Object, @@ -43,7 +50,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn public void Constructor_InitializesSuccessfully() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -63,7 +70,7 @@ public void Constructor_InitializesSuccessfully() public void IsCheckButtonEnabled_ReflectsCheckingState() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -72,4 +79,493 @@ public void IsCheckButtonEnabled_ReflectsCheckingState() Assert.True(vm.IsCheckButtonEnabled); } + + /// + /// Verifies that pull request display title formats properly with PR number and title. + /// + [Fact] + public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle() + { + var prInfo = new PullRequestInfo + { + Number = 265, + Title = "feat: UI Downloads", + BranchName = "feat/ui-downloads", + Author = "developer", + State = "open", + UpdatedAt = DateTimeOffset.UtcNow, + }; + + Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle); + } + + /// + /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024), + }; + + var loadTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => loadTcs.TrySetCanceled(ct)); + return await loadTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(389); + + Assert.True(vm.IsLoadingVersions); + loadTcs.SetResult(artifacts); + + // wait briefly for async continuation + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Equal(2, vm.AvailableVersions.Count); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal("e1212a5", vm.SelectedVersion.GitHash); + Assert.True(vm.CanDownloadUpdate); + } + + /// + /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048), + }; + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny())) + .ReturnsAsync(artifacts); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("development"); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version); + } + + /// + /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var pr391Tcs = new TaskCompletionSource>(); + var pr389Tcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr391Tcs.TrySetCanceled(ct)); + return await pr391Tcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr389Tcs.TrySetCanceled(ct)); + return await pr389Tcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + // subscribe to 391 first + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + // immediately switch to 389 while 391 is loading + vm.SubscribeToPrCommand.Execute(389); + + // resolve 389 artifacts + var pr389Artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + }; + pr389Tcs.TrySetResult(pr389Artifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(pr391Tcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal(389, vm.SelectedVersion.PullRequestNumber); + } + + /// + /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var branchOldTcs = new TaskCompletionSource>(); + var branchNewTcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchOldTcs.TrySetCanceled(ct)); + return await branchOldTcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchNewTcs.TrySetCanceled(ct)); + return await branchNewTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("old-branch"); + Assert.True(vm.IsLoadingVersions); + + vm.SubscribeToBranchCommand.Execute("new-branch"); + + var newArtifacts = new List + { + new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048), + }; + branchNewTcs.TrySetResult(newArtifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(branchOldTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version); + } + + /// + /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var prTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => prTcs.TrySetCanceled(ct)); + return await prTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + vm.UnsubscribeCommand.Execute(null); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(prTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Empty(vm.AvailableVersions); + Assert.Null(vm.SelectedVersion); + } + + /// + /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers. + /// + /// The PR number under test. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber) + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + // verify command execution does not throw + vm.OpenPullRequestUrlCommand.Execute(prNumber); + Assert.NotNull(vm); + } + + /// + /// Verifies that changing the sort option reorders available pull requests accordingly. + /// + [Fact] + public void SelectedSortOption_ReordersAvailablePullRequests() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var now = DateTimeOffset.UtcNow; + var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) }; + var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) }; + var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now }; + + vm.AvailablePullRequests.Add(pr100); + vm.AvailablePullRequests.Add(pr200); + vm.AvailablePullRequests.Add(pr300); + + // sort by PR number descending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(100, vm.AvailablePullRequests[2].Number); + + // sort by PR number ascending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc; + Assert.Equal(100, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(300, vm.AvailablePullRequests[2].Number); + + // sort by last updated (newest first) + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(100, vm.AvailablePullRequests[1].Number); + Assert.Equal(200, vm.AvailablePullRequests[2].Number); + } + + /// + /// Verifies that tab commands correctly switch between Update and Browse Builds tabs. + /// + [Fact] + public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.ShowBrowseBuildsTabCommand.Execute(null); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.ShowUpdateTabCommand.Execute(null); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute("1"); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute(0); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + // Clamping out-of-range inputs + vm.SelectTabCommand.Execute(-1); + Assert.Equal(0, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute(5); + Assert.Equal(1, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute("99"); + Assert.Equal(1, vm.SelectedTabIndex); + } + + /// + /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string. + /// + [Fact] + public void DisplayCurrentVersion_ReturnsNonEmptyVersion() + { + var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion; + Assert.False(string.IsNullOrWhiteSpace(displayVersion)); + Assert.StartsWith("v", displayVersion); + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(displayVersion, vm.InstalledVersionDisplay); + } + + /// + /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true. + /// + [Fact] + public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = newerArtifact; + + Assert.True(vm.IsUpdateAvailable); + Assert.Equal("0.0.99999-pr389", vm.LatestVersion); + Assert.Contains("0.0.99999-pr389", vm.StatusMessage); + } + + /// + /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl. + /// + [Fact] + public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" }); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object) + { + IsUpdateAvailable = true, + LatestVersion = "0.0.88888", + ReleaseNotesUrl = "https://example.com/notes", + }; + + var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = dismissedArtifact; + + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message. + /// + [Fact] + public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object) + { + SubscribedPr = new PullRequestInfo + { + Number = 389, + Title = "Test PR", + BranchName = "feature/test", + Author = "testuser", + State = "open", + }, + SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + IsUpdateAvailable = true, + LatestVersion = "0.0.99999-pr389", + ReleaseNotesUrl = "https://example.com/notes", + }; + + vm.UnsubscribeCommand.Execute(null); + + Assert.Null(vm.SubscribedPr); + Assert.Null(vm.SubscribedBranch); + Assert.Null(vm.SelectedVersion); + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.False(string.IsNullOrEmpty(vm.StatusMessage)); + Assert.Null(mockVelopack.Object.SubscribedPrNumber); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index 5422334ce..33d4f7d2a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate await Assert.ThrowsAnyAsync( () => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token)); } + + /// + /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync() + { + // Arrange + var specializedProviderMock = new Mock(); + var githubProviderMock = new Mock(); + + const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2"; + + var specializedResult = new ContentSearchResult + { + Id = duplicateId, + Name = "TheSuperHackers Patch 2", + ProviderName = "thesuperhackers", + }; + + var githubResult = new ContentSearchResult + { + Id = duplicateId, + Name = "GeneralsGamePatch2", + ProviderName = "GitHub", + }; + + specializedProviderMock.Setup(p => p.IsEnabled).Returns(true); + specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult])); + + githubProviderMock.Setup(p => p.IsEnabled).Returns(true); + githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([githubResult])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [githubProviderMock.Object, specializedProviderMock.Object], + [], + [], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.SearchAsync(new ContentSearchQuery()); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("thesuperhackers", items[0].ProviderName); + Assert.Equal("TheSuperHackers Patch 2", items[0].Name); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs new file mode 100644 index 000000000..73e18587f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs @@ -0,0 +1,63 @@ +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.ModDB; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.ModDB; + +/// +/// Unit tests for . +/// +public class ModDBCategoryMapperTests +{ + /// + /// Verifies that MapCategory maps ModDB category codes correctly, especially mapping patches and scripts to Mod. + /// + /// The category code to map. + /// The expected content type. + [Theory] + [InlineData("2", ContentType.Mod)] + [InlineData("3", ContentType.Mod)] + [InlineData("4", ContentType.Mod)] + [InlineData("28", ContentType.Mod)] + [InlineData("29", ContentType.Addon)] + [InlineData("7", ContentType.Video)] + [InlineData("8", ContentType.Video)] + [InlineData("101", ContentType.Map)] + [InlineData("102", ContentType.Map)] + [InlineData("112", ContentType.Addon)] + [InlineData("125", ContentType.Addon)] + [InlineData("126", ContentType.Addon)] + [InlineData("20", ContentType.ModdingTool)] + [InlineData("30", ContentType.LanguagePack)] + public void MapCategory_MapsCategoryCodesCorrectly(string categoryCode, ContentType expected) + { + var result = ModDBCategoryMapper.MapCategory(categoryCode); + Assert.Equal(expected, result); + } + + /// + /// Verifies that MapCategoryByName maps category names correctly, mapping patch and script names to Mod. + /// + /// The category name to map. + /// The expected content type. + [Theory] + [InlineData("Full Version", ContentType.Mod)] + [InlineData("Demo", ContentType.Mod)] + [InlineData("Patch", ContentType.Mod)] + [InlineData("v1.01 Patch", ContentType.Mod)] + [InlineData("Script", ContentType.Mod)] + [InlineData("Multiplayer Map", ContentType.Map)] + [InlineData("Singleplayer Map", ContentType.Map)] + [InlineData("Player Skin", ContentType.Addon)] + [InlineData("GUI", ContentType.Addon)] + [InlineData("HUD", ContentType.Addon)] + [InlineData("Mapping Tool", ContentType.ModdingTool)] + [InlineData("Language Pack", ContentType.LanguagePack)] + [InlineData("Trailer", ContentType.Video)] + public void MapCategoryByName_MapsNamesCorrectly(string categoryName, ContentType expected) + { + var result = ModDBCategoryMapper.MapCategoryByName(categoryName); + Assert.Equal(expected, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs new file mode 100644 index 000000000..a630b8b5c --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs @@ -0,0 +1,1691 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Parsers; +using GenHub.Features.Content.Services.Parsers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Content.Parsers; + +/// +/// Regression tests for the current ModDB detail markup and Cloudflare-aware section loading. +/// +public sealed class ModDBPageParserTests +{ + /// + /// Verifies the current game-addon detail page maps its metadata and /addons/start route into + /// a usable file rather than returning an empty download URL. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_CurrentAddonDetailMarkup_ExtractsArchiveNameAndAddonStartUrlAsync() + { + // Arrange + var playwright = CreatePlaywrightMock(); + var pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons/lemuria-2026-fixes"; + var doc = await CreateDocumentAsync(""" + +
+
Filename
Lemuria_2026_Fixes.rar
+ +
Uploader
BagaturKhan
+
Added
+
Size
1.07mb (1,125,450 bytes)
+ +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("Lemuria_2026_Fixes.rar", file.Name); + Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl); + Assert.Equal("Singleplayer Map", file.Category); + Assert.Equal(1_125_450, file.SizeBytes); + } + + /// + /// Game-scoped FileDetail URLs (from the ModDB downloads listing) have no parent /mods/ page + /// to sweep, so the detail view must still populate Community from comments on the file page + /// itself instead of leaving only a single Releases row. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_GameFileDetail_ExtractsOnPageCommentsWithoutParentSweepAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/downloads/genbigeditbig-editor"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+
Filename
GenBigEdit.zip
+
Size
174.33mb (182,801,143 bytes)
+ +
+
+
+
mah_boi May 30 2026
+
Please, provide us the source code of this program.
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + Assert.Equal(PageType.FileDetail, parsed.PageType); + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("GenBigEdit.zip", file.Name); + Assert.Equal("https://www.moddb.com/downloads/start/310120", file.DownloadUrl); + + var comment = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("mah_boi", comment.Author); + Assert.Equal("Please, provide us the source code of this program.", comment.Content); + + // Must not attempt a parent-mod section sweep for /games/... FileDetail URLs (fetches only the single URL). + playwright.Verify( + service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.Is>(urls => urls.Count == 1 && urls[0] == pageUrl), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies an addons-list row retains its ModDB category so a map does not become a generic + /// add-on later in the resolver and manifest pipeline. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_AddonsListRow_ExtractsSingleplayerMapCategoryAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+

Lemuria 2026

+ Singleplayer Map + 1.07 MB + Download +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("Singleplayer Map", file.Category); + Assert.Equal(FileSectionType.Addons, file.FileSectionType); + Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl); + } + + /// + /// Verifies that rich ModDB sections use the verified persistent Chromium profile instead of + /// a separate headless browser that loses Cloudflare clearance. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_ModDetail_UsesPersistentProfileForDownloadsAndAddonsAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod"; + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [pageUrl] = await CreateDocumentAsync("

Example Mod

"), + [pageUrl + "/downloads"] = await CreateDocumentAsync(""" +

Example Release

4 MBDownload
+ """), + [pageUrl + "/addons"] = await CreateDocumentAsync(""" +

Example Addon

2 MBDownload
+ """), + [pageUrl + "/videos"] = await CreateDocumentAsync(""), + [pageUrl + "/images"] = await CreateDocumentAsync(""), + [pageUrl + "/reviews"] = await CreateDocumentAsync(""), + [pageUrl + "/articles"] = await CreateDocumentAsync(""), + }; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .Returns((string _, IReadOnlyList urls, CancellationToken _) => + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (documents.TryGetValue(url, out var d)) + { + result[url] = d; + } + } + + return Task.FromResult>(result); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var files = parsed.Sections.OfType().ToList(); + Assert.Contains(files, file => file.Name == "Example Release" && file.DownloadUrl == "https://www.moddb.com/downloads/start/100"); + Assert.Contains(files, file => file.Name == "Example Addon" && file.DownloadUrl == "https://www.moddb.com/addons/start/200"); + playwright.Verify( + service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.Is>(urls => + urls.Contains(pageUrl) && urls.Contains(pageUrl + "/downloads") && urls.Contains(pageUrl + "/addons")), + It.IsAny()), + Times.Once); + playwright.Verify(service => service.FetchAndParseAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies the file-only acquisition path resolves a FileDetail download without fetching the + /// parent mod's downloads/addons/videos/images/reviews/articles sections (the seven-page sweep + /// that previously fired on every card download). + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseFileDetailAsync_FetchesOnlyFileDetailPageAndSkipsSectionSweepAsync() + { + // Arrange: the FileDetail page already carries a real (non-guest) icon, so the parent-mod + // icon fallback fetch is skipped too — exactly one fetch total. + const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25"; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + pageUrl, + It.IsAny())) + .ReturnsAsync(await CreateDocumentAsync(""" + +

GenSpeed v2.5

+
+
+
Filename
GenSpeed-v2.5.zip
+
Size
65.04mb (68,197,650 bytes)
+ +
+ + """)); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseFileDetailAsync(pageUrl); + + // Assert: exactly one DownloadableFile, no section sweep, icon from the FileDetail page. + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("GenSpeed v2.5", file.Name); + Assert.Equal("GenSpeed-v2.5.zip", file.Filename); + Assert.Equal("https://www.moddb.com/downloads/start/311183", file.DownloadUrl); + Assert.Equal(68_197_650, file.SizeBytes); + Assert.Equal("https://static.moddb.com/mods/genspeed/icon.png", parsed.Context.IconUrl); + + playwright.Verify( + service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + It.Is(url => url != pageUrl), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that ParseFileDetailAsync performs only a single page fetch for file details without + /// secondary parent mod fetches or section sweeps. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseFileDetailAsync_WithGuestIcon_FetchesOnlyFileDetailPageAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25"; + var fetchedUrls = new List(); + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + It.IsAny(), + It.IsAny())) + .Returns((string _, string url, CancellationToken _) => + { + fetchedUrls.Add(url); + return Task.FromResult(CreateDocumentAsync(""" + +

GenSpeed v2.5

+
+
+
Filename
GenSpeed-v2.5.zip
+ +
+ + """).GetAwaiter().GetResult()); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseFileDetailAsync(pageUrl); + + // Assert: exactly one fetch (FileDetail), never parent mod or section pages. + Assert.Equal(new[] { pageUrl }, fetchedUrls); + Assert.Contains(parsed.Sections.OfType(), f => f.Filename == "GenSpeed-v2.5.zip"); + } + + /// + /// Verifies that comment parsing creates nested reply threads with correct author attribution + /// and cleans out ModDB action text like 'Reply Good karma Bad karma+1 vote'. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_NestedComments_ParsesThreadHierarchyAndCleansActionTextAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod/comments"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+
+
Scorpionwins Jul 31 2026
+
+ How to activate additional weapons? +
Reply Good karma Bad karma+1 vote
+
+
+
+
BagaturKhan Jul 31 2026
+
+ If you are talking about stolen tech, train your infiltrator. +
Reply Good karma Bad karma+1 vote
+
+
+
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var topLevelComments = parsed.Sections.OfType().ToList(); + var parentComment = Assert.Single(topLevelComments); + Assert.Equal("Scorpionwins", parentComment.Author); + Assert.Equal("How to activate additional weapons?", parentComment.Content); + Assert.Equal(0, parentComment.IndentLevel); + + var reply = Assert.Single(parentComment.Replies!); + Assert.Equal("BagaturKhan", reply.Author); + Assert.Equal("If you are talking about stolen tech, train your infiltrator.", reply.Content); + Assert.Equal(1, reply.IndentLevel); + } + + /// + /// Verifies reply markup nested inside .commentbody does not inflate the parent content + /// into a huge whitespace block (the layout bug seen in the Community tab). + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_NestedCommentsInsideCommentBody_DoesNotPolluteParentContentAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod/comments"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+
+
Scorpionwins Jul 31 2026
+
+ + + How to activate additional weapons? +
Reply Good karma Bad karma+1 vote
+
+
+
BagaturKhan Jul 31 2026
+
+ Train your infiltrator. +
Reply
+
+
+
+
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var parentComment = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("How to activate additional weapons?", parentComment.Content); + Assert.DoesNotContain("BagaturKhan", parentComment.Content); + Assert.DoesNotContain("infiltrator", parentComment.Content, StringComparison.OrdinalIgnoreCase); + + var reply = Assert.Single(parentComment.Replies!); + Assert.Equal("BagaturKhan", reply.Author); + Assert.Equal("Train your infiltrator.", reply.Content); + } + + /// + /// Verifies rating widgets without author/body are not surfaced as empty Community review cards. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_BareRatingWidget_IsNotTreatedAsReviewAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/example-mod/reviews"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
9.0people found this helpful
+
+ Alice +
Solid patch for ROTR.
+ 8.5 +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var review = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("Alice", review.Author); + Assert.Equal("Solid patch for ROTR.", review.Content); + } + + /// + /// The live ModDB composer (#commentform plus guest/email rows and injected CSS) must + /// not appear as Community comments. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_CommentComposer_IsNotTreatedAsCommentsAsync() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +

C&C Generals Undone file

+

C&C Generals Undone

+
+
Filename
GeneralsUndone_v1.0.zip
+ +
+
+
+
+

Your comment will be anonymous unless you join the community. +

+
+
+
+
+
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + Assert.Empty(parsed.Sections.OfType()); + } + + /// + /// File-page chrome (game icon, developer avatar, download title art) must not appear in Media. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_FileDetailChromeImages_AreNotGalleryMediaAsync() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +

C&C Generals Undone

+
+
Filename
GeneralsUndone_v1.0.zip
+ +
+ + C&C Generals Undone + + + + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + Assert.Empty(parsed.Sections.OfType()); + } + + /// + /// The images tab should yield unique gallery shots, not share icons or duplicate featured thumbs. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_ImagesPage_ExtractsUniqueGalleryShotsAsync() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/images"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + +
+ + View media + + + View media + +
+ ICBM POWTruck +
+ Share on Facebook +
+ + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var images = parsed.Sections.OfType().ToList(); + Assert.Equal(2, images.Count); + Assert.Contains(images, image => image.Title.Contains("ICBM", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(images, image => image.Title.Contains("Spectre", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(images, image => image.Title.Contains("Share", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(images, image => image.ThumbnailUrl?.StartsWith("data:", StringComparison.OrdinalIgnoreCase) == true); + Assert.All(images, image => Assert.DoesNotContain("crop_", image.ThumbnailUrl ?? string.Empty)); + Assert.All(images, image => Assert.DoesNotContain("/cache/", image.ThumbnailUrl ?? string.Empty)); + } + + /// + /// Image titles with CamelCase or raw filenames should be formatted with clean spaces. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_ImageTitles_FormatsCamelCaseAndFilenamesAsync() + { + const string pageUrl = "https://www.moddb.com/mods/test-mod/images"; + var playwright = CreatePlaywrightMock(); + var doc = await CreateDocumentAsync(""" + + + + """); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var images = parsed.Sections.OfType().ToList(); + Assert.Equal(2, images.Count); + Assert.Equal("Life Of BRRRRTTT", images[0].Title); + Assert.Equal("BASSBASSBASSASS", images[1].Title); + Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].ThumbnailUrl); + Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].FullSizeUrl); + } + + /// + /// FileDetail filename plus the parent downloads listing of the same start URL must collapse + /// to one release, keeping the human listing name. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ParseAsync_FileDetailAndParentDownloads_DedupesSameBinaryAsync() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone"; + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [pageUrl] = await CreateDocumentAsync(""" + +

C&C Generals Undone file

+

C&C Generals Undone

+ register + + Games : C&C: Generals Zero Hour : Mods : C&C Generals Undone : Files +

This is the first version of Undone, and I know it's still very much in development.

+
+
Filename
GeneralsUndone_v1.0.zip
+ +
+ + """), + [parentUrl] = await CreateDocumentAsync("

C&C Generals Undone

"), + [parentUrl + "/downloads"] = await CreateDocumentAsync(""" +
+

C&C Generals Undone

+ 289.6 MB + Download +
+
+

Generals Undone v1.01 Patch

+ 1 MB + Download +
+ """), + [parentUrl + "/addons"] = await CreateDocumentAsync(""), + [parentUrl + "/videos"] = await CreateDocumentAsync(""), + [parentUrl + "/images"] = await CreateDocumentAsync(""), + [parentUrl + "/reviews"] = await CreateDocumentAsync(""), + [parentUrl + "/articles"] = await CreateDocumentAsync(""), + }; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .Returns((string _, IReadOnlyList urls, CancellationToken _) => + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (documents.TryGetValue(url, out var doc)) + { + result[url] = doc; + } + } + + return Task.FromResult>(result); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var files = parsed.Sections.OfType().ToList(); + Assert.Equal(2, files.Count); + Assert.Contains(files, file => file.Name == "C&C Generals Undone" && file.DownloadUrl == "https://www.moddb.com/downloads/start/313719"); + Assert.Contains(files, file => file.Name == "Generals Undone v1.01 Patch"); + Assert.DoesNotContain(files, file => file.Name == "GeneralsUndone_v1.0.zip"); + Assert.Equal("C&C Generals Undone", parsed.Context.Title); + Assert.Equal("WhiteSkull#9044", parsed.Context.Developer); + Assert.Contains("first version of Undone", parsed.Context.Description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Games :", parsed.Context.Description, StringComparison.Ordinal); + } + + /// + /// Verifies that ParseFileDetailAsync correctly parses metadata when ModDB uses alternative label names + /// such as "File Name", "File Size", "Uploaded By", "MD5 Checksum", and "Total Downloads". + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseFileDetailAsync_WithAlternativeLabels_ParsesMd5ChecksumTotalDownloadsAndUploaderAsync() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch"; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, + pageUrl, + It.IsAny())) + .ReturnsAsync(await CreateDocumentAsync(""" + +
+
File Name
GeneralsUndone_v1.01.csf
+
Category
Patch
+
Uploaded By
WhiteSkull#9044
+
File Size
289.6mb (303,663,235 bytes)
+
MD5 Checksum
6e5b1fd58fc7a58cf21af86933116942
+
Total Downloads
185
+ +
+ + """)); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseFileDetailAsync(pageUrl); + + var file = Assert.Single(parsed.Sections.OfType()); + Assert.Equal("GeneralsUndone_v1.01.csf", file.Filename); + Assert.Equal("Patch", file.Category); + Assert.Equal("WhiteSkull#9044", file.Uploader); + Assert.Equal(303_663_235, file.SizeBytes); + Assert.Equal("6e5b1fd58fc7a58cf21af86933116942", file.Md5Hash); + Assert.Equal(185, file.DownloadCount); + Assert.Equal("https://www.moddb.com/downloads/start/313720", file.DownloadUrl); + } + + /// + /// Verifies that ModDB download listing rows with subheading metadata (size in subheading, button class) + /// extract size, category, uploader, details URL, and download URL correctly. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_ModernModDBDownloadsListing_ExtractsSubheadingSizeAndLinksAsync() + { + const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone"; + const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone"; + var documents = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [pageUrl] = await CreateDocumentAsync(""" + +

C&C Generals Undone file

+

C&C Generals Undone

+ +

First release of Undone.

+
+
Filename
GeneralsUndone_v1.0.zip
+
Category
Full Version
+
Size
289.6mb (303,663,235 bytes)
+
MD5 Hash
6e5b3fcf30fc7a58ef21af869551bb942
+ +
+ + """), + [parentUrl] = await CreateDocumentAsync("

C&C Generals Undone

"), + [parentUrl + "/downloads"] = await CreateDocumentAsync(""" +
+
+

C&C Generals Undone

+ - Full Version, 289.6mb +
+
+ Download +
+
+
+
+

Generals Undone v1.01 Patch

+ - Patch, 1 MB +
+
+ Download +
+
+ """), + [parentUrl + "/addons"] = await CreateDocumentAsync(""), + [parentUrl + "/videos"] = await CreateDocumentAsync(""), + [parentUrl + "/images"] = await CreateDocumentAsync(""), + [parentUrl + "/reviews"] = await CreateDocumentAsync(""), + [parentUrl + "/articles"] = await CreateDocumentAsync(""), + }; + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .Returns((string _, IReadOnlyList urls, CancellationToken _) => + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var url in urls) + { + if (documents.TryGetValue(url, out var doc)) + { + result[url] = doc; + } + } + + return Task.FromResult>(result); + }); + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + var parsed = await parser.ParseAsync(pageUrl); + + var files = parsed.Sections.OfType().ToList(); + Assert.Equal(2, files.Count); + + var mainRelease = Assert.Single(files, f => f.Name == "C&C Generals Undone"); + Assert.Equal("GeneralsUndone_v1.0.zip", mainRelease.Filename); + Assert.Equal("https://www.moddb.com/downloads/start/313719", mainRelease.DownloadUrl); + Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone", mainRelease.DetailsUrl); + Assert.Equal("Full Version", mainRelease.Category); + Assert.Equal(303_663_235, mainRelease.SizeBytes); + Assert.Equal("6e5b3fcf30fc7a58ef21af869551bb942", mainRelease.Md5Hash); + + var patchRelease = Assert.Single(files, f => f.Name == "Generals Undone v1.01 Patch"); + Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch", patchRelease.DetailsUrl); + Assert.Equal("Patch", patchRelease.Category); + Assert.Equal(1048576, patchRelease.SizeBytes); + Assert.Equal("1 MB", patchRelease.SizeDisplay); + } + + /// + /// Verifies that embedded YouTube iframes on mod pages have their title, thumbnail, platform, + /// and normalized embed URL properly extracted. + /// + /// A task that represents the asynchronous test. + [Fact] + public async Task ParseAsync_WithYouTubeIframe_ExtractsTitlePlatformThumbnailAndEmbedUrlAsync() + { + // Arrange + const string pageUrl = "https://www.moddb.com/mods/korean-war-2"; + var doc = await CreateDocumentAsync(""" + +

Korean War 2

+
+ +
+
+

Gameplay Teaser

+ +
+ + """); + var playwright = CreatePlaywrightMock(); + playwright + .Setup(service => service.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc }); + + var parser = new ModDBPageParser(playwright.Object, new Mock>().Object); + + // Act + var parsed = await parser.ParseAsync(pageUrl); + + // Assert + var videos = parsed.Sections.OfType
private async Task InitializeAsync() { - // Load subscribed PR and Branch from settings + // load subscribed pr and branch from settings var settings = _userSettingsService.Get(); if (settings.SubscribedPrNumber.HasValue) { @@ -359,16 +456,16 @@ private async Task InitializeAsync() _logger.LogInformation("Loaded subscribed branch '{Branch}' from settings", settings.SubscribedBranch); } - // Load data if we have a PAT + // load data if we have a pat if (HasPat) { - // Initial check/load + // initial check and load await Task.WhenAll( LoadPullRequestsAsync(), LoadBranchesAsync()); } - // Now check for updates - subscriptions will be properly populated + // check for updates after subscriptions are populated await CheckForUpdatesAsync(); } @@ -419,14 +516,14 @@ public string DisplayLatestVersion return GameClientConstants.UnknownVersion; } - // 1. PR Update takes precedence + // 1. pr update takes precedence if (SubscribedPr?.LatestArtifact != null && string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) { return SubscribedPr.LatestArtifact.DisplayVersion; } - // 2. Branch Update + // 2. branch update if (!string.IsNullOrEmpty(SubscribedBranch)) { return LatestVersion.StartsWith(SubscribedBranch, StringComparison.OrdinalIgnoreCase) @@ -445,34 +542,135 @@ public string DisplayLatestVersion ///
public void Dispose() { + _loadArtifactsCts?.Cancel(); + _loadArtifactsCts?.Dispose(); + _loadArtifactsCts = null; + _cancellationTokenSource.Cancel(); _cancellationTokenSource.Dispose(); GC.SuppressFinalize(this); } - /// - /// Extracts the workflow run number from a version string like "0.0.641-pr241". - /// - private static int ExtractRunNumber(string version) + private void ProcessPrArtifactUpdate(ArtifactUpdateInfo artifact, int prNumber) + { + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var prVersionBase = artifact.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(prVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = prVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{prNumber}"; + StatusMessage = $"New PR build available: {artifact.DisplayVersion}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}, new build available: {Version}", prNumber, artifact.DisplayVersion); + return; + } + + StatusMessage = $"You dismissed the update for PR #{prNumber}"; + return; + } + + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for PR #{prNumber}"; + } + + private void ProcessBranchArtifactUpdate(ArtifactUpdateInfo artifact, string branch) + { + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var branchVersionBase = artifact.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(branchVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(branchVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = branchVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{branch}"; + StatusMessage = $"New {branch} build available: {artifact.DisplayVersion}"; + _logger.LogInformation("Branch '{Branch}' has new build: {Version}", branch, LatestVersion); + return; + } + + StatusMessage = $"You dismissed the update for branch '{branch}'"; + return; + } + + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for {branch}"; + } + + partial void OnSelectedVersionChanged(ArtifactUpdateInfo? value) { - // Try to extract the run number before the PR suffix - var match = System.Text.RegularExpressions.Regex.Match(version, @"(\d+)(?:-pr\d+|-\w+)?$"); - if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber)) + UpdateCommandStates(); + + if (value == null) { - return runNumber; + return; } - // Fallback: try to parse the entire version as a number - var parts = version.Split('.', '-', '+'); - foreach (var part in parts.Reverse()) + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var selectedVersionBase = value.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(selectedVersionBase, currentVersionBase)) { - if (int.TryParse(part, out var number)) + var settings = _userSettingsService.Get(); + if (!string.Equals(selectedVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - return number; + IsUpdateAvailable = true; + LatestVersion = selectedVersionBase; + if (value.PullRequestNumber.HasValue) + { + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{value.PullRequestNumber.Value}"; + StatusMessage = $"New PR build available: {value.DisplayVersion}"; + } + else if (!string.IsNullOrEmpty(SubscribedBranch)) + { + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{SubscribedBranch}"; + StatusMessage = $"New {SubscribedBranch} build available: {value.DisplayVersion}"; + } + else + { + StatusMessage = $"New build available: {value.DisplayVersion}"; + } + + return; } + + IsUpdateAvailable = false; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + StatusMessage = $"You dismissed update {value.DisplayVersion}"; + return; } - return 0; + var currentRun = AppUpdateVersionHelper.ExtractRunNumber(currentVersionBase); + var selectedRun = AppUpdateVersionHelper.ExtractRunNumber(selectedVersionBase); + + if (currentRun > 0 && selectedRun > 0 && currentRun == selectedRun) + { + IsUpdateAvailable = false; + if (value.PullRequestNumber.HasValue) + { + StatusMessage = $"You are on the latest build for PR #{value.PullRequestNumber.Value}"; + } + else if (!string.IsNullOrEmpty(SubscribedBranch)) + { + StatusMessage = $"You are on the latest build for {SubscribedBranch}"; + } + else + { + StatusMessage = $"You are on the latest build ({value.DisplayVersion})"; + } + } + else + { + IsUpdateAvailable = false; + StatusMessage = $"Selected build: {value.DisplayVersion}"; + } } /// @@ -496,86 +694,32 @@ private async Task CheckForUpdatesAsync() _logger.LogInformation("Starting Velopack update check"); - // Check if subscribed to a PR + // check if subscribed to a pr if (SubscribedPr != null) { if (SubscribedPr.LatestArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var prVersionBase = SubscribedPr.LatestArtifact.Version.Split('+')[0]; - - // Extract run numbers for numeric comparison - var currentRun = ExtractRunNumber(currentVersionBase); - var prRun = ExtractRunNumber(prVersionBase); - - _logger.LogDebug("Comparing PR #{PrNumber} versions: current run #{CurrentRun} vs new run #{PrRun}", SubscribedPr.Number, currentRun, prRun); - - if (prRun > currentRun) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = prVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; - StatusMessage = $"New PR build available: {SubscribedPr.LatestArtifact.DisplayVersion}"; - _logger.LogInformation("Subscribed to PR #{PrNumber}, new build available: run #{PrRun} (current: #{CurrentRun})", SubscribedPr.Number, prRun, currentRun); - return; - } - - StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; - return; - } - - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + ProcessPrArtifactUpdate(SubscribedPr.LatestArtifact, SubscribedPr.Number); return; } - // Try to fetch artifact for update check + // try to fetch artifact for update check _logger.LogInformation("PR #{PrNumber} has no cached artifact, fetching for update check", SubscribedPr.Number); var prArtifact = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (prArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var prVersionBase = prArtifact.Version.Split('+')[0]; - - // Extract run numbers for numeric comparison - var currentRun = ExtractRunNumber(currentVersionBase); - var prRun = ExtractRunNumber(prVersionBase); - - _logger.LogDebug("Comparing fetched PR #{PrNumber} versions: current run #{CurrentRun} vs new run #{PrRun}", SubscribedPr.Number, currentRun, prRun); - - if (prRun > currentRun) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = prVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; - StatusMessage = $"New PR build available: {prArtifact.DisplayVersion}"; - _logger.LogInformation("Fetched PR #{PrNumber} artifact, new build available: run #{PrRun} (current: #{CurrentRun})", SubscribedPr.Number, prRun, currentRun); - return; - } - - StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; - return; - } - - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + ProcessPrArtifactUpdate(prArtifact, SubscribedPr.Number); return; } - // If subscribed to PR but no artifact found, don't fall through to main release + // if subscribed to pr but no artifact found, do not fall through to main release _logger.LogInformation("Subscribed to PR #{PrNumber} but no artifact available yet", SubscribedPr.Number); StatusMessage = $"Waiting for PR #{SubscribedPr.Number} build..."; IsUpdateAvailable = false; return; } - // Check Branch updates if subscribed + // check branch updates if subscribed if (!string.IsNullOrEmpty(SubscribedBranch)) { _logger.LogInformation("Checking for artifact updates on branch: {Branch}", SubscribedBranch); @@ -583,38 +727,18 @@ private async Task CheckForUpdatesAsync() if (branchArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var artifactVersionBase = branchArtifact.Version.Split('+')[0]; - - if (!string.Equals(artifactVersionBase, currentVersionBase, StringComparison.OrdinalIgnoreCase)) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = artifactVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{SubscribedBranch}"; - StatusMessage = $"New {SubscribedBranch} build available: {branchArtifact.Version}"; - _logger.LogInformation("Branch '{Branch}' has new build: {Version}", SubscribedBranch, LatestVersion); - return; - } - } - else - { - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for {SubscribedBranch}"; - return; - } + ProcessBranchArtifactUpdate(branchArtifact, SubscribedBranch); + return; } - // If subscribed to branch but no artifact found, don't fall through to main release + // if subscribed to branch but no artifact found, do not fall through to main release _logger.LogInformation("Subscribed to branch '{Branch}' but no artifact available yet", SubscribedBranch); StatusMessage = $"Waiting for {SubscribedBranch} build..."; IsUpdateAvailable = false; return; } - // Check main branch releases + // check main branch releases _currentUpdateInfo = await _velopackUpdateManager.CheckForUpdatesAsync(_cancellationTokenSource.Token); if (_currentUpdateInfo != null) @@ -681,7 +805,7 @@ private async Task ManualRefreshAsync() _logger.LogInformation("Manual refresh requested - clearing cache and dismissal status"); - // Clear dismissal status in settings so the user can see the update again + // clear dismissal status in settings so the user can see the update again var settings = _userSettingsService.Get(); if (!string.IsNullOrEmpty(settings.DismissedUpdateVersion)) { @@ -689,10 +813,10 @@ private async Task ManualRefreshAsync() await _userSettingsService.SaveAsync(); } - // Clear manager cache + // clear manager cache _velopackUpdateManager.ClearCache(); - // Reload data + // reload data if (HasPat) { await Task.WhenAll( @@ -703,6 +827,41 @@ await Task.WhenAll( await CheckForUpdatesAsync(); } + /// + /// Shows the update tab. + /// + [RelayCommand] + private void ShowUpdateTab() + { + SelectedTabIndex = AppUpdateConstants.UpdateTabIndex; + } + + /// + /// Shows the browse builds tab. + /// + [RelayCommand] + private void ShowBrowseBuildsTab() + { + SelectedTabIndex = AppUpdateConstants.BrowseBuildsTabIndex; + } + + /// + /// Selects the specified tab by index (0 = Update, 1 = Browse Builds). + /// + /// The tab index to select. + [RelayCommand] + private void SelectTab(object? parameter) + { + if (parameter is int i) + { + SelectedTabIndex = Math.Clamp(i, AppUpdateConstants.UpdateTabIndex, AppUpdateConstants.MaxTabIndex); + } + else if (parameter is string s && int.TryParse(s, out var parsed)) + { + SelectedTabIndex = Math.Clamp(parsed, AppUpdateConstants.UpdateTabIndex, AppUpdateConstants.MaxTabIndex); + } + } + /// /// Opens the release notes in the default browser. /// @@ -722,6 +881,29 @@ private void ViewReleaseNotes() } } + /// + /// Opens the specified pull request in the default browser. + /// + /// The PR number to open. + [RelayCommand] + private void OpenPullRequestUrl(int prNumber) + { + if (prNumber <= 0) + { + return; + } + + var url = $"{AppConstants.GitHubRepositoryUrl}/pull/{prNumber}"; + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open browser for PR #{PrNumber}", prNumber); + } + } + /// /// Downloads and applies the update using Velopack. /// @@ -733,7 +915,7 @@ private async Task InstallUpdateAsync() return; } - // 0. Handle Explicitly Selected Version + // 0. handle explicitly selected version if (SelectedVersion != null) { _logger.LogInformation("Installing selected artifact version: {Version}", SelectedVersion.DisplayVersion); @@ -741,7 +923,7 @@ private async Task InstallUpdateAsync() return; } - // 1. Handle PR Artifact Update (Auto-latest) + // 1. handle pr artifact update if (SubscribedPr?.LatestArtifact != null && string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) { @@ -750,7 +932,7 @@ private async Task InstallUpdateAsync() return; } - // 1.5 Handle Branch Artifact Update (Auto-latest) + // 1.5 handle branch artifact update if (!string.IsNullOrEmpty(SubscribedBranch)) { _logger.LogInformation("Installing Branch '{Branch}' artifact update", SubscribedBranch); @@ -758,7 +940,7 @@ private async Task InstallUpdateAsync() return; } - // 2. Handle Standard Velopack Update + // 2. handle standard velopack update if (_currentUpdateInfo == null) { _logger.LogError("Cannot install update - UpdateInfo is null (app not installed via Setup.exe)"); @@ -858,10 +1040,10 @@ private async Task InstallPrArtifactAsync() ArtifactUpdateInfo? artifactToInstall = SubscribedPr.LatestArtifact; if (artifactToInstall == null) { - // Clear cache to force fresh check + // clear cache to force fresh check _velopackUpdateManager.ClearCache(); - // Try to fetch the latest artifact for the PR + // try to fetch the latest artifact for the pr artifactToInstall = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (artifactToInstall == null) { @@ -875,7 +1057,7 @@ private async Task InstallPrArtifactAsync() await _velopackUpdateManager.InstallArtifactAsync(artifactToInstall, progress, _cancellationTokenSource.Token); - // App will restart, this code won't execute + // app will restart, this code will not execute } catch (Exception ex) { @@ -932,10 +1114,10 @@ private async Task InstallBranchArtifactAsync() }); }); - // Clear cache to force fresh check + // clear cache to force fresh check _velopackUpdateManager.ClearCache(); - // Check for latest artifact for the subscribed branch + // check for latest artifact for the subscribed branch var artifactUpdate = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (artifactUpdate == null) { @@ -948,7 +1130,7 @@ private async Task InstallBranchArtifactAsync() await _velopackUpdateManager.InstallArtifactAsync(artifactUpdate, progress, _cancellationTokenSource.Token); - // App will restart, this code won't execute + // app will restart, this code will not execute } catch (Exception ex) { @@ -992,7 +1174,7 @@ private async Task InstallArtifactAsync(ArtifactUpdateInfo artifact) await _velopackUpdateManager.InstallArtifactAsync(artifact, progress, _cancellationTokenSource.Token); - // App will restart + // app will restart } catch (Exception ex) { @@ -1089,10 +1271,9 @@ private async Task LoadPullRequestsAsync() await Dispatcher.UIThread.InvokeAsync(() => { - foreach (var pr in prs) - { - AvailablePullRequests.Add(pr); - } + _allPullRequests.Clear(); + _allPullRequests.AddRange(prs); + ApplyPullRequestSorting(); }); if (_velopackUpdateManager.IsPrMergedOrClosed && _velopackUpdateManager.SubscribedPrNumber.HasValue) @@ -1102,9 +1283,13 @@ await Dispatcher.UIThread.InvokeAsync(() => _logger.LogInformation("Subscribed PR has been merged/closed, showing warning"); } - if (_velopackUpdateManager.SubscribedPrNumber.HasValue && SubscribedPr == null) + if (_velopackUpdateManager.SubscribedPrNumber.HasValue) { - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber); + var matchingPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber.Value); + if (matchingPr != null && (SubscribedPr == null || SubscribedPr.Number == matchingPr.Number)) + { + SubscribedPr = matchingPr; + } } } catch (Exception ex) @@ -1118,6 +1303,33 @@ await Dispatcher.UIThread.InvokeAsync(() => } } + private void ApplyPullRequestSorting() + { + if (_allPullRequests.Count == 0 && AvailablePullRequests.Count == 0) + { + return; + } + + if (_allPullRequests.Count == 0 && AvailablePullRequests.Count > 0) + { + _allPullRequests.AddRange(AvailablePullRequests); + } + + IEnumerable sorted = SelectedSortOption switch + { + AppUpdateConstants.SortOptionPrNumberDesc => _allPullRequests.OrderByDescending(p => p.Number), + AppUpdateConstants.SortOptionPrNumberAsc => _allPullRequests.OrderBy(p => p.Number), + _ => _allPullRequests.OrderByDescending(p => p.UpdatedAt ?? DateTimeOffset.MinValue), + }; + + var sortedList = sorted.ToList(); + AvailablePullRequests.Clear(); + foreach (var pr in sortedList) + { + AvailablePullRequests.Add(pr); + } + } + [RelayCommand] private async Task LoadBranchesAsync() { @@ -1154,11 +1366,19 @@ await Dispatcher.UIThread.InvokeAsync(() => private void SubscribeToPr(int prNumber) { _velopackUpdateManager.SubscribedPrNumber = prNumber; - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber); + _velopackUpdateManager.SubscribedBranch = null; SubscribedBranch = null; + SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber) ?? new PullRequestInfo + { + Number = prNumber, + Title = $"PR #{prNumber}", + BranchName = "unknown", + Author = "unknown", + State = "open", + }; ShowPrMergedWarning = false; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1168,11 +1388,8 @@ private void SubscribeToPr(int prNumber) }); _ = _userSettingsService.SaveAsync(); - if (SubscribedPr != null) - { - StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; - _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); - } + StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); } [RelayCommand] @@ -1180,12 +1397,13 @@ private void SubscribeToBranch(string branchName) { if (string.IsNullOrEmpty(branchName)) return; - SubscribedBranch = branchName; _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = branchName; SubscribedPr = null; + SubscribedBranch = branchName; ShowPrMergedWarning = false; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1201,6 +1419,7 @@ private void SubscribeToBranch(string branchName) partial void OnSubscribedBranchChanged(string? value) { + _velopackUpdateManager.SubscribedBranch = value; _ = LoadArtifactsForSubscribedItemAsync(); OnPropertyChanged(nameof(IsSubscribedToAny)); UpdateCommandStates(); @@ -1220,9 +1439,15 @@ partial void OnSubscribedPrChanged(PullRequestInfo? value) private void Unsubscribe() { _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = null; SubscribedPr = null; SubscribedBranch = null; + SelectedVersion = null; ShowPrMergedWarning = false; + IsUpdateAvailable = false; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + _currentUpdateInfo = null; StatusMessage = "Switched to MAIN branch updates"; _userSettingsService.Update(settings => @@ -1233,6 +1458,7 @@ private void Unsubscribe() _ = _userSettingsService.SaveAsync(); _logger.LogInformation("Unsubscribed from dev builds, switched to MAIN"); + _ = CheckForUpdatesAsync(); } [RelayCommand] diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml index fc4a522ba..63c35be95 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml @@ -11,39 +11,128 @@ - - + + + + + + + + + - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs index 5fd0a2bf2..ba60939db 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs @@ -62,23 +62,43 @@ public async Task InitializeAsync() private void InitializeComponent() => AvaloniaXamlLoader.Load(this); + /// + /// Handles the maximize/restore button click event. + /// + /// The sender. + /// The event args. + private void MaximizeButton_Click(object? sender, RoutedEventArgs e) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + /// /// Handles the close button click event. /// /// The sender. /// The event args. - private void CloseButton_Click(object sender, RoutedEventArgs e) + private void CloseButton_Click(object? sender, RoutedEventArgs e) { Close(); } /// - /// Handles pointer pressed event for the title bar to enable window dragging. + /// Handles pointer pressed event for the title bar to enable window dragging and double-click maximize. /// /// The sender. /// The pointer event args. - private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e) + private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e) { - BeginMoveDrag(e); + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (e.ClickCount == 2) + { + MaximizeButton_Click(sender, new RoutedEventArgs()); + } + else + { + BeginMoveDrag(e); + } + } } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index be8788503..9bc5bd496 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -97,7 +97,7 @@ await Task.Run( throw new FileNotFoundException($"Archive file not found or empty: {archivePath}"); } - using var archive = ArchiveFactory.Open(archivePath); + using var archive = ArchiveFactory.Open(fileInfo); foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index 1a2a4b154..53cdf9437 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -108,7 +108,7 @@ public async Task>> SearchAsync _logger.LogDebug("Starting orchestrated content search with query: {SearchTerm}, ContentType: {ContentType}", query.SearchTerm, query.ContentType); // Check cache first - var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.Skip}::{query.Take}::{query.SortOrder}"; + var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.TargetGame}::{query.AuthorName}::{query.GitHubAuthor}::{query.Language}::{query.Skip}::{query.Take}::{query.SortOrder}"; var cachedResults = await _cache.GetAsync>(cacheKey, cancellationToken); if (cachedResults != null) { @@ -187,8 +187,19 @@ public async Task>> SearchAsync // than an exception, which would otherwise surface here as an empty successful search. cancellationToken.ThrowIfCancellationRequested(); + // Deduplicate results by manifest ID across providers before sorting and pagination, + // preferring specialized publisher providers over generic GitHub providers. + var deduplicatedResults = allResults + .GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase) + .Select(g => g + .OrderByDescending(r => + !string.Equals(r.ProviderName, ContentSourceNames.GitHubDiscoverer, StringComparison.OrdinalIgnoreCase) && + !string.Equals(r.ProviderName, ContentSourceNames.GitHubReleasesDiscoverer, StringComparison.OrdinalIgnoreCase) ? 1 : 0) + .First()) + .ToList(); + // Apply orchestrator-level sorting and pagination - var sortedResults = ApplySorting(allResults, query.SortOrder) + var sortedResults = ApplySorting(deduplicatedResults, query.SortOrder) .Skip(query.Skip) .Take(query.Take) .ToList(); diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index 1814c5362..5d8578b60 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -381,7 +381,7 @@ private async Task ExtractArchiveAsync( await Task.Run( () => { - using var archive = ArchiveFactory.Open(archiveFile); + using var archive = ArchiveFactory.Open(new FileInfo(archiveFile)); int totalEntries = archive.Entries.Count(e => !e.IsDirectory); int currentEntry = 0; diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs index f91075390..e7ac16dc3 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -186,6 +186,11 @@ await manifest.AddRemoteFileAsync( } var builtManifest = manifest.Build(); + if (!string.IsNullOrEmpty(release.TagName)) + { + builtManifest.Version = release.TagName; + } + logger.LogInformation("GitHubResolver: Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } @@ -373,6 +378,11 @@ await manifest.AddRemoteFileAsync( logger.LogInformation("Successfully resolved single release asset: {AssetName}", asset.Name); var builtManifest = manifest.Build(); + if (!string.IsNullOrEmpty(tag)) + { + builtManifest.Version = tag; + } + logger.LogInformation("GitHubResolver (Single Asset): Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } diff --git a/GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs b/GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs new file mode 100644 index 000000000..b435fbd30 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs @@ -0,0 +1,3253 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Parsers; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Parsers; +using Microsoft.Extensions.Logging; +using IDocument = AngleSharp.Dom.IDocument; + +namespace GenHub.Features.Content.Services.Parsers; + +/// +/// Parser for ModDB pages that extracts rich content including files, videos, images, articles, reviews, and comments. +/// +public partial class ModDBPageParser(IPlaywrightService playwrightService, ILogger logger) : IWebPageParser +{ + [GeneratedRegex(ModDBParserConstants.ParentModPathRegex, RegexOptions.IgnoreCase, "en-US")] + private static partial Regex MyRegex(); + + [GeneratedRegex(@"/(?:crop|thumb)_[^/]+/", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex ModDBImageCropRegex(); + + [GeneratedRegex(@"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", RegexOptions.None, "en-US")] + private static partial Regex CamelCaseSplitRegex(); + + [GeneratedRegex(@"(?:\b|\()(\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|B|bytes|byte|gigabytes|megabytes|kilobytes)(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex FileSizeRegex(); + + [GeneratedRegex(@"(?:\(|\b)(\d[\d,\s]*)\s*bytes", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex ExactBytesRegex(); + + [GeneratedRegex(@"(\d+(?:[.,]\d+)?)\s*([a-zA-Z]+)", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex NumericSizeWithUnitRegex(); + + [GeneratedRegex(@"(?:(?:v|embed|shorts|vi)/|(?:\?|&)v=|youtu\.be/)([a-zA-Z0-9_-]{11})", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex YouTubeVideoIdRegex(); + + [GeneratedRegex(@"vimeo\.com/(?:video/)?(\d+)", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex VimeoVideoIdRegex(); + + [GeneratedRegex(@"^(?.+?)\s+(?:file|addon|patch|demo|mod)\s+-", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex ModDBPageTitleRegex(); + + /// <summary> + /// Extracts parent mod URL from a FileDetail page URL. + /// Example: /mods/mod-name/downloads/file-name -> /mods/mod-name. + /// </summary> + private static string? ExtractParentModUrl(string url) + { + // Remove query string and fragment + var cleanUrl = url.Split('?')[0].Split('#')[0]; + + // Match pattern: /mods/mod-name/downloads/file-name or /mods/mod-name/addons/file-name + var match = MyRegex().Match(cleanUrl); + + if (match.Success) + { + var parentPath = match.Groups[1].Value; + + // Ensure it's a full URL + if (!parentPath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return ModDBConstants.BaseUrl.TrimEnd('/') + parentPath; + } + + return parentPath; + } + + return null; + } + + /// <summary> + /// Merges primary and fallback global contexts, taking fallback values when primary is missing. + /// </summary> + private static GlobalContext MergeContext(GlobalContext primary, GlobalContext fallback) + { + return new GlobalContext( + Title: string.IsNullOrWhiteSpace(primary.Title) || primary.Title == "Unknown" ? fallback.Title : primary.Title, + Developer: string.IsNullOrWhiteSpace(primary.Developer) ? fallback.Developer : primary.Developer, + ReleaseDate: primary.ReleaseDate ?? fallback.ReleaseDate, + GameName: string.IsNullOrWhiteSpace(primary.GameName) ? fallback.GameName : primary.GameName, + IconUrl: string.IsNullOrWhiteSpace(primary.IconUrl) ? fallback.IconUrl : primary.IconUrl, + Description: string.IsNullOrWhiteSpace(primary.Description) ? fallback.Description : primary.Description); + } + + /// <summary> + /// Detects the page type based on URL patterns and DOM structure. + /// </summary> + private static PageType DetectPageType(string url, IDocument document) + { + // Check for file detail page + if (document.QuerySelector(ModDBParserConstants.DownloadsInfoSelector) != null) + { + return PageType.FileDetail; + } + + // Addons listings are row tables. A mod's /images tab is a gallery (#imagebox), not a list. + if (url.Contains("/addons", StringComparison.OrdinalIgnoreCase)) + { + return PageType.List; + } + + if (url.Contains("/images", StringComparison.OrdinalIgnoreCase) && + document.QuerySelector(ModDBParserConstants.ImageGallerySelector) == null) + { + return PageType.List; + } + + // Check for summary/news pages + if (document.QuerySelector(ModDBParserConstants.ArticlesBrowseSelector) != null) + { + return PageType.Summary; + } + + // Default to detail page + return PageType.Detail; + } + + /// <summary> + /// Extracts content sections from summary/news pages. + /// </summary> + private static List<ContentSection> ExtractSummarySections(IDocument document) + { + var sections = new List<ContentSection>(); + + // Extract articles + sections.AddRange(ExtractArticles(document)); + + return sections; + } + + /// <summary> + /// Extracts a file from a row element. + /// </summary> + /// <param name="row">The row element containing file information.</param> + /// <param name="sectionType">The type of file section (Release or Addon).</param> + private static DownloadableFile? ExtractFileFromRow(IElement row, FileSectionType sectionType = FileSectionType.Downloads) + { + var nameEl = row.QuerySelector(ModDBParserConstants.FileNameSelector); + var name = nameEl?.TextContent?.Trim(); + if (string.IsNullOrEmpty(name)) + { + return null; + } + + var (sizeBytes, sizeText) = ExtractRowFileSize(row); + var (uploadDate, releaseDate) = ExtractRowDates(row); + var category = ExtractRowCategory(row); + var uploader = ExtractRowUploader(row); + var (downloadUrl, detailsUrl) = ExtractRowUrls(row, nameEl); + var thumbnailUrl = ExtractRowThumbnail(row); + var summary = ExtractRowSummary(row); + var commentCount = ExtractRowCommentCount(row); + + return new DownloadableFile( + Name: name, + SizeBytes: sizeBytes, + SizeDisplay: sizeText, + UploadDate: uploadDate, + Category: category, + Uploader: uploader, + DownloadUrl: downloadUrl, + CommentCount: commentCount, + ThumbnailUrl: thumbnailUrl, + FileSectionType: sectionType, + ReleaseDate: releaseDate, + DetailsUrl: detailsUrl, + Description: summary); + } + + private static (long? SizeBytes, string? SizeText) ExtractRowFileSize(IElement row) + { + var sizeEl = row.QuerySelector(ModDBParserConstants.FileSizeSelector); + var sizeText = sizeEl?.TextContent?.Trim(); + long? sizeBytes = null; + if (!string.IsNullOrEmpty(sizeText)) + { + sizeBytes = ParseFileSize(sizeText); + } + + if (sizeBytes == null || string.IsNullOrEmpty(sizeText)) + { + var subHeadingEl = row.QuerySelector(ModDBParserConstants.FileSubheadingSelector); + var textToSearch = subHeadingEl?.TextContent ?? row.TextContent; + if (!string.IsNullOrWhiteSpace(textToSearch)) + { + var sizeMatch = FileSizeRegex().Match(textToSearch); + if (sizeMatch.Success) + { + sizeText = sizeMatch.Groups[1].Value.Trim(); + sizeBytes = ParseFileSize(sizeText); + } + } + } + + return (sizeBytes, sizeText); + } + + private static (DateTime? UploadDate, DateTime? ReleaseDate) ExtractRowDates(IElement row) + { + var dateEl = row.QuerySelector(ModDBParserConstants.FileDateSelector); + DateTime? uploadDate = null; + DateTime? releaseDate = null; + if (dateEl != null) + { + var dateStr = dateEl.GetAttribute("datetime") ?? dateEl.GetAttribute("title") ?? dateEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(dateStr)) + { + if (DateTime.TryParse(dateStr, out var standardDate)) + { + uploadDate = standardDate; + releaseDate = standardDate; + } + else + { + var modDBDate = ParseModDBDate(dateStr); + if (modDBDate.HasValue) + { + uploadDate = modDBDate; + releaseDate = modDBDate; + } + } + } + } + + return (uploadDate, releaseDate); + } + + private static string? ExtractRowCategory(IElement row) + { + var category = row.QuerySelector(ModDBParserConstants.FileCategorySelector)?.TextContent?.Trim(); + if (string.IsNullOrEmpty(category)) + { + var subHeadingEl = row.QuerySelector(ModDBParserConstants.FileSubheadingSelector); + var subHeadingText = subHeadingEl?.TextContent?.Trim(); + if (!string.IsNullOrEmpty(subHeadingText)) + { + var categoryKeywords = new[] { "Full Version", "Patch", "Demo", "Tool", "Addon", "Map", "Skin" }; + foreach (var keyword in categoryKeywords) + { + if (subHeadingText.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return keyword; + } + } + } + } + + return category; + } + + private static string? ExtractRowUploader(IElement row) + { + var uploaderEl = row.QuerySelector(ModDBParserConstants.FileUploaderSelector); + var uploader = uploaderEl?.TextContent?.Trim(); + return IsJunkDeveloperName(uploader) ? null : uploader; + } + + private static (string? DownloadUrl, string? DetailsUrl) ExtractRowUrls(IElement row, IElement? nameEl) + { + string? downloadUrl = null; + string? detailsUrl = null; + + var titleLinkEl = row.QuerySelector("h4 a, h5 a, h3 a, .heading a, .title a, a.title, .name a, a[href*='/downloads/'], a[href*='/addons/']") + ?? (string.Equals(nameEl?.TagName, "A", StringComparison.OrdinalIgnoreCase) ? nameEl : nameEl?.QuerySelector("a")); + var titleHref = titleLinkEl?.GetAttribute("href"); + if (!string.IsNullOrEmpty(titleHref)) + { + titleHref = ToAbsoluteUrl(titleHref); + if (!IsDirectDownloadUrl(titleHref)) + { + detailsUrl = titleHref; + } + else + { + downloadUrl = titleHref; + } + } + + var linkEl = row.QuerySelector(ModDBParserConstants.FileDownloadSelector); + var buttonHref = linkEl?.GetAttribute("href"); + if (!string.IsNullOrEmpty(buttonHref)) + { + if (!buttonHref.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + buttonHref = ModDBConstants.BaseUrl.TrimEnd('/') + "/" + buttonHref.TrimStart('/'); + } + + if (IsDirectDownloadUrl(buttonHref)) + { + downloadUrl = buttonHref; + } + else if (string.IsNullOrEmpty(detailsUrl)) + { + detailsUrl = buttonHref; + } + } + + downloadUrl ??= detailsUrl; + return (downloadUrl, detailsUrl); + } + + private static string? ExtractRowThumbnail(IElement row) + { + var imgEl = row.QuerySelector("img"); + var thumbnailUrl = imgEl?.GetAttribute("src") ?? imgEl?.GetAttribute("data-src"); + if (!string.IsNullOrEmpty(thumbnailUrl)) + { + return (thumbnailUrl.Contains("blank.gif", StringComparison.OrdinalIgnoreCase) || + thumbnailUrl.Contains("clear.gif", StringComparison.OrdinalIgnoreCase)) + ? null + : ToAbsoluteUrl(thumbnailUrl); + } + + return null; + } + + private static string? ExtractRowSummary(IElement row) + { + var summaryEl = row.QuerySelector("p.summary, .summary, p, div.summary"); + var summary = summaryEl?.TextContent?.Trim(); + return IsBreadcrumbOrLocationText(summary) ? null : summary; + } + + private static int? ExtractRowCommentCount(IElement row) + { + var commentCountEl = row.QuerySelector(ModDBParserConstants.FileCommentCountSelector); + if (commentCountEl != null) + { + var countText = commentCountEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(countText) && int.TryParse(countText, out var count)) + { + return count; + } + } + + return null; + } + + /// <summary> + /// Extracts metadata from the sidebar profile section. + /// </summary> + private static ProfileMeta ExtractProfileMeta(IDocument document) + { + var sidebar = document.QuerySelector(ModDBParserConstants.ProfileSidebarSelector); + if (sidebar == null) + { + return new ProfileMeta(null, null, null, null, null, null); + } + + string? name = null; // Often the page title, but sometimes in sidebar + long? sizeBytes = null; + string? sizeDisplay = null; + DateTime? releaseDate = null; + string? developer = null; + string? md5Hash = null; + + var rows = sidebar.QuerySelectorAll(ModDBParserConstants.ProfileRowSelector); + foreach (var row in rows) + { + var labelEl = row.QuerySelector(ModDBParserConstants.ProfileLabelSelector); + var contentEl = row.QuerySelector(ModDBParserConstants.ProfileContentSelector); + + if (labelEl == null || contentEl == null) + { + continue; + } + + var label = labelEl.TextContent?.Trim().ToLowerInvariant().Replace(":", string.Empty); + var content = contentEl.TextContent?.Trim(); + + if (string.IsNullOrEmpty(label) || string.IsNullOrEmpty(content)) + { + continue; + } + + switch (label) + { + case "filename": + case "file": + name = content; + break; + case "size": + sizeDisplay = content; + + // Sometimes format is "134mb (140,505,749 bytes)" + // Try to parse the bytes part first if available + if (content.Contains("bytes") && content.Contains('(') && content.Contains(')')) + { + var bytesPart = content.Split('(').Last().Replace("bytes)", string.Empty).Replace(",", string.Empty).Trim(); + if (long.TryParse(bytesPart, out var bytesVal)) + { + sizeBytes = bytesVal; + } + } + + sizeBytes ??= ParseFileSize(content); + + break; + case "uploader": + case "author": + developer = content; + break; + case "added": + case "date": + case "release date": + if (DateTime.TryParse(content, out var dt)) + { + releaseDate = dt; + } + + break; + case "md5 hash": + case "md5": + md5Hash = content; + break; + default: + // Ignore other metadata labels + break; + } + } + + return new ProfileMeta(name, sizeBytes, sizeDisplay, releaseDate, developer, md5Hash); + } + + /// <summary> + /// Extracts videos from the document, including embedded iframes, HTML5 video elements, + /// and video gallery cards (e.g., from the /videos tab or media widgets). + /// </summary> + private static List<Video> ExtractVideos(IDocument document) + { + var videos = new List<Video>(); + videos.AddRange(ExtractIFrameVideos(document)); + videos.AddRange(ExtractHtml5Videos(document)); + videos.AddRange(ExtractGalleryCardVideos(document)); + return DeduplicateVideoList(videos); + } + + private static List<Video> ExtractIFrameVideos(IDocument document) + { + var videos = new List<Video>(); + var videoElements = document.QuerySelectorAll(ModDBParserConstants.VideoSelector); + foreach (var videoEl in videoElements) + { + var src = videoEl.GetAttribute("src") + ?? videoEl.GetAttribute("data-src") + ?? videoEl.GetAttribute("data-video"); + + if (string.IsNullOrWhiteSpace(src)) + { + continue; + } + + if (src.StartsWith("//", StringComparison.Ordinal)) + { + src = "https:" + src; + } + + if (IsNonVideoIframe(src)) + { + continue; + } + + var title = ResolveIFrameVideoTitle(videoEl); + if (!IsUsableVideoTitle(title)) + { + continue; + } + + var (platform, thumbnailUrl, embedUrl) = ResolveVideoPlatformDetails(src, videoEl); + + if (!embedUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + embedUrl = ToAbsoluteUrl(embedUrl); + } + + videos.Add(new Video( + Title: title, + ThumbnailUrl: thumbnailUrl, + EmbedUrl: embedUrl, + Platform: platform)); + } + + return videos; + } + + private static (string Platform, string? ThumbnailUrl, string EmbedUrl) ResolveVideoPlatformDetails(string src, IElement videoEl) + { + string? thumbnailUrl = null; + var platform = "Unknown"; + var embedUrl = src; + + var ytMatch = YouTubeVideoIdRegex().Match(src); + if (ytMatch.Success) + { + var videoId = ytMatch.Groups[1].Value; + platform = "YouTube"; + thumbnailUrl = $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg"; + embedUrl = $"https://www.youtube.com/embed/{videoId}"; + } + else + { + var vimeoMatch = VimeoVideoIdRegex().Match(src); + if (vimeoMatch.Success) + { + var videoId = vimeoMatch.Groups[1].Value; + platform = "Vimeo"; + embedUrl = $"https://player.vimeo.com/video/{videoId}"; + } + else if (src.Contains("dailymotion", StringComparison.OrdinalIgnoreCase)) + { + platform = "Dailymotion"; + } + else if (src.Contains("moddb.com", StringComparison.OrdinalIgnoreCase)) + { + platform = "ModDB"; + } + } + + if (string.IsNullOrWhiteSpace(thumbnailUrl)) + { + var container = videoEl.Closest(".video, .media, .mediabox, .holder, .row, .embed, figure, .video-container"); + var thumbEl = container?.QuerySelector(ModDBParserConstants.VideoThumbnailSelector); + var rawThumb = thumbEl?.GetAttribute("src") ?? thumbEl?.GetAttribute("data-src"); + if (!string.IsNullOrWhiteSpace(rawThumb)) + { + thumbnailUrl = ToAbsoluteUrl(rawThumb); + } + } + + return (platform, thumbnailUrl, embedUrl); + } + + private static List<Video> ExtractHtml5Videos(IDocument document) + { + var videos = new List<Video>(); + var html5VideoElements = document.QuerySelectorAll("video"); + foreach (var videoEl in html5VideoElements) + { + var src = videoEl.GetAttribute("src") + ?? videoEl.QuerySelector("source")?.GetAttribute("src"); + + if (string.IsNullOrWhiteSpace(src)) + { + continue; + } + + var poster = videoEl.GetAttribute("poster"); + var thumbnailUrl = !string.IsNullOrWhiteSpace(poster) ? ToAbsoluteUrl(poster) : null; + var title = ResolveIFrameVideoTitle(videoEl); + if (!IsUsableVideoTitle(title)) + { + continue; + } + + videos.Add(new Video( + Title: title, + ThumbnailUrl: thumbnailUrl, + EmbedUrl: ToAbsoluteUrl(src), + Platform: "ModDB")); + } + + return videos; + } + + private static List<Video> ExtractGalleryCardVideos(IDocument document) + { + var videos = new List<Video>(); + var videoLinks = document.QuerySelectorAll(ModDBParserConstants.VideoLinkSelector); + foreach (var linkEl in videoLinks) + { + var href = linkEl.GetAttribute("href"); + if (string.IsNullOrWhiteSpace(href)) + { + continue; + } + + if (IsNavigationOrPaginationLink(href, linkEl)) + { + continue; + } + + if (IsInRecommendationsOrAds(linkEl)) + { + continue; + } + + var absoluteHref = ToAbsoluteUrl(href); + var (video, isVideo) = ExtractVideoFromGalleryLink(linkEl, absoluteHref); + if (isVideo && video != null) + { + videos.Add(video); + } + } + + return videos; + } + + private static bool IsNonVideoIframe(string src) + { + if (string.IsNullOrWhiteSpace(src)) + { + return true; + } + + if (src.Contains("/widget", StringComparison.OrdinalIgnoreCase) || + src.Contains("/widgets/", StringComparison.OrdinalIgnoreCase) || + src.Contains("doubleclick", StringComparison.OrdinalIgnoreCase) || + src.Contains("googlesyndication", StringComparison.OrdinalIgnoreCase) || + src.Contains("adnxs", StringComparison.OrdinalIgnoreCase) || + src.Contains("amazon-adsystem", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (src.Contains("moddb.com", StringComparison.OrdinalIgnoreCase) && + !src.Contains("/media/iframe/", StringComparison.OrdinalIgnoreCase) && + !src.Contains("/media/embed/", StringComparison.OrdinalIgnoreCase) && + !src.Contains("/videos/iframe/", StringComparison.OrdinalIgnoreCase) && + !src.Contains("/videos/embed/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static bool IsInRecommendationsOrAds(IElement el) + { + return el.Closest(ModDBParserConstants.RecommendationsSelector) != null || + el.Closest("aside, #sidecolumn, .sidecolumn") != null; + } + + private static string ResolveIFrameVideoTitle(IElement element) + { + var title = element.GetAttribute("title"); + if (IsUsableVideoTitle(title)) + { + return FormatVideoTitle(title!); + } + + var ariaLabel = element.GetAttribute("aria-label"); + if (IsUsableVideoTitle(ariaLabel)) + { + return FormatVideoTitle(ariaLabel!); + } + + var container = element.Closest(".video, .media, .mediabox, .holder, .row, .embed, figure, .video-container, [class*='video'], [class*='media']"); + var containerTitleEl = container?.QuerySelector(".title, .caption, figcaption, h1, h2, h3, h4, h5, strong"); + var containerTitle = containerTitleEl?.TextContent?.Trim(); + if (IsUsableVideoTitle(containerTitle)) + { + return FormatVideoTitle(containerTitle!); + } + + var prev = element.PreviousElementSibling; + if (prev != null && (prev.TagName.StartsWith('H') || prev.ClassList.Contains("title") || prev.ClassList.Contains("caption"))) + { + var prevTitle = prev.TextContent?.Trim(); + if (IsUsableVideoTitle(prevTitle)) + { + return FormatVideoTitle(prevTitle!); + } + } + + var next = element.NextElementSibling; + if (next != null && (next.ClassList.Contains("title") || next.ClassList.Contains("caption") || next.TagName.Equals("FIGCAPTION", StringComparison.OrdinalIgnoreCase))) + { + var nextTitle = next.TextContent?.Trim(); + if (IsUsableVideoTitle(nextTitle)) + { + return FormatVideoTitle(nextTitle!); + } + } + + return "Video"; + } + + private static bool IsUsableVideoTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return false; + } + + var clean = title.Trim(); + return !clean.Equals("Video", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("YouTube video player", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("YouTube player", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Play", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("View media", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Image", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Next Media", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Previous Media", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Next", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Previous", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Prev", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("RSS", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("RSS Feed", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Feed", StringComparison.OrdinalIgnoreCase) && + !clean.Equals("Subscribe", StringComparison.OrdinalIgnoreCase) && + !clean.StartsWith("You may also like", StringComparison.OrdinalIgnoreCase) && + !clean.StartsWith("Recommended", StringComparison.OrdinalIgnoreCase) && + !clean.StartsWith("Related", StringComparison.OrdinalIgnoreCase); + } + + private static string FormatVideoTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return "Video"; + } + + var formatted = title.Trim('*', ' ', '\t', '\r', '\n').Replace('_', ' ').Replace('-', ' '); + + if (!formatted.Contains(' ')) + { + formatted = CamelCaseSplitRegex().Replace(formatted, " "); + } + + formatted = Regex.Replace(formatted, @"\s+", " ").Trim(); + + return string.IsNullOrWhiteSpace(formatted) ? "Video" : formatted; + } + + private static (Video? Video, bool IsVideo) ExtractVideoFromGalleryLink(IElement linkEl, string absoluteHref) + { + if (absoluteHref.Contains("/widget", StringComparison.OrdinalIgnoreCase) || + absoluteHref.Contains("/downloads/", StringComparison.OrdinalIgnoreCase) || + absoluteHref.Contains("/addons/", StringComparison.OrdinalIgnoreCase)) + { + return (null, false); + } + + if (IsNavigationOrPaginationLink(absoluteHref, linkEl)) + { + return (null, false); + } + + var container = linkEl.Closest(".holder, .mediabox, .mediarow, .row, .rowcontent, .media, .videobox") ?? linkEl; + + var img = linkEl.QuerySelector("img") + ?? container.QuerySelector("img"); + + var rawThumb = img?.GetAttribute("src") ?? img?.GetAttribute("data-src"); + string? thumbnailUrl = null; + if (!string.IsNullOrWhiteSpace(rawThumb)) + { + thumbnailUrl = ToAbsoluteUrl(rawThumb); + } + + var platform = "ModDB"; + var embedUrl = absoluteHref; + + var ytMatch = YouTubeVideoIdRegex().Match(absoluteHref); + if (!ytMatch.Success && thumbnailUrl != null) + { + ytMatch = YouTubeVideoIdRegex().Match(thumbnailUrl); + } + + if (ytMatch.Success) + { + var videoId = ytMatch.Groups[1].Value; + platform = "YouTube"; + thumbnailUrl = $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg"; + embedUrl = $"https://www.youtube.com/embed/{videoId}"; + } + else + { + var vimeoMatch = VimeoVideoIdRegex().Match(absoluteHref); + if (vimeoMatch.Success) + { + var videoId = vimeoMatch.Groups[1].Value; + platform = "Vimeo"; + embedUrl = $"https://player.vimeo.com/video/{videoId}"; + } + } + + var title = ResolveGalleryVideoTitle(linkEl, container, img, absoluteHref); + if (!IsUsableVideoTitle(title)) + { + return (null, false); + } + + if (string.IsNullOrWhiteSpace(thumbnailUrl) && platform == "ModDB") + { + return (null, false); + } + + return (new Video( + Title: title, + ThumbnailUrl: thumbnailUrl, + EmbedUrl: embedUrl, + Platform: platform), true); + } + + private static string ResolveGalleryVideoTitle(IElement linkEl, IElement container, IElement? img, string absoluteHref) + { + var linkTitle = linkEl.GetAttribute("title"); + if (IsUsableVideoTitle(linkTitle)) + { + return FormatVideoTitle(linkTitle!); + } + + var imgAlt = img?.GetAttribute("alt") ?? img?.GetAttribute("title"); + if (IsUsableVideoTitle(imgAlt)) + { + return FormatVideoTitle(imgAlt!); + } + + var containerTitleEl = container.QuerySelector(".title a, .title, h4 a, h4, h3 a, h3, h5, .caption"); + var containerTitle = containerTitleEl?.TextContent?.Trim(); + if (IsUsableVideoTitle(containerTitle)) + { + return FormatVideoTitle(containerTitle!); + } + + var slug = GalleryImageKey(absoluteHref); + var dot = slug.LastIndexOf('.'); + if (dot > 0) + { + slug = slug[..dot]; + } + + if (IsUsableVideoTitle(slug)) + { + return FormatVideoTitle(slug); + } + + return "Video"; + } + + private static bool IsNavigationOrPaginationLink(string href, IElement linkEl) + { + if (string.IsNullOrWhiteSpace(href)) + { + return true; + } + + var cleanHref = href.Split('?')[0].Split('#')[0].TrimEnd('/'); + if (cleanHref.EndsWith("/videos", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith("/media", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith("/images", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith("/downloads", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith("/addons", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith("/reviews", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith("/articles", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (cleanHref.Contains("/page/", StringComparison.OrdinalIgnoreCase) || + cleanHref.Contains("/rss", StringComparison.OrdinalIgnoreCase) || + cleanHref.Contains("/feed", StringComparison.OrdinalIgnoreCase) || + cleanHref.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) || + href.Contains("?page=", StringComparison.OrdinalIgnoreCase) || + href.Contains("&page=", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var text = linkEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(text) && + (text.Equals("Next Media", StringComparison.OrdinalIgnoreCase) || + text.Equals("Previous Media", StringComparison.OrdinalIgnoreCase) || + text.Equals("Next", StringComparison.OrdinalIgnoreCase) || + text.Equals("Previous", StringComparison.OrdinalIgnoreCase) || + text.Equals("Prev", StringComparison.OrdinalIgnoreCase) || + text.Equals("RSS", StringComparison.OrdinalIgnoreCase) || + text.Equals("RSS Feed", StringComparison.OrdinalIgnoreCase) || + text.Equals("Feed", StringComparison.OrdinalIgnoreCase) || + text.Equals("Subscribe", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + var inNav = linkEl.Closest("nav, .tabs, .navigation, .pagination, .pages, .heading, #nav, .feed, .rss, .subheading, .actions") != null; + return inNav; + } + + /// <summary> + /// Extracts gallery images from the document. Game icons, member avatars, file-page chrome, + /// and social-share glyphs are excluded — those previously flooded the Media tab. + /// </summary> + private static List<Image> ExtractImages(IDocument document) + { + var images = new List<Image>(); + var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + var elements = document.QuerySelectorAll(ModDBParserConstants.GalleryImageSelector); + foreach (var img in elements) + { + var src = img.GetAttribute("src") ?? img.GetAttribute("data-src"); + if (!IsUsableModGalleryImage(src, img)) + { + continue; + } + + var thumbnailUrl = ToAbsoluteUrl(src!); + var key = GalleryImageKey(thumbnailUrl); + if (!seenKeys.Add(key)) + { + continue; + } + + var parentAnchor = img.Closest("a"); + var anchorHref = parentAnchor?.GetAttribute("href"); + var fullSizeUrl = GetFullSizeModDBImageSource(thumbnailUrl); + + if (!string.IsNullOrWhiteSpace(anchorHref)) + { + var absoluteHref = ToAbsoluteUrl(anchorHref); + if (absoluteHref.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || + absoluteHref.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || + absoluteHref.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || + absoluteHref.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)) + { + fullSizeUrl = absoluteHref; + } + } + + var title = ResolveGalleryImageTitle(img, thumbnailUrl, anchorHref); + + images.Add(new Image( + Title: title, + ThumbnailUrl: fullSizeUrl, + FullSizeUrl: fullSizeUrl, + Description: title)); + } + + return images; + } + + private static bool IsUsableModGalleryImage(string? src, IElement img) + { + if (IsDisallowedImageSrc(src)) + { + return false; + } + + var alt = img.GetAttribute("alt") ?? string.Empty; + if (IsDisallowedAltText(alt)) + { + return false; + } + + var inSidebar = img.Closest(ModDBParserConstants.ImageSidebarSelector) != null; + var inGallery = img.Closest(ModDBParserConstants.ImageGallerySelector) != null; + if (inSidebar && !inGallery) + { + return false; + } + + var href = img.Closest("a")?.GetAttribute("href") ?? string.Empty; + if (IsDisallowedVideoLink(href)) + { + return false; + } + + var isModImagePage = href.Contains("/mods/", StringComparison.OrdinalIgnoreCase) && + (href.Contains("/images/", StringComparison.OrdinalIgnoreCase) || + href.Contains("/downloads/", StringComparison.OrdinalIgnoreCase) || + href.Contains("/addons/", StringComparison.OrdinalIgnoreCase)); + var isModMediaFile = src != null && (src.Contains("/images/mods/", StringComparison.OrdinalIgnoreCase) || + src.Contains("/cache/images/mods/", StringComparison.OrdinalIgnoreCase) || + src.Contains("/images/downloads/", StringComparison.OrdinalIgnoreCase) || + src.Contains("/cache/images/downloads/", StringComparison.OrdinalIgnoreCase)); + + return inGallery || isModImagePage || isModMediaFile; + } + + private static bool IsDisallowedImageSrc(string? src) + { + if (string.IsNullOrWhiteSpace(src)) + { + return true; + } + + var disallowedTokens = new[] + { + "data:", "clear.gif", "blank.gif", "/avatar/", "/button", + "/guest/", "/default/error", "error_50x50", "/images/games/", + "/images/groups/", "/images/members/", "/cache/images/games/", + "/cache/images/groups/", "/cache/images/members/", "icon.gif", + }; + + foreach (var token in disallowedTokens) + { + if (src.Contains(token, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsDisallowedAltText(string alt) + { + return alt.Contains("Share on", StringComparison.OrdinalIgnoreCase) || + alt.Equals("Post", StringComparison.OrdinalIgnoreCase) || + alt.Contains("Email a friend", StringComparison.OrdinalIgnoreCase) || + alt.Contains("Tweeeeeet", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDisallowedVideoLink(string href) + { + return href.Contains("/videos/", StringComparison.OrdinalIgnoreCase) || + href.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || + href.Contains("youtu.be", StringComparison.OrdinalIgnoreCase) || + href.Contains("vimeo.com", StringComparison.OrdinalIgnoreCase); + } + + private static string GalleryImageKey(string url) + { + var path = url.Split('?')[0].Split('#')[0]; + var slash = path.LastIndexOf('/'); + return slash >= 0 ? path[(slash + 1)..] : path; + } + + /// <summary> + /// Converts a ModDB cached/cropped thumbnail image source to a high-resolution full-size image source. + /// </summary> + private static string GetFullSizeModDBImageSource(string imageSource) + { + if (string.IsNullOrWhiteSpace(imageSource)) + { + return imageSource; + } + + var result = imageSource; + if (result.Contains("/cache/images/", StringComparison.OrdinalIgnoreCase)) + { + result = result.Replace("/cache/images/", "/images/", StringComparison.OrdinalIgnoreCase); + } + + result = ModDBImageCropRegex().Replace(result, "/"); + return result; + } + + private static string ResolveGalleryImageTitle(IElement img, string thumbnailUrl, string? anchorHref) + { + string? rawTitle = null; + + var imgAlt = img.GetAttribute("alt") ?? img.GetAttribute("title"); + if (!string.IsNullOrWhiteSpace(imgAlt) && + !imgAlt.Equals("View media", StringComparison.OrdinalIgnoreCase) && + !imgAlt.Equals("Image", StringComparison.OrdinalIgnoreCase) && + !imgAlt.StartsWith("Share on", StringComparison.OrdinalIgnoreCase)) + { + rawTitle = imgAlt.Trim(); + } + + if (string.IsNullOrWhiteSpace(rawTitle)) + { + var parentAnchor = img.Closest("a"); + if (parentAnchor != null) + { + var anchorTitle = parentAnchor.GetAttribute("title"); + if (!string.IsNullOrWhiteSpace(anchorTitle) && + !anchorTitle.Equals("View media", StringComparison.OrdinalIgnoreCase) && + !anchorTitle.Equals("Image", StringComparison.OrdinalIgnoreCase)) + { + rawTitle = anchorTitle.Trim(); + } + } + } + + if (string.IsNullOrWhiteSpace(rawTitle)) + { + var container = img.Closest(".imagebox, .mediabox, .mediarow, .holder"); + var titleEl = container?.QuerySelector(".title, .caption"); + var containerTitle = titleEl?.TextContent?.Trim(); + if (!string.IsNullOrWhiteSpace(containerTitle) && + !containerTitle.Equals("View media", StringComparison.OrdinalIgnoreCase) && + !containerTitle.Equals("Image", StringComparison.OrdinalIgnoreCase)) + { + rawTitle = containerTitle; + } + } + + if (string.IsNullOrWhiteSpace(rawTitle)) + { + var fileName = GalleryImageKey(thumbnailUrl); + var dot = fileName.LastIndexOf('.'); + if (dot > 0) + { + fileName = fileName[..dot]; + } + + if (!string.IsNullOrWhiteSpace(fileName) && + !fileName.Equals("image", StringComparison.OrdinalIgnoreCase)) + { + rawTitle = fileName; + } + } + + if (string.IsNullOrWhiteSpace(rawTitle) && !string.IsNullOrWhiteSpace(anchorHref)) + { + var slug = GalleryImageKey(anchorHref.Split('#')[0]); + if (!string.IsNullOrWhiteSpace(slug)) + { + rawTitle = slug; + } + } + + if (string.IsNullOrWhiteSpace(rawTitle)) + { + return "Image"; + } + + return FormatImageTitle(rawTitle); + } + + private static string FormatImageTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return "Image"; + } + + var formatted = title.Trim('*', ' ', '\t', '\r', '\n').Replace('_', ' ').Replace('-', ' '); + + if (!formatted.Contains(' ')) + { + formatted = CamelCaseSplitRegex().Replace(formatted, " "); + } + + formatted = Regex.Replace(formatted, @"\s+", " ").Trim(); + + return string.IsNullOrWhiteSpace(formatted) ? "Image" : formatted; + } + + private static string ToAbsoluteUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return string.Empty; + } + + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return url; + } + + if (url.StartsWith("//", StringComparison.Ordinal)) + { + return "https:" + url; + } + + if (url.StartsWith('/')) + { + return ModDBConstants.BaseUrl.TrimEnd('/') + url; + } + + return ModDBConstants.BaseUrl.TrimEnd('/') + "/" + url; + } + + /// <summary> + /// Extracts an image from a row element. + /// </summary> + private static Image? ExtractImageFromRow(IElement row) + { + var img = row.QuerySelector(ModDBParserConstants.ImageSelector); + if (img == null) + { + return null; + } + + var src = img.GetAttribute("src"); + if (string.IsNullOrEmpty(src)) + { + return null; + } + + var absUrl = ToAbsoluteUrl(src); + var fullSizeUrl = GetFullSizeModDBImageSource(absUrl); + var alt = img.GetAttribute("alt"); + var title = !string.IsNullOrWhiteSpace(alt) ? FormatImageTitle(alt) : "Image"; + + return new Image( + Title: title, + ThumbnailUrl: fullSizeUrl, + FullSizeUrl: fullSizeUrl, + Description: alt); + } + + /// <summary> + /// Extracts articles from the document. + /// </summary> + private static List<Article> ExtractArticles(IDocument document) + { + var articles = new List<Article>(); + + var articleRows = document.QuerySelectorAll(ModDBParserConstants.ArticlesSelector); + foreach (var row in articleRows) + { + var titleEl = row.QuerySelector(ModDBParserConstants.ArticleTitleSelector); + var title = titleEl?.TextContent?.Trim(); + if (string.IsNullOrEmpty(title)) + { + continue; + } + + var authorEl = row.QuerySelector(ModDBParserConstants.ArticleAuthorSelector); + var author = authorEl?.TextContent?.Trim(); + + var dateEl = row.QuerySelector(ModDBParserConstants.ArticleDateSelector); + DateTime? publishDate = null; + if (dateEl != null) + { + var dateStr = dateEl.GetAttribute("datetime") ?? dateEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var parsedDate)) + { + publishDate = parsedDate; + } + } + + var contentEl = row.QuerySelector(ModDBParserConstants.ArticleContentSelector); + var content = contentEl?.TextContent?.Trim(); + + var linkEl = row.QuerySelector(ModDBParserConstants.ArticleLinkSelector); + var url = linkEl?.GetAttribute("href"); + if (!string.IsNullOrEmpty(url) && !url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + url = "https:" + url; + } + + articles.Add(new Article( + Title: title, + Author: author, + PublishDate: publishDate, + Content: content, + Url: url)); + } + + return articles; + } + + /// <summary> + /// Extracts reviews from the document. + /// </summary> + private static List<Review> ExtractReviews(IDocument document) + { + var reviews = new List<Review>(); + + var reviewRows = document.QuerySelectorAll(ModDBParserConstants.ReviewsSelector); + foreach (var row in reviewRows) + { + var authorEl = row.QuerySelector(ModDBParserConstants.ReviewAuthorSelector); + var author = authorEl?.TextContent?.Trim(); + + var ratingEl = row.QuerySelector(ModDBParserConstants.ReviewRatingSelector); + float? rating = null; + if (ratingEl != null) + { + var ratingText = ratingEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(ratingText) && float.TryParse(ratingText, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedRating)) + { + rating = parsedRating; + } + } + + var contentEl = row.QuerySelector(ModDBParserConstants.ReviewContentSelector); + var content = contentEl?.TextContent?.Trim(); + + var dateEl = row.QuerySelector(ModDBParserConstants.ReviewDateSelector); + DateTime? date = null; + if (dateEl != null) + { + var dateStr = dateEl.GetAttribute("datetime") ?? dateEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var parsedDate)) + { + date = parsedDate; + } + } + + var helpfulEl = row.QuerySelector(ModDBParserConstants.ReviewHelpfulSelector); + int? helpfulVotes = null; + if (helpfulEl != null) + { + var votesText = helpfulEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(votesText) && int.TryParse(votesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var votes)) + { + helpfulVotes = votes; + } + else if (!string.IsNullOrEmpty(votesText)) + { + // "12 people found this helpful" — take the leading integer when present. + var digits = new string(votesText.TakeWhile(char.IsDigit).ToArray()); + if (int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out votes)) + { + helpfulVotes = votes; + } + } + } + + // Broad selectors also match bare rating widgets; skip shells with no review body. + if (string.IsNullOrWhiteSpace(author) && string.IsNullOrWhiteSpace(content)) + { + continue; + } + + reviews.Add(new Review( + Author: author, + Rating: rating, + Content: content, + Date: date, + HelpfulVotes: helpfulVotes)); + } + + return reviews; + } + + /// <summary> + /// Extracts comments from the document with hierarchical thread structure. + /// Only posted threads inside <c>#commentsbrowse</c> are considered — the composer + /// (<c>#commentform</c>, guest/email rows, injected CSS) is not a comment. + /// </summary> + private static List<Comment> ExtractComments(IDocument document) + { + var comments = new List<Comment>(); + + var browse = document.QuerySelector(ModDBParserConstants.CommentsSelector); + if (browse == null) + { + return comments; + } + + var allCommentRows = browse.QuerySelectorAll(ModDBParserConstants.CommentRowSelector); + var seenRows = new HashSet<IElement>(); + var rootRows = new List<IElement>(); + + foreach (var row in allCommentRows) + { + if (!seenRows.Add(row)) + { + continue; + } + + // Nested replies live under .children / another .rowcomment. Do not treat + // #commentsbrowse (id starts with "comment") as a nesting ancestor. + var isNested = row.Ancestors<IElement>().Any(a => + a.ClassList.Contains("children") || + a.ClassList.Contains("rowcomment") || + (a.Id?.StartsWith("comment", StringComparison.OrdinalIgnoreCase) == true && + !a.Id.StartsWith("comments", StringComparison.OrdinalIgnoreCase))); + + if (!isNested) + { + rootRows.Add(row); + } + } + + foreach (var rootRow in rootRows) + { + var comment = ParseCommentElement(rootRow, indentLevel: 0); + if (comment != null) + { + comments.Add(comment); + } + } + + return comments; + } + + private static Comment? ParseCommentElement(IElement row, int indentLevel) + { + if (row.ClassList.Contains("rowcommentguest") || + row.ClassList.Contains("rowcommentsummary") || + row.ClassList.Contains("rowcommentemail")) + { + return null; + } + + var author = ExtractCommentAuthor(row); + var content = ExtractCommentContent(row); + + if (string.IsNullOrWhiteSpace(author) && string.IsNullOrWhiteSpace(content)) + { + return null; + } + + var date = ExtractCommentDate(row); + var karma = ExtractCommentKarma(row); + var isCreator = row.QuerySelector(ModDBParserConstants.CommentCreatorSelector) != null; + var childReplies = ExtractChildReplies(row, indentLevel); + + return new Comment( + Author: author ?? "Anonymous", + Content: content, + Date: date, + Karma: karma, + IsCreator: isCreator, + IndentLevel: indentLevel, + Replies: childReplies.Count > 0 ? childReplies : null); + } + + private static string? ExtractCommentAuthor(IElement row) + { + var authorEl = row.QuerySelector(ModDBParserConstants.CommentAuthorSelector) + ?? row.QuerySelector(".username") + ?? row.QuerySelector("a[href*='/members/']:not([href*='/register']):not([href*='/login'])"); + var author = authorEl?.TextContent?.Trim(); + return IsJunkDeveloperName(author) ? null : author; + } + + private static string? ExtractCommentContent(IElement row) + { + var contentEl = row.QuerySelector(":scope > .commentbody") + ?? row.QuerySelector(".commentbody") + ?? row.QuerySelector(ModDBParserConstants.CommentContentSelector) + ?? row.QuerySelector("p.comment"); + + string? content = null; + if (contentEl != null) + { + content = ExtractCleanCommentBody(contentEl); + } + else + { + var pEl = row.QuerySelector(":scope > p") ?? row.QuerySelector("p"); + if (pEl != null) + { + content = ExtractCleanCommentBody(pEl); + } + } + + return IsJunkCommentContent(content) ? null : content; + } + + private static DateTime? ExtractCommentDate(IElement row) + { + var dateEl = row.QuerySelector(ModDBParserConstants.CommentDateSelector); + if (dateEl != null) + { + var dateStr = dateEl.GetAttribute("datetime") ?? dateEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(dateStr)) + { + return DateTime.TryParse(dateStr, out var parsedDate) + ? parsedDate + : ParseModDBDate(dateStr); + } + } + + return null; + } + + private static int? ExtractCommentKarma(IElement row) + { + var karmaEl = row.QuerySelector(ModDBParserConstants.CommentKarmaSelector); + if (karmaEl != null) + { + var karmaText = karmaEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(karmaText) && int.TryParse(karmaText, out var karmaValue)) + { + return karmaValue; + } + } + + return null; + } + + private static List<Comment> ExtractChildReplies(IElement row, int indentLevel) + { + var childReplies = new List<Comment>(); + var childrenContainer = row.QuerySelector(".children"); + if (childrenContainer == null) + { + return childReplies; + } + + var directChildRows = childrenContainer.QuerySelectorAll(".rowcomment, div[id^='comment']:not([id^='comments'])"); + var seenChildren = new HashSet<IElement>(); + foreach (var childRow in directChildRows) + { + if (!seenChildren.Add(childRow)) + { + continue; + } + + var intermediateParent = childRow.Ancestors<IElement>() + .TakeWhile(a => a != childrenContainer) + .FirstOrDefault(a => + a.ClassList.Contains("rowcomment") || + (a.Id?.StartsWith("comment", StringComparison.OrdinalIgnoreCase) == true && + !a.Id.StartsWith("comments", StringComparison.OrdinalIgnoreCase))); + + if (intermediateParent == null) + { + var childComment = ParseCommentElement(childRow, indentLevel + 1); + if (childComment != null) + { + childReplies.Add(childComment); + } + } + } + + return childReplies; + } + + /// <summary> + /// Reads comment body text without nested reply threads or ModDB action chrome, which would + /// otherwise inflate the comment content into a huge empty-looking block in the UI. + /// </summary> + private static string? ExtractCleanCommentBody(IElement contentEl) + { + var clone = contentEl.Clone(deep: true) as IElement; + if (clone == null) + { + return CleanCommentContent(contentEl.TextContent); + } + + foreach (var junk in clone.QuerySelectorAll(".children, .actions, .reply, .commentoptions, .toolbar")) + { + junk.Remove(); + } + + // Nested .rowcomment nodes sometimes sit inside .commentbody on live ModDB markup. + foreach (var nested in clone.QuerySelectorAll(".rowcomment, div[id^='comment']")) + { + nested.Remove(); + } + + return CleanCommentContent(clone.TextContent); + } + + private static string? CleanCommentContent(string? rawText) + { + if (string.IsNullOrWhiteSpace(rawText)) + { + return null; + } + + var lines = rawText.Split(['\r', '\n']); + var cleanLines = new List<string>(); + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || + trimmed.Equals("Reply", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Good karma", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Bad karma", StringComparison.OrdinalIgnoreCase) || + trimmed.EndsWith("vote", StringComparison.OrdinalIgnoreCase) || + IsJunkCommentContent(trimmed)) + { + continue; + } + + cleanLines.Add(trimmed); + } + + return cleanLines.Count > 0 ? string.Join("\n", cleanLines) : null; + } + + private static bool IsJunkCommentContent(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + if (text.Contains("Your comment will be anonymous", StringComparison.OrdinalIgnoreCase) || + text.Contains("join the community", StringComparison.OrdinalIgnoreCase) || + text.Contains("Post a comment", StringComparison.OrdinalIgnoreCase) || + text.Contains("Save comment", StringComparison.OrdinalIgnoreCase) || + text.Contains("sign in with your social", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Cloudflare/ModDB inject obfuscated CSS into the composer (span.abc { display: none; }). + return text.Contains('{') && + (text.Contains("display:", StringComparison.OrdinalIgnoreCase) || + text.Contains("formouter", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsJunkDeveloperName(string? name) => + string.IsNullOrWhiteSpace(name) || + name.Equals("register", StringComparison.OrdinalIgnoreCase) || + name.Equals("sign in", StringComparison.OrdinalIgnoreCase) || + name.Equals("login", StringComparison.OrdinalIgnoreCase) || + name.Equals("guest", StringComparison.OrdinalIgnoreCase); + + private static bool IsBreadcrumbOrLocationText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + var trimmed = text.Trim(); + return trimmed.StartsWith("Games :", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Mods :", StringComparison.OrdinalIgnoreCase) || + (trimmed.Contains(" : Mods : ", StringComparison.OrdinalIgnoreCase) && + trimmed.Contains(" : Files", StringComparison.OrdinalIgnoreCase)) || + trimmed.Equals("Location", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksLikeArchiveFileName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + var dot = name.LastIndexOf('.'); + if (dot <= 0 || dot >= name.Length - 1) + { + return false; + } + + var ext = name[(dot + 1)..]; + return ext.Length is >= 2 and <= 4 && ext.All(char.IsLetterOrDigit); + } + + private static string? GetCanonicalModDBFileKey(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) && + !Uri.TryCreate(new Uri(ModDBConstants.BaseUrl), url, out uri)) + { + return url.Trim().TrimEnd('/').ToLowerInvariant(); + } + + var path = uri.AbsolutePath.TrimEnd('/'); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + return null; + } + + if (segments.Length >= 3 && (segments[^2].Equals("start", StringComparison.OrdinalIgnoreCase) || segments[^2].Equals("mirror", StringComparison.OrdinalIgnoreCase))) + { + return $"start:{segments[^1].ToLowerInvariant()}"; + } + + var lastSegment = segments[^1].ToLowerInvariant(); + if (lastSegment.Equals("downloads", StringComparison.OrdinalIgnoreCase) || + lastSegment.Equals("addons", StringComparison.OrdinalIgnoreCase) || + lastSegment.Equals("files", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return lastSegment; + } + + private static string? NormalizeDownloadUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + var trimmed = url.Trim().TrimEnd('/'); + return Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) + ? uri.GetLeftPart(UriPartial.Path).TrimEnd('/') + : trimmed; + } + + /// <summary> + /// Collapses FileDetail filename rows with the parent /downloads listing of the same binary + /// so the Releases tab does not show both "GeneralsUndone_v1.0.zip" and "C&C Generals Undone". + /// </summary> + private static List<ContentSection> DeduplicateDownloadableFiles(List<ContentSection> sections) + { + var files = sections.OfType<DownloadableFile>().ToList(); + if (files.Count <= 1) + { + return sections; + } + + var merged = new List<DownloadableFile>(); + var indexByKey = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + + foreach (var file in files) + { + var existingIndex = FindExistingFileIndex(file, merged, indexByKey); + + if (existingIndex >= 0) + { + var updated = MergeDownloadableFiles(merged[existingIndex], file); + merged[existingIndex] = updated; + UpdateFileIndexKeys(updated, existingIndex, indexByKey); + continue; + } + + var newIndex = merged.Count; + UpdateFileIndexKeys(file, newIndex, indexByKey); + merged.Add(file); + } + + var sortedMerged = merged + .OrderByDescending(f => f.ReleaseDate ?? f.UploadDate ?? DateTime.MinValue) + .ThenByDescending(f => f.Version, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var result = new List<ContentSection>(sections.Count); + var filesEmitted = false; + foreach (var section in sections) + { + if (section is DownloadableFile) + { + if (!filesEmitted) + { + result.AddRange(sortedMerged); + filesEmitted = true; + } + + continue; + } + + result.Add(section); + } + + return result; + } + + private static int FindExistingFileIndex( + DownloadableFile file, + List<DownloadableFile> merged, + Dictionary<string, int> indexByKey) + { + var downloadKey = NormalizeDownloadUrl(file.DownloadUrl); + var detailsKey = NormalizeDownloadUrl(file.DetailsUrl); + var canonicalDownloadKey = GetCanonicalModDBFileKey(file.DownloadUrl); + var canonicalDetailsKey = GetCanonicalModDBFileKey(file.DetailsUrl); + var nameKey = !string.IsNullOrWhiteSpace(file.Name) ? file.Name.Trim().ToLowerInvariant() : null; + var filenameKey = !string.IsNullOrWhiteSpace(file.Filename) ? file.Filename.Trim().ToLowerInvariant() : null; + + var keysToTry = new[] { downloadKey, detailsKey, canonicalDownloadKey, canonicalDetailsKey, filenameKey, nameKey }; + int existingIndex = -1; + + foreach (var key in keysToTry) + { + if (key != null && indexByKey.TryGetValue(key, out var idx)) + { + existingIndex = idx; + break; + } + } + + if (existingIndex >= 0) + { + var existing = merged[existingIndex]; + var existingCanonDetails = GetCanonicalModDBFileKey(existing.DetailsUrl); + var existingCanonDownload = GetCanonicalModDBFileKey(existing.DownloadUrl); + + var detailsConflict = canonicalDetailsKey != null && existingCanonDetails != null && + !string.Equals(canonicalDetailsKey, existingCanonDetails, StringComparison.OrdinalIgnoreCase); + var downloadConflict = IsDirectDownloadUrl(file.DownloadUrl) && IsDirectDownloadUrl(existing.DownloadUrl) && + canonicalDownloadKey != null && existingCanonDownload != null && + !string.Equals(canonicalDownloadKey, existingCanonDownload, StringComparison.OrdinalIgnoreCase); + + if (detailsConflict || downloadConflict) + { + return -1; + } + } + + return existingIndex; + } + + private static void UpdateFileIndexKeys( + DownloadableFile file, + int targetIndex, + Dictionary<string, int> indexByKey) + { + var downloadKey = NormalizeDownloadUrl(file.DownloadUrl); + var detailsKey = NormalizeDownloadUrl(file.DetailsUrl); + var canonDownload = GetCanonicalModDBFileKey(file.DownloadUrl); + var canonDetails = GetCanonicalModDBFileKey(file.DetailsUrl); + var nameKey = !string.IsNullOrWhiteSpace(file.Name) ? file.Name.Trim().ToLowerInvariant() : null; + var filenameKey = !string.IsNullOrWhiteSpace(file.Filename) ? file.Filename.Trim().ToLowerInvariant() : null; + + if (downloadKey != null) indexByKey[downloadKey] = targetIndex; + if (detailsKey != null) indexByKey[detailsKey] = targetIndex; + if (canonDownload != null) indexByKey[canonDownload] = targetIndex; + if (canonDetails != null) indexByKey[canonDetails] = targetIndex; + if (nameKey != null) indexByKey[nameKey] = targetIndex; + if (filenameKey != null) indexByKey[filenameKey] = targetIndex; + } + + private static DownloadableFile MergeDownloadableFiles(DownloadableFile left, DownloadableFile right) + { + var finalName = ResolveMergedName(left.Name, right.Name); + var finalFilename = ResolveMergedFilename(left, right); + var bestDownloadUrl = ResolveBestDownloadUrl(left.DownloadUrl, right.DownloadUrl); + var bestDetailsUrl = ResolveBestDetailsUrl(left, right); + + FileSectionType bestSectionType = left.FileSectionType != FileSectionType.Downloads + ? left.FileSectionType + : right.FileSectionType; + + return left with + { + Name = finalName ?? left.Name ?? string.Empty, + Filename = finalFilename, + DownloadUrl = bestDownloadUrl, + DetailsUrl = bestDetailsUrl, + FileSectionType = bestSectionType, + ReleaseDate = left.ReleaseDate ?? right.ReleaseDate, + UploadDate = left.UploadDate ?? right.UploadDate, + SizeBytes = left.SizeBytes ?? right.SizeBytes, + SizeDisplay = left.SizeDisplay ?? right.SizeDisplay, + Version = left.Version ?? right.Version, + Category = left.Category ?? right.Category, + Uploader = left.Uploader ?? right.Uploader, + Description = left.Description ?? right.Description, + ThumbnailUrl = left.ThumbnailUrl ?? right.ThumbnailUrl, + Md5Hash = left.Md5Hash ?? right.Md5Hash, + DownloadCount = left.DownloadCount ?? right.DownloadCount, + CommentCount = left.CommentCount ?? right.CommentCount, + }; + } + + private static string? ResolveMergedName(string? leftName, string? rightName) + { + var preferRightName = LooksLikeArchiveFileName(leftName) && !LooksLikeArchiveFileName(rightName); + var preferLeftName = !LooksLikeArchiveFileName(leftName) && LooksLikeArchiveFileName(rightName); + + if (preferRightName) + { + return rightName; + } + + if (preferLeftName) + { + return leftName; + } + + return leftName ?? rightName; + } + + private static string? ResolveMergedFilename(DownloadableFile left, DownloadableFile right) + { + var finalFilename = left.Filename ?? right.Filename; + if (string.IsNullOrEmpty(finalFilename)) + { + if (LooksLikeArchiveFileName(left.Name)) + { + finalFilename = left.Name; + } + else if (LooksLikeArchiveFileName(right.Name)) + { + finalFilename = right.Name; + } + } + + return finalFilename; + } + + private static string? ResolveBestDownloadUrl(string? leftDownloadUrl, string? rightDownloadUrl) + { + if (IsDirectDownloadUrl(leftDownloadUrl)) + { + return leftDownloadUrl; + } + + if (IsDirectDownloadUrl(rightDownloadUrl)) + { + return rightDownloadUrl; + } + + return leftDownloadUrl ?? rightDownloadUrl; + } + + private static string? ResolveBestDetailsUrl(DownloadableFile left, DownloadableFile right) + { + string? bestDetailsUrl = left.DetailsUrl ?? right.DetailsUrl; + if (bestDetailsUrl == null && !IsDirectDownloadUrl(left.DownloadUrl)) + { + bestDetailsUrl = left.DownloadUrl; + } + + if (bestDetailsUrl == null && !IsDirectDownloadUrl(right.DownloadUrl)) + { + bestDetailsUrl = right.DownloadUrl; + } + + return bestDetailsUrl; + } + + private static List<ContentSection> DeduplicateImages(List<ContentSection> sections) + { + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var result = new List<ContentSection>(sections.Count); + foreach (var section in sections) + { + if (section is Image image) + { + var key = GalleryImageKey(image.ThumbnailUrl ?? image.FullSizeUrl ?? image.Title); + if (!seen.Add(key)) + { + continue; + } + } + + result.Add(section); + } + + return result; + } + + private static List<ContentSection> DeduplicateVideos(List<ContentSection> sections) + { + var videoList = sections.OfType<Video>().ToList(); + if (videoList.Count == 0) + { + return sections; + } + + var deduplicated = DeduplicateVideoList(videoList); + var result = new List<ContentSection>(sections.Count); + var videoEnumerator = deduplicated.GetEnumerator(); + + foreach (var section in sections) + { + if (section is Video) + { + if (videoEnumerator.MoveNext()) + { + result.Add(videoEnumerator.Current); + } + } + else + { + result.Add(section); + } + } + + while (videoEnumerator.MoveNext()) + { + result.Add(videoEnumerator.Current); + } + + return result; + } + + private static List<Video> DeduplicateVideoList(List<Video> videos) + { + var dict = new Dictionary<string, Video>(StringComparer.OrdinalIgnoreCase); + + foreach (var video in videos) + { + var key = GetVideoKey(video); + if (!dict.TryGetValue(key, out var existing)) + { + dict[key] = video; + } + else + { + var betterTitle = existing.Title; + if (!IsUsableVideoTitle(betterTitle) && IsUsableVideoTitle(video.Title)) + { + betterTitle = video.Title; + } + + var betterThumb = !string.IsNullOrWhiteSpace(existing.ThumbnailUrl) + ? existing.ThumbnailUrl + : video.ThumbnailUrl; + var betterEmbed = !string.IsNullOrWhiteSpace(existing.EmbedUrl) + ? existing.EmbedUrl + : video.EmbedUrl; + var betterPlatform = !string.Equals(existing.Platform, "Unknown", StringComparison.OrdinalIgnoreCase) + ? existing.Platform + : video.Platform; + + dict[key] = new Video( + Title: betterTitle, + ThumbnailUrl: betterThumb, + EmbedUrl: betterEmbed, + Platform: betterPlatform); + } + } + + return [.. dict.Values]; + } + + private static string GetVideoKey(Video video) + { + if (!string.IsNullOrWhiteSpace(video.EmbedUrl)) + { + var ytMatch = YouTubeVideoIdRegex().Match(video.EmbedUrl); + if (ytMatch.Success) + { + return "yt:" + ytMatch.Groups[1].Value.ToLowerInvariant(); + } + + var vimeoMatch = VimeoVideoIdRegex().Match(video.EmbedUrl); + if (vimeoMatch.Success) + { + return "vimeo:" + vimeoMatch.Groups[1].Value.ToLowerInvariant(); + } + + return "url:" + GalleryImageKey(video.EmbedUrl.Split('?')[0]).ToLowerInvariant(); + } + + if (!string.IsNullOrWhiteSpace(video.ThumbnailUrl)) + { + var ytMatch = YouTubeVideoIdRegex().Match(video.ThumbnailUrl); + if (ytMatch.Success) + { + return "yt:" + ytMatch.Groups[1].Value.ToLowerInvariant(); + } + + return "thumb:" + GalleryImageKey(video.ThumbnailUrl.Split('?')[0]).ToLowerInvariant(); + } + + return "title:" + video.Title.ToLowerInvariant(); + } + + /// <summary> + /// Parses a file size string into bytes (e.g., "15.5 MB", "188.3kb (192,819 bytes)", "9,72 MB"). + /// </summary> + private static long? ParseFileSize(string sizeText) + { + if (string.IsNullOrWhiteSpace(sizeText)) + { + return null; + } + + // Check for exact byte count in parentheses or text, e.g. "(192,819 bytes)" + var bytesMatch = ExactBytesRegex().Match(sizeText); + if (bytesMatch.Success) + { + var rawDigits = bytesMatch.Groups[1].Value.Replace(",", string.Empty).Replace(" ", string.Empty).Trim(); + if (long.TryParse(rawDigits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var exactBytesVal)) + { + return exactBytesVal; + } + } + + // Parse floating point size with unit, supporting comma or dot decimals and optional spacing + var match = NumericSizeWithUnitRegex().Match(sizeText); + if (!match.Success) + { + return null; + } + + var numStr = match.Groups[1].Value.Replace(',', '.'); + if (!double.TryParse(numStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + { + return null; + } + + var unit = match.Groups[2].Value.ToUpperInvariant(); + + if (unit.StartsWith("G", StringComparison.OrdinalIgnoreCase)) + { + return (long)(value * 1024 * 1024 * 1024); + } + + if (unit.StartsWith("M", StringComparison.OrdinalIgnoreCase)) + { + return (long)(value * 1024 * 1024); + } + + if (unit.StartsWith("K", StringComparison.OrdinalIgnoreCase)) + { + return (long)(value * 1024); + } + + if (unit.StartsWith("B", StringComparison.OrdinalIgnoreCase)) + { + return (long)value; + } + + return null; + } + + /// <summary> + /// Parses ModDB date formats like "Mar 15th, 2024" or "Added Mar 15th, 2024". + /// </summary> + private static DateTime? ParseModDBDate(string dateStr) + { + if (string.IsNullOrEmpty(dateStr)) + { + return null; + } + + // Remove common prefixes + dateStr = dateStr.Replace("Added", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Released", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Updated", string.Empty, StringComparison.OrdinalIgnoreCase) + .Trim(); + + // Try parsing common formats + // Format: "Mar 15th, 2024" + var format1 = "MMM d'th', yyyy"; + if (DateTime.TryParseExact(dateStr, format1, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result1)) + { + return result1; + } + + // Try with st, nd, rd, th suffixes + var formats = new[] + { + "MMM d'st', yyyy", + "MMM d'nd', yyyy", + "MMM d'rd', yyyy", + "MMM d'th', yyyy", + "MMM dd, yyyy", + "MMM d, yyyy", + "MMMM d, yyyy", + "yyyy-MM-dd", + "MM/dd/yyyy", + "dd/MM/yyyy", + }; + + foreach (var format in formats) + { + if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) + { + return result; + } + } + + return null; + } + + /// <summary> + /// Helper class for profile metadata. + /// </summary> + private record ProfileMeta( + string? Name, + long? SizeBytes, + string? SizeDisplay, + DateTime? ReleaseDate, + string? Developer, + string? Md5Hash); + + /// <summary> + /// Extracts content sections from list pages (addons, images). + /// </summary> + /// <param name="document">The document to extract from.</param> + /// <param name="sectionType">The type of file section (Release or Addon).</param> + private static List<ContentSection> ExtractListSections(IDocument document, FileSectionType sectionType = FileSectionType.Downloads) + { + var sections = new List<ContentSection>(); + + var rows = document.QuerySelectorAll(ModDBParserConstants.RowContentSelector); + foreach (var row in rows) + { + // Extract image + var image = ExtractImageFromRow(row); + if (image != null) + { + sections.Add(image); + } + + // Extract file info if present + var file = ExtractFileFromRow(row, sectionType); + if (file != null) + { + sections.Add(file); + } + } + + return sections; + } + + /// <summary> + /// Extracts files from the document. + /// </summary> + /// <param name="document">The document to extract files from.</param> + /// <param name="sectionType">The type of file section (Release or Addon).</param> + private static List<DownloadableFile> ExtractFiles(IDocument document, FileSectionType sectionType = FileSectionType.Downloads) + { + var files = new List<DownloadableFile>(); + + var fileRows = document.QuerySelectorAll(ModDBParserConstants.FileRowSelector); + foreach (var row in fileRows) + { + var file = ExtractFileFromRow(row, sectionType); + if (file != null) + { + files.Add(file); + } + } + + return files; + } + + /// <summary> + /// Extracts content sections from detail pages. + /// </summary> + /// <param name="document">The document to extract from.</param> + /// <param name="sectionType">The type of file section (Release or Addon).</param> + private static List<ContentSection> ExtractDetailSections(IDocument document, FileSectionType sectionType = FileSectionType.Downloads) + { + var sections = new List<ContentSection>(); + + // Extract files + sections.AddRange(ExtractFiles(document, sectionType)); + + // Extract videos + sections.AddRange(ExtractVideos(document)); + + // Extract images + sections.AddRange(ExtractImages(document)); + + // Extract articles + sections.AddRange(ExtractArticles(document)); + + // Extract reviews + sections.AddRange(ExtractReviews(document)); + + // Extract comments + sections.AddRange(ExtractComments(document)); + + return sections; + } + + /// <inheritdoc /> + public string ParserId => "ModDB"; + + /// <inheritdoc /> + public bool CanParse(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + (uri.Host.Equals("moddb.com", StringComparison.OrdinalIgnoreCase) || + uri.Host.EndsWith(".moddb.com", StringComparison.OrdinalIgnoreCase)); + + /// <inheritdoc /> + public async Task<ParsedWebPage> ParseAsync(string url, CancellationToken cancellationToken = default) + { + logger.LogInformation("Parsing ModDB page: {Url}", url); + + return await playwrightService.ExecuteInPersistentContextAsync( + ModDBConstants.BrowserProfileName, + async () => + { + var urlsToFetch = GetUrlsToFetch(url); + + // fetch all required URLs on one persistent Chromium page (profile) in a single batch. + var fetched = await playwrightService.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + urlsToFetch, + cancellationToken); + + if (!fetched.TryGetValue(url, out var document)) + { + throw new InvalidOperationException($"Failed to fetch document for {url}"); + } + + var parsedPage = ParseInternalWithFetched(url, document, fetched); + + // check whether there are releases and addons to be loaded before closing the browser window + var releaseFilesToEnrich = parsedPage.Sections.OfType<DownloadableFile>() + .Where(f => f.FileSectionType == FileSectionType.Downloads && !string.IsNullOrEmpty(f.DetailsUrl ?? f.DownloadUrl)) + .OrderByDescending(f => f.ReleaseDate ?? f.UploadDate ?? DateTime.MinValue) + .ThenByDescending(f => f.Version, StringComparer.OrdinalIgnoreCase) + .Take(ContentConstants.PreloadRecentItemsLimit); + + var addonFilesToEnrich = parsedPage.Sections.OfType<DownloadableFile>() + .Where(f => f.FileSectionType == FileSectionType.Addons && !string.IsNullOrEmpty(f.DetailsUrl ?? f.DownloadUrl)) + .OrderByDescending(f => f.ReleaseDate ?? f.UploadDate ?? DateTime.MinValue) + .ThenByDescending(f => f.Version, StringComparer.OrdinalIgnoreCase) + .Take(ContentConstants.PreloadRecentItemsLimit); + + var filesToEnrich = releaseFilesToEnrich.Concat(addonFilesToEnrich).ToList(); + + var urlsToEnrich = filesToEnrich + .Select(f => f.DetailsUrl ?? f.DownloadUrl!) + .Where(u => !fetched.ContainsKey(u)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (urlsToEnrich.Count > 0) + { + logger.LogInformation("Enriching {Count} recent releases/addons in same browser window", urlsToEnrich.Count); + var enrichedFetched = await playwrightService.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + urlsToEnrich, + cancellationToken); + + if (enrichedFetched.Count > 0) + { + var enrichedFiles = new Dictionary<string, DownloadableFile>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in enrichedFetched) + { + var detailUrl = kvp.Key; + var detailDoc = kvp.Value; + var detailed = ExtractDetailedFile(detailDoc, detailUrl); + if (detailed != null) + { + enrichedFiles[detailUrl] = detailed; + } + } + + if (enrichedFiles.Count > 0) + { + var updatedSections = new List<ContentSection>(); + foreach (var section in parsedPage.Sections) + { + if (section is DownloadableFile file) + { + var key = file.DetailsUrl ?? file.DownloadUrl; + if (key != null && enrichedFiles.TryGetValue(key, out var detailed)) + { + var merged = detailed with + { + Category = !string.IsNullOrEmpty(detailed.Category) ? detailed.Category : file.Category, + FileSectionType = file.FileSectionType, + UploadDate = detailed.UploadDate ?? file.UploadDate, + ReleaseDate = detailed.ReleaseDate ?? file.ReleaseDate, + ThumbnailUrl = !string.IsNullOrEmpty(detailed.ThumbnailUrl) ? detailed.ThumbnailUrl : file.ThumbnailUrl, + DetailsUrl = !string.IsNullOrEmpty(detailed.DetailsUrl) ? detailed.DetailsUrl : file.DetailsUrl, + DownloadUrl = !string.IsNullOrEmpty(detailed.DownloadUrl) ? detailed.DownloadUrl : file.DownloadUrl, + SizeBytes = detailed.SizeBytes ?? file.SizeBytes, + SizeDisplay = !string.IsNullOrEmpty(detailed.SizeDisplay) ? detailed.SizeDisplay : file.SizeDisplay, + Name = !string.IsNullOrEmpty(detailed.Name) && !string.Equals(detailed.Name, "Unknown", StringComparison.OrdinalIgnoreCase) ? detailed.Name : file.Name, + }; + updatedSections.Add(merged); + continue; + } + } + + updatedSections.Add(section); + } + + parsedPage = parsedPage with { Sections = updatedSections }; + } + } + } + + return parsedPage; + }, + cancellationToken); + } + + /// <summary> + /// File-only parse for acquisition: returns the single <see cref="DownloadableFile"/> from a + /// FileDetail page and the page's own metadata, without fetching the parent mod's downloads, + /// addons, videos, images, reviews, and articles sections. The download path needs only the + /// file; the FileDetail page already carries title/developer/icon/description via its profile + /// sidebar. Use <see cref="ParseAsync(string, CancellationToken)"/> when the full rich page + /// (Media/Community/Releases/Addons) is required for display. + /// </summary> + /// <param name="url">A FileDetail URL (<c>/mods/.../downloads/file-name</c> or <c>/addons/...</c>).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>A parsed page containing the single file and the FileDetail page's context.</returns> + public async Task<ParsedWebPage> ParseFileDetailAsync(string url, CancellationToken cancellationToken = default) + { + logger.LogInformation("Parsing ModDB file detail (acquisition path): {Url}", url); + + var document = await playwrightService.FetchAndParsePersistentAsync( + ModDBConstants.BrowserProfileName, url, cancellationToken); + + var sections = new List<ContentSection>(); + var file = ExtractDetailedFile(document, url); + if (file != null) + { + sections.Add(file); + } + + var context = ExtractGlobalContext(document); + + return new ParsedWebPage( + Url: new Uri(url), + Context: context, + Sections: sections, + PageType: PageType.FileDetail); + } + + /// <summary> + /// Parses multiple file detail pages in a single parallel batch using persistent Chromium tabs. + /// </summary> + /// <param name="urls">The file detail URLs to parse.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>A dictionary of URL -> ParsedWebPage results.</returns> + public async Task<IReadOnlyDictionary<string, ParsedWebPage>> ParseFileDetailsManyAsync( + IReadOnlyList<string> urls, + CancellationToken cancellationToken = default) + { + if (urls == null || urls.Count == 0) + { + return new Dictionary<string, ParsedWebPage>(); + } + + logger.LogInformation("Parsing ModDB file details in parallel batch ({Count} URLs)", urls.Count); + + var fetched = await playwrightService.FetchAndParsePersistentManyAsync( + ModDBConstants.BrowserProfileName, + urls, + cancellationToken); + + var results = new Dictionary<string, ParsedWebPage>(StringComparer.OrdinalIgnoreCase); + foreach (var (url, document) in fetched) + { + try + { + var sections = new List<ContentSection>(); + var file = ExtractDetailedFile(document, url); + if (file != null) + { + sections.Add(file); + } + + var context = ExtractGlobalContext(document); + results[url] = new ParsedWebPage( + Url: new Uri(url), + Context: context, + Sections: sections, + PageType: PageType.FileDetail); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to parse file detail for {Url}", url); + } + } + + return results; + } + + /// <inheritdoc /> + public Task<ParsedWebPage> ParseAsync(string url, string html, CancellationToken cancellationToken = default) + { + var browsingContext = BrowsingContext.New(Configuration.Default); + var document = browsingContext.OpenAsync(req => req.Content(html), cancellationToken).GetAwaiter().GetResult(); + return Task.FromResult(ParseInternal(url, document)); + } + + /// <summary> + /// Determines whether a ModDB URL points directly to an acquisition endpoint (/start/ or /mirror/) + /// rather than an HTML content or detail listing page. + /// </summary> + /// <param name="url">The URL to test.</param> + /// <returns><see langword="true"/> if the URL is a direct download link; otherwise <see langword="false"/>.</returns> + internal static bool IsDirectDownloadUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + if (url.Contains("/downloads/start/", StringComparison.OrdinalIgnoreCase) || + url.Contains("/addons/start/", StringComparison.OrdinalIgnoreCase) || + url.Contains("/downloads/mirror/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if ((url.Contains("/mods/", StringComparison.OrdinalIgnoreCase) || url.Contains("/games/", StringComparison.OrdinalIgnoreCase)) && + (url.Contains("/downloads/", StringComparison.OrdinalIgnoreCase) || url.Contains("/addons/", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return true; + } + + /// <summary> + /// Computes the complete list of URLs needed to parse a ModDB page and its related sections + /// in a single Playwright batch, avoiding multiple browser launches. + /// </summary> + private static IReadOnlyList<string> GetUrlsToFetch(string url) + { + if (IsModDetailPage(url)) + { + var baseUrl = Uri.TryCreate(url, UriKind.Absolute, out var uri) + ? $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath.TrimEnd('/')}" + : url.Split('?')[0].Split('#')[0].TrimEnd('/'); + + return + [ + url, + baseUrl + "/downloads", + baseUrl + "/addons", + baseUrl + "/videos", + baseUrl + "/images", + baseUrl + "/reviews", + baseUrl + "/articles", + ]; + } + + var parentModUrl = ExtractParentModUrl(url); + if (!string.IsNullOrEmpty(parentModUrl)) + { + var baseUrl = Uri.TryCreate(parentModUrl, UriKind.Absolute, out var parentUri) + ? $"{parentUri.Scheme}://{parentUri.Authority}{parentUri.AbsolutePath.TrimEnd('/')}" + : parentModUrl.Split('?')[0].Split('#')[0].TrimEnd('/'); + + return + [ + url, + parentModUrl, + baseUrl + "/downloads", + baseUrl + "/addons", + baseUrl + "/videos", + baseUrl + "/images", + baseUrl + "/reviews", + baseUrl + "/articles", + ]; + } + + return [url]; + } + + /// <summary> + /// Determines if the URL is a mod detail page that should have all sections fetched. + /// Only the mod root (<c>/mods/{slug}</c>) qualifies — subpaths like comments, downloads, + /// or tutorials must not trigger the six-section Chromium sweep. + /// </summary> + private static bool IsModDetailPage(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return false; + } + + // /mods/{slug} optionally with a trailing slash — nothing after the slug. + var segments = uri.AbsolutePath.TrimEnd('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments.Length == 2 && + segments[0].Equals("mods", StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractDeveloper(IDocument document) + { + foreach (var el in document.QuerySelectorAll(ModDBParserConstants.DeveloperProfileSelector)) + { + var href = el.GetAttribute("href") ?? string.Empty; + if (href.Contains("/register", StringComparison.OrdinalIgnoreCase) || + href.Contains("/login", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var text = el.TextContent?.Trim(); + if (!IsJunkDeveloperName(text)) + { + return text!; + } + } + + foreach (var el in document.QuerySelectorAll(ModDBParserConstants.DeveloperSelector)) + { + var href = el.GetAttribute("href") ?? string.Empty; + if (href.Contains("/register", StringComparison.OrdinalIgnoreCase) || + href.Contains("/login", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var text = el.TextContent?.Trim(); + if (!IsJunkDeveloperName(text)) + { + return text!; + } + } + + return "Unknown"; + } + + private static string? ExtractDescription(IDocument document) + { + var fileDesc = ReadDescriptionFrom(document.QuerySelector(ModDBParserConstants.FileDescriptionSelector) + ?? document.QuerySelector("#downloaddescription") + ?? document.QuerySelector("#downloadsummary")); + if (!IsBreadcrumbOrLocationText(fileDesc)) + { + var extra = document.QuerySelector("#downloaddescription"); + var summary = document.QuerySelector("#downloadsummary"); + var parts = new List<string>(); + var summaryText = ReadDescriptionFrom(summary); + var fullText = extra != null && extra != summary ? ReadDescriptionFrom(extra) : null; + if (!IsBreadcrumbOrLocationText(summaryText)) + { + parts.Add(summaryText!); + } + + if (!IsBreadcrumbOrLocationText(fullText) && + (summaryText == null || fullText!.IndexOf(summaryText, StringComparison.OrdinalIgnoreCase) < 0)) + { + parts.Add(fullText!); + } + + if (parts.Count > 0) + { + return string.Join("\n\n", parts); + } + + return fileDesc; + } + + var fullDescEl = document.QuerySelector(ModDBParserConstants.FullDescriptionSelector); + var fromFull = ReadDescriptionFrom(fullDescEl); + if (!IsBreadcrumbOrLocationText(fromFull)) + { + return fromFull; + } + + var summaryEl = document.QuerySelector(ModDBParserConstants.SummarySelector); + var fromSummary = ReadDescriptionFrom(summaryEl); + if (!IsBreadcrumbOrLocationText(fromSummary)) + { + return fromSummary; + } + + var metaDesc = document.QuerySelector("meta[name='description'], meta[property='og:description']"); + var meta = metaDesc?.GetAttribute("content")?.Trim(); + return IsBreadcrumbOrLocationText(meta) ? null : meta; + } + + private static string? ReadDescriptionFrom(IElement? element) + { + if (element == null) + { + return null; + } + + var paragraphs = element.QuerySelectorAll("p") + .Select(p => p.TextContent?.Trim()) + .Where(t => !string.IsNullOrWhiteSpace(t) && !IsBreadcrumbOrLocationText(t)) + .ToList(); + + if (paragraphs.Count > 0) + { + return string.Join("\n\n", paragraphs); + } + + var text = element.TextContent?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + /// <summary> + /// Internal parsing logic that works with a parsed AngleSharp document. + /// </summary> + private ParsedWebPage ParseInternal(string url, IDocument document) + { + var context = ExtractGlobalContext(document); + var pageType = DetectPageType(url, document); + + logger.LogDebug("Detected page type: {PageType}", pageType); + + var sections = new List<ContentSection>(); + + switch (pageType) + { + case PageType.List: + // Determine if this is an addons list or generic list + var sectionType = url.Contains("/addons", StringComparison.OrdinalIgnoreCase) + ? FileSectionType.Addons + : FileSectionType.Downloads; + sections.AddRange(ExtractListSections(document, sectionType)); + break; + + case PageType.Summary: + sections.AddRange(ExtractSummarySections(document)); + break; + + case PageType.Detail: + sections.AddRange(ExtractDetailSections(document)); + break; + + case PageType.FileDetail: + sections.AddRange(ExtractFileDetailSections(document)); + break; + + default: + logger.LogWarning("Unknown page type for URL: {Url}", url); + break; + } + + sections = DeduplicateDownloadableFiles(sections); + sections = DeduplicateImages(sections); + sections = DeduplicateVideos(sections); + + logger.LogInformation( + "Parsed ModDB page: {Url}, Type={PageType}, Sections={SectionCount}", + url, + pageType, + sections.Count); + + return new ParsedWebPage( + Url: new Uri(url), + Context: context, + Sections: sections, + PageType: pageType); + } + + /// <summary> + /// Synchronous internal parsing logic that extracts content from the primary document + /// and any pre-fetched section documents (from the single Playwright batch). + /// </summary> + private ParsedWebPage ParseInternalWithFetched( + string url, + IDocument document, + IReadOnlyDictionary<string, IDocument> fetched) + { + var context = ExtractGlobalContext(document); + var pageType = DetectPageType(url, document); + + logger.LogDebug("Detected page type: {PageType}", pageType); + + var sections = new List<ContentSection>(); + + // Special handling for mod detail pages - populate ALL sections + if (IsModDetailPage(url) && pageType == PageType.Detail) + { + logger.LogInformation("Mod detail page detected, parsing all sections (downloads, addons, videos, images, reviews, articles)"); + + // Extract non-file content from the main page + sections.AddRange(ExtractVideos(document)); + sections.AddRange(ExtractImages(document)); + sections.AddRange(ExtractArticles(document)); + sections.AddRange(ExtractReviews(document)); + sections.AddRange(ExtractComments(document)); + + var baseUrl = url.TrimEnd('/'); + AppendFetchedSections(sections, baseUrl, fetched); + } + else if (pageType == PageType.FileDetail) + { + var file = ExtractDetailedFile(document, url); + if (file != null) + { + sections.Add(file); + } + + // Game downloads/addons are discovered as FileDetail URLs under /games/... and never enter + // the /mods/ parent sweep below. Pull comments and any inline media from this page so the + // detail view is not left with only a single Releases row. + AppendOnPageFileDetailSections(sections, document); + + var parentModUrl = ExtractParentModUrl(url); + if (!string.IsNullOrEmpty(parentModUrl) && fetched.TryGetValue(parentModUrl, out var parentDoc)) + { + logger.LogInformation("FileDetail page detected, extracted parent mod sections from: {ParentUrl}", parentModUrl); + + var parentContext = ExtractGlobalContext(parentDoc); + if (!string.IsNullOrEmpty(parentContext.IconUrl)) + { + logger.LogInformation("Extracted icon from parent mod: {IconUrl}", parentContext.IconUrl); + } + + context = MergeContext(context, parentContext); + + sections.AddRange(ExtractVideos(parentDoc)); + sections.AddRange(ExtractImages(parentDoc)); + sections.AddRange(ExtractArticles(parentDoc)); + sections.AddRange(ExtractReviews(parentDoc)); + sections.AddRange(ExtractComments(parentDoc)); + + var baseUrl = parentModUrl.TrimEnd('/'); + AppendFetchedSections(sections, baseUrl, fetched); + } + } + else + { + // Standard page handling + switch (pageType) + { + case PageType.List: + var listSectionType = url.Contains("/addons", StringComparison.OrdinalIgnoreCase) + ? FileSectionType.Addons + : FileSectionType.Downloads; + sections.AddRange(ExtractListSections(document, listSectionType)); + break; + + case PageType.Summary: + sections.AddRange(ExtractSummarySections(document)); + break; + + case PageType.Detail: + sections.AddRange(ExtractDetailSections(document)); + break; + + default: + logger.LogWarning("Unknown page type for URL: {Url}", url); + break; + } + } + + sections = DeduplicateDownloadableFiles(sections); + sections = DeduplicateImages(sections); + sections = DeduplicateVideos(sections); + + logger.LogInformation( + "Parsed ModDB page: {Url}, Type={PageType}, Sections={SectionCount}", + url, + pageType, + sections.Count); + + return new ParsedWebPage( + Url: new Uri(url), + Context: context, + Sections: sections, + PageType: pageType); + } + + /// <summary> + /// Extracts global context from the page header and profile sidebar. + /// </summary> + private GlobalContext ExtractGlobalContext(IDocument document) + { + // 1. Extract title. File pages use h1 "... file" and h2 for the real content name. + var h2 = document.QuerySelector("h2 a, h2")?.TextContent?.Trim(); + var h1 = document.QuerySelector("h1 a, h1")?.TextContent?.Trim(); + var title = h1 ?? "Unknown"; + if (!string.IsNullOrWhiteSpace(h2) && + (string.IsNullOrWhiteSpace(h1) || + h1.EndsWith(" file", StringComparison.OrdinalIgnoreCase))) + { + title = h2; + } + + if (string.IsNullOrWhiteSpace(title) || title == "Unknown") + { + title = document.QuerySelector(".title")?.TextContent?.Trim() ?? "Unknown"; + } + + // 2. Extract developer — never the header "register" / "sign in" links. + var developer = ExtractDeveloper(document); + + // 3. Extract release date + DateTime? releaseDate = null; + var releaseDateEl = document.QuerySelector(ModDBParserConstants.ReleaseDateSelector); + if (releaseDateEl != null) + { + var dateStr = releaseDateEl.GetAttribute("datetime") ?? releaseDateEl.TextContent?.Trim(); + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var parsedDate)) + { + releaseDate = parsedDate; + } + } + + // 4. Extract icon from profile sidebar + var iconEl = document.QuerySelector(ModDBParserConstants.ProfileIconSelector) + ?? document.QuerySelector(ModDBParserConstants.IconSelector); + var iconUrl = iconEl?.GetAttribute("src"); + if (!string.IsNullOrEmpty(iconUrl)) + { + if (!iconUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + iconUrl = "https:" + iconUrl; + } + + if (iconUrl.Contains("error_50x50", StringComparison.OrdinalIgnoreCase) || + iconUrl.Contains("default/error", StringComparison.OrdinalIgnoreCase) || + iconUrl.Contains("blank.gif", StringComparison.OrdinalIgnoreCase) || + iconUrl.Contains("clear.gif", StringComparison.OrdinalIgnoreCase)) + { + iconUrl = null; + } + } + + // 5. Extract description. File pages put copy in #downloadsummary / #downloaddescription; + // the first .summary on those pages is the breadcrumb "Games : ... : Files". + var description = ExtractDescription(document); + + // 6. Extract game name + var gameNameEl = document.QuerySelector(ModDBParserConstants.GameNameSelector); + var gameName = gameNameEl?.TextContent?.Trim(); + + logger.LogDebug( + "Extracted context: Title={Title}, Developer={Developer}, IconUrl={Icon}, DescriptionLength={DescLen}", + title, + developer, + iconUrl, + description?.Length ?? 0); + + return new GlobalContext( + Title: title, + Developer: developer, + ReleaseDate: releaseDate, + GameName: gameName, + IconUrl: iconUrl, + Description: description); + } + + /// <summary> + /// Extracts the downloadable file plus any Media/Community content already rendered on a + /// FileDetail page (comments, inline images, etc.). Game-scoped FileDetail URLs + /// (<c>/games/.../downloads/...</c>) never have a parent mod to sweep, so this on-page pass is + /// the only way those detail views get Community/Media tabs. + /// </summary> + private List<ContentSection> ExtractFileDetailSections(IDocument document) + { + var sections = new List<ContentSection>(); + + var file = ExtractDetailedFile(document, document.Url); + if (file != null) + { + sections.Add(file); + } + + AppendOnPageFileDetailSections(sections, document); + return sections; + } + + /// <summary> + /// Appends videos, images, articles, reviews, and comments already present on the current + /// FileDetail document. Does not navigate to sibling section URLs. + /// </summary> + private void AppendOnPageFileDetailSections(List<ContentSection> sections, IDocument document) + { + sections.AddRange(ExtractVideos(document)); + sections.AddRange(ExtractImages(document)); + sections.AddRange(ExtractArticles(document)); + sections.AddRange(ExtractReviews(document)); + sections.AddRange(ExtractComments(document)); + } + + /// <summary> + /// Maps already-fetched section documents into content sections. Missing keys are soft-failures + /// (logged by the Playwright batch fetch); this method only extracts what loaded. + /// </summary> + private void AppendFetchedSections( + List<ContentSection> sections, + string baseUrl, + IReadOnlyDictionary<string, IDocument> fetched) + { + TryAppendSection(sections, fetched, baseUrl + "/downloads", "downloads", doc => ExtractFiles(doc, FileSectionType.Downloads)); + TryAppendSection(sections, fetched, baseUrl + "/addons", "addons", doc => ExtractFiles(doc, FileSectionType.Addons)); + TryAppendSection(sections, fetched, baseUrl + "/videos", "videos", ExtractVideos); + TryAppendSection(sections, fetched, baseUrl + "/images", "images", ExtractImages); + TryAppendSection(sections, fetched, baseUrl + "/reviews", "reviews", ExtractReviews); + TryAppendSection(sections, fetched, baseUrl + "/articles", "articles", ExtractArticles); + } + + private void TryAppendSection( + List<ContentSection> sections, + IReadOnlyDictionary<string, IDocument> fetched, + string sectionUrl, + string sectionName, + Func<IDocument, IEnumerable<ContentSection>> extract) + { + if (!fetched.TryGetValue(sectionUrl, out var sectionDoc)) + { + logger.LogWarning("Failed to fetch {Section} section for {Url}", sectionName, sectionUrl); + return; + } + + var sectionItems = extract(sectionDoc).ToList(); + sections.AddRange(sectionItems); + logger.LogInformation("Found {Count} items in {Section} section", sectionItems.Count, sectionName); + } + + /// <summary> + /// Extracts detailed file information from a file detail page. + /// Parses the metadata table with rows like: Filename, Category, Uploader, Size, MD5 Hash. + /// </summary> + private DownloadableFile? ExtractDetailedFile(IDocument document, string? pageUrl = null) + { + var detailsUrl = pageUrl; + if (string.IsNullOrEmpty(detailsUrl) && document.Url != null && document.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + detailsUrl = document.Url; + } + + var metadata = CollectDetailedFileMetadata(document); + var filename = metadata.GetValueOrDefault(ModDBParserConstants.MetadataFilename) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataFileNameAlt) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataFileAlt); + + var name = ResolveDetailedFileName(document, filename); + var (sizeBytes, sizeDisplay) = ExtractDetailedFileSize(document, metadata); + var uploader = metadata.GetValueOrDefault(ModDBParserConstants.MetadataUploader) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataUploadedBy) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataAuthor); + + var category = metadata.GetValueOrDefault(ModDBParserConstants.MetadataCategory) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataFileCategory) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataType); + + var md5Hash = metadata.GetValueOrDefault(ModDBParserConstants.MetadataMd5Hash) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataMd5HashAlt) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataMd5Checksum) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataMd5) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataHash) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataChecksum); + + var (uploadDate, releaseDate) = ExtractDetailedFileDates(metadata); + var downloadUrl = ExtractDetailedDownloadUrl(document); + var downloadCount = ExtractDetailedDownloadCount(metadata); + var description = ExtractDescription(document) + ?? document.QuerySelector(ModDBParserConstants.FileDescriptionContainerSelector)?.TextContent?.Trim(); + var previewImages = ExtractDetailedPreviewImages(document); + + logger.LogInformation( + "Extracted file: Name={Name}, Size={Size}, Uploader={Uploader}, DownloadUrl={Url}", + name, + sizeDisplay, + uploader, + downloadUrl); + + var sectionType = detailsUrl?.Contains("/addons/", StringComparison.OrdinalIgnoreCase) == true || + category?.Contains("addon", StringComparison.OrdinalIgnoreCase) == true + ? FileSectionType.Addons + : FileSectionType.Downloads; + + return new DownloadableFile( + Name: name, + SizeBytes: sizeBytes, + SizeDisplay: sizeDisplay, + UploadDate: uploadDate, + Category: category, + Uploader: uploader, + DownloadUrl: downloadUrl, + Md5Hash: md5Hash, + DownloadCount: downloadCount, + FileSectionType: sectionType, + ReleaseDate: releaseDate, + DetailsUrl: detailsUrl, + Description: description, + PreviewImages: previewImages.Count > 0 ? previewImages : null, + Filename: filename); + } + + private Dictionary<string, string> CollectDetailedFileMetadata(IDocument document) + { + var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + var containers = document.QuerySelectorAll("#downloadsinfo, .table, table.table, #downloadsfiles, .sidecolumn, #modsinfo, #profile"); + foreach (var container in containers) + { + var rows = container.QuerySelectorAll("tr"); + foreach (var row in rows) + { + var cells = row.QuerySelectorAll("td, th"); + if (cells.Length >= 2) + { + var key = cells[0].TextContent?.Trim().ToLowerInvariant().Replace(":", string.Empty); + var value = cells[1].TextContent?.Trim(); + if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value)) + { + metadata[key] = value; + } + } + } + + var flexRows = container.QuerySelectorAll(".row.clear, .row, div.heading, dl"); + foreach (var row in flexRows) + { + var labelEl = row.QuerySelector("h5, h4, dt, strong, .label, .rowlabel"); + var label = labelEl?.TextContent?.Trim().ToLowerInvariant().Replace(":", string.Empty); + if (string.IsNullOrWhiteSpace(label)) + { + continue; + } + + var valueElement = labelEl?.NextElementSibling + ?? row.QuerySelector("span.summary, dd, time, a, .content, span"); + var value = valueElement?.TextContent?.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + { + metadata[label] = value; + } + } + } + + return metadata; + } + + private string ResolveDetailedFileName(IDocument document, string? filename) + { + string? fileHeading = null; + var headingCandidates = document.QuerySelectorAll(ModDBParserConstants.FilePageTitleSelector); + foreach (var cand in headingCandidates) + { + if (cand.Closest(ModDBParserConstants.HeaderBoxSelector) != null) + { + continue; + } + + var text = cand.TextContent?.Trim(); + if (!string.IsNullOrWhiteSpace(text) && !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + fileHeading = text; + break; + } + } + + string? titleTagCandidate = null; + var docTitle = document.Title?.Trim(); + if (!string.IsNullOrWhiteSpace(docTitle)) + { + var match = ModDBPageTitleRegex().Match(docTitle); + if (match.Success) + { + titleTagCandidate = match.Groups["title"].Value.Trim(); + } + } + + var h1Title = document.QuerySelector("h1 a, h1")?.TextContent?.Trim(); + if (!string.IsNullOrWhiteSpace(h1Title) && h1Title.EndsWith(" file", StringComparison.OrdinalIgnoreCase)) + { + h1Title = h1Title[..^5].Trim(); + } + + var humanName = fileHeading + ?? titleTagCandidate + ?? (!string.IsNullOrWhiteSpace(h1Title) && !h1Title.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ? h1Title : null) + ?? document.QuerySelector(ModDBParserConstants.FallbackTitleSelector)?.TextContent?.Trim(); + + return humanName ?? filename ?? "Unknown"; + } + + private (long? SizeBytes, string? SizeDisplay) ExtractDetailedFileSize(IDocument document, Dictionary<string, string> metadata) + { + string? sizeDisplay = metadata.GetValueOrDefault(ModDBParserConstants.MetadataSize) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataFileSizeAlt); + long? sizeBytes = null; + + if (!string.IsNullOrEmpty(sizeDisplay)) + { + if (sizeDisplay.Contains("bytes", StringComparison.OrdinalIgnoreCase) && + sizeDisplay.Contains('(') && sizeDisplay.Contains(')')) + { + var bytesPart = sizeDisplay.Split('(').LastOrDefault()?.Replace("bytes)", string.Empty).Replace(",", string.Empty).Trim(); + if (long.TryParse(bytesPart, out var bytesVal)) + { + sizeBytes = bytesVal; + } + } + + sizeBytes ??= ParseFileSize(sizeDisplay); + } + + if (string.IsNullOrEmpty(sizeDisplay)) + { + var downloadButton = document.QuerySelector(ModDBParserConstants.MainDownloadButtonSelector); + sizeDisplay = downloadButton?.TextContent?.Trim(); + if (!string.IsNullOrEmpty(sizeDisplay)) + { + sizeBytes = ParseFileSize(sizeDisplay); + } + } + + return (sizeBytes, sizeDisplay); + } + + private (DateTime? UploadDate, DateTime? ReleaseDate) ExtractDetailedFileDates(Dictionary<string, string> metadata) + { + DateTime? uploadDate = null; + DateTime? releaseDate = null; + var addedStr = metadata.GetValueOrDefault(ModDBParserConstants.MetadataAdded) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataUpdated); + + if (!string.IsNullOrEmpty(addedStr)) + { + if (DateTime.TryParse(addedStr, out var parsedDate)) + { + uploadDate = parsedDate; + releaseDate = parsedDate; + } + else + { + var modDBDate = ParseModDBDate(addedStr); + if (modDBDate.HasValue) + { + uploadDate = modDBDate; + releaseDate = modDBDate; + } + } + } + + return (uploadDate, releaseDate); + } + + private string? ExtractDetailedDownloadUrl(IDocument document) + { + var downloadButton = document.QuerySelector(ModDBParserConstants.MainDownloadButtonSelector); + if (downloadButton != null) + { + var href = downloadButton.GetAttribute("href"); + if (!string.IsNullOrEmpty(href)) + { + return ToAbsoluteUrl(href); + } + } + + return null; + } + + private int? ExtractDetailedDownloadCount(Dictionary<string, string> metadata) + { + var downloadsStr = metadata.GetValueOrDefault(ModDBParserConstants.MetadataTotalDownloads) + ?? metadata.GetValueOrDefault(ModDBParserConstants.MetadataDownloadCount) + ?? metadata.GetValueOrDefault("downloads"); + + if (!string.IsNullOrEmpty(downloadsStr)) + { + var numberMatch = System.Text.RegularExpressions.Regex.Match(downloadsStr, @"[\d,]+"); + if (numberMatch.Success && int.TryParse(numberMatch.Value.Replace(",", string.Empty), out var parsedDl)) + { + return parsedDl; + } + } + + return null; + } + + private List<string> ExtractDetailedPreviewImages(IDocument document) + { + var previewImages = new List<string>(); + var imageEls = document.QuerySelectorAll(ModDBParserConstants.FilePreviewImagesSelector); + foreach (var img in imageEls) + { + var src = img.GetAttribute("src") ?? img.GetAttribute("data-src"); + if (string.IsNullOrWhiteSpace(src) || + src.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || + src.Contains("blank.gif", StringComparison.OrdinalIgnoreCase) || + src.Contains("clear.gif", StringComparison.OrdinalIgnoreCase) || + src.Contains("guest", StringComparison.OrdinalIgnoreCase) || + src.Contains("/avatar/", StringComparison.OrdinalIgnoreCase) || + src.Contains("button", StringComparison.OrdinalIgnoreCase) || + src.Contains("icon.gif", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var fullUrl = ToAbsoluteUrl(src); + var parentAnchor = img.Closest("a"); + var anchorHref = parentAnchor?.GetAttribute("href"); + if (!string.IsNullOrWhiteSpace(anchorHref)) + { + var absAnchor = ToAbsoluteUrl(anchorHref); + var isDirectImage = absAnchor.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || + absAnchor.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || + absAnchor.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || + absAnchor.EndsWith(".webp", StringComparison.OrdinalIgnoreCase); + + fullUrl = isDirectImage ? absAnchor : GetFullSizeModDBImageSource(fullUrl); + } + else + { + fullUrl = GetFullSizeModDBImageSource(fullUrl); + } + + if (!previewImages.Contains(fullUrl)) + { + previewImages.Add(fullUrl); + } + } + + return previewImages; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs index 411041ef5..0d62e14ec 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using Microsoft.Extensions.Logging; @@ -28,14 +28,30 @@ public class PublisherManifestFactoryResolver(IEnumerable<IPublisherManifestFact "Resolved {FactoryType} for manifest {ManifestId} (Publisher: {Publisher})", factory.GetType().Name, manifest.Id, - manifest.Publisher?.PublisherType ?? GameClientConstants.UnknownVersion); + manifest.Publisher?.PublisherType ?? "unknown"); return factory; } + // Fallback to generic GitHub manifest factory for non-GameClient publisher content (e.g., patches, mods) + if (manifest.ContentType != ContentType.GameClient) + { + var fallbackFactory = factories.OfType<GitHubManifestFactory>().FirstOrDefault(); + if (fallbackFactory != null) + { + logger.LogInformation( + "Resolved fallback {FactoryType} for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})", + fallbackFactory.GetType().Name, + manifest.Id, + manifest.Publisher?.PublisherType ?? "unknown", + manifest.ContentType); + return fallbackFactory; + } + } + logger.LogWarning( "No factory found for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})", manifest.Id, - manifest.Publisher?.PublisherType ?? GameClientConstants.UnknownVersion, + manifest.Publisher?.PublisherType ?? "unknown", manifest.ContentType); return null; diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index b7bbf93a2..3f1b78008 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -71,57 +71,98 @@ public override async Task<OperationResult<IEnumerable<ContentSearchResult>>> Se { try { + cancellationToken.ThrowIfCancellationRequested(); var results = new List<ContentSearchResult>(); + var errors = new List<string>(); - // Directly fetch latest release from TheSuperHackers/GeneralsGameCode - var latestRelease = await gitHubApiClient.GetLatestReleaseAsync( - SuperHackersConstants.GeneralsGameCodeOwner, - SuperHackersConstants.GeneralsGameCodeRepo, - cancellationToken); + var targets = new (string Owner, string Repo, ContentType ContentType, GameType? TargetGame, string DisplayName)[] + { + (SuperHackersConstants.GeneralsGameCodeOwner, SuperHackersConstants.GeneralsGameCodeRepo, ContentType.GameClient, GameType.Generals, SuperHackersConstants.PublisherName), + (SuperHackersConstants.GeneralsGamePatch2Owner, SuperHackersConstants.GeneralsGamePatch2Repo, ContentType.Patch, null, SuperHackersConstants.GeneralsGamePatch2DisplayName), + }; - if (latestRelease != null && - (string.IsNullOrWhiteSpace(query.AuthorName) || - query.AuthorName.Equals(SuperHackersConstants.GeneralsGameCodeOwner, StringComparison.OrdinalIgnoreCase)) && - (string.IsNullOrWhiteSpace(query.SearchTerm) || - latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true || - SuperHackersConstants.GeneralsGameCodeRepo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase))) + var matchingTargets = targets.Where(t => + (!query.ContentType.HasValue || query.ContentType.Value == t.ContentType) && + (!query.TargetGame.HasValue || t.TargetGame == null || query.TargetGame.Value == t.TargetGame.Value) && + (string.IsNullOrWhiteSpace(query.AuthorName) || query.AuthorName.Equals(t.Owner, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrWhiteSpace(query.GitHubAuthor) || query.GitHubAuthor.Equals(t.Owner, StringComparison.OrdinalIgnoreCase))).ToList(); + + foreach (var (owner, repo, contentType, targetGame, displayName) in matchingTargets) { - // Generate manifest ID - var manifestId = ManifestIdGenerator.GenerateGitHubContentId( - SuperHackersConstants.GeneralsGameCodeOwner, - SuperHackersConstants.GeneralsGameCodeRepo, - ContentType.GameClient, - latestRelease.TagName); - - var result = new ContentSearchResult + try { - Id = manifestId, - Name = latestRelease.Name ?? $"{SuperHackersConstants.PublisherName} {latestRelease.TagName}", - Description = latestRelease.Body ?? "SuperHackers release - details available after resolution", - Version = latestRelease.TagName ?? "latest", - AuthorName = SuperHackersConstants.GeneralsGameCodeOwner, - ContentType = ContentType.GameClient, - TargetGame = GameType.Generals, // Simplification, could infer - IsInferred = false, - ProviderName = SourceName, - RequiresResolution = true, - ResolverId = SuperHackersConstants.ResolverId, - SourceUrl = latestRelease.HtmlUrl, - LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime, - ResolverMetadata = + cancellationToken.ThrowIfCancellationRequested(); + + var latestRelease = await gitHubApiClient.GetLatestReleaseAsync( + owner, + repo, + cancellationToken); + + if (latestRelease != null && + (string.IsNullOrWhiteSpace(query.SearchTerm) || + latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true || + repo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) || + displayName.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) || + latestRelease.Body?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true)) { - [GitHubConstants.OwnerMetadataKey] = SuperHackersConstants.GeneralsGameCodeOwner, - [GitHubConstants.RepoMetadataKey] = SuperHackersConstants.GeneralsGameCodeRepo, - [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest", - }, - }; - - result.SetData(latestRelease); - results.Add(result); + var manifestId = ManifestIdGenerator.GenerateGitHubContentId( + owner, + repo, + contentType, + latestRelease.TagName); + + var resolvedTargetGame = targetGame ?? query.TargetGame ?? GameType.Unknown; + + var result = new ContentSearchResult + { + Id = manifestId, + Name = !string.IsNullOrWhiteSpace(latestRelease.Name) ? latestRelease.Name : $"{displayName} {latestRelease.TagName}", + Description = latestRelease.Body ?? "SuperHackers release - details available after resolution", + Version = latestRelease.TagName ?? "latest", + AuthorName = owner, + ContentType = contentType, + TargetGame = resolvedTargetGame, + IsInferred = false, + ProviderName = SourceName, + RequiresResolution = true, + ResolverId = SuperHackersConstants.ResolverId, + SourceUrl = latestRelease.HtmlUrl, + LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime, + ResolverMetadata = + { + [GitHubConstants.OwnerMetadataKey] = owner, + [GitHubConstants.RepoMetadataKey] = repo, + [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest", + }, + }; + + result.SetData(latestRelease); + results.Add(result); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to fetch SuperHackers release for {Owner}/{Repo}", owner, repo); + errors.Add($"{owner}/{repo}: {ex.Message}"); + } + } + + if (results.Count == 0 && errors.Count > 0) + { + return OperationResult<IEnumerable<ContentSearchResult>>.CreateFailure( + $"Search failed for SuperHackers targets: {string.Join("; ", errors)}"); } return OperationResult<IEnumerable<ContentSearchResult>>.CreateSuccess(results); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { Logger.LogError(ex, "Failed to search SuperHackers content"); diff --git a/GenHub/GenHub/Features/Content/Services/Tools/ManagedChromiumRuntime.cs b/GenHub/GenHub/Features/Content/Services/Tools/ManagedChromiumRuntime.cs new file mode 100644 index 000000000..a8034bac7 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Tools/ManagedChromiumRuntime.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace GenHub.Features.Content.Services.Tools; + +/// <summary> +/// Owns the app-local Playwright Chromium runtime. Playwright's NuGet package supplies the +/// driver but deliberately does not ship browser binaries, so GenHub provisions Chromium under +/// its application-data directory instead of relying on a system browser installation. +/// System Chrome/Edge cannot satisfy this requirement — Playwright needs its own patched build. +/// </summary> +internal sealed class ManagedChromiumRuntime( + string runtimeDirectory, + Func<string[], int> installer, + Func<string, Task<bool>> requestInstallConsentAsync, + ILogger logger) +{ + /// <summary> + /// Environment variable used by Playwright to locate app-owned browser binaries. + /// </summary> + internal const string BrowserPathEnvironmentVariable = "PLAYWRIGHT_BROWSERS_PATH"; + + /// <summary> + /// Environment variable used by Playwright to locate its driver binary (node.exe). + /// </summary> + internal const string DriverPathEnvironmentVariable = "PLAYWRIGHT_DRIVER_PATH"; + + /// <summary> + /// Configures Playwright to resolve browsers only from GenHub's managed runtime directory. + /// </summary> + public void ConfigureEnvironment() + { + Directory.CreateDirectory(runtimeDirectory); + Environment.SetEnvironmentVariable(BrowserPathEnvironmentVariable, runtimeDirectory); + EnsureDriverEnvironmentVariable(); + } + + /// <summary> + /// Installs Chromium exactly once when the app-owned executable is unavailable, + /// after the user confirms the download via the standard confirmation dialog. + /// </summary> + /// <param name="chromium">The Playwright Chromium browser type.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>A task representing the asynchronous provisioning operation.</returns> + /// <exception cref="OperationCanceledException">Thrown when the user declines installation.</exception> + /// <exception cref="InvalidOperationException">Thrown when Chromium could not be provisioned.</exception> + public async Task EnsureInstalledAsync(IBrowserType chromium, CancellationToken cancellationToken) + { + ConfigureEnvironment(); + + if (File.Exists(chromium.ExecutablePath)) + { + return; + } + + logger.LogInformation( + "Managed Chromium is missing. Requesting user consent before installing under {RuntimeDirectory}", + runtimeDirectory); + + var consented = await requestInstallConsentAsync(runtimeDirectory); + cancellationToken.ThrowIfCancellationRequested(); + + if (!consented) + { + logger.LogInformation("User declined managed Chromium installation under {RuntimeDirectory}", runtimeDirectory); + throw new OperationCanceledException( + "ModDB requires GenHub's managed Chromium runtime. Installation was cancelled."); + } + + logger.LogInformation("Managed Chromium install consented. Installing under {RuntimeDirectory}", runtimeDirectory); + + int exitCode = 0; + try + { + exitCode = await Task.Run( + () => + { + cancellationToken.ThrowIfCancellationRequested(); + return installer(["install", "chromium"]); + }, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new InvalidOperationException( + "GenHub could not install its managed Chromium runtime. Check the network connection and try the ModDB action again.", + ex); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (exitCode != 0 || !File.Exists(chromium.ExecutablePath)) + { + throw new InvalidOperationException( + "GenHub could not install its managed Chromium runtime. Check the network connection and try the ModDB action again."); + } + + logger.LogInformation("Managed Chromium installation completed in {RuntimeDirectory}", runtimeDirectory); + } + + private static string GetPlatformFolder() + { + if (OperatingSystem.IsWindows()) + { + return "win32_x64"; + } + + if (OperatingSystem.IsLinux()) + { + return System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm64 + ? "linux-arm64" + : "linux-x64"; + } + + if (OperatingSystem.IsMacOS()) + { + return System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm64 + ? "darwin-arm64" + : "darwin-x64"; + } + + return "win32_x64"; + } + + private static string? _cachedDriverPath; + + private void EnsureDriverEnvironmentVariable() + { + if (!string.IsNullOrWhiteSpace(_cachedDriverPath) && File.Exists(_cachedDriverPath)) + { + Environment.SetEnvironmentVariable(DriverPathEnvironmentVariable, _cachedDriverPath); + return; + } + + var existingDriverPath = Environment.GetEnvironmentVariable(DriverPathEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(existingDriverPath) && File.Exists(existingDriverPath)) + { + _cachedDriverPath = existingDriverPath; + return; + } + + var platformFolder = GetPlatformFolder(); + var nodeBinaryName = OperatingSystem.IsWindows() ? "node.exe" : "node"; + + var searchDirectories = new[] + { + AppContext.BaseDirectory, + AppDomain.CurrentDomain.BaseDirectory, + Path.GetDirectoryName(typeof(ManagedChromiumRuntime).Assembly.Location) ?? string.Empty, + Path.GetDirectoryName(Environment.ProcessPath) ?? string.Empty, + }; + + foreach (var dir in searchDirectories) + { + if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir)) + { + continue; + } + + var candidate = Path.Combine(dir, ".playwright", "node", platformFolder, nodeBinaryName); + if (File.Exists(candidate)) + { + _cachedDriverPath = candidate; + Environment.SetEnvironmentVariable(DriverPathEnvironmentVariable, candidate); + logger.LogInformation("Resolved Playwright driver path: {DriverPath}", candidate); + return; + } + + var current = new DirectoryInfo(dir); + for (var depth = 0; depth < 4 && current?.Parent != null; depth++) + { + current = current.Parent; + candidate = Path.Combine(current.FullName, ".playwright", "node", platformFolder, nodeBinaryName); + if (File.Exists(candidate)) + { + _cachedDriverPath = candidate; + Environment.SetEnvironmentVariable(DriverPathEnvironmentVariable, candidate); + logger.LogInformation("Resolved Playwright driver path from parent directory: {DriverPath}", candidate); + return; + } + } + } + + logger.LogWarning("Could not resolve Playwright driver node executable in any standard directory"); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Tools/PlaywrightService.cs b/GenHub/GenHub/Features/Content/Services/Tools/PlaywrightService.cs new file mode 100644 index 000000000..b88b754bf --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/Tools/PlaywrightService.cs @@ -0,0 +1,1372 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using Avalonia.Threading; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace GenHub.Features.Content.Services.Tools; + +/// <summary> +/// Service for managing Playwright browser instances and fetching web content. +/// Provides shared browser resources across the application. +/// </summary> +/// <remarks> +/// Initializes a new instance of the <see cref="PlaywrightService"/> class. +/// </remarks> +/// <param name="logger">Logger instance.</param> +/// <param name="configurationProvider">Application configuration provider.</param> +/// <param name="dialogService">Dialog service used to confirm managed Chromium installation.</param> +/// <param name="notificationService">Optional notifications shown before a headed browser window opens.</param> +public class PlaywrightService( + ILogger<PlaywrightService> logger, + IConfigurationProviderService configurationProvider, + IDialogService dialogService, + INotificationService? notificationService = null) : IPlaywrightService, IDisposable, IAsyncDisposable +{ + private static readonly SemaphoreSlim _browserLock = new(1, 1); + private static readonly SemaphoreSlim _persistentLock = new(1, 1); + private static readonly SemaphoreSlim _playwrightLock = new(1, 1); + + /// <summary> + /// Serializes all headed persistent-profile operations (single fetch, multi-URL sweep, ModDB + /// download). + /// </summary> + private static readonly SemaphoreSlim _persistentFetchLock = new(1, 1); + private static readonly AsyncLocal<bool> _isInPersistentSession = new(); + private static readonly HashSet<IPage> _inUsePersistentPages = []; + private static readonly HashSet<string> UnsafeExtraHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Connection", + "Content-Length", + "Cookie", + "Host", + "Proxy-Connection", + "Transfer-Encoding", + "Upgrade", + }; + + private static IPlaywright? _playwright; + private static IBrowser? _browser; + private static IBrowserContext? _persistentContext; + private static string? _persistentProfileName; + private static int _activePersistentSessions; + + private ManagedChromiumRuntime? managedChromiumRuntime; + + private bool _disposed; + + /// <inheritdoc /> + public async Task<IPage> CreatePageAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default) + { + await EnsurePlaywrightInitializedAsync(cancellationToken); + + if (_browser == null) + { + throw new InvalidOperationException("Browser not initialized"); + } + + var contextOptions = options ?? new BrowserNewContextOptions + { + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ViewportSize = new ViewportSize { Width = 1366, Height = 768 }, + Locale = "en-US", + }; + + var context = await _browser.NewContextAsync(contextOptions); + + try + { + return await context.NewPageAsync(); + } + catch + { + try + { + await context.CloseAsync(); + } + catch (PlaywrightException) + { + } + + throw; + } + } + + /// <inheritdoc /> + public async Task<IPage> CreatePersistentPageAsync(string profileName, CancellationToken cancellationToken = default) + { + var profileDir = Path.Combine( + configurationProvider.GetApplicationDataPath(), + DirectoryNames.BrowserProfiles, + profileName); + + // Closing the last page of a headed persistent context also terminates its browser process, + // leaving the cached _persistentContext pointing at a dead channel. Detect that up front so + // the first operation after a previous page-close relaunches the profile instead of throwing + // TargetClosedException deep inside EnsurePersistentContextAsync/NewPageAsync. + await EnsurePersistentContextAsync(profileDir, cancellationToken); + + if (_persistentContext == null) + { + throw new InvalidOperationException("Persistent browser context not initialized"); + } + + return await CreatePersistentPageWithRecoveryAsync(profileDir, cancellationToken); + } + + /// <inheritdoc /> + public async Task ClosePersistentPageAsync(IPage page, bool keepOpen = false) + { + ArgumentNullException.ThrowIfNull(page); + + if (keepOpen) + { + return; + } + + await _persistentLock.WaitAsync(); + try + { + _inUsePersistentPages.Remove(page); + + if (_persistentContext != null && IsPersistentContextAlive()) + { + try + { + var otherPages = _persistentContext.Pages.Where(p => !p.IsClosed && p != page).ToList(); + + if (_activePersistentSessions > 0) + { + // inside an active session: if this is the last page, navigate to about:blank + // so chromium remains alive for subsequent steps within the session. + if (otherPages.Count == 0 && _inUsePersistentPages.Count == 0) + { + if (!page.IsClosed) + { + try + { + await page.GotoAsync("about:blank"); + } + catch (PlaywrightException) + { + } + } + } + else if (!page.IsClosed) + { + try + { + await page.CloseAsync(); + } + catch (PlaywrightException ex) + { + logger.LogDebug(ex, "Persistent page already closed during cleanup."); + } + } + } + else + { + // outside an active session: close page and immediately shut down persistent context + // once all active pages finish. + if (!page.IsClosed) + { + try + { + await page.CloseAsync(); + } + catch (PlaywrightException ex) + { + logger.LogDebug(ex, "Persistent page already closed during cleanup."); + } + } + + if (_inUsePersistentPages.Count == 0) + { + await ClosePersistentContextCoreUnderLockAsync(); + } + } + } + catch (PlaywrightException ex) when (IsContextClosedError(ex)) + { + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to inspect persistent context after page close."); + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + } + else + { + if (!page.IsClosed) + { + try + { + await page.CloseAsync(); + } + catch (PlaywrightException) + { + } + } + } + } + finally + { + _persistentLock.Release(); + } + } + + /// <inheritdoc /> + public async Task<string> FetchHtmlAsync(string url, CancellationToken cancellationToken = default) + { + try + { + logger.LogDebug("Fetching HTML from {Url}", url); + + var page = await CreatePageAsync(cancellationToken: cancellationToken); + try + { + await page.GotoAsync(url, new PageGotoOptions + { + Timeout = 30000, + WaitUntil = WaitUntilState.DOMContentLoaded, + }); + + // Wait a bit for dynamic content to load + await Task.Delay(500, cancellationToken); + + return await page.ContentAsync(); + } + finally + { + var context = page.Context; + try + { + await page.CloseAsync(); + } + finally + { + if (context != null) + { + await context.CloseAsync(); + } + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to fetch HTML from {Url}", url); + throw; + } + } + + /// <inheritdoc /> + public async Task<IDocument> FetchAndParseAsync(string url, CancellationToken cancellationToken = default) + { + var html = await FetchHtmlAsync(url, cancellationToken); + var browsingContext = BrowsingContext.New(Configuration.Default); + return await browsingContext.OpenAsync(req => req.Content(html), cancellationToken); + } + + /// <inheritdoc /> + public async Task<IDocument> FetchAndParsePersistentAsync(string profileName, string url, CancellationToken cancellationToken = default) + { + var html = await FetchPersistentHtmlAsync(profileName, url, cancellationToken); + return await OpenDocumentAsync(html, cancellationToken); + } + + /// <summary> + /// Executes an operation within a scoped persistent browser context session. + /// </summary> + /// <typeparam name="T">The return type of the operation.</typeparam> + /// <param name="profileName">The on-disk profile name.</param> + /// <param name="operation">The asynchronous operation to execute.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The result of the operation.</returns> + public async Task<T> ExecuteInPersistentContextAsync<T>( + string profileName, + Func<Task<T>> operation, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + + var profileDir = Path.Combine( + configurationProvider.GetApplicationDataPath(), + DirectoryNames.BrowserProfiles, + profileName); + + var isOuterSession = !_isInPersistentSession.Value; + if (isOuterSession) + { + await _persistentFetchLock.WaitAsync(cancellationToken); + _isInPersistentSession.Value = true; + } + + try + { + Interlocked.Increment(ref _activePersistentSessions); + try + { + await EnsurePersistentContextAsync(profileDir, cancellationToken); + return await operation(); + } + finally + { + if (Interlocked.Decrement(ref _activePersistentSessions) == 0) + { + await ClosePersistentContextCoreAsync(); + } + } + } + finally + { + if (isOuterSession) + { + _isInPersistentSession.Value = false; + _persistentFetchLock.Release(); + } + } + } + + /// <inheritdoc /> + public async Task<IReadOnlyDictionary<string, IDocument>> FetchAndParsePersistentManyAsync( + string profileName, + IReadOnlyList<string> urls, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(urls); + + var results = new Dictionary<string, IDocument>(StringComparer.Ordinal); + if (urls.Count == 0) + { + return results; + } + + // Preserve caller order while skipping duplicate URLs (section sweeps sometimes repeat a path). + var orderedUnique = new List<string>(urls.Count); + var seen = new HashSet<string>(StringComparer.Ordinal); + foreach (var url in urls) + { + if (string.IsNullOrWhiteSpace(url) || !seen.Add(url)) + { + continue; + } + + orderedUnique.Add(url); + } + + if (orderedUnique.Count == 0) + { + return results; + } + + var isOuterSession = !_isInPersistentSession.Value; + if (isOuterSession) + { + await _persistentFetchLock.WaitAsync(cancellationToken); + _isInPersistentSession.Value = true; + } + + try + { + logger.LogDebug( + "Fetching {Count} URL(s) in parallel on persistent context (profile '{Profile}')", + orderedUnique.Count, + profileName); + + var profileDir = Path.Combine( + configurationProvider.GetApplicationDataPath(), + DirectoryNames.BrowserProfiles, + profileName); + + await EnsurePersistentContextAsync(profileDir, cancellationToken); + if (_persistentContext == null) + { + throw new InvalidOperationException("Persistent browser context not initialized"); + } + + var concurrentResults = new System.Collections.Concurrent.ConcurrentDictionary<string, IDocument>(StringComparer.Ordinal); + + // Limit parallel tabs to avoid overwhelming system resources (max 5 parallel tabs) + using var tabSemaphore = new SemaphoreSlim(Math.Min(orderedUnique.Count, 5)); + + var tasks = orderedUnique.Select(async url => + { + await tabSemaphore.WaitAsync(cancellationToken); + IPage? page = null; + try + { + cancellationToken.ThrowIfCancellationRequested(); + + page = await CreatePersistentPageAsync(profileName, cancellationToken); + + var html = await NavigatePersistentPageAsync(page, url, cancellationToken); + var doc = await OpenDocumentAsync(html, cancellationToken); + concurrentResults[url] = doc; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Soft-fail per URL so one dead section does not abort the rest of the sweep. + logger.LogWarning(ex, "Failed to fetch persistent URL in parallel batch: {Url}", url); + } + finally + { + if (page != null) + { + await ClosePersistentPageAsync(page); + } + + tabSemaphore.Release(); + } + }); + + await Task.WhenAll(tasks); + + foreach (var kvp in concurrentResults) + { + results[kvp.Key] = kvp.Value; + } + } + finally + { + if (isOuterSession) + { + if (_activePersistentSessions == 0 && _inUsePersistentPages.Count == 0) + { + await ClosePersistentContextCoreAsync(); + } + + _isInPersistentSession.Value = false; + _persistentFetchLock.Release(); + } + } + + return results; + } + + /// <inheritdoc /> + public async Task WarmupAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureManagedPlaywrightAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Background Playwright warmup did not complete."); + } + } + + /// <inheritdoc /> + public void Dispose() + { + if (_disposed) + { + return; + } + + Task.Run(() => DisposeAsync().AsTask()).GetAwaiter().GetResult(); + } + + /// <inheritdoc /> + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + if (_persistentContext != null) + { + try + { + await _persistentContext.CloseAsync(); + } + catch + { + } + finally + { + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + } + + if (_browser != null) + { + await _browser.CloseAsync(); + _browser = null; + } + + if (_playwright != null) + { + _playwright.Dispose(); + _playwright = null; + } + + _disposed = true; + } + + /// <inheritdoc /> + public async Task<DownloadResult> DownloadFileAsync(GenHub.Core.Models.Common.DownloadConfiguration configuration, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Starting Playwright download from {Url}", configuration.Url); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var usePersistentModDbProfile = configuration.Url.Host.Equals("moddb.com", StringComparison.OrdinalIgnoreCase) || + configuration.Url.Host.EndsWith(".moddb.com", StringComparison.OrdinalIgnoreCase); + + // Headed ModDB profile is shared with FetchPersistentHtmlAsync / multi-URL sweeps — + // serialize so a download cannot relaunch Chromium while a section page is mid-Goto. + var isOuterSession = usePersistentModDbProfile && !_isInPersistentSession.Value; + if (isOuterSession) + { + await _persistentFetchLock.WaitAsync(cancellationToken); + _isInPersistentSession.Value = true; + } + + try + { + return await DownloadFileCoreAsync(configuration, usePersistentModDbProfile, stopwatch, cancellationToken); + } + finally + { + if (isOuterSession) + { + if (_activePersistentSessions == 0 && _inUsePersistentPages.Count == 0) + { + await ClosePersistentContextCoreAsync(); + } + + _isInPersistentSession.Value = false; + _persistentFetchLock.Release(); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Playwright download failed for {Url}", configuration.Url); + var message = ex.Message.Contains("Executable doesn't exist", StringComparison.OrdinalIgnoreCase) + ? "The bundled Chromium runtime is unavailable. Install the application browser runtime and retry the ModDB download." + : ex.Message; + return DownloadResult.CreateFailure(message, bytesDownloaded: 0, elapsed: TimeSpan.Zero); + } + } + + /// <summary> + /// Builds browser-safe request headers from a download configuration. Headers which Chromium + /// owns itself are ignored, while site-required headers such as <c>Referer</c> are preserved. + /// </summary> + /// <param name="configuration">Download configuration containing optional request headers.</param> + /// <returns>Headers safe to apply to a Playwright page.</returns> + internal static IReadOnlyDictionary<string, string> BuildSafeDownloadHeaders( + GenHub.Core.Models.Common.DownloadConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + Dictionary<string, string> headers = new(StringComparer.OrdinalIgnoreCase); + foreach (var (name, value) in configuration.Headers) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value) || UnsafeExtraHeaders.Contains(name)) + { + continue; + } + + headers[name] = value; + } + + if (!string.IsNullOrWhiteSpace(configuration.UserAgent) && !headers.ContainsKey("User-Agent")) + { + headers["User-Agent"] = configuration.UserAgent; + } + + return headers; + } + + /// <summary> + /// Determines whether a Playwright exception represents a transient navigation or closed-target + /// condition that a polling loop can safely retry (Cloudflare interstitial redirecting to the + /// real document, a frame detaching mid-probe, or the persistent context dying between pages). + /// Shared with <see cref="ContentDiscoverers.ModDBDiscoverer"/> for its listing-wait loop. + /// </summary> + /// <param name="ex">The Playwright exception to classify.</param> + /// <returns><see langword="true"/> if the caller should retry rather than propagate.</returns> + internal static bool IsContextClosedError(PlaywrightException ex) => + ex.Message.Contains("Target page, context or browser has been closed", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("Browser has been closed", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("Target closed", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("Execution context was destroyed", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("Target frame was detached", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("Target.createTarget", StringComparison.OrdinalIgnoreCase); + + /// <summary> + /// A persistent context is only reusable while it is live and still owns at least one open + /// page. A headed Chromium process exits once its final page closes, after which the cached + /// IBrowserContext reference throws on every call. Touching <see cref="IBrowserContext.Pages"/> + /// is the cheapest probe: it fails on a dead channel and succeeds on a live one. + /// </summary> + private static bool IsPersistentContextAlive() + { + if (_persistentContext == null) + { + return false; + } + + try + { + // Any property/method round-trip to the dead channel raises TargetClosedException. + _ = _persistentContext.Pages; + return true; + } + catch (PlaywrightException ex) when (IsContextClosedError(ex)) + { + return false; + } + } + + private static bool IsBlankStartupUrl(string url) => + string.IsNullOrWhiteSpace(url) || + url.Equals("about:blank", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("chrome://newtab", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("chrome://new-tab", StringComparison.OrdinalIgnoreCase); + + /// <summary> + /// Clears a closed persistent browser context so the next request launches a fresh one. + /// </summary> + private static async Task InvalidatePersistentContextAsync() + { + await _persistentLock.WaitAsync(); + try + { + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + finally + { + _persistentLock.Release(); + } + } + + /// <summary> + /// Closes the persistent browser context when called under _persistentLock. + /// </summary> + private static async Task ClosePersistentContextCoreUnderLockAsync() + { + if (_persistentContext != null) + { + try + { + var nonClosedPages = _persistentContext.Pages.Where(p => !p.IsClosed).ToList(); + foreach (var page in nonClosedPages) + { + try + { + await page.CloseAsync(); + } + catch (PlaywrightException) + { + } + } + + await _persistentContext.CloseAsync(); + } + catch (PlaywrightException) + { + } + finally + { + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + } + } + + /// <summary> + /// Closes the persistent browser context and acquires _persistentLock. + /// </summary> + private static async Task ClosePersistentContextCoreAsync() + { + await _persistentLock.WaitAsync(); + try + { + await ClosePersistentContextCoreUnderLockAsync(); + } + finally + { + _persistentLock.Release(); + } + } + + private static bool IsModDbVerificationPage(string? title) => + !string.IsNullOrWhiteSpace(title) && + (title.Contains("Just a moment", StringComparison.OrdinalIgnoreCase) || + title.Contains("Attention Required", StringComparison.OrdinalIgnoreCase) || + title.Contains("Checking your browser", StringComparison.OrdinalIgnoreCase) || + title.Contains("Verify you are human", StringComparison.OrdinalIgnoreCase) || + title.Contains("Cloudflare", StringComparison.OrdinalIgnoreCase)); + + private static async Task<IDocument> OpenDocumentAsync(string html, CancellationToken cancellationToken) + { + var browsingContext = BrowsingContext.New(Configuration.Default); + return await browsingContext.OpenAsync(req => req.Content(html), cancellationToken); + } + + private async Task<DownloadResult> DownloadFileCoreAsync( + GenHub.Core.Models.Common.DownloadConfiguration configuration, + bool usePersistentModDbProfile, + System.Diagnostics.Stopwatch stopwatch, + CancellationToken cancellationToken) + { + var page = usePersistentModDbProfile + ? await CreatePersistentPageAsync(ModDBConstants.BrowserProfileName, cancellationToken) + : await CreatePageAsync(cancellationToken: cancellationToken); + + List<IPage> popups = []; + var downloadTcs = new TaskCompletionSource<IDownload>(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnDownload(object? sender, IDownload download) => downloadTcs.TrySetResult(download); + void OnPopup(object? sender, IPage popup) + { + popups.Add(popup); + popup.Download += OnDownload; + } + + try + { + var requestHeaders = BuildSafeDownloadHeaders(configuration); + if (requestHeaders.Count > 0) + { + await page.SetExtraHTTPHeadersAsync(requestHeaders); + } + + if (usePersistentModDbProfile) + { + logger.LogInformation("Using persistent ModDB browser profile for protected download {Url}", configuration.Url); + NotifyBrowserWindowOpening( + "ModDB download starting", + "A browser window is opening to download this file. Wait for the download to finish and do not click anything in that window."); + } + + // ModDB frequently hands the actual binary off to a new tab/popup rather than the page + // that performed the navigation. A page-level Download handler therefore misses the + // event and the wait times out, surfacing as a download that "never completes". The + // context-level handler catches downloads started in ANY page of the persistent context, + // which is what actually fires for ModDB's popup-style mirrors. + page.Download += OnDownload; + page.Popup += OnPopup; + + // Trigger the download by navigating to the URL. + await page.GotoAsync(configuration.Url.ToString(), new PageGotoOptions + { + Timeout = (float)configuration.Timeout.TotalMilliseconds, + WaitUntil = WaitUntilState.DOMContentLoaded, + }); + + // Race the download TCS against a short delay to check if it auto-started. If not, + // try to click a fallback download link on the page (mirrors, manual Download btn). + var waitTask = Task.Delay(5000, cancellationToken); + var completedTask = await Task.WhenAny(downloadTcs.Task, waitTask); + + if (completedTask != downloadTcs.Task) + { + logger.LogInformation("Download did not start automatically within 5s. Attempting to find fallback link..."); + + const string FallbackSelector = "a[href*='media.moddb.com'], a[href*='files.moddb.com'], a:has-text('click here'), a:has-text('Click here'), a:has-text('here'), a#download, a.download, a.btn-download, a.buttondownload, a[href*='/mirror/'], a[href*='/downloads/start/'], a[href*='/addons/start/'], a:has-text('Download Now'), a:has-text('download now'), a:has-text('Download'), a:has-text('download'), a:has-text('mirror')"; + + var fallbackLink = await page.QuerySelectorAsync(FallbackSelector); + + if (fallbackLink != null) + { + var text = await fallbackLink.InnerTextAsync(); + logger.LogInformation("Found fallback link '{Text}', clicking...", text); + try + { + await fallbackLink.ClickAsync(new ElementHandleClickOptions { Timeout = 5000 }); + + // If clicking navigated to a /start/ page, wait briefly and check for direct mirror link + var secondWait = Task.Delay(4000, cancellationToken); + var secondCompleted = await Task.WhenAny(downloadTcs.Task, secondWait); + if (secondCompleted != downloadTcs.Task && !string.IsNullOrWhiteSpace(page.Url) && page.Url.Contains("/start/", StringComparison.OrdinalIgnoreCase)) + { + var startPageFallback = await page.QuerySelectorAsync("a:has-text('click here'), a:has-text('Click here'), a[href*='media.moddb.com'], a[href*='files.moddb.com'], a[href*='/mirror/']"); + if (startPageFallback != null) + { + var startText = await startPageFallback.InnerTextAsync(); + logger.LogInformation("Found start-page mirror link '{Text}', clicking...", startText); + await startPageFallback.ClickAsync(new ElementHandleClickOptions { Timeout = 5000 }); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to click fallback link."); + } + } + else + { + logger.LogWarning("No fallback download link found. Continuing to wait for download event..."); + } + } + + // Wait for the download to START (not finish) with a generous timeout. ModDB's + // redirect chain plus Cloudflare can take a while before the binary begins streaming. + var waitTimeout = TimeSpan.FromMilliseconds(Math.Max(60000, configuration.Timeout.TotalMilliseconds)); + var download = await downloadTcs.Task.WaitAsync(waitTimeout, cancellationToken); + + if (download == null) + { + return DownloadResult.CreateFailure("Download failed to initialize (null download object)."); + } + + if (File.Exists(configuration.DestinationPath) && configuration.OverwriteExisting) + { + File.Delete(configuration.DestinationPath); + } + + var dir = Path.GetDirectoryName(configuration.DestinationPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + // Wait for the full byte stream to land on disk before reporting success. SaveAsAsync + // streams the download into the destination; without awaiting it the file would be + // incomplete (or missing) and downstream extraction would fail. + await download.SaveAsAsync(configuration.DestinationPath); + + var fileInfo = new FileInfo(configuration.DestinationPath); + logger.LogInformation("Playwright download completed: {Path}, Size: {Size}", configuration.DestinationPath, fileInfo.Length); + + return DownloadResult.CreateSuccess( + configuration.DestinationPath, + fileInfo.Length, + stopwatch.Elapsed, + hashVerified: false); + } + finally + { + page.Popup -= OnPopup; + page.Download -= OnDownload; + foreach (var popup in popups) + { + popup.Download -= OnDownload; + if (!popup.IsClosed) + { + try + { + await popup.CloseAsync(); + } + catch (PlaywrightException ex) + { + logger.LogDebug(ex, "Popup already closed during download cleanup."); + } + } + } + + if (usePersistentModDbProfile) + { + await ClosePersistentPageAsync(page); + } + else + { + var context = page.Context; + try + { + if (!page.IsClosed) + { + await page.CloseAsync(); + } + } + finally + { + if (context != null) + { + try + { + await context.CloseAsync(); + } + catch (PlaywrightException ex) + { + logger.LogDebug(ex, "Context already closed during download cleanup."); + } + } + } + } + } + } + + /// <summary> + /// Ensures Playwright is initialized with a browser instance. + /// </summary> + private async Task EnsurePlaywrightInitializedAsync(CancellationToken cancellationToken) + { + if (_browser != null) return; + + await _browserLock.WaitAsync(cancellationToken); + try + { + if (_browser != null) return; + + await EnsureManagedPlaywrightAsync(cancellationToken); + _browser = await _playwright!.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + // The shared browser is headless for ordinary parsing/fetching. Bot-protected + // sites that need a visible window use the persistent context instead. + Headless = true, + Args = + [ + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage", + "--no-default-browser-check", + "--no-first-run", + "--disable-background-networking", + "--disable-component-update", + "--disable-sync", + ], + }); + } + finally + { + _browserLock.Release(); + } + } + + /// <summary> + /// Lazily creates (or reuses) a persistent, headed browser context for the given profile path. + /// Cookies and storage are written to disk, so a Cloudflare clearance cookie obtained via a + /// single manual challenge solve is reused for every subsequent page in the profile — across + /// pages and across app restarts until it expires. + /// </summary> + /// <param name="profileDir">Absolute on-disk directory for the persistent profile.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> + private async Task EnsurePersistentContextAsync(string profileDir, CancellationToken cancellationToken) + { + if (IsPersistentContextAlive() && string.Equals(_persistentProfileName, profileDir, StringComparison.Ordinal)) + { + return; + } + + await _persistentLock.WaitAsync(cancellationToken); + try + { + // Re-check under the lock: another caller may have just recreated the context. Also + // drop a cached-but-dead context so the relaunch below actually runs. + if (!IsPersistentContextAlive()) + { + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + else if (string.Equals(_persistentProfileName, profileDir, StringComparison.Ordinal)) + { + return; + } + else if (_persistentContext != null) + { + await ClosePersistentContextCoreUnderLockAsync(); + } + + await EnsureManagedPlaywrightAsync(cancellationToken); + Directory.CreateDirectory(profileDir); + + var context = await _playwright!.Chromium.LaunchPersistentContextAsync( + profileDir, + new BrowserTypeLaunchPersistentContextOptions + { + Headless = false, + Args = + [ + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage", + "--no-default-browser-check", + "--no-first-run", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-breakpad", + "--disable-component-update", + "--disable-domain-reliability", + "--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider", + "--disable-ipc-flooding-protection", + "--disable-renderer-backgrounding", + "--disable-sync", + "--metrics-recording-only", + "--no-pings", + "--password-store=basic", + "--use-mock-keychain", + ], + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ViewportSize = new ViewportSize { Width = 1366, Height = 768 }, + Locale = "en-US", + IgnoreDefaultArgs = ["--enable-automation"], + }); + + var ctx = context; + context.Close += (_, _) => + { + if (ReferenceEquals(_persistentContext, ctx)) + { + _inUsePersistentPages.Clear(); + _persistentContext = null; + _persistentProfileName = null; + } + }; + + _persistentContext = context; + _persistentProfileName = profileDir; + } + finally + { + _persistentLock.Release(); + } + } + + /// <summary> + /// Opens a new page in the persistent context, recovering once if the cached context or its + /// browser process has died (the headed Chromium exits when its last page closes). The recovery + /// recreates the profile — the Cloudflare clearance cookie persists on disk, so the user does + /// not need to re-solve any challenge. + /// </summary> + private async Task<IPage> CreatePersistentPageWithRecoveryAsync(string profileDir, CancellationToken cancellationToken) + { + IPage page = null!; + try + { + await _persistentLock.WaitAsync(cancellationToken); + try + { + page = await GetOrCreatePersistentPageCoreAsync(); + } + finally + { + _persistentLock.Release(); + } + } + catch (PlaywrightException ex) when (IsContextClosedError(ex)) + { + logger.LogInformation("Persistent browser context was closed; recreating profile {ProfileName}", Path.GetFileName(profileDir)); + await InvalidatePersistentContextAsync(); + await EnsurePersistentContextAsync(profileDir, cancellationToken); + + if (_persistentContext == null) + { + throw new InvalidOperationException("Persistent browser context could not be recreated"); + } + + await _persistentLock.WaitAsync(cancellationToken); + try + { + page = await GetOrCreatePersistentPageCoreAsync(); + } + finally + { + _persistentLock.Release(); + } + } + + return page; + } + + private async Task<IPage> GetOrCreatePersistentPageCoreAsync() + { + if (_persistentContext == null) + { + throw new InvalidOperationException("Persistent browser context not initialized"); + } + + foreach (var existing in _persistentContext.Pages) + { + if (!existing.IsClosed && IsBlankStartupUrl(existing.Url) && !_inUsePersistentPages.Contains(existing)) + { + _inUsePersistentPages.Add(existing); + await CloseOrphanStartupPagesAsync(existing); + return existing; + } + } + + var newPage = await _persistentContext.NewPageAsync(); + _inUsePersistentPages.Add(newPage); + await CloseOrphanStartupPagesAsync(newPage); + return newPage; + } + + /// <summary> + /// Manages pre-existing blank pages opened at persistent-context launch. + /// Closes startup blank tabs so about:blank does not linger on screen after operations complete. + /// </summary> + private async Task CloseOrphanStartupPagesAsync(IPage keepPage) + { + if (_persistentContext == null) + { + return; + } + + List<IPage> pages = []; + try + { + pages = [.. _persistentContext.Pages]; + } + catch (PlaywrightException ex) when (IsContextClosedError(ex)) + { + logger.LogDebug(ex, "Persistent context closed while collecting orphan startup pages."); + return; + } + + var blankPages = new List<IPage>(); + foreach (var existing in pages) + { + if (existing == keepPage || existing.IsClosed || _inUsePersistentPages.Contains(existing)) + { + continue; + } + + string url = string.Empty; + try + { + url = existing.Url ?? string.Empty; + } + catch (PlaywrightException ex) when (IsContextClosedError(ex)) + { + continue; + } + + if (IsBlankStartupUrl(url)) + { + blankPages.Add(existing); + } + } + + // Close all orphan blank pages. + foreach (var blankPage in blankPages) + { + try + { + await blankPage.CloseAsync(); + } + catch (PlaywrightException ex) + { + logger.LogDebug(ex, "Could not close orphan startup page; it may have closed already."); + } + } + } + + /// <summary> + /// Keeps Playwright's browser lookup app-owned, then installs Chromium only for a clean + /// runtime directory. This is shared by the headed ModDB profile and headless parsing. + /// </summary> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>A task representing the browser runtime initialization.</returns> + private async Task EnsureManagedPlaywrightAsync(CancellationToken cancellationToken) + { + await _playwrightLock.WaitAsync(cancellationToken); + try + { + var runtime = GetOrCreateManagedChromiumRuntime(); + runtime.ConfigureEnvironment(); + _playwright ??= await Playwright.CreateAsync(); + await runtime.EnsureInstalledAsync(_playwright.Chromium, cancellationToken); + } + finally + { + _playwrightLock.Release(); + } + } + + private ManagedChromiumRuntime GetOrCreateManagedChromiumRuntime() + { + return managedChromiumRuntime ??= new ManagedChromiumRuntime( + Path.Combine(configurationProvider.GetApplicationDataPath(), DirectoryNames.BrowserRuntime), + Microsoft.Playwright.Program.Main, + RequestManagedChromiumInstallConsentAsync, + logger); + } + + /// <summary> + /// Asks the user (via the same confirmation dialog used for profile deletion) whether to + /// download GenHub's managed Playwright Chromium runtime for ModDB. System Chrome/Edge is + /// not used — Playwright requires its own patched build under the app data directory. + /// </summary> + private async Task<bool> RequestManagedChromiumInstallConsentAsync(string runtimeDirectory) + { + var message = + "ModDB requires GenHub's managed Chromium runtime (Playwright). " + + "This is separate from any Chrome or Edge browser already installed on your PC — those cannot be used.\n\n" + + "What will be installed (~240 MB download):\n" + + "• Playwright Chromium browser\n" + + "• Chromium Headless Shell\n" + + "• FFmpeg and Windows helper binaries\n\n" + + $"Install location:\n{runtimeDirectory}"; + + async Task<bool> ShowAsync() => + await dialogService.ShowConfirmationAsync( + "Install Chromium Runtime for ModDB", + message, + confirmText: "Install", + cancelText: "Cancel"); + + if (Dispatcher.UIThread.CheckAccess()) + { + return await ShowAsync(); + } + + return await Dispatcher.UIThread.InvokeAsync(ShowAsync); + } + + /// <summary> + /// Fetches HTML in the persistent headed context, reusing the on-disk Cloudflare clearance + /// cookie so bot-protected pages load without another manual challenge. + /// </summary> + /// <param name="profileName">The on-disk profile name.</param> + /// <param name="url">The URL to fetch.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The page HTML.</returns> + private async Task<string> FetchPersistentHtmlAsync(string profileName, string url, CancellationToken cancellationToken) + { + logger.LogDebug("Fetching HTML (persistent profile '{Profile}') from {Url}", profileName, url); + + var isOuterSession = !_isInPersistentSession.Value; + if (isOuterSession) + { + await _persistentFetchLock.WaitAsync(cancellationToken); + _isInPersistentSession.Value = true; + } + + try + { + var page = await CreatePersistentPageAsync(profileName, cancellationToken); + try + { + return await NavigatePersistentPageAsync(page, url, cancellationToken); + } + finally + { + await ClosePersistentPageAsync(page); + } + } + finally + { + if (isOuterSession) + { + _isInPersistentSession.Value = false; + _persistentFetchLock.Release(); + } + } + } + + /// <summary> + /// Navigates an already-open persistent page and returns its HTML after ModDB (or generic) + /// content is ready. Does not open or close the page — callers own the page lifetime. + /// </summary> + private async Task<string> NavigatePersistentPageAsync(IPage page, string url, CancellationToken cancellationToken) + { + await page.GotoAsync(url, new PageGotoOptions + { + Timeout = ModDBConstants.DefaultGotoTimeout, + WaitUntil = WaitUntilState.DOMContentLoaded, + }); + + if (url.Contains("moddb.com", StringComparison.OrdinalIgnoreCase)) + { + await WaitForModDbContentAsync(page, url, cancellationToken); + } + else + { + await Task.Delay(500, cancellationToken); + } + + return await page.ContentAsync(); + } + + private void NotifyBrowserWindowOpening(string title, string message) + { + try + { + notificationService?.ShowInfo(title, message, NotificationDurations.VeryLong); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to show browser-window notification."); + } + } + + /// <summary> + /// Waits for real ModDB content instead of snapshotting the Cloudflare interstitial. This is + /// state-based waiting: a user can solve verification in the headed Chromium window and the + /// parser resumes as soon as the actual document is available. + /// </summary> + private async Task WaitForModDbContentAsync(IPage page, string url, CancellationToken cancellationToken) + { + var deadline = DateTime.UtcNow.AddMinutes(2); + var verificationObserved = false; + + while (DateTime.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var title = await page.TitleAsync(); + var isVerificationPage = IsModDbVerificationPage(title); + if (isVerificationPage) + { + if (!verificationObserved) + { + logger.LogInformation( + "ModDB verification is open in Chromium for {Url}. Waiting for the user to complete it.", + url); + verificationObserved = true; + } + } + else + { + var contentMarker = await page.QuerySelectorAsync( + "#downloadsinfo, .row.rowcontent, .headerbox, #articlebrowse, #profile"); + if (contentMarker != null) + { + if (verificationObserved) + { + logger.LogInformation("ModDB verification completed; parsing content from {Url}", url); + } + + return; + } + } + } + catch (PlaywrightException ex) when (IsContextClosedError(ex)) + { + // The page is navigating (e.g. the user just solved the Cloudflare challenge, or + // the interstitial redirected to the real document). The in-flight title/selector + // probe dies mid-navigation; retry on the next tick once the new document settles. + logger.LogDebug(ex, "Transient navigation during ModDB content wait for {Url}; retrying", url); + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + + throw new TimeoutException( + verificationObserved + ? "ModDB verification was not completed in the Chromium window. Complete the check and open the content again." + : $"ModDB did not expose parseable content for {url}."); + } +} diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index b62d5aeef..4fddc56f1 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -97,7 +97,7 @@ public async Task<OperationResult<GameProcessInfo>> StartProcessAsync(GameLaunch if (process.HasExited) { - return await HandleImmediateProcessExitAsync(process, configuration, capturedErrors); + return HandleImmediateProcessExit(process, configuration, capturedErrors); } } @@ -767,7 +767,7 @@ private ProcessStartInfo ConfigureProcessStartInfo(GameLaunchConfiguration confi return processStartInfo; } - private async Task<OperationResult<GameProcessInfo>> HandleImmediateProcessExitAsync( + private OperationResult<GameProcessInfo> HandleImmediateProcessExit( Process process, GameLaunchConfiguration configuration, BoundedErrorBuffer capturedErrors) diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs index 2ffca7e4c..7e36d6669 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs @@ -451,99 +451,25 @@ private async Task<ProfileOperationResult<GameLaunchInfo>> LaunchToolProfileAsyn { logger.LogInformation("[Launch] Detected Tool profile, launching tool directly"); - // Get the tool manifest - if (string.IsNullOrWhiteSpace(profile.ToolContentId)) - { - return ProfileOperationResult<GameLaunchInfo>.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); - } - - if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) - { - return ProfileOperationResult<GameLaunchInfo>.CreateFailure( - $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); - } - - var toolManifestResult = await manifestPool.GetManifestAsync( - toolManifestId, - cancellationToken); - - if (toolManifestResult.Failed || toolManifestResult.Data == null) + var manifestResult = await ResolveToolManifestAsync(profile, cancellationToken); + if (manifestResult.Failed || manifestResult.Data == null) { return ProfileOperationResult<GameLaunchInfo>.CreateFailure( - $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + manifestResult.FirstError ?? ProfileValidationConstants.FailedToLoadToolManifest); } - var toolManifest = toolManifestResult.Data; + var toolManifest = manifestResult.Data; logger.LogDebug("[Launch] Tool manifest loaded: {ManifestId}", toolManifest.Id); - var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); - string toolWorkspacePath = string.Empty; - string? actualWorkspaceId = null; - - if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + var workspaceResult = await ResolveToolWorkspaceAsync(profile, toolManifest, cancellationToken); + if (workspaceResult.Failed) { - toolWorkspacePath = toolDirectory.Data; - logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolWorkspacePath); - } - else - { - logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); - - var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient - { - Name = toolManifest.Name, - GameType = toolManifest.TargetGame, - }; - - var appDataBase = configurationProvider.GetApplicationDataPath(); - if (!Directory.Exists(appDataBase)) - { - Directory.CreateDirectory(appDataBase); - } - - var baseDetails = appDataBase; - - var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); - var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; - - var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); - var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); - - if (effectiveToolStrategy != requestedToolStrategy) - { - logger.LogInformation( - "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", - requestedToolStrategy); - } - - actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; - var workspaceConfig = new WorkspaceConfiguration - { - Id = actualWorkspaceId, - Manifests = [.. allManifests], - GameClient = dummyGameClient, - Strategy = effectiveToolStrategy, - ForceRecreate = false, - ValidateAfterPreparation = true, - BaseInstallationPath = baseDetails, - WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), - SkipCleanup = false, - }; - - var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); - if (prepareResult.Failed) - { - return ProfileOperationResult<GameLaunchInfo>.CreateFailure( - $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); - } - - toolWorkspacePath = prepareResult.Data!.WorkspacePath; - logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult<GameLaunchInfo>.CreateFailure( + workspaceResult.FirstError ?? ProfileValidationConstants.FailedToPrepareToolWorkspace); } - var toolDirectoryPath = toolWorkspacePath; - var toolExecutable = toolManifest.Files?.FirstOrDefault(f => f.IsExecutable) - ?? toolManifest.Files?.FirstOrDefault(f => f.RelativePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + var (toolDirectoryPath, actualWorkspaceId) = workspaceResult.Data; + var toolExecutable = ResolveToolExecutable(toolManifest); if (toolExecutable == null) { @@ -564,35 +490,7 @@ private async Task<ProfileOperationResult<GameLaunchInfo>> LaunchToolProfileAsyn try { - var processStartInfo = new ProcessStartInfo - { - FileName = toolExecutablePath, - WorkingDirectory = toolDirectoryPath, - Arguments = profile.CommandLineArguments ?? string.Empty, - UseShellExecute = false, - }; - - if (profile.EnvironmentVariables != null) - { - foreach (var envVar in profile.EnvironmentVariables) - { - processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; - } - } - - Process? process = null; - try - { - process = Process.Start(processStartInfo); - } - catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) - { - logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); - processStartInfo.UseShellExecute = true; - processStartInfo.Verb = "runas"; - process = Process.Start(processStartInfo); - } - + var process = StartToolProcess(toolExecutablePath, toolDirectoryPath, profile); if (process == null) { return ProfileOperationResult<GameLaunchInfo>.CreateFailure(ProfileValidationConstants.ToolProcessStartFailed); @@ -631,12 +529,181 @@ private async Task<ProfileOperationResult<GameLaunchInfo>> LaunchToolProfileAsyn } catch (Exception ex) { - logger.LogError(ex, "[Launch] Tool launch failed"); + logger.LogError(ex, "[Launch] Unexpected error launching tool for profile {ProfileId}", profileId); notificationService.ShowError( ProfileValidationConstants.ToolLaunchFailedTitle, $"Failed to launch '{profile.Name}': {ex.Message}", NotificationDurations.VeryLong); - return ProfileOperationResult<GameLaunchInfo>.CreateFailure($"Tool launch failed: {ex.Message}"); + return ProfileOperationResult<GameLaunchInfo>.CreateFailure( + $"Tool launch failed: {ex.Message}"); + } + } + + private async Task<ProfileOperationResult<ContentManifest>> ResolveToolManifestAsync( + GameProfile profile, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(profile.ToolContentId)) + { + return ProfileOperationResult<ContentManifest>.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); + } + + if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) + { + return ProfileOperationResult<ContentManifest>.CreateFailure( + $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); + } + + var toolManifestResult = await manifestPool.GetManifestAsync( + toolManifestId, + cancellationToken); + + if (toolManifestResult.Failed || toolManifestResult.Data == null) + { + return ProfileOperationResult<ContentManifest>.CreateFailure( + $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + } + + return ProfileOperationResult<ContentManifest>.CreateSuccess(toolManifestResult.Data); + } + + private async Task<ProfileOperationResult<(string WorkspacePath, string? WorkspaceId)>> ResolveToolWorkspaceAsync( + GameProfile profile, + ContentManifest toolManifest, + CancellationToken cancellationToken) + { + var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); + if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + { + logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolDirectory.Data); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolDirectory.Data, null)); + } + + logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); + + var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient + { + Name = toolManifest.Name, + GameType = toolManifest.TargetGame, + }; + + var appDataBase = configurationProvider.GetApplicationDataPath(); + if (!Directory.Exists(appDataBase)) + { + Directory.CreateDirectory(appDataBase); + } + + var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); + var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; + + var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); + var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); + + if (effectiveToolStrategy != requestedToolStrategy) + { + logger.LogInformation( + "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", + requestedToolStrategy); + } + + var actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; + var workspaceConfig = new WorkspaceConfiguration + { + Id = actualWorkspaceId, + Manifests = [.. allManifests], + GameClient = dummyGameClient, + Strategy = effectiveToolStrategy, + ForceRecreate = false, + ValidateAfterPreparation = true, + BaseInstallationPath = appDataBase, + WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), + SkipCleanup = false, + }; + + var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); + if (prepareResult.Failed) + { + return ProfileOperationResult<(string, string?)>.CreateFailure( + $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); + } + + var toolWorkspacePath = prepareResult.Data!.WorkspacePath; + logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolWorkspacePath, actualWorkspaceId)); + } + + private ManifestFile? ResolveToolExecutable(ContentManifest toolManifest) + { + var resolvedFiles = ManifestVariantResolver.ResolveFiles(toolManifest); + var resolution = ManifestVariantResolver.ResolveEntryPoint(toolManifest); + + if (resolution.Success && resolution.RelativePath != null) + { + var toolExecutable = resolvedFiles?.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, resolution.RelativePath)); + + if (toolExecutable != null) + { + logger.LogInformation( + "[Launch] Tool executable resolved for manifest {ManifestId}: {RelativePath} ({Reason})", + toolManifest.Id, + toolExecutable.RelativePath, + resolution.Reason); + } + else + { + logger.LogWarning( + "[Launch] Entry point '{RelativePath}' resolved for tool manifest {ManifestId} ({Reason}) but not found in resolved files", + resolution.RelativePath, + toolManifest.Id, + resolution.Reason); + } + + return toolExecutable; + } + + logger.LogWarning( + "[Launch] Entry point resolution for tool manifest '{ManifestId}' did not succeed: {Resolution}", + toolManifest.Id, + resolution); + + return null; + } + + private Process? StartToolProcess(string toolExecutablePath, string toolDirectoryPath, GameProfile profile) + { + var processStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = false, + }; + + if (profile.EnvironmentVariables != null) + { + foreach (var envVar in profile.EnvironmentVariables) + { + processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; + } + } + + try + { + return Process.Start(processStartInfo); + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) + { + logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); + var elevatedStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = true, + Verb = "runas", + }; + return Process.Start(elevatedStartInfo); } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs index 55a28b999..386f01d9b 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs @@ -12,6 +12,8 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -29,7 +31,7 @@ public partial class AddLocalContentViewModel( IContentStorageService? contentStorageService, IGenLauncherNormalizationService? genLauncherNormalizationService, IDialogService? dialogService, - ILogger<AddLocalContentViewModel>? logger = null) : ObservableObject + ILogger<AddLocalContentViewModel>? logger = null) : ObservableObject, IDisposable { /// <summary> /// Gets the list of available game types. @@ -56,6 +58,26 @@ public partial class AddLocalContentViewModel( ContentType.Mission, ]; + /// <summary> + /// Counts the total number of executables in the given file tree items recursively. + /// </summary> + /// <param name="items">The file tree items to inspect.</param> + /// <returns>The total number of executable files found.</returns> + internal static int CountExecutables(IEnumerable<FileTreeItem> items) + { + int count = 0; + foreach (var item in items) + { + if (item.IsExecutable) count++; + count += CountExecutables(item.Children); + } + + return count; + } + + private static bool RequiresExecutable(ContentType contentType) => + contentType is ContentType.GameClient or ContentType.ModdingTool or ContentType.Executable; + private static FileTreeItem? FindFirstExecutable(IEnumerable<FileTreeItem> items) { foreach (var item in items) @@ -75,21 +97,10 @@ public partial class AddLocalContentViewModel( return null; } - private static int CountExecutables(IEnumerable<FileTreeItem> items) - { - int count = 0; - foreach (var item in items) - { - if (item.IsExecutable) count++; - count += CountExecutables(item.Children); - } - - return count; - } - private readonly string _stagingPath = Path.Combine(Path.GetTempPath(), "GenHub_Staging_" + Guid.NewGuid()); private string? _originalManifestId; + private string? _pendingEntryPoint; /// <summary> /// Gets a value indicating whether we are editing existing content. @@ -177,7 +188,7 @@ private static int CountExecutables(IEnumerable<FileTreeItem> items) private bool _isDemoMode; /// <summary> - /// Gets or sets the selected executable item (for Executable/ModdingTool content type). + /// Gets or sets the selected executable item (for GameClient/Executable/ModdingTool content type). /// </summary> [ObservableProperty] private FileTreeItem? _selectedExecutableItem; @@ -192,7 +203,7 @@ private static int CountExecutables(IEnumerable<FileTreeItem> items) /// <summary> /// Gets a value indicating whether the executable selection should be shown. /// </summary> - public bool ShowExecutableSelection => (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) && ExecutableCount > 1; + public bool ShowExecutableSelection => RequiresExecutable(SelectedContentType) && ExecutableCount > 0; /// <summary> /// Gets the text to display in the preview area when no content is loaded. @@ -255,6 +266,7 @@ public async Task LoadFromManifestAsync(ContentDisplayItem item) StatusMessage = "Loading existing content..."; _originalManifestId = item.ManifestId.Value; + _pendingEntryPoint = item.Manifest?.EntryPoint; ContentName = item.DisplayName ?? string.Empty; SelectedContentType = item.ContentType; SelectedGameType = item.GameType; @@ -487,24 +499,81 @@ public async Task ImportContentAsync(string path) } } + /// <inheritdoc /> + public void Dispose() + { + _cts?.Dispose(); + _cts = null; + CleanupStaging(); + GC.SuppressFinalize(this); + } + private static List<FileTreeItem> BuildDirectoryTree(DirectoryInfo dir) + => BuildDirectoryTree(dir, CollectExecutableDirectories(dir)); + + private static HashSet<string> CollectExecutableDirectories(DirectoryInfo root) + { + var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var file in root.EnumerateFiles("*", SearchOption.AllDirectories)) + { + if (!ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(file.Name) + && !file.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var d = file.Directory; d != null; d = d.Parent) + { + if (!result.Add(d.FullName)) + { + break; + } + } + } + } + catch + { + // ignore inaccessible directories + } + + return result; + } + + private static List<FileTreeItem> BuildDirectoryTree(DirectoryInfo dir, HashSet<string> executableDirs) { var items = new List<FileTreeItem>(); - if (!dir.Exists) return items; + if (!dir.Exists) + { + return items; + } + + var subDirs = dir.GetDirectories(); + var prioritizedDirs = subDirs + .OrderByDescending(d => executableDirs.Contains(d.FullName)) + .ThenBy(d => d.Name) + .Take(20); - foreach (var d in dir.GetDirectories().Take(20)) + foreach (var d in prioritizedDirs) { items.Add(new FileTreeItem { Name = d.Name, IsFile = false, FullPath = d.FullName, - Children = new ObservableCollection<FileTreeItem>(BuildDirectoryTree(d)), + Children = new ObservableCollection<FileTreeItem>(BuildDirectoryTree(d, executableDirs)), }); } - foreach (var f in dir.GetFiles().Take(50)) + var files = dir.GetFiles(); + var prioritizedFiles = files + .OrderByDescending(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.Name) || f.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + .ThenBy(f => f.Name) + .Take(50); + + foreach (var f in prioritizedFiles) { items.Add(new FileTreeItem { Name = f.Name, IsFile = true, FullPath = f.FullName }); } @@ -640,6 +709,20 @@ private async Task AddContentAsync() _cts = new CancellationTokenSource(); + string? entryPoint = null; + if (RequiresExecutable(SelectedContentType) && SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + entryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to determine relative path for selected executable '{FullPath}'. Falling back to file name '{Name}'", SelectedExecutableItem.FullPath, SelectedExecutableItem.Name); + entryPoint = SelectedExecutableItem.Name; + } + } + // Preserve SourcePath metadata if available // Note: We no longer write to "source.path" file to avoid polluting the content. // Instead we pass the SourcePath directly to the service. @@ -652,7 +735,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token) + _cts.Token, + entryPoint) : await localContentService.CreateLocalContentManifestAsync( _stagingPath, ContentName, @@ -660,7 +744,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token); + _cts.Token, + entryPoint); if (result.Success) { @@ -770,6 +855,29 @@ private void CreateMapFoldersIfNeeded() } } + private FileTreeItem? FindFileItemByRelativePath(IEnumerable<FileTreeItem> items, string relativePath) + { + var normalizedTarget = relativePath.Replace('\\', '/').TrimStart('/'); + foreach (var item in items) + { + if (item.IsFile) + { + var itemRel = Path.GetRelativePath(_stagingPath, item.FullPath).Replace('\\', '/').TrimStart('/'); + if (ManifestVariantResolver.PathsMatch(itemRel, normalizedTarget)) + { + return item; + } + } + else + { + var found = FindFileItemByRelativePath(item.Children, relativePath); + if (found != null) return found; + } + } + + return null; + } + private async Task RefreshStagingTreeAsync() { bool wasBusy = IsBusy; @@ -777,6 +885,23 @@ private async Task RefreshStagingTreeAsync() { if (!wasBusy) IsBusy = true; + string? previousRelativePath = null; + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + previousRelativePath = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + else if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + previousRelativePath = _pendingEntryPoint; + } + FileTree.Clear(); SelectedExecutableItem = null; // Clear previous selection on refresh if (Directory.Exists(_stagingPath)) @@ -791,10 +916,29 @@ private async Task RefreshStagingTreeAsync() ExecutableCount = CountExecutables(FileTree); - // Auto-select first executable if content type requires it - if (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) + // Reselect previously selected executable or auto-select first if content type requires it + if (RequiresExecutable(SelectedContentType)) { - AutoSelectFirstExecutable(); + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(previousRelativePath)) + { + matchedItem = FindFileItemByRelativePath(FileTree, previousRelativePath); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + _pendingEntryPoint = null; + AutoSelectFirstExecutable(); + } + } + else + { + SelectedExecutableItem = null; } Validate(); @@ -816,8 +960,8 @@ private void Validate() var stagingExists = Directory.Exists(_stagingPath); var stagingHasEntries = stagingExists && Directory.EnumerateFileSystemEntries(_stagingPath).Any(); - // For ModdingTool (Tool) and Executable, we also need an executable selected - var requiresExecutable = SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable; + // For GameClient, ModdingTool (Tool), and Executable, we also need an executable selected + var requiresExecutable = RequiresExecutable(SelectedContentType); var hasExecutableIfNeeded = !requiresExecutable || SelectedExecutableItem != null; CanAdd = hasName && (hasFiles || stagingHasEntries) && hasExecutableIfNeeded; @@ -842,10 +986,44 @@ partial void OnSelectedContentTypeChanged(ContentType value) OnPropertyChanged(nameof(ShowExecutableSelection)); OnPropertyChanged(nameof(PreviewIdleText)); - // Auto-select first executable if switching to ModdingTool or Executable - if ((value == ContentType.ModdingTool || value == ContentType.Executable) && SelectedExecutableItem == null) + // Auto-select first executable if switching to a content type that requires it, + // or clear selection when switching to a non-executable content type + if (RequiresExecutable(value)) + { + if (SelectedExecutableItem == null) + { + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + matchedItem = FindFileItemByRelativePath(FileTree, _pendingEntryPoint); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + AutoSelectFirstExecutable(); + } + } + } + else { - AutoSelectFirstExecutable(); + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + _pendingEntryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + + SelectedExecutableItem = null; } Validate(); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs index afbd8961b..a22b52d3d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs @@ -147,6 +147,7 @@ private void InitializeDemoData() }; FileTree.Add(modFolder); + ExecutableCount = CountExecutables(FileTree); // Set status message StatusMessage = "Demo content ready. Click buttons to see what they do!"; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs index 20b12e965..81052c517 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs @@ -14,17 +14,23 @@ public partial class FileTreeItem : ObservableObject /// <summary> /// Gets or sets the name of the file or directory. /// </summary> - public string Name { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _name = string.Empty; /// <summary> /// Gets or sets a value indicating whether this item is a file. /// </summary> - public bool IsFile { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private bool _isFile; /// <summary> /// Gets or sets the full path of the file or directory. /// </summary> - public string FullPath { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _fullPath = string.Empty; /// <summary> /// Gets or sets the children of this item (for directories). diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index ae01bb830..fd7e36b13 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -699,7 +699,7 @@ private async Task AddLocalContentAsync(Avalonia.Controls.Window? owner) if (dialogOwner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, @@ -767,7 +767,7 @@ private async Task EditContentAsync(ContentDisplayItem? contentItem) if (owner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 081789f27..d9256dfae 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -13,6 +13,7 @@ <conv:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> <conv:InvertedBoolToVisibilityConverter x:Key="InvertedBoolToVisibilityConverter" /> <conv:BoolToTextConverter x:Key="BoolToTextConverter" /> + <conv:ExecutableHighlightConverter x:Key="ExecutableHighlightConverter" /> </UserControl.Resources> <UserControl.Styles> @@ -115,7 +116,13 @@ <ComboBox ItemsSource="{Binding AllowedContentTypes}" SelectedItem="{Binding SelectedContentType}" HorizontalAlignment="Stretch" - Classes="glass"/> + Classes="glass"> + <ComboBox.ItemTemplate> + <DataTemplate> + <TextBlock Text="{Binding Converter={x:Static conv:ContentTypeDisplayConverter.Instance}}"/> + </DataTemplate> + </ComboBox.ItemTemplate> + </ComboBox> </StackPanel> <!-- Target Game --> @@ -157,7 +164,7 @@ <TextBlock Text="CONTENT PREVIEW" FontSize="11" FontWeight="Bold" Foreground="#80FFFFFF" LetterSpacing="1"/> </Border> - <TreeView Grid.Row="1" ItemsSource="{Binding FileTree}" Background="Transparent" Foreground="#DDDDDD" Margin="5"> + <TreeView Grid.Row="1" ItemsSource="{Binding FileTree}" Background="Transparent" Foreground="#DDDDDD" Margin="5" ScrollViewer.HorizontalScrollBarVisibility="Disabled"> <TreeView.Styles> <Style Selector="TreeViewItem"> <Setter Property="MinHeight" Value="24"/> @@ -166,8 +173,8 @@ </TreeView.Styles> <TreeView.ItemTemplate> <TreeDataTemplate ItemsSource="{Binding Children}"> - <Grid ColumnDefinitions="*,Auto" Background="Transparent"> - <DockPanel LastChildFill="True"> + <Grid ColumnDefinitions="*,Auto,Auto" Background="Transparent" Margin="0,0,10,0"> + <DockPanel Grid.Column="0" LastChildFill="True"> <TextBlock DockPanel.Dock="Left" Text="{Binding IsFile, Converter={StaticResource BoolToTextConverter}, ConverterParameter='📄|📁'}" Opacity="0.7" @@ -175,15 +182,44 @@ <TextBlock Text="{Binding Name}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" - ToolTip.Tip="{Binding Name}"/> + ToolTip.Tip="{Binding Name}"> + <TextBlock.Foreground> + <MultiBinding Converter="{StaticResource ExecutableHighlightConverter}"> + <Binding Path="IsExecutable"/> + <Binding Path="IsSelectedExecutable"/> + </MultiBinding> + </TextBlock.Foreground> + </TextBlock> </DockPanel> + <!-- Select as Executable button (only for executable files when content type requires executable selection) --> <Button Grid.Column="1" + Command="{Binding ((vm:AddLocalContentViewModel)DataContext).SelectExecutableCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" + CommandParameter="{Binding}" + Classes="icon-btn" + Padding="4,2" + Margin="5,0,0,0" + AutomationProperties.Name="Select as main executable" + ToolTip.Tip="Select as main executable"> + <Button.IsVisible> + <MultiBinding Converter="{x:Static conv:MultiBooleanAndConverter.Instance}"> + <Binding Path="IsExecutable"/> + <Binding Path="((vm:AddLocalContentViewModel)DataContext).ShowExecutableSelection" RelativeSource="{RelativeSource AncestorType=UserControl}"/> + </MultiBinding> + </Button.IsVisible> + <StackPanel Orientation="Horizontal" Spacing="4"> + <TextBlock Text="▶" FontSize="10"/> + <TextBlock Text="{Binding IsSelectedExecutable, Converter={StaticResource BoolToTextConverter}, ConverterParameter='Selected|Select'}" FontSize="10"/> + </StackPanel> + </Button> + + <!-- Remove Item button --> + <Button Grid.Column="2" Command="{Binding ((vm:AddLocalContentViewModel)DataContext).DeleteItemCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}" Classes="icon-btn" Padding="4,2" - Margin="10,0,25,0" + Margin="10,0,0,0" ToolTip.Tip="Remove Item"> <TextBlock Text="✕" FontSize="10"/> </Button> @@ -195,7 +231,7 @@ <!-- Empty State Overlay --> <StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="10" IsVisible="{Binding !FileTree.Count}"> <TextBlock Text="No Content Loaded" Foreground="#40FFFFFF" FontSize="16" HorizontalAlignment="Center"/> - <TextBlock Text="Select a source to preview structure" Foreground="#30FFFFFF" FontSize="12" HorizontalAlignment="Center"/> + <TextBlock Text="{Binding PreviewIdleText}" Foreground="#30FFFFFF" FontSize="12" HorizontalAlignment="Center"/> </StackPanel> </Grid> </Border> diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs index 941a115d4..598e05199 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml.cs @@ -34,6 +34,13 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) InitializeBrowseActions(); } + /// <inheritdoc /> + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + (DataContext as IDisposable)?.Dispose(); + } + /// <summary> /// Called when the data context changes. /// </summary> diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml index 8823922d1..7e77f4339 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml @@ -44,7 +44,9 @@ <Grid RowDefinitions="30,*" Margin="20,10,20,20"> <!-- Custom Title Bar Drag Area --> - <Grid Grid.Row="0" Background="Transparent" IsHitTestVisible="False"> + <Grid Grid.Row="0" + Background="Transparent" + PointerPressed="OnTitleBarPointerPressed"> <TextBlock Text="Add Local Content" HorizontalAlignment="Center" VerticalAlignment="Center" diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs index bf07322b1..923853957 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs @@ -31,6 +31,13 @@ protected override void OnOpened(EventArgs e) GenHub.Infrastructure.Interop.AdminDragDropFix.Apply(this, OnAdminDrop); } + /// <inheritdoc /> + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + (DataContext as IDisposable)?.Dispose(); + } + /// <inheritdoc /> protected override void OnDataContextChanged(EventArgs e) { @@ -47,12 +54,13 @@ protected override void OnDataContextChanged(EventArgs e) return null; } - var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select Content Folder", AllowMultiple = false, }); - return result.Count > 0 ? result[0].Path.LocalPath : null; + + return folders.Count > 0 ? folders[0].Path.LocalPath : null; }; vm.BrowseFileAction = async () => @@ -62,17 +70,48 @@ protected override void OnDataContextChanged(EventArgs e) return null; } - var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { - Title = "Select Files", + Title = "Select Archive File", AllowMultiple = true, - FileTypeFilter = [FilePickerFileTypes.All, new("Zip Archives") { Patterns = ["*.zip"] }], + FileTypeFilter = + [ + new FilePickerFileType("Archive Files") + { + Patterns = ["*.zip", "*.7z", "*.rar", "*.tar", "*.gz", "*.big"], + }, + new FilePickerFileType("All Files") + { + Patterns = ["*.*"], + }, + ], }); - return result.Count > 0 ? result.Select(f => f.Path.LocalPath).ToList() : null; + + return files.Count > 0 ? files.Select(f => f.Path.LocalPath).ToList() : null; }; } } + /// <summary> + /// Handles pointer pressed on the title bar for dragging and maximizing. + /// </summary> + /// <param name="sender">The sender.</param> + /// <param name="e">The event arguments.</param> + private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (e.ClickCount == 2 && CanResize) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + else + { + BeginMoveDrag(e); + } + } + } + private void OnAdminDrop(string[] files) { _ = ProcessAdminDropAsync(files); diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml index e87816c32..ba31ea2a3 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml @@ -18,6 +18,7 @@ WindowStartupLocation="CenterOwner" Background="#0F0F0F" Icon="avares://GenHub/Assets/Icons/generalshub-icon.png" + SystemDecorations="Full" ExtendClientAreaToDecorationsHint="True" ExtendClientAreaChromeHints="NoChrome" ExtendClientAreaTitleBarHeightHint="-1"> @@ -437,9 +438,7 @@ <Border Grid.Row="0" Background="Transparent" Height="60" - PointerPressed="OnHeaderPointerPressed" - PointerMoved="OnHeaderPointerMoved" - PointerReleased="OnHeaderPointerReleased"> + PointerPressed="OnHeaderPointerPressed"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0"> <!-- Profile Icon & Name Group --> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="16" VerticalAlignment="Center"> diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs index 64273c1c5..bd3288392 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs @@ -3,7 +3,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; -using Avalonia.VisualTree; using GenHub.Core.Constants; using GenHub.Features.GameProfiles.ViewModels; @@ -18,11 +17,6 @@ public partial class GameProfileSettingsWindow : Window private static double? _savedWidth; private static double? _savedHeight; - // Fields for manual drag detection to allow double-click to work - private bool _isMouseDown; - private Point _mouseDownPosition; - private PointerPressedEventArgs? _pressedEventArgs; - /// <summary> /// Initializes a new instance of the <see cref="GameProfileSettingsWindow"/> class. /// </summary> @@ -30,9 +24,6 @@ public GameProfileSettingsWindow() { InitializeComponent(); - // Wire up drag handlers to the header in the shared content view - WireUpDragHandlers(); - // Subscribe to DataContext changes to handle commands DataContextChanged += OnDataContextChanged; @@ -50,69 +41,19 @@ public GameProfileSettingsWindow() /// <param name="e">The event arguments.</param> public void OnHeaderPointerPressed(object? sender, PointerPressedEventArgs e) { - if (e.ClickCount == 2) + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - _isMouseDown = false; - _pressedEventArgs = null; - } - else - { - _isMouseDown = true; - _mouseDownPosition = e.GetPosition(this); - _pressedEventArgs = e; - } - } - - /// <summary> - /// Handles pointer moved to initiate drag only after a threshold, allowing double-clicks to pass through. - /// </summary> - /// <param name="sender">The sender.</param> - /// <param name="e">The event arguments.</param> - public void OnHeaderPointerMoved(object? sender, PointerEventArgs e) - { - if (!_isMouseDown || _pressedEventArgs == null) - { - return; - } - - var currentPosition = e.GetPosition(this); - var distance = Math.Sqrt(Math.Pow(currentPosition.X - _mouseDownPosition.X, 2) + Math.Pow(currentPosition.Y - _mouseDownPosition.Y, 2)); - - // Drag threshold of 3 pixels - if (distance > 3) - { - if (WindowState == WindowState.Maximized) + if (e.ClickCount == 2 && CanResize) { - var screenX = Position.X + (currentPosition.X * RenderScaling); - var screenY = Position.Y + (currentPosition.Y * RenderScaling); - - WindowState = WindowState.Normal; - - var targetWidth = _savedWidth ?? Width; - var newX = screenX - ((targetWidth * RenderScaling) / 2); - var newY = screenY - (_mouseDownPosition.Y * RenderScaling); - - Position = new PixelPoint((int)newX, (int)newY); + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + else + { + BeginMoveDrag(e); } - - BeginMoveDrag(_pressedEventArgs); - _isMouseDown = false; - _pressedEventArgs = null; } } - /// <summary> - /// Handles pointer released to reset drag state. - /// </summary> - /// <param name="sender">The sender.</param> - /// <param name="e">The event arguments.</param> - public void OnHeaderPointerReleased(object? sender, PointerReleasedEventArgs e) - { - _isMouseDown = false; - _pressedEventArgs = null; - } - /// <summary> /// Handles the toggle fullscreen button click. /// </summary> @@ -140,20 +81,6 @@ protected override void OnClosed(EventArgs e) base.OnClosed(e); } - /// <summary> - /// Wires up pointer event handlers to the header border in the shared content view. - /// </summary> - private void WireUpDragHandlers() - { - // Find the named header border in the shared content view - if (this.FindControl<GameProfileSettingsContentView>("ContentView")?.FindControl<Border>("HeaderBorder") is { } headerBorder) - { - headerBorder.PointerPressed += OnHeaderPointerPressed; - headerBorder.PointerMoved += OnHeaderPointerMoved; - headerBorder.PointerReleased += OnHeaderPointerReleased; - } - } - private void InitializeComponent() { AvaloniaXamlLoader.Load(this); diff --git a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs index 07fe4ce37..78c4b9180 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs @@ -1,8 +1,11 @@ +using System; using Avalonia; using Avalonia.Controls; +#if DEBUG +using Avalonia.Diagnostics; +#endif using Avalonia.Markup.Xaml; using GenHub.Features.GameProfiles.ViewModels.Wizard; -using System; namespace GenHub.Features.GameProfiles.Views.Wizard; diff --git a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs index 0e1aaae7e..50ced9287 100644 --- a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs +++ b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs @@ -6,6 +6,7 @@ using System.Reactive.Subjects; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameProfiles; @@ -43,6 +44,7 @@ public class MockNotificationService : INotificationService private readonly Subject<Guid> _dismissRequests = new(); private readonly Subject<bool> _dismissAllRequests = new(); private readonly Subject<NotificationMessage> _notificationHistory = new(); + private readonly Subject<(Guid Id, string? Title, string Message)> _updateRequests = new(); /// <inheritdoc/> public IObservable<NotificationMessage> Notifications => _notifications.AsObservable(); @@ -56,6 +58,9 @@ public class MockNotificationService : INotificationService /// <inheritdoc/> public IObservable<NotificationMessage> NotificationHistory => _notificationHistory.AsObservable(); + /// <inheritdoc/> + public IObservable<(Guid Id, string? Title, string Message)> UpdateRequests => _updateRequests.AsObservable(); + /// <inheritdoc/> public void Show(NotificationMessage notification) => _notifications.OnNext(notification); @@ -75,6 +80,10 @@ public void ShowWarning(string title, string message, int? autoDismissMs = null, public void ShowError(string title, string message, int? autoDismissMs = null, bool showInBadge = false) => Show(new NotificationMessage(NotificationType.Error, title, message, autoDismissMs, showInBadge: showInBadge)); + /// <inheritdoc/> + public void Update(Guid notificationId, string message, string? title = null) + => _updateRequests.OnNext((notificationId, title, message)); + /// <inheritdoc/> public void Dismiss(Guid id) => _dismissRequests.OnNext(id); @@ -457,7 +466,18 @@ public Task<MapPack> CreateMapPackAsync(string name, Guid? profileId, IEnumerabl public class MockLocalContentService : ILocalContentService { /// <inheritdoc/> - public IReadOnlyList<ContentType> AllowedContentTypes => [ContentType.Mod, ContentType.Map, ContentType.GameClient]; + public IReadOnlyList<ContentType> AllowedContentTypes => + [ + ContentType.Mod, + ContentType.GameClient, + ContentType.Executable, + ContentType.ModdingTool, + ContentType.Patch, + ContentType.Addon, + ContentType.Map, + ContentType.MapPack, + ContentType.Mission, + ]; /// <inheritdoc/> public Task<OperationResult<ContentManifest>> AddLocalContentAsync(string name, string directoryPath, ContentType contentType, GameType targetGame, CancellationToken cancellationToken = default) @@ -466,18 +486,26 @@ public Task<OperationResult<ContentManifest>> AddLocalContentAsync(string name, } /// <inheritdoc/> - public Task<OperationResult<ContentManifest>> CreateLocalContentManifestAsync(string directoryPath, string name, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress<ContentStorageProgress>? progress = null, CancellationToken cancellationToken = default) + public Task<OperationResult<ContentManifest>> CreateLocalContentManifestAsync(string directoryPath, string name, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress<ContentStorageProgress>? progress = null, CancellationToken cancellationToken = default, string? entryPoint = null) { - return Task.FromResult(OperationResult<ContentManifest>.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath })); + var normalizedEntryPoint = !string.IsNullOrWhiteSpace(entryPoint) + ? entryPoint.Replace('\\', '/').TrimStart('/') + : null; + + return Task.FromResult(OperationResult<ContentManifest>.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath, EntryPoint = normalizedEntryPoint })); } /// <inheritdoc/> public Task<OperationResult> DeleteLocalContentAsync(string manifestId, CancellationToken cancellationToken = default) => Task.FromResult(OperationResult.CreateSuccess()); /// <inheritdoc/> - public Task<OperationResult<ContentManifest>> UpdateLocalContentManifestAsync(string existingManifestId, string name, string directoryPath, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress<ContentStorageProgress>? progress = null, CancellationToken cancellationToken = default) + public Task<OperationResult<ContentManifest>> UpdateLocalContentManifestAsync(string existingManifestId, string name, string directoryPath, ContentType contentType, GameType targetGame, string? sourcePath = null, IProgress<ContentStorageProgress>? progress = null, CancellationToken cancellationToken = default, string? entryPoint = null) { - return Task.FromResult(OperationResult<ContentManifest>.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath })); + var normalizedEntryPoint = !string.IsNullOrWhiteSpace(entryPoint) + ? entryPoint.Replace('\\', '/').TrimStart('/') + : null; + + return Task.FromResult(OperationResult<ContentManifest>.CreateSuccess(new ContentManifest { Name = name, ContentType = contentType, TargetGame = targetGame, SourcePath = sourcePath, EntryPoint = normalizedEntryPoint })); } } @@ -573,6 +601,12 @@ public static void UseDefaultConfiguration() /// <inheritdoc/> public bool GetAutoCheckForUpdatesOnStartup() => true; + /// <inheritdoc/> + public bool GetAutoCheckForUpdatesPeriodically() => true; + + /// <inheritdoc/> + public int GetPeriodicUpdateCheckIntervalMinutes() => AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + /// <inheritdoc/> public bool GetEnableDetailedLogging() => false; diff --git a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs index 92c8154e2..fac3c8e95 100644 --- a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs +++ b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs @@ -24,6 +24,7 @@ public class NotificationService : INotificationService, IDisposable private readonly Subject<Guid> _dismissSubject = new(); private readonly Subject<bool> _dismissAllSubject = new(); private readonly Subject<NotificationMessage> _historySubject = new(); + private readonly Subject<(Guid Id, string? Title, string Message)> _updateSubject = new(); private readonly List<NotificationMessage> _notificationHistory = new(); private readonly object _historyLock = new(); private readonly object _muteLock = new(); @@ -74,6 +75,9 @@ public NotificationService( /// <inheritdoc/> public IObservable<NotificationMessage> NotificationHistory => _historySubject; + /// <inheritdoc/> + public IObservable<(Guid Id, string? Title, string Message)> UpdateRequests => _updateSubject; + /// <inheritdoc/> public NotificationMuteState MuteState { @@ -175,6 +179,35 @@ public void Show(NotificationMessage notification) } } + /// <inheritdoc/> + public void Update(Guid notificationId, string message, string? title = null) + { + if (_disposed) + { + _logger.LogWarning("Attempted to update notification after service disposal"); + return; + } + + ArgumentNullException.ThrowIfNull(message); + + lock (_historyLock) + { + var index = _notificationHistory.FindIndex(n => n.Id == notificationId); + if (index >= 0) + { + var existing = _notificationHistory[index]; + _notificationHistory[index] = existing with + { + Title = title ?? existing.Title, + Message = message, + }; + } + } + + _logger.LogDebug("Updating notification {NotificationId}: {Message}", notificationId, message); + _updateSubject.OnNext((notificationId, title, message)); + } + /// <inheritdoc/> public async Task MuteSession(CancellationToken cancellationToken = default) { @@ -317,6 +350,7 @@ public void Dispose() _dismissSubject?.Dispose(); _dismissAllSubject?.Dispose(); _historySubject?.Dispose(); + _updateSubject?.Dispose(); _disposed = true; GC.SuppressFinalize(this); } diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs index 235bacfea..a010e81bd 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs @@ -37,15 +37,11 @@ public partial class NotificationItemViewModel : ViewModelBase, IDisposable /// </summary> public NotificationType Type { get; } - /// <summary> - /// Gets the notification title. - /// </summary> - public string Title { get; } + [ObservableProperty] + private string _title; - /// <summary> - /// Gets the notification message. - /// </summary> - public string Message { get; } + [ObservableProperty] + private string _message; /// <summary> /// Gets the timestamp when the notification was created. @@ -118,8 +114,8 @@ public NotificationItemViewModel( Id = notification.Id; Type = notification.Type; - Title = notification.Title; - Message = notification.Message; + _title = notification.Title; + _message = notification.Message; Timestamp = notification.Timestamp; IsActionable = notification.IsActionable; _isVisible = false; @@ -135,10 +131,7 @@ public NotificationItemViewModel( StartDismissTimer(notification.AutoDismissMilliseconds.Value); } - Dispatcher.UIThread.Post(() => - { - IsVisible = true; - }); + Dispatcher.UIThread.Post(() => IsVisible = true); } /// <summary> diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs index 2637c7758..8b174a82f 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs @@ -20,6 +20,7 @@ public class NotificationManagerViewModel : ViewModelBase, IDisposable private readonly IDisposable _notificationSubscription; private readonly IDisposable _dismissSubscription; private readonly IDisposable _dismissAllSubscription; + private readonly IDisposable _updateSubscription; private readonly object _lock = new(); private bool _disposed; @@ -48,6 +49,7 @@ public NotificationManagerViewModel( _notificationSubscription = _notificationService.Notifications.Subscribe(HandleNotificationReceived); _dismissSubscription = _notificationService.DismissRequests.Subscribe(HandleDismissRequest); _dismissAllSubscription = _notificationService.DismissAllRequests.Subscribe(_ => HandleDismissAllRequest()); + _updateSubscription = _notificationService.UpdateRequests.Subscribe(HandleUpdateRequest); _logger.LogInformation("NotificationManagerViewModel initialized"); } @@ -133,6 +135,7 @@ public void Dispose() _notificationSubscription?.Dispose(); _dismissSubscription?.Dispose(); _dismissAllSubscription?.Dispose(); + _updateSubscription?.Dispose(); foreach (var notification in ActiveNotifications) { @@ -157,6 +160,37 @@ private void HandleDismissRequest(Guid notificationId) RemoveNotification(notificationId); } + private void HandleUpdateRequest((Guid Id, string? Title, string Message) update) + { + _logger.LogDebug("Update request received for notification {NotificationId}", update.Id); + Dispatcher.UIThread.InvokeAsync( + () => + { + try + { + lock (_lock) + { + var notification = ActiveNotifications.FirstOrDefault(n => n.Id == update.Id); + if (notification != null) + { + if (update.Title is not null) + { + notification.Title = update.Title; + } + + notification.Message = update.Message; + _logger.LogDebug("Updated notification {NotificationId} message: {Message}", update.Id, update.Message); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating notification {NotificationId}", update.Id); + } + }, + DispatcherPriority.Send); + } + private void HandleDismissAllRequest() { _logger.LogDebug("Dismiss all request received"); diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index 32cce78ad..07322e27e 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -128,6 +128,12 @@ public partial class SettingsViewModel : ObservableObject, IDisposable [ObservableProperty] private bool _autoCheckForUpdatesOnStartup = true; + [ObservableProperty] + private bool _autoCheckForUpdatesPeriodically = true; + + [ObservableProperty] + private int _periodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + [ObservableProperty] private bool _allowBackgroundDownloads = true; @@ -195,12 +201,6 @@ public partial class SettingsViewModel : ObservableObject, IDisposable [ObservableProperty] private string _patStatusMessage = string.Empty; - [ObservableProperty] - private bool _isLoadingArtifacts; - - [ObservableProperty] - private ObservableCollection<ArtifactUpdateInfo> _availableArtifacts = []; - /// <summary> /// Initializes a new instance of the <see cref="SettingsViewModel"/> class. /// </summary> @@ -486,6 +486,8 @@ private void LoadSettings() WorkspacePath = settings.WorkspacePath; MaxConcurrentDownloads = settings.MaxConcurrentDownloads; AutoCheckForUpdatesOnStartup = settings.AutoCheckForUpdatesOnStartup; + AutoCheckForUpdatesPeriodically = settings.AutoCheckForUpdatesPeriodically; + PeriodicUpdateCheckIntervalMinutes = settings.PeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = settings.AllowBackgroundDownloads; EnableDetailedLogging = settings.EnableDetailedLogging; DefaultWorkspaceStrategy = settings.DefaultWorkspaceStrategy; @@ -540,6 +542,8 @@ private async Task SaveSettings() settings.WorkspacePath = WorkspacePath; settings.MaxConcurrentDownloads = MaxConcurrentDownloads; settings.AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup; + settings.AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically; + settings.PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes; settings.AllowBackgroundDownloads = AllowBackgroundDownloads; settings.EnableDetailedLogging = EnableDetailedLogging; settings.DefaultWorkspaceStrategy = DefaultWorkspaceStrategy; @@ -569,6 +573,12 @@ private async Task SaveSettings() await _userSettingsService.SaveAsync(); + // Notify components of updated update settings + WeakReferenceMessenger.Default.Send(new UpdateSettingsChangedMessage( + AutoCheckForUpdatesOnStartup, + AutoCheckForUpdatesPeriodically, + PeriodicUpdateCheckIntervalMinutes)); + // Apply log level change immediately without restart Infrastructure.DependencyInjection.LoggingModule.SetLogLevel(EnableDetailedLogging); @@ -600,6 +610,8 @@ private async Task ResetToDefaults() WorkspacePath = string.Empty; MaxConcurrentDownloads = DownloadDefaults.MaxConcurrentDownloads; AutoCheckForUpdatesOnStartup = true; + AutoCheckForUpdatesPeriodically = true; + PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; AllowBackgroundDownloads = true; EnableDetailedLogging = false; DefaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy; @@ -744,6 +756,14 @@ private bool ValidateSettings() DownloadBufferSizeKB = DownloadDefaults.BufferSizeKB; } + // Validate periodic update check interval + if (PeriodicUpdateCheckIntervalMinutes < AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes || + PeriodicUpdateCheckIntervalMinutes > AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes) + { + _logger.LogWarning("Invalid PeriodicUpdateCheckIntervalMinutes value: {Value}. Resetting to default.", PeriodicUpdateCheckIntervalMinutes); + PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + } + // Validate game install path if specified if (!string.IsNullOrEmpty(WorkspacePath) && !Directory.Exists(WorkspacePath)) { @@ -1007,7 +1027,6 @@ private async Task DeletePatAsync() HasGitHubPat = false; IsPatValid = false; PatStatusMessage = "GitHub PAT removed"; - AvailableArtifacts.Clear(); } catch (Exception ex) { @@ -1034,45 +1053,6 @@ private void OpenUpdateWindow() } } - /// <summary> - /// Loads available CI artifacts for selection. - /// </summary> - [RelayCommand] - private async Task LoadArtifactsAsync() - { - if (_updateManager == null || !HasGitHubPat) - { - PatStatusMessage = "Configure a GitHub PAT to load artifacts"; - return; - } - - IsLoadingArtifacts = true; - AvailableArtifacts.Clear(); - - try - { - var artifact = await _updateManager.CheckForArtifactUpdatesAsync(); - if (artifact != null) - { - AvailableArtifacts.Add(artifact); - PatStatusMessage = $"Found {AvailableArtifacts.Count} artifact(s)"; - } - else - { - PatStatusMessage = "No artifacts available"; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load artifacts"); - PatStatusMessage = $"Error loading artifacts: {ex.Message}"; - } - finally - { - IsLoadingArtifacts = false; - } - } - [RelayCommand] private async Task DeleteAllData() { diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml index 719a8ae05..7a022331b 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml @@ -734,6 +734,28 @@ Classes="setting-description" Margin="24,0,0,0" /> </StackPanel> + <!-- Periodic Update Check --> + <StackPanel Spacing="8"> + <CheckBox Content="Check for updates periodically" + IsChecked="{Binding AutoCheckForUpdatesPeriodically}" /> + <TextBlock Text="Automatically check for updates in the background at regular intervals" + Classes="setting-description" Margin="24,0,0,0" /> + </StackPanel> + + <StackPanel Spacing="8" IsVisible="{Binding AutoCheckForUpdatesPeriodically}"> + <TextBlock Text="Update Check Interval (Minutes)" Classes="setting-label" /> + <NumericUpDown Value="{Binding PeriodicUpdateCheckIntervalMinutes}" + Minimum="5" + Maximum="10080" + Increment="5" + HorizontalAlignment="Left" + Width="120" + FormatString="F0" + LostFocus="OnTextBoxLostFocus" /> + <TextBlock Text="How often to automatically check for updates (5-10080 minutes, default: 30 minutes)" + Classes="setting-description" /> + </StackPanel> + <!-- GitHub PAT for Artifact Access --> <Border Background="#1E1E1E" CornerRadius="4" Padding="15" BorderBrush="#4E4E4E" BorderThickness="1"> <StackPanel Spacing="12"> @@ -770,10 +792,6 @@ <Button Content="Remove PAT" Command="{Binding DeletePatCommand}" Classes="secondary-button" /> - <Button Content="Refresh Artifacts" - Command="{Binding LoadArtifactsCommand}" - IsEnabled="{Binding !IsLoadingArtifacts}" - Classes="browse-button" /> </StackPanel> <StackPanel Orientation="Horizontal" Spacing="12"> @@ -800,38 +818,6 @@ </Border> </StackPanel> </Border> - - <!-- Available Artifacts (for PAT users) --> - <StackPanel Spacing="8" IsVisible="{Binding HasGitHubPat}"> - <TextBlock Text="Available CI Artifacts" Classes="setting-label" /> - <ItemsControl ItemsSource="{Binding AvailableArtifacts}"> - <ItemsControl.ItemTemplate> - <DataTemplate> - <Border Background="#3D3D3D" CornerRadius="4" Padding="12" Margin="0,4"> - <Grid ColumnDefinitions="*,Auto"> - <StackPanel Grid.Column="0" Spacing="4"> - <TextBlock Text="{Binding DisplayVersion}" FontWeight="SemiBold" Foreground="#FFFFFF" /> - <StackPanel Orientation="Horizontal" Spacing="8"> - <TextBlock Text="{Binding CreatedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm}'}" - Foreground="#AAAAAA" FontSize="11" /> - <TextBlock Text="{Binding ArtifactName}" - Foreground="#888888" FontSize="11" /> - </StackPanel> - </StackPanel> - <Button Grid.Column="1" - Content="View Run →" - Classes="browse-button" - Click="OnViewWorkflowRun" - Tag="{Binding WorkflowRunUrl}" /> - </Grid> - </Border> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - <TextBlock Text="No artifacts available" - IsVisible="{Binding !AvailableArtifacts.Count}" - Foreground="#888888" FontStyle="Italic" /> - </StackPanel> </StackPanel> </Border> </Expander> diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs index 35feceb9d..a4bb91502 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs @@ -20,7 +20,7 @@ public SettingsView() InitializeComponent(); // Handle pointer press to unfocus text boxes when clicking elsewhere - this.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); } /// <summary> @@ -59,7 +59,7 @@ protected override void OnDataContextChanged(EventArgs e) if (DataContext is SettingsViewModel vm) { // Sync visibility state with current visual tree state - vm.IsViewVisible = this.VisualRoot != null; + vm.IsViewVisible = VisualRoot != null; } } @@ -68,7 +68,7 @@ private void OnPointerPressed(object? sender, Avalonia.Input.PointerPressedEvent // If clicking outside of a TextBox, clear focus from any focused TextBox if (e.Source is not TextBox) { - this.Focus(); + Focus(); } } @@ -95,24 +95,6 @@ private void OnOpenPatCreationUrl(object? sender, RoutedEventArgs e) } } - private void OnViewWorkflowRun(object? sender, RoutedEventArgs e) - { - if (sender is Button button && button.Tag is string url && !string.IsNullOrEmpty(url)) - { - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) - { - UseShellExecute = true, - }); - } - catch - { - // Silently fail if browser cannot be opened - } - } - } - /// <summary> /// Loads and initializes the XAML components for this view. /// </summary> diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs index a1e4365df..a371e17bd 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs @@ -42,7 +42,7 @@ public async Task<ImportResult> ImportFromUrlAsync( var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); - var fileName = GetFileNameFromUri(new Uri(url), response); + var fileName = ExtractFileName(new Uri(url), response); Directory.CreateDirectory(tempDir); var tempPath = Path.Combine(tempDir, fileName); @@ -512,7 +512,7 @@ public async Task<ImportResult> ImportFromStreamAsync( } } - private static string GetFileNameFromUri(Uri uri, HttpResponseMessage response) + private static string ExtractFileName(Uri uri, HttpResponseMessage response) { var rawName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName; diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs index 827c83b21..2b6540a4d 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs @@ -101,7 +101,7 @@ public async Task<ImportResult> ImportFromUrlAsync( return await ImportFromZipAsync(tempPath, targetVersion, progress, ct); } - var importedFileName = GetFileNameFromUri(new Uri(directUrl)); + var importedFileName = ExtractFileName(new Uri(directUrl)); using var stream = File.OpenRead(tempPath); return await ImportFromStreamAsync(stream, importedFileName, targetVersion, ct); } @@ -336,7 +336,7 @@ private static string GetUniquePath(string path) return path; } - private static string GetFileNameFromUri(Uri uri) + private static string ExtractFileName(Uri uri) { try { diff --git a/GenHub/GenHub/Infrastructure/Converters/ExecutableHighlightConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ExecutableHighlightConverter.cs index b77a7a101..4687e4a74 100644 --- a/GenHub/GenHub/Infrastructure/Converters/ExecutableHighlightConverter.cs +++ b/GenHub/GenHub/Infrastructure/Converters/ExecutableHighlightConverter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using Avalonia; using Avalonia.Data.Converters; using Avalonia.Media; @@ -11,7 +12,6 @@ namespace GenHub.Infrastructure.Converters; /// </summary> public class ExecutableHighlightConverter : IMultiValueConverter { - private static readonly SolidColorBrush DefaultBrush = new(Color.Parse("#DDDDDD")); private static readonly SolidColorBrush ExecutableBrush = new(Color.Parse("#90CAF9")); // Light blue for executables private static readonly SolidColorBrush SelectedExecutableBrush = new(Color.Parse("#4CAF50")); // Green for selected @@ -20,7 +20,7 @@ public class ExecutableHighlightConverter : IMultiValueConverter { if (values.Count < 2) { - return DefaultBrush; + return AvaloniaProperty.UnsetValue; } var isExecutable = values[0] is true; @@ -36,6 +36,6 @@ public class ExecutableHighlightConverter : IMultiValueConverter return ExecutableBrush; } - return DefaultBrush; + return AvaloniaProperty.UnsetValue; } } diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index ea7246dec..58d9be795 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -4,8 +4,10 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Parsers; using GenHub.Core.Interfaces.Providers; using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.Tools; using GenHub.Core.Services.Content; using GenHub.Core.Services.Providers; using GenHub.Core.Services.Providers.VersionSchemes; @@ -18,9 +20,11 @@ using GenHub.Features.Content.Services.GeneralsOnline; using GenHub.Features.Content.Services.GitHub; using GenHub.Features.Content.Services.LocalContent; +using GenHub.Features.Content.Services.Parsers; using GenHub.Features.Content.Services.Publishers; using GenHub.Features.Content.Services.Reconciliation; using GenHub.Features.Content.Services.SuperHackers; +using GenHub.Features.Content.Services.Tools; using GenHub.Features.Downloads.ViewModels; using GenHub.Features.GitHub.Services; using GenHub.Features.Manifest; @@ -308,6 +312,14 @@ private static void AddModDBPipeline(IServiceCollection services) httpClient.DefaultRequestHeaders.Add("User-Agent", ApiConstants.DefaultUserAgent); }); + // Register Playwright service for web page parsing (singleton for shared browser instance) + services.AddSingleton<IPlaywrightService, PlaywrightService>(); + + // Register ModDB page parser (concrete and interface) + // Note: Multiple IWebPageParser implementations are registered for IEnumerable<IWebPageParser> injection + services.AddSingleton<ModDBPageParser>(); + services.AddSingleton<IWebPageParser>(sp => sp.GetRequiredService<ModDBPageParser>()); + // Register ModDB discoverer (concrete and interface) with named HttpClient services.AddTransient<ModDBDiscoverer>(sp => { diff --git a/docs/dev/constants.md b/docs/dev/constants.md index 0c05a990e..628285b5c 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -68,11 +68,60 @@ Application-wide constants for GenHub. Constants related to application updates and Velopack. -| Constant | Value/Type | Description | -| ---------------------------- | --------------------------- | ------------------------------------------------ | -| `PostUpdateExitDelay` | `TimeSpan.FromSeconds(5)` | Delay before exit after applying update | -| `CacheDuration` | `TimeSpan.FromHours(1)` | Cache duration for update checks | -| `MaxHttpRetries` | `3` | Maximum number of HTTP retries for failed requests | +| Constant | Value/Type | Description | +| --------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------- | +| `MaxHttpRetries` | `3` | Maximum number of HTTP retries for failed requests | +| `UpdateTabIndex` | `0` | Index for the Update tab in update notification views | +| `BrowseBuildsTabIndex` | `1` | Index for the Browse Builds tab in update notification views | +| `MaxTabIndex` | `1` | Maximum valid tab index in update notification views | +| `VelopackDirectory` | `"velopack"` | Velopack directory name | +| `ArtifactPrefixWindows` | `"genhub-velopack-windows-"`| Artifact name prefix for Windows builds | +| `ArtifactPrefixLinux` | `"genhub-velopack-linux-"` | Artifact name prefix for Linux builds | +| `ArtifactNameRelease` | `"GenHub-Release"` | Artifact name for release builds | +| `PlatformWindows` | `"windows"` | Platform string for Windows | +| `PlatformLinux` | `"linux"` | Platform string for Linux | +| `CheckingForUpdatesMessage` | `"Checking..."` | Update checking message | +| `UpdateAvailableTitleFormat` | `"Update available: v{0}"` | Update available title format string | +| `UpdateUpToDateMessage` | `"You're up to date!"` | Update up to date message | +| `UpdateCheckFailedMessage` | `"Update check failed"` | Update check failed message | +| `InstallingMessage` | `"Installing..."` | Installing message | +| `InstallUpdateAction` | `"Install Update"` | Install update action text | +| `InitializingMessage` | `"Initializing..."` | Initializing message | +| `ReadyToRestartMessage` | `"Ready to restart"` | Ready to restart message | +| `DownloadingFormat` | `"Downloading... {0}%"` | Downloading format string | +| `UpdateDownloadedRestartingMessage` | `"Update downloaded! Restarting application..."` | Update downloaded and restarting message | +| `UpdateCompleteRestartingMessage` | `"Update complete! Restarting..."` | Update complete and restarting message | +| `DownloadingUpdateMessage` | `"Downloading update..."` | Downloading update status message | +| `CannotInstallFromLocationMessage` | `"Cannot install from this location"` | Cannot install from location status message | +| `UpdateFailedMessage` | `"Update failed"` | Update failed status message | +| `InstallationFailedMessage` | `"Installation failed"` | Installation failed status message | +| `NoArtifactAvailableMessage` | `"No artifact available"` | No artifact available status message | +| `NoVersionsFoundMessage` | `"No versions found"` | No versions found dropdown placeholder | +| `LoadingVersionsMessage` | `"Loading versions..."` | Loading versions dropdown placeholder | +| `SelectVersionMessage` | `"Select a version"` | Select a version dropdown placeholder | +| `NotAvailable` | `"N/A"` | Not available string | +| `UpdateInstallationRequiresAppInstalledMessage` | Format string | Message format when trying to install update from uninstalled debug directory | +| `UpdateAvailableNotificationTitle` | `"Update Available"` | Update available notification title for release channel | +| `BranchUpdateAvailableNotificationTitle` | `"Branch Update Available"` | Update available notification title for branch subscriptions | +| `PrUpdateAvailableNotificationTitle` | `"PR Update Available"` | Update available notification title for PR subscriptions | +| `UpdateAction` | `"Update"` | Update action button text | +| `UpdatingAppNotificationTitle` | `"Updating GenHub"` | Title for update in progress notification | +| `UpdateStartingMessage` | `"Starting update..."` | Starting update progress message | +| `UpdateFailedNotificationTitle` | `"Update Failed"` | Title for update failed notification | +| `UpdateFailedNotificationFormat` | `"Failed to install update: {0}"` | Update failed notification body format string | +| `ViewUpdatesAction` | `"View Updates"` | View updates action button text | +| `ReleaseUpdateNotificationFormat` | `"A new version ({0}) is available."` | Release update notification body format string | +| `BranchUpdateNotificationFormat` | `"A new build ({0}) is available on branch '{1}'."` | Branch update notification body format string | +| `PrUpdateNotificationFormat` | `"A new build ({0}) is available for PR #{1}."` | PR update notification body format string | +| `SortOptionLastUpdated` | `"Last Updated"` | Sort option: sort by last updated date descending | +| `SortOptionPrNumberDesc` | `"PR Number (Highest)"` | Sort option: sort by pull request number descending | +| `SortOptionPrNumberAsc` | `"PR Number (Lowest)"` | Sort option: sort by pull request number ascending | +| `DefaultPeriodicUpdateCheckIntervalMinutes` | `30` | Default interval in minutes for periodic update checks (30 minutes) | +| `MinPeriodicUpdateCheckIntervalMinutes` | `5` | Minimum interval in minutes for periodic update checks (5 minutes) | +| `MaxPeriodicUpdateCheckIntervalMinutes` | `10080` | Maximum interval in minutes for periodic update checks (10080 minutes / 7 days) | +| `PeriodicUpdateCheckIntervalIncrementMinutes` | `5` | Increment step in minutes for periodic update check interval setting (5 minutes) | +| `PostUpdateExitDelay` | `TimeSpan.FromSeconds(5)` | Delay before exit after applying update (5 seconds) | +| `CacheDuration` | `TimeSpan.FromHours(1)` | Cache duration for update checks (1 hour) | --- diff --git a/docs/dev/index.md b/docs/dev/index.md index a9350bf97..e8c8e1a56 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -64,6 +64,12 @@ client.Timeout = TimeIntervals.DownloadTimeout; --- +### Window Styling & OS Animations + +GeneralsHub defines a mandatory [Window styling and OS animation standard](./window-styling.md) to ensure all windows achieve smooth, native Desktop Window Manager (DWM) animations, proper client area extension, and reliable title bar drag/maximize handling. + +--- + ## Architecture ### Dependency Injection diff --git a/docs/dev/window-styling.md b/docs/dev/window-styling.md new file mode 100644 index 000000000..dd0d0ab20 --- /dev/null +++ b/docs/dev/window-styling.md @@ -0,0 +1,147 @@ +--- +title: Window Styling and OS Animation Standards +description: Guidelines and architectural rules for Avalonia window configuration, custom title bars, and native OS maximize/restore animations in GenHub +--- + +# Window Styling & OS Animation Standards + +This document establishes the mandatory standards for creating and configuring `Window` instances in GenHub. Following these patterns ensures that all windows achieve smooth, native OS animations (such as Desktop Window Manager / DWM fluid maximize, restore, snap, and dragging transitions) without clunkiness or visual glitches. + +--- + +## 1. The Core Architecture: Native DWM Integration + +Avalonia runs cross-platform across Windows, Linux, and macOS. On Windows (Win32), the operating system's **Desktop Window Manager (DWM)** manages fluid maximize/restore zoom animations, Aero Snap, and window shadows. + +For DWM to provide native fluid animations on windows with custom-styled title bars, the window **MUST** retain its native top-level frame (`WS_OVERLAPPEDWINDOW`) while extending its client area over the OS chrome. + +### Mandatory Window XAML Properties + +All resizable windows with custom title bars in GenHub must define these attributes: + +```xml +<Window xmlns="https://github.com/avaloniaui" + ... + CanResize="True" + SystemDecorations="Full" + ExtendClientAreaToDecorationsHint="True" + ExtendClientAreaChromeHints="NoChrome" + ExtendClientAreaTitleBarHeightHint="-1"> +``` + +### Why Each Property Matters + +| Property | Value | Purpose | Why It Fails Without It | +|---|---|---|---| +| `SystemDecorations` | `"Full"` | Retains top-level OS window styles (`WS_CAPTION`, `WS_THICKFRAME`, `WS_MAXIMIZEBOX`). | Setting `"BorderOnly"` or `"None"` strips maximize styles, causing DWM to disable maximize/restore animations and snap instantly. | +| `ExtendClientAreaToDecorationsHint` | `"True"` | Extends the application XAML drawing surface across the entire window. | Without it, the OS renders a standard generic white/grey caption bar above the content. | +| `ExtendClientAreaChromeHints` | `"NoChrome"` | Hides the default OS minimize, maximize, and close caption buttons. | Without it, default OS caption buttons clash with custom UI buttons. | +| `ExtendClientAreaTitleBarHeightHint` | `"-1"` | Instructs Avalonia to remove default title bar reservation space. | Ensures full control of header height via XAML. | + +--- + +## 2. Standard Title Bar Interaction Pattern + +### XAML Header Definition + +The header area should be an interactive container (`Grid` or `Border`) with a transparent background that captures pointer events: + +```xml +<!-- Custom Title Bar Header Area --> +<Grid Grid.Row="0" + Background="Transparent" + PointerPressed="OnTitleBarPointerPressed"> + <!-- Header Content: Icons, Title, Actions, Window Controls --> +</Grid> +``` + +> [!IMPORTANT] +> Never set `IsHitTestVisible="False"` on the drag area container, or pointer events cannot be captured for dragging or double-click maximizing. + +### Code-Behind Handler + +The code-behind must implement pointer dragging and double-click maximizing using Avalonia's built-in `BeginMoveDrag`: + +```csharp +/// <summary> +/// Handles pointer pressed events on the title bar for dragging and maximizing. +/// </summary> +/// <param name="sender">The sender object.</param> +/// <param name="e">The pointer event arguments.</param> +private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) +{ + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (e.ClickCount == 2 && CanResize) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + else + { + BeginMoveDrag(e); + } + } +} + +/// <summary> +/// Handles the maximize/restore button click. +/// </summary> +/// <param name="sender">The sender object.</param> +/// <param name="e">The routed event arguments.</param> +private void MaximizeButton_Click(object? sender, RoutedEventArgs e) +{ + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; +} +``` + +--- + +## 3. Strict Rules & Anti-Patterns (For Agents & Developers) + +> [!CAUTION] +> **NEVER MANUALLY TRACK MOUSE MOVES OR MANUALLY UNMAXIMIZE DURING DRAG** +> +> A common anti-pattern is writing manual `PointerMoved` tracking with a pixel distance threshold, manually setting `WindowState = WindowState.Normal`, calculating pixel coordinates, and setting `Position = new PixelPoint(...)`. +> +> **Why this breaks:** +> 1. It bypasses DWM's native interactive unmaximize animation. +> 2. It causes the window to jarringly jump/teleport on screen. +> 3. It breaks mouse capture and makes window dragging feel laggy and disconnected. +> +> **Solution:** Always call `BeginMoveDrag(e)` directly on pointer press. Avalonia and the OS window manager will handle dragging off maximized state smoothly. + +--- + +> [!CAUTION] +> **NEVER USE `SystemDecorations="BorderOnly"` ON RESIZABLE/MAXIMIZABLE WINDOWS** +> +> Setting `BorderOnly` disables DWM maximize/restore zoom transitions. Always use `SystemDecorations="Full"` combined with `ExtendClientArea*`. + +--- + +## 4. Window Types Reference in GenHub + +| Window Class | Role | `SystemDecorations` | `CanResize` | Custom Title Bar Drag | +|---|---|---|---|---| +| `MainWindow` | Primary application shell | `Full` | `True` | `OnTitleBarPointerPressed` | +| `GameProfileSettingsWindow` | Profile configuration editor | `Full` | `True` | `OnHeaderPointerPressed` | +| `UpdateNotificationWindow` | Velopack update dialog | `Full` | `True` | `TitleBar_PointerPressed` | +| `AddLocalContentWindow` | Content importer dialog | `Full` | `True` | `OnTitleBarPointerPressed` | +| `GenericMessageWindow` | Modal message/announcement dialog | `None` | `False` | Drag anywhere (`OnPointerPressed`) | +| `ConfirmationDialogWindow` | Modal confirmation dialog | `None` | `False` | Drag anywhere (`OnPointerPressed`) | +| `UpdateOptionDialogWindow` | Modal update option dialog | `None` | `False` | Modal centered | +| `SetupWizardView` | First-run wizard dialog | `None` | `False` | Modal centered | +| `GitHubTokenDialogView` | GitHub PAT configuration dialog | `BorderOnly` | `False` | Modal centered | + +--- + +## 5. Checklist for New Windows + +When creating a new `Window` in GenHub: + +- [ ] Set `SystemDecorations="Full"` if the window can be resized or maximized. +- [ ] Set `ExtendClientAreaToDecorationsHint="True"`, `ExtendClientAreaChromeHints="NoChrome"`, and `ExtendClientAreaTitleBarHeightHint="-1"`. +- [ ] Implement `OnTitleBarPointerPressed` with `BeginMoveDrag(e)` and double-click maximize toggle. +- [ ] Ensure the drag container has `Background="Transparent"` and `IsHitTestVisible="True"`. +- [ ] Avoid manual coordinate calculation or custom drag threshold tracking. +- [ ] Adhere to code style: no `this.`, primary constructors where applicable, no mid-comment capitalization. diff --git a/docs/velopack-integration.md b/docs/velopack-integration.md index e347e1ffa..4ba8ecb11 100644 --- a/docs/velopack-integration.md +++ b/docs/velopack-integration.md @@ -110,38 +110,52 @@ This allows users to reinstall the same PR build with different commits without ## Update Channels -GenHub provides two update channels that users can switch between: +GenHub provides three update channels that users can switch between: -### Stable Channel +### 1. Stable Channel (Default) - **Source**: GitHub Releases -- **Versions**: `0.0.X` (no PR suffix) -- **Updates**: Only stable builds from main branch -- **Recommended for**: Production use +- **Versions**: `0.0.X` (no branch/PR suffix) +- **Updates**: Only published releases from the main branch +- **Recommended for**: General production use -### Artifacts Channel (PR Subscription) +### 2. PR Artifacts Channel (PR Subscription) -- **Source**: GitHub Actions CI artifacts +- **Source**: GitHub Actions CI workflow artifacts - **Versions**: `0.0.X-prY` format -- **Updates**: Specific PR builds -- **Recommended for**: Testing features, bug fixes +- **Updates**: Specific Pull Request CI builds +- **Recommended for**: Testing specific feature branches or bug fix pull requests - **Requires**: GitHub Personal Access Token (PAT) with `repo` scope #### Subscribing to PR Builds 1. Navigate to Settings → Updates 2. Click "Manage Updates & PRs" -3. Enter GitHub PAT (if not already configured) -4. Select a PR from the list -5. Click "Subscribe" +3. In the "Browse Builds" tab, select a pull request +4. Click "Subscribe" -The app will now check for updates from that PR instead of stable releases. +The application will automatically query and notify when newer CI builds are published for that PR. + +### 3. Branch Artifacts Channel (Branch Subscription) + +- **Source**: GitHub Actions CI workflow artifacts on a branch (e.g., `development`, `main`) +- **Versions**: `0.0.X-branchname` format +- **Updates**: Continuous integration builds on the selected branch +- **Recommended for**: Developers and testers wanting bleeding-edge builds #### Unsubscribing 1. Open "Manage Updates & PRs" -2. Click "Unsubscribe" on the currently subscribed PR -3. App returns to stable channel +2. Click "Unsubscribe" on the currently subscribed PR or branch +3. The app returns to the stable release channel + +### Periodic Background Update Checks + +GenHub supports periodic background update checks configured in **Settings**: +- **Automatic Background Checks**: Enable or disable periodic checks +- **Configurable Interval**: Set between 5 minutes and 7 days (default: 30 minutes) +- **Persistent Notifications & Badges**: Prompts users with a non-intrusive one-click "Update" action in the notification feed +- **Duplicate Prevention**: Notification records are uniquely tracked per update identity (`pr:{prNumber}:{version}`, `branch:{branch}:{version}`, or `release:{version}`) to avoid notification spam ## Building Releases diff --git a/global.json b/global.json index 1834d84d1..da333ae07 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "version": "8.0.424", - "rollForward": "latestFeature", + "rollForward": "latestMajor", "allowPrerelease": false } }