From dd3a96a6ce1463df8d5d4bbd993624d5083818d0 Mon Sep 17 00:00:00 2001 From: Jaredl-Dev <260281143+Jaredl-Dev@users.noreply.github.com> Date: Tue, 19 May 2026 16:12:01 -0700 Subject: [PATCH 1/7] refactor!: complete application rewrite and modernization * update to .NET 10 and the latest C# version * overhaul the application architecture to follow layered architecture, MVVM, and dependency injection principles * modernize and improve the codebase * add logging throughout the codebase * greatly improve download performance and reliability * support only GeneralsOnline and SuperHackers clients * add support for SuperHackers world builders * replace deployment with a manifest-driven system that uses hard links, with copy/move operations as a fallback * redesign the launcher options menu * remove support for legacy launcher workflows and functionality related to GenTool, modded.exe, Vulkan, unsupported World Builder features, recommended game options, game option tweaking, custom camera height adjustment, and other features that are unnecessary or incompatible with GeneralsOnline and SuperHackers * rework content validation to be always enforced, more performant, more reliable, and stricter, and add a UI dialog to display validation deviations * switch archive extraction to SharpCompress * consolidate runtime folder creation into a single subfolder within the game directory and add support for running the application directly from that subfolder * add several quality-of-life improvements, including download ETA and speed indicators, and active addon and patch counts in tab titles * remove the application's self-updater to prevent fork installations from being overwritten by updates from the upstream repository --- .agents/skills/dotnet-patterns/SKILL.md | 321 + .editorconfig | 164 + .github/ISSUE_TEMPLATE/bug_report.md | 28 - .github/ISSUE_TEMPLATE/feature_request.md | 20 - .gitignore | 3 +- AGENTS.md | 113 + Directory.Build.props | 20 + Directory.Packages.props | 30 + GenLauncher.sln | 31 - GenLauncherGO.Core/AGENTS.md | 31 + .../Archives/ArchiveExtractionOptions.cs | 13 + .../Archives/IArchiveExtractor.cs | 43 + GenLauncherGO.Core/GenLauncherGO.Core.csproj | 8 + .../Contracts/IContentIntegrityService.cs | 70 + .../Integrity/Models/ContentIntegrityIssue.cs | 22 + .../Models/ContentIntegrityReport.cs | 52 + .../Models/ContentIntegrityTarget.cs | 63 + .../Integrity/Models/ContentSourceKind.cs | 27 + .../Integrity/Models/IntegrityIssueAction.cs | 37 + .../Integrity/Models/IntegrityIssueKind.cs | 42 + .../Launching/Contracts/IDeploymentService.cs | 41 + .../IGameExecutableDiscoveryService.cs | 32 + .../Contracts/IGameProcessLauncher.cs | 36 + ...LaunchContentIntegrityResolutionService.cs | 70 + .../ILaunchContentIntegrityTargetBuilder.cs | 18 + .../Contracts/ILaunchPreparationService.cs | 42 + .../Models/DeploymentCleanupRequest.cs | 27 + .../Launching/Models/DeploymentFailure.cs | 43 + .../Launching/Models/DeploymentFailureKind.cs | 22 + .../Launching/Models/DeploymentFileEntry.cs | 64 + .../Launching/Models/DeploymentManifest.cs | 68 + .../Launching/Models/DeploymentMethod.cs | 17 + .../Launching/Models/DeploymentPackage.cs | 64 + .../Launching/Models/DeploymentPackageKind.cs | 22 + .../Models/DeploymentRecoveryRequest.cs | 27 + .../Launching/Models/DeploymentRequest.cs | 39 + .../Launching/Models/DeploymentResult.cs | 67 + .../Launching/Models/GameClientExecutable.cs | 35 + .../Models/GameClientExecutableKind.cs | 17 + .../Launching/Models/GameLaunchRequest.cs | 104 + .../Launching/Models/GameLaunchResult.cs | 93 + .../Launching/Models/GameLaunchTargetKind.cs | 17 + ...aunchContentIntegrityResolutionProgress.cs | 69 + ...LaunchContentIntegrityResolutionRequest.cs | 52 + .../LaunchContentIntegrityTargetContext.cs | 48 + .../LaunchContentIntegrityTargetRequest.cs | 111 + ...aunchContentIntegrityVerificationResult.cs | 41 + .../LaunchContentIntegrityVersionRequest.cs | 108 + .../Models/LaunchPreparationRequest.cs | 88 + .../Models/LaunchPreparationResult.cs | 81 + .../Models/WorldBuilderExecutable.cs | 35 + .../Models/WorldBuilderExecutableKind.cs | 17 + .../ILauncherContentCatalogCommands.cs | 54 + .../ILauncherContentCatalogLoader.cs | 60 + .../ILauncherContentCatalogQueries.cs | 102 + .../ILauncherContentCatalogService.cs | 11 + .../Contracts/ILauncherContentPathResolver.cs | 22 + .../Contracts/ILauncherContentStateStore.cs | 21 + .../Contracts/ILocalLauncherContentService.cs | 78 + .../Contracts/IManualModificationImporter.cs | 19 + .../IModificationImageFileService.cs | 50 + .../Mods/Models/AdvertisingData.cs | 36 + .../Mods/Models/ColorsInfoString.cs | 122 + .../Mods/Models/GameModification.cs | 116 + ...cherContentCatalogInitializationRequest.cs | 17 + .../Mods/Models/LauncherContentEntryState.cs | 45 + .../Mods/Models/LauncherContentLayout.cs | 39 + .../Mods/Models/LauncherContentState.cs | 24 + .../Mods/Models/LauncherContentType.cs | 27 + .../Models/LauncherContentVersionState.cs | 44 + .../Mods/Models/LauncherData.cs | 177 + .../Models/ManualModificationImportRequest.cs | 12 + .../Mods/Models/ModAddonsAndPatches.cs | 42 + .../ModificationImageReplacementRequest.cs | 44 + .../Mods/Models/ModificationReposVersion.cs | 216 + .../Mods/Models/ModificationType.cs | 27 + .../Mods/Models/ModificationVersion.cs | 239 + .../Mods/Models/ReposModsData.cs | 50 + .../Services/LauncherContentPathResolver.cs | 60 + .../Remote/IRemoteAssetDownloader.cs | 22 + .../Remote/IRemoteConnectionProbe.cs | 19 + .../Remote/IRemoteYamlDocumentReader.cs | 20 + .../Contracts/ILauncherPreferencesService.cs | 26 + .../Contracts/ILauncherSettingsLinkService.cs | 31 + .../Settings/Models/LauncherPreferences.cs | 47 + .../LauncherPreferencesChangedEventArgs.cs | 25 + .../Shell/Contracts/ILauncherShellService.cs | 24 + .../Shell/Models/ShellOpenFailureKind.cs | 27 + .../Shell/Models/ShellOpenResult.cs | 83 + .../ILauncherHostEnvironmentService.cs | 47 + .../Contracts/ILauncherSingleInstanceGuard.cs | 14 + .../ILauncherStartupEnvironmentService.cs | 21 + .../Startup/ILauncherPathResolver.cs | 21 + GenLauncherGO.Core/Startup/LauncherPaths.cs | 229 + .../Models/LauncherStartupEnvironment.cs | 14 + .../Startup/SessionInformation.cs | 38 + .../Contracts/IDownloadFileMetadataReader.cs | 22 + .../Updating/Contracts/IFileHashService.cs | 18 + .../Contracts/IPackageDownloadOperation.cs | 43 + .../IPackageDownloadOperationFactory.cs | 21 + .../Contracts/IResumableFileDownloader.cs | 24 + .../Contracts/ISingleFilePackageUpdater.cs | 23 + .../Updating/Contracts/ISystemClockService.cs | 19 + .../Updating/Models/DownloadFileMetadata.cs | 14 + .../Updating/Models/DownloadFileRequest.cs | 16 + .../Updating/Models/DownloadFileResult.cs | 12 + .../Updating/Models/DownloadProgress.cs | 12 + .../ModificationPackageDownloadRequest.cs | 12 + .../Models/PackageDownloadReadiness.cs | 17 + .../Models/PackageDownloadReadinessError.cs | 17 + .../Updating/Models/PackageDownloadResult.cs | 27 + .../Updating/Models/PackageUpdateProgress.cs | 20 + .../Models/RemoteFileManifestEntry.cs | 12 + .../Models/SingleFilePackageUpdateRequest.cs | 14 + GenLauncherGO.Infrastructure/AGENTS.md | 35 + .../Archives/ArchiveExtractor.cs | 127 + .../ArchiveServiceCollectionExtensions.cs | 28 + .../Common/FileSystemPathSafety.cs | 191 + .../GenLauncherGO.Infrastructure.csproj | 24 + .../IntegrityServiceCollectionExtensions.cs | 33 + .../FileSystemContentIntegrityService.cs | 532 + .../Support/ContentIntegrityScanner.cs | 216 + .../Support/ContentIntegritySnapshotStore.cs | 186 + .../DeploymentServiceCollectionExtensions.cs | 35 + .../DeploymentLaunchPreparationService.cs | 202 + .../Services/FileSystemDeploymentService.cs | 507 + ...LaunchContentIntegrityResolutionService.cs | 592 + ...stemLaunchContentIntegrityTargetBuilder.cs | 229 + .../WindowsGameExecutableDiscoveryService.cs | 134 + .../Services/WindowsGameProcessLauncher.cs | 152 + .../Services/WindowsProcessFamilyLauncher.cs | 488 + .../Support/DeploymentFilePlanner.cs | 102 + .../Support/DeploymentPathResolver.cs | 97 + .../Launching/Support/DeploymentStateStore.cs | 717 + .../Launching/Support/IHardLinkCreator.cs | 15 + .../Support/IProcessFamilyLauncher.cs | 23 + .../Support/WindowsHardLinkCreator.cs | 28 + .../LoggingServiceCollectionExtensions.cs | 115 + ...frastructureServiceCollectionExtensions.cs | 72 + .../Contracts/ILauncherCatalogImageCache.cs | 36 + .../Contracts/ILauncherContentStateMapper.cs | 47 + .../ILauncherLocalContentReconciler.cs | 50 + .../Contracts/IRemoteLauncherCatalogClient.cs | 72 + .../Mods/Models/RemoteAdvertisingReference.cs | 41 + .../RemoteCatalogModificationReference.cs | 49 + .../Mods/Models/RemoteContentManifest.cs | 105 + .../Mods/Models/RemoteLauncherCatalog.cs | 67 + .../Mods/Models/RemoteModificationManifest.cs | 41 + .../FileSystemLocalLauncherContentService.cs | 657 + .../FileSystemManualModificationImporter.cs | 133 + .../FileSystemModificationImageFileService.cs | 178 + .../Services/LauncherCatalogImageCache.cs | 209 + .../Services/LauncherContentCatalogService.cs | 605 + .../LauncherContentSelectionService.cs | 233 + .../Services/LauncherContentStateMapper.cs | 298 + .../LauncherLocalContentReconciler.cs | 313 + .../Services/RemoteLauncherCatalogClient.cs | 273 + .../Services/YamlLauncherContentStateStore.cs | 40 + .../Support/RemoteLauncherCatalogMapper.cs | 154 + .../Options/YamlDocumentStoreOptions.cs | 28 + .../Services/IYamlDocumentStore.cs | 22 + .../Persistence/Services/YamlDocumentStore.cs | 95 + .../Properties/AssemblyInfo.cs | 3 + .../Remote/HttpRemoteAssetDownloader.cs | 81 + .../Remote/HttpRemoteConnectionProbe.cs | 90 + .../Remote/HttpRemoteYamlDocumentReader.cs | 76 + .../Remote/SharedHttpClientFactory.cs | 39 + ...frastructureServiceCollectionExtensions.cs | 43 + .../LauncherPreferencesStoreOptions.cs | 28 + .../Options/LauncherSettingsLinkOptions.cs | 28 + .../Settings/Services/PreferencesService.cs | 140 + .../ProcessLauncherSettingsLinkService.cs | 110 + .../ShellServiceCollectionExtensions.cs | 25 + .../Services/WindowsLauncherShellService.cs | 146 + .../Startup/FileSystemLauncherPathResolver.cs | 232 + ...SystemLauncherStartupEnvironmentService.cs | 186 + .../WindowsLauncherHostEnvironmentService.cs | 198 + .../Clients/HttpDownloadFileMetadataReader.cs | 135 + .../Updating/Clients/MinioClientFactory.cs | 56 + .../Clients/MinioS3ObjectManifestReader.cs | 127 + .../Clients/ResumableHttpFileDownloader.cs | 438 + .../UpdatingServiceCollectionExtensions.cs | 47 + .../Contracts/IS3ObjectManifestReader.cs | 23 + .../Updating/Contracts/IS3PackageUpdater.cs | 24 + .../Models/S3ObjectManifestRequest.cs | 21 + .../Updating/Models/S3PackageUpdateRequest.cs | 34 + .../Options/ResumableHttpDownloadOptions.cs | 39 + .../Options/S3PackageUpdateOptions.cs | 19 + .../HttpSingleFilePackageDownloadOperation.cs | 196 + .../Updating/Services/Md5FileHashService.cs | 32 + .../PackageDownloadOperationFactory.cs | 123 + .../Services/S3PackageDownloadOperation.cs | 293 + .../Updating/Services/S3PackageUpdater.cs | 491 + .../Services/SingleFilePackageUpdater.cs | 126 + .../Services/WindowsSystemClockService.cs | 215 + .../Updating/Support/BigFileVariantPath.cs | 76 + .../Updating/Support/DownloadLinkResolver.cs | 85 + .../Updating/Support/ManifestPathResolver.cs | 93 + .../Support/PackageInstallFolderReplacer.cs | 190 + .../Support/PackageProgressTracker.cs | 115 + .../Support/PackageStagingFolderCleaner.cs | 315 + .../Updating/Support/S3CatalogDefaults.cs | 80 + .../Support/S3HashValidationPolicy.cs | 50 + .../Support/S3ReusablePackageFileCopier.cs | 213 + GenLauncherGO.Tests/AGENTS.md | 32 + GenLauncherGO.Tests/Core/.gitkeep | 1 + .../Models/ContentIntegrityReportTests.cs | 30 + .../Models/ContentIntegrityTargetTests.cs | 30 + .../Models/DeploymentContractTests.cs | 248 + .../GameExecutableDiscoveryModelTests.cs | 49 + ...odificationImageReplacementRequestTests.cs | 32 + .../LauncherContentPathResolverTests.cs | 191 + .../Core/SessionInformationTests.cs | 38 + .../Core/Shell/Models/ShellOpenResultTests.cs | 33 + .../Core/Startup/LauncherPathsTests.cs | 171 + .../GenLauncherGO.Tests.csproj | 33 + GenLauncherGO.Tests/GlobalUsings.cs | 3 + GenLauncherGO.Tests/Infrastructure/.gitkeep | 1 + .../Infrastructure/ArchiveExtractorTests.cs | 133 + ...tegrityServiceCollectionExtensionsTests.cs | 52 + .../FileSystemContentIntegrityServiceTests.cs | 570 + ...loymentServiceCollectionExtensionsTests.cs | 74 + ...DeploymentLaunchPreparationServiceTests.cs | 146 + .../FileSystemDeploymentServiceTests.cs | 650 + ...aunchContentIntegrityTargetBuilderTests.cs | 78 + ...dowsGameExecutableDiscoveryServiceTests.cs | 182 + .../WindowsGameProcessLauncherTests.cs | 119 + ...LoggingServiceCollectionExtensionsTests.cs | 142 + ...ructureServiceCollectionExtensionsTests.cs | 74 + ...eSystemLocalLauncherContentServiceTests.cs | 300 + ...leSystemManualModificationImporterTests.cs | 134 + ...SystemModificationImageFileServiceTests.cs | 150 + .../LauncherCatalogImageCacheTests.cs | 105 + .../LauncherContentCatalogServiceTests.cs | 195 + .../LauncherContentSelectionServiceTests.cs | 55 + .../LauncherContentStateMapperTests.cs | 250 + .../LauncherLocalContentReconcilerTests.cs | 177 + .../RemoteLauncherCatalogClientTests.cs | 139 + .../YamlLauncherContentStateStoreTests.cs | 44 + .../Services/YamlDocumentStoreTests.cs | 171 + .../Remote/HttpRemoteAssetDownloaderTests.cs | 57 + .../Services/PreferencesServiceTests.cs | 78 + .../ShellServiceCollectionExtensionsTests.cs | 52 + .../WindowsLauncherShellServiceTests.cs | 74 + .../FileSystemLauncherPathResolverTests.cs | 149 + ...mLauncherStartupEnvironmentServiceTests.cs | 137 + ...dowsLauncherHostEnvironmentServiceTests.cs | 71 + .../ResumableHttpFileDownloaderTests.cs | 309 + ...pdatingServiceCollectionExtensionsTests.cs | 82 + .../Updating/Models/S3RequestDefaultsTests.cs | 86 + .../Services/S3PackageUpdaterBehaviorTests.cs | 301 + .../Updating/Services/S3UpdaterTests.cs | 72 + .../Services/SingleFilePackageUpdaterTests.cs | 168 + .../Support/DownloadLinkResolverTests.cs | 58 + GenLauncherGO.Tests/Testing/.gitkeep | 1 + GenLauncherGO.Tests/Testing/TestDirectory.cs | 35 + .../Testing/TestLauncherModsContext.cs | 30 + .../Testing/TestStringLocalizer.cs | 57 + GenLauncherGO.Tests/UI/.gitkeep | 1 + .../DialogServiceCollectionExtensionsTests.cs | 82 + .../LauncherPackageActivityServiceTests.cs | 34 + .../IntegrityReviewViewModelTests.cs | 85 + ...ncherUiServiceCollectionExtensionsTests.cs | 341 + .../LauncherContentListServiceTests.cs | 345 + .../LauncherContentViewStateServiceTests.cs | 146 + ...LauncherExecutableSelectionServiceTests.cs | 153 + .../LauncherGameArgumentServiceTests.cs | 73 + .../LauncherSelectedContentServiceTests.cs | 129 + .../Services/LauncherTabStateServiceTests.cs | 110 + .../LauncherTileActionServiceTests.cs | 249 + ...auncherTileVersionSelectionServiceTests.cs | 77 + .../LauncherVisualThemeServiceTests.cs | 124 + .../LauncherSelectionControllerTests.cs | 196 + .../ViewModels/MainWindowViewModelTests.cs | 452 + .../ModificationImageSourceFactoryTests.cs | 128 + .../Mods/ModificationViewModelTests.cs | 152 + .../ModsUiServiceCollectionExtensionsTests.cs | 37 + .../ViewModels/ModificationTileStateTests.cs | 165 + .../ViewModels/ModsDialogViewModelTests.cs | 219 + ...tingsUiServiceCollectionExtensionsTests.cs | 22 + .../LauncherSettingsViewModelTests.cs | 184 + ...artupUiServiceCollectionExtensionsTests.cs | 90 + .../ViewModels/InitWindowViewModelTests.cs | 248 + .../Shared/Commands/AsyncRelayCommandTests.cs | 42 + .../WpfLauncherStringLocalizerTests.cs | 35 + GenLauncherGO.UI/AGENTS.md | 27 + .../DialogServiceCollectionExtensions.cs | 30 + .../Contracts/ILauncherDialogService.cs | 69 + .../Contracts/ILauncherDialogWindowFactory.cs | 53 + .../Models/LauncherInfoDialogRequest.cs | 40 + .../Models/ManualModificationDialogRequest.cs | 36 + .../Models/ManualModificationDialogResult.cs | 52 + .../LauncherDialogViewModelFactory.cs | 97 + .../Services/LauncherDialogWindowFactory.cs | 90 + .../Services/WpfLauncherDialogService.cs | 156 + .../ILaunchContentIntegrityProgressTarget.cs | 37 + .../Integrity/IntegrityReviewDialog.xaml | 157 + .../Integrity/IntegrityReviewDialog.xaml.cs | 59 + .../Integrity/IntegrityReviewDialogOptions.cs | 64 + .../LaunchContentIntegrityCoordinator.cs | 562 + .../LauncherPackageActivityService.cs | 124 + .../ViewModels/IntegrityReviewViewModel.cs | 178 + .../LauncherUiServiceCollectionExtensions.cs | 47 + .../Launcher/Contracts/ILauncherFilePicker.cs | 25 + .../Contracts/ILauncherWindowWorkflowView.cs | 95 + .../Launcher/Models/ExecutableOption.cs | 54 + .../Launcher/Models/GameClientOption.cs | 48 + .../Models/LauncherContentTabState.cs | 16 + .../Models/LauncherContentViewKind.cs | 22 + .../Models/LauncherContentViewState.cs | 38 + .../Models/LauncherContextMenuState.cs | 10 + .../Models/LauncherLaunchFailureKind.cs | 32 + .../Launcher/Models/LauncherLaunchRequest.cs | 84 + .../Launcher/Models/LauncherLaunchResult.cs | 64 + .../Models/LauncherLaunchTargetKind.cs | 17 + .../Models/LauncherManualImportKind.cs | 22 + .../Models/LauncherManualImportRequest.cs | 14 + .../Models/LauncherManualImportResult.cs | 12 + .../Launcher/Models/LauncherTileLinkAction.cs | 10 + .../Resources/MainWindowResources.xaml | 352 + .../Services/LauncherContentListService.cs | 270 + .../LauncherContentViewStateService.cs | 84 + .../LauncherExecutableSelectionService.cs | 144 + .../Services/LauncherGameArgumentService.cs | 174 + .../Services/LauncherLaunchCoordinator.cs | 412 + .../LauncherLaunchReadinessCoordinator.cs | 339 + .../LauncherManualImportCoordinator.cs | 311 + ...LauncherModificationDownloadCoordinator.cs | 398 + .../LauncherSelectedContentService.cs | 106 + .../LauncherShellNavigationCoordinator.cs | 283 + .../Services/LauncherTabStateService.cs | 118 + .../Services/LauncherTileActionService.cs | 245 + .../LauncherTileVersionSelectionService.cs | 29 + .../Services/LauncherVisualThemeService.cs | 139 + .../LauncherWindowWorkflowCoordinator.cs | 749 + .../Services/WpfLauncherFilePicker.cs | 57 + .../Support/LauncherDragDropController.cs | 315 + .../Support/LauncherSelectionController.cs | 523 + .../LauncherSelectionControllerFactory.cs | 100 + .../Support/LauncherWindowListController.cs | 382 + .../LauncherWindowListControllerFactory.cs | 52 + ...ainWindowManualImportRequestedEventArgs.cs | 24 + .../MainWindowModificationActionKind.cs | 42 + ...dowModificationActionRequestedEventArgs.cs | 33 + ...inWindowVersionActionRequestedEventArgs.cs | 24 + .../ViewModels/MainWindowViewModel.cs | 1735 + .../Features/Launcher/Views/MainWindow.xaml | 617 + .../Launcher/Views/MainWindow.xaml.cs | 413 + .../ModsUiServiceCollectionExtensions.cs | 28 + .../Mods/Contracts/ILauncherModsContext.cs | 20 + .../Mods/ModificationImageSourceFactory.cs | 222 + .../Mods/ModificationVersionSelection.cs | 47 + .../Features/Mods/ModificationViewModel.cs | 1228 + .../Mods/ModificationViewModelFactory.cs | 78 + .../Resources/UserAddedModBannerGenerals.jpg | Bin .../Resources/UserAddedModBannerZeroHour.jpg | Bin .../ViewModels/AddModificationViewModel.cs | 79 + .../Mods/ViewModels/InfoDialogKind.cs | 22 + .../Mods/ViewModels/InfoDialogViewModel.cs | 190 + .../ManualAddModificationViewModel.cs | 173 + .../ModificationTileImageProvider.cs | 246 + .../Mods/ViewModels/ModificationTileState.cs | 160 + .../Mods/Views/AddModificationWindow.xaml | 32 + .../Mods/Views/AddModificationWindow.xaml.cs | 66 + .../Features/Mods/Views/InfoWindow.xaml | 261 + .../Features/Mods/Views/InfoWindow.xaml.cs | 80 + .../Views/ManualAddModificationWindow.xaml | 41 + .../Views/ManualAddModificationWindow.xaml.cs | 69 + ...erSettingsUiServiceCollectionExtensions.cs | 37 + .../Contracts/ILauncherCultureService.cs | 15 + .../ILauncherSettingsTextProvider.cs | 15 + .../ILauncherSettingsWindowFactory.cs | 15 + .../LocalizedLauncherSettingsTextProvider.cs | 66 + .../Settings/Models/LauncherSettingsText.cs | 152 + .../Services/LauncherSettingsWindowFactory.cs | 31 + .../Services/WpfLauncherCultureService.cs | 42 + .../ViewModels/LauncherSettingsViewModel.cs | 231 + .../Views/LauncherSettingsWindow.xaml | 334 + .../Views/LauncherSettingsWindow.xaml.cs | 39 + .../StartupUiServiceCollectionExtensions.cs | 32 + .../Contracts/IStartupDialogService.cs | 31 + .../Features/Startup/EntryPoint.cs | 17 + .../Startup/LauncherApplicationDefaults.cs | 18 + .../Startup/LauncherApplicationHost.cs | 281 + .../Startup/LauncherRuntimeContext.cs | 92 + .../Startup/LauncherWpfApplication.cs | 29 + .../Services/InitWindowWorkflowCoordinator.cs | 48 + .../Services/WpfStartupDialogService.cs | 46 + .../InitWindowStartupCompletedEventArgs.cs | 23 + .../Startup/ViewModels/InitWindowViewModel.cs | 343 + .../Features/Startup/Views/InitWindow.xaml | 21 + .../Features/Startup/Views/InitWindow.xaml.cs | 71 + GenLauncherGO.UI/GenLauncherGO.UI.csproj | 77 + GenLauncherGO.UI/Properties/AssemblyInfo.cs | 12 + GenLauncherGO.UI/Resources/Strings.ar.resx | 601 + GenLauncherGO.UI/Resources/Strings.cs | 30 + GenLauncherGO.UI/Resources/Strings.de.resx | 599 + GenLauncherGO.UI/Resources/Strings.es.resx | 597 + GenLauncherGO.UI/Resources/Strings.fr.resx | 597 + GenLauncherGO.UI/Resources/Strings.hr.resx | 597 + GenLauncherGO.UI/Resources/Strings.pt.resx | 597 + GenLauncherGO.UI/Resources/Strings.resx | 636 + GenLauncherGO.UI/Resources/Strings.ru.resx | 597 + GenLauncherGO.UI/Resources/Strings.tr.resx | 597 + GenLauncherGO.UI/Resources/Strings.uk.resx | 597 + GenLauncherGO.UI/Resources/Strings.zh.resx | 597 + .../Shared/Commands/AsyncRelayCommand.cs | 78 + .../Shared/Commands/RelayCommand.cs | 50 + .../Shared/Controls/InfoTextBlock.cs | 10 + .../Controls/LauncherLoadingIndicator.xaml | 33 + .../Controls/LauncherLoadingIndicator.xaml.cs | 17 + .../Shared/Controls/NameTextBox.cs | 10 + .../Shared/Controls/UpdateButton.cs | 183 + .../Shared/Controls/VersionTextBox.cs | 10 + .../PackageProgressTextFormatter.cs | 113 + .../ILauncherStringCultureSetter.cs | 15 + .../Localization/ILauncherStringLocalizer.cs | 14 + .../WpfLauncherStringLocalizer.cs | 38 + .../Shared/Resources/Icons/GenLauncherGo.ico | Bin .../Images/LauncherBackgroundGenerals.png | Bin .../Images/LauncherBackgroundZeroHour.png | Bin .../Images/LauncherEmblemCompact.png | Bin .../Resources/Images/LauncherEmblemFramed.png | Bin .../Shared/Themes}/ColorsDictionary.xaml | 22 +- GenLauncherGO.UI/Shared/Themes/ColorsInfo.cs | 176 + .../Shared/Themes/GenLauncherStyles.xaml | 456 + .../Themes/LauncherThemeResourceApplier.cs | 129 + GenLauncherGO.sln | 79 + GenLauncherNet/App.config | 14 - GenLauncherNet/App.xaml | 13 - GenLauncherNet/App.xaml.cs | 20 - GenLauncherNet/DataClasses/ColorsInfo.cs | 121 - GenLauncherNet/DataClasses/ComboBoxData.cs | 28 - .../DataClasses/GameModification.cs | 68 - GenLauncherNet/DataClasses/LauncherData.cs | 109 - .../DataClasses/ModificationFileInfo.cs | 51 - .../DataClasses/ModificationVersion.cs | 167 - .../DataClasses/ModificationViewModel.cs | 555 - .../DataClasses/ReposModificationsVersion.cs | 80 - GenLauncherNet/DataClasses/ReposModsData.cs | 50 - .../DataClasses/SessionInformation.cs | 21 - .../DataClasses/StringConcurrentDictionary.cs | 45 - GenLauncherNet/DataClasses/StringHashSet.cs | 19 - GenLauncherNet/DataClasses/VulkanData.cs | 14 - GenLauncherNet/DataHandler.cs | 806 - GenLauncherNet/Dlls/Minio.dll | Bin 305664 -> 0 bytes GenLauncherNet/Dlls/RestSharp.dll | Bin 185856 -> 0 bytes GenLauncherNet/Dlls/SevenZipExtractor.dll | Bin 31744 -> 0 bytes GenLauncherNet/Dlls/SymbolicLinkSupport.dll | Bin 8704 -> 0 bytes GenLauncherNet/Dlls/System.Reactive.dll | Bin 1211392 -> 0 bytes GenLauncherNet/Dlls/WPFLocalizeExtension.dll | Bin 90624 -> 0 bytes GenLauncherNet/Dlls/XAMLMarkupExtensions.dll | Bin 36352 -> 0 bytes GenLauncherNet/Dlls/YamlDotNet.dll | Bin 222208 -> 0 bytes .../Dlls/ar/GenLauncher.resources.dll | Bin 15360 -> 0 bytes .../Dlls/de/GenLauncher.resources.dll | Bin 14336 -> 0 bytes .../Dlls/es/GenLauncher.resources.dll | Bin 14336 -> 0 bytes .../Dlls/fr/GenLauncher.resources.dll | Bin 14336 -> 0 bytes .../Dlls/hr/GenLauncher.resources.dll | Bin 13824 -> 0 bytes .../Dlls/pt/GenLauncher.resources.dll | Bin 13824 -> 0 bytes .../Dlls/ru/GenLauncher.resources.dll | Bin 16384 -> 0 bytes .../Dlls/tr/GenLauncher.resources.dll | Bin 13824 -> 0 bytes .../Dlls/uk/GenLauncher.resources.dll | Bin 17408 -> 0 bytes GenLauncherNet/Dlls/x64/7z.dll | Bin 1710080 -> 0 bytes GenLauncherNet/Dlls/x86/7z.dll | Bin 1167872 -> 0 bytes .../Dlls/zh/GenLauncher.resources.dll | Bin 12800 -> 0 bytes GenLauncherNet/EntryPoint.cs | 289 - GenLauncherNet/FodyWeavers.xml | 3 - GenLauncherNet/GameLauncher.cs | 353 - GenLauncherNet/GenLauncher.csproj | 483 - GenLauncherNet/GenLauncher.csproj.user | 13 - .../HttpHandlers/ContentDownloader.cs | 170 - .../HttpHandlers/GitHubMainDataReader.cs | 167 - .../HttpHandlers/GitHubYamlReader.cs | 88 - GenLauncherNet/Images/Background.png | Bin 979816 -> 0 bytes GenLauncherNet/Images/vulkan.png | Bin 19474 -> 0 bytes GenLauncherNet/ModificationsFileHandler.cs | 47 - GenLauncherNet/Options/options.ini | 32 - GenLauncherNet/Properties/AssemblyInfo.cs | 55 - .../Properties/Resources.Designer.cs | 63 - GenLauncherNet/Properties/Resources.resx | 117 - .../Properties/Settings.Designer.cs | 26 - GenLauncherNet/Properties/Settings.settings | 7 - GenLauncherNet/Resources/Strings.Designer.cs | 1314 - GenLauncherNet/Resources/Strings.ar.resx | 535 - GenLauncherNet/Resources/Strings.de.resx | 537 - GenLauncherNet/Resources/Strings.es.resx | 537 - GenLauncherNet/Resources/Strings.fr.resx | 537 - GenLauncherNet/Resources/Strings.hr.resx | 535 - GenLauncherNet/Resources/Strings.pt.resx | 537 - GenLauncherNet/Resources/Strings.resx | 537 - .../Resources/Strings.ru.Designer.cs | 0 GenLauncherNet/Resources/Strings.ru.resx | 537 - GenLauncherNet/Resources/Strings.tr.resx | 537 - GenLauncherNet/Resources/Strings.uk.resx | 538 - GenLauncherNet/Resources/Strings.zh.resx | 538 - GenLauncherNet/S3StorageHandler.cs | 65 - GenLauncherNet/Updaters/DownloadReadiness.cs | 20 - GenLauncherNet/Updaters/DownloadResult.cs | 16 - GenLauncherNet/Updaters/FTPUpdater.cs | 56 - .../Updaters/HttpSingleFileUpdater.cs | 361 - GenLauncherNet/Updaters/IUpdater.cs | 21 - GenLauncherNet/Updaters/IUpdaterFactory.cs | 13 - GenLauncherNet/Updaters/S3Updater.cs | 456 - GenLauncherNet/Updaters/UpdaterFactory.cs | 35 - GenLauncherNet/Utility/BigHandler.cs | 193 - .../Utility/BlackWhiteImageGenerator.cs | 49 - GenLauncherNet/Utility/DownloadLinkParser.cs | 48 - GenLauncherNet/Utility/FilesHandler.cs | 40 - GenLauncherNet/Utility/GameOptionsHandler.cs | 154 - GenLauncherNet/Utility/GeneralUtilities.cs | 56 - GenLauncherNet/Utility/GentoolHandler.cs | 157 - GenLauncherNet/Utility/LocalizedStrings.cs | 39 - .../Utility/MD5ChecksumCalculator.cs | 26 - GenLauncherNet/Utility/SymbolicLinkHandler.cs | 157 - GenLauncherNet/Utility/TimeUtility.cs | 140 - GenLauncherNet/Utility/Unpacker.cs | 155 - GenLauncherNet/Utility/VulkanDllsHandler.cs | 80 - GenLauncherNet/WPFElements/ChangeLogButton.cs | 15 - GenLauncherNet/WPFElements/GridControls.cs | 57 - GenLauncherNet/WPFElements/InfoButton.cs | 13 - GenLauncherNet/WPFElements/InfoTextBlock.cs | 13 - GenLauncherNet/WPFElements/NameTextBox.cs | 13 - .../WPFElements/NetworkInfoButton.cs | 15 - GenLauncherNet/WPFElements/UpdateButton.cs | 85 - GenLauncherNet/WPFElements/VersionTextBox.cs | 13 - .../Windows/AddModificationWindow.xaml | 378 - .../Windows/AddModificationWindow.xaml.cs | 62 - GenLauncherNet/Windows/InfoWindow.xaml | 90 - GenLauncherNet/Windows/InfoWindow.xaml.cs | 78 - GenLauncherNet/Windows/InitWindow.xaml | 33 - GenLauncherNet/Windows/InitWindow.xaml.cs | 345 - GenLauncherNet/Windows/MainWindow.xaml | 875 - GenLauncherNet/Windows/MainWindow.xaml.cs | 2608 -- .../Windows/ManualAddMidificationWindow.xaml | 111 - .../ManualAddMidificationWindow.xaml.cs | 131 - GenLauncherNet/Windows/OptionsWindow.xaml | 972 - GenLauncherNet/Windows/OptionsWindow.xaml.cs | 575 - GenLauncherNet/Windows/UpdateAvailable.xaml | 86 - .../Windows/UpdateAvailable.xaml.cs | 52 - GenLauncherNet/Windows/VisualDictionary.xaml | 807 - GenLauncherNet/app.manifest | 79 - GenLauncherNet/app1.manifest | 76 - GenLauncherNet/d3d8.cfg | 15 - GenLauncherNet/packages.config | 61 - ModificationContainer.cs | 544 - README.md | 141 +- WpfSurface.dxvk-cache | Bin 1464 -> 0 bytes packages/Crc32.NET.1.2.0/.signature.p7s | Bin 9461 -> 0 bytes .../Crc32.NET.1.2.0/Crc32.NET.1.2.0.nupkg | Bin 26123 -> 0 bytes .../Crc32.NET.1.2.0/lib/net20/Crc32.NET.dll | Bin 7680 -> 0 bytes .../Crc32.NET.1.2.0/lib/net20/Crc32.NET.xml | 220 - .../lib/netstandard1.3/Crc32.NET.dll | Bin 7680 -> 0 bytes .../lib/netstandard1.3/Crc32.NET.xml | 220 - .../lib/netstandard2.0/Crc32.NET.dll | Bin 7680 -> 0 bytes .../lib/netstandard2.0/Crc32.NET.xml | 220 - packages/Minio.3.1.13/.signature.p7s | Bin 9461 -> 0 bytes packages/Minio.3.1.13/Minio.3.1.13.nupkg | Bin 422303 -> 0 bytes packages/Minio.3.1.13/lib/net46/Minio.dll | Bin 305664 -> 0 bytes packages/Minio.3.1.13/lib/net46/Minio.xml | 1517 - .../Minio.3.1.13/lib/netstandard2.0/Minio.dll | Bin 305152 -> 0 bytes .../Minio.3.1.13/lib/netstandard2.0/Minio.xml | 1517 - packages/RestSharp.106.10.1/.signature.p7s | Bin 9489 -> 0 bytes .../RestSharp.106.10.1.nupkg | Bin 213774 -> 0 bytes .../lib/net452/RestSharp.dll | Bin 185856 -> 0 bytes .../lib/net452/RestSharp.xml | 3722 --- .../lib/netstandard2.0/RestSharp.dll | Bin 185856 -> 0 bytes .../lib/netstandard2.0/RestSharp.xml | 3722 --- .../SymbolicLinkSupport.1.2.0/.signature.p7s | Bin 9472 -> 0 bytes .../SymbolicLinkSupport.1.2.0.nupkg | Bin 22850 -> 0 bytes .../lib/net35/SymbolicLinkSupport.dll | Bin 8704 -> 0 bytes .../lib/net35/SymbolicLinkSupport.xml | 122 - .../netstandard1.3/SymbolicLinkSupport.dll | Bin 9216 -> 0 bytes .../netstandard1.3/SymbolicLinkSupport.xml | 122 - packages/System.Reactive.4.0.0/.signature.p7s | Bin 18549 -> 0 bytes .../System.Reactive.4.0.0.nupkg | Bin 2300907 -> 0 bytes .../lib/net46/System.Reactive.dll | Bin 1211392 -> 0 bytes .../lib/net46/System.Reactive.xml | 26072 --------------- .../lib/netstandard2.0/System.Reactive.dll | Bin 1196544 -> 0 bytes .../lib/netstandard2.0/System.Reactive.xml | 25670 --------------- .../lib/uap10.0.16299/System.Reactive.dll | Bin 1226240 -> 0 bytes .../lib/uap10.0.16299/System.Reactive.pri | Bin 688 -> 0 bytes .../lib/uap10.0.16299/System.Reactive.xml | 26234 ---------------- .../lib/uap10.0/System.Reactive.dll | Bin 1375232 -> 0 bytes .../lib/uap10.0/System.Reactive.pri | Bin 688 -> 0 bytes .../lib/uap10.0/System.Reactive.xml | 26220 --------------- .../System.Reactive.Linq.4.0.0/.signature.p7s | Bin 18549 -> 0 bytes .../System.Reactive.Linq.4.0.0.nupkg | Bin 45179 -> 0 bytes .../lib/net46/System.Reactive.Linq.dll | Bin 14336 -> 0 bytes .../lib/net46/System.Reactive.Linq.xml | 8 - .../netstandard2.0/System.Reactive.Linq.dll | Bin 14336 -> 0 bytes .../netstandard2.0/System.Reactive.Linq.xml | 8 - .../lib/uap10.0/System.Reactive.Linq.dll | Bin 14336 -> 0 bytes .../lib/uap10.0/System.Reactive.Linq.pri | Bin 704 -> 0 bytes .../lib/uap10.0/System.Reactive.Linq.xml | 8 - packages/YamlDotNet.11.2.1/.signature.p7s | Bin 9462 -> 0 bytes packages/YamlDotNet.11.2.1/LICENSE.txt | 19 - .../YamlDotNet.11.2.1/YamlDotNet.11.2.1.nupkg | Bin 707795 -> 0 bytes .../YamlDotNet.11.2.1/images/yamldotnet.png | Bin 2669 -> 0 bytes .../lib/net20/YamlDotNet.dll | Bin 246784 -> 0 bytes .../lib/net20/YamlDotNet.xml | 4913 --- .../lib/net35-client/YamlDotNet.dll | Bin 223744 -> 0 bytes .../lib/net35-client/YamlDotNet.xml | 4929 --- .../lib/net35/YamlDotNet.dll | Bin 225792 -> 0 bytes .../lib/net35/YamlDotNet.xml | 4936 --- .../lib/net45/YamlDotNet.dll | Bin 222208 -> 0 bytes .../lib/net45/YamlDotNet.xml | 4936 --- .../lib/netstandard1.3/YamlDotNet.dll | Bin 224256 -> 0 bytes .../lib/netstandard1.3/YamlDotNet.xml | 4929 --- .../lib/netstandard2.1/YamlDotNet.dll | Bin 220160 -> 0 bytes .../lib/netstandard2.1/YamlDotNet.xml | 4793 --- test.txt | 6 - 611 files changed, 53994 insertions(+), 167041 deletions(-) create mode 100644 .agents/skills/dotnet-patterns/SKILL.md create mode 100644 .editorconfig delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 AGENTS.md create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props delete mode 100644 GenLauncher.sln create mode 100644 GenLauncherGO.Core/AGENTS.md create mode 100644 GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs create mode 100644 GenLauncherGO.Core/Archives/IArchiveExtractor.cs create mode 100644 GenLauncherGO.Core/GenLauncherGO.Core.csproj create mode 100644 GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs create mode 100644 GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs create mode 100644 GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/DeploymentResult.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs create mode 100644 GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs create mode 100644 GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs create mode 100644 GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs create mode 100644 GenLauncherGO.Core/Mods/Models/AdvertisingData.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs create mode 100644 GenLauncherGO.Core/Mods/Models/GameModification.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentState.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentType.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherData.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationType.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationVersion.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ReposModsData.cs create mode 100644 GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs create mode 100644 GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs create mode 100644 GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs create mode 100644 GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs create mode 100644 GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs create mode 100644 GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs create mode 100644 GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs create mode 100644 GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs create mode 100644 GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs create mode 100644 GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs create mode 100644 GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs create mode 100644 GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs create mode 100644 GenLauncherGO.Core/Startup/ILauncherPathResolver.cs create mode 100644 GenLauncherGO.Core/Startup/LauncherPaths.cs create mode 100644 GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs create mode 100644 GenLauncherGO.Core/Startup/SessionInformation.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs create mode 100644 GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs create mode 100644 GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs create mode 100644 GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs create mode 100644 GenLauncherGO.Core/Updating/Models/DownloadProgress.cs create mode 100644 GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs create mode 100644 GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs create mode 100644 GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs create mode 100644 GenLauncherGO.Infrastructure/AGENTS.md create mode 100644 GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs create mode 100644 GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs create mode 100644 GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj create mode 100644 GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs create mode 100644 GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs create mode 100644 GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs create mode 100644 GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs create mode 100644 GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs create mode 100644 GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs create mode 100644 GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs create mode 100644 GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs create mode 100644 GenLauncherGO.Tests/AGENTS.md create mode 100644 GenLauncherGO.Tests/Core/.gitkeep create mode 100644 GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs create mode 100644 GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs create mode 100644 GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs create mode 100644 GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs create mode 100644 GenLauncherGO.Tests/Core/SessionInformationTests.cs create mode 100644 GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs create mode 100644 GenLauncherGO.Tests/GenLauncherGO.Tests.csproj create mode 100644 GenLauncherGO.Tests/GlobalUsings.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/.gitkeep create mode 100644 GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs create mode 100644 GenLauncherGO.Tests/Testing/.gitkeep create mode 100644 GenLauncherGO.Tests/Testing/TestDirectory.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs create mode 100644 GenLauncherGO.Tests/Testing/TestStringLocalizer.cs create mode 100644 GenLauncherGO.Tests/UI/.gitkeep create mode 100644 GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs create mode 100644 GenLauncherGO.UI/AGENTS.md create mode 100644 GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialogOptions.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/LaunchContentIntegrityCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/LauncherPackageActivityService.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/ViewModels/IntegrityReviewViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Composition/LauncherUiServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Contracts/ILauncherFilePicker.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Contracts/ILauncherWindowWorkflowView.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/ExecutableOption.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/GameClientOption.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherContentTabState.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherContentViewKind.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherContentViewState.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherContextMenuState.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchFailureKind.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchRequest.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchResult.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchTargetKind.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherManualImportKind.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherManualImportRequest.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherManualImportResult.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherTileLinkAction.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Resources/MainWindowResources.xaml create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherContentListService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherContentViewStateService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherExecutableSelectionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherGameArgumentService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherLaunchCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherManualImportCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherModificationDownloadCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherSelectedContentService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherShellNavigationCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherTabStateService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherTileActionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherTileVersionSelectionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherVisualThemeService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/WpfLauncherFilePicker.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherDragDropController.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherSelectionController.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherSelectionControllerFactory.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherWindowListController.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherWindowListControllerFactory.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowManualImportRequestedEventArgs.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowModificationActionKind.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowModificationActionRequestedEventArgs.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowVersionActionRequestedEventArgs.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/MainWindow.xaml create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/MainWindow.xaml.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Composition/ModsUiServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Contracts/ILauncherModsContext.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationViewModelFactory.cs rename GenLauncherNet/Images/uamG.jpg => GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerGenerals.jpg (100%) rename GenLauncherNet/Images/uamZH.jpg => GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerZeroHour.jpg (100%) create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileState.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.xaml create mode 100644 GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.xaml.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Views/InfoWindow.xaml create mode 100644 GenLauncherGO.UI/Features/Mods/Views/InfoWindow.xaml.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Views/ManualAddModificationWindow.xaml create mode 100644 GenLauncherGO.UI/Features/Mods/Views/ManualAddModificationWindow.xaml.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Composition/LauncherSettingsUiServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Contracts/ILauncherCultureService.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Contracts/ILauncherSettingsTextProvider.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Contracts/ILauncherSettingsWindowFactory.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Localization/LocalizedLauncherSettingsTextProvider.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Models/LauncherSettingsText.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Services/LauncherSettingsWindowFactory.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Services/WpfLauncherCultureService.cs create mode 100644 GenLauncherGO.UI/Features/Settings/ViewModels/LauncherSettingsViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherSettingsWindow.xaml create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherSettingsWindow.xaml.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Composition/StartupUiServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Contracts/IStartupDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Startup/EntryPoint.cs create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherApplicationDefaults.cs create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherApplicationHost.cs create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherRuntimeContext.cs create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherWpfApplication.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/InitWindowWorkflowCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/WpfStartupDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/InitWindowStartupCompletedEventArgs.cs create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/InitWindowViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Views/InitWindow.xaml create mode 100644 GenLauncherGO.UI/Features/Startup/Views/InitWindow.xaml.cs create mode 100644 GenLauncherGO.UI/GenLauncherGO.UI.csproj create mode 100644 GenLauncherGO.UI/Properties/AssemblyInfo.cs create mode 100644 GenLauncherGO.UI/Resources/Strings.ar.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.cs create mode 100644 GenLauncherGO.UI/Resources/Strings.de.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.es.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.fr.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.hr.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.pt.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.ru.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.tr.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.uk.resx create mode 100644 GenLauncherGO.UI/Resources/Strings.zh.resx create mode 100644 GenLauncherGO.UI/Shared/Commands/AsyncRelayCommand.cs create mode 100644 GenLauncherGO.UI/Shared/Commands/RelayCommand.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/InfoTextBlock.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherLoadingIndicator.xaml create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherLoadingIndicator.xaml.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/NameTextBox.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/UpdateButton.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/VersionTextBox.cs create mode 100644 GenLauncherGO.UI/Shared/Formatting/PackageProgressTextFormatter.cs create mode 100644 GenLauncherGO.UI/Shared/Localization/ILauncherStringCultureSetter.cs create mode 100644 GenLauncherGO.UI/Shared/Localization/ILauncherStringLocalizer.cs create mode 100644 GenLauncherGO.UI/Shared/Localization/WpfLauncherStringLocalizer.cs rename GenLauncherNet/fd.ico => GenLauncherGO.UI/Shared/Resources/Icons/GenLauncherGo.ico (100%) rename GenLauncherNet/Images/BackgroundGenerals.png => GenLauncherGO.UI/Shared/Resources/Images/LauncherBackgroundGenerals.png (100%) rename GenLauncherNet/Background.png => GenLauncherGO.UI/Shared/Resources/Images/LauncherBackgroundZeroHour.png (100%) rename GenLauncherNet/Images/gl01.png => GenLauncherGO.UI/Shared/Resources/Images/LauncherEmblemCompact.png (100%) rename GenLauncherNet/Images/gl02.png => GenLauncherGO.UI/Shared/Resources/Images/LauncherEmblemFramed.png (100%) rename {GenLauncherNet/Windows => GenLauncherGO.UI/Shared/Themes}/ColorsDictionary.xaml (86%) create mode 100644 GenLauncherGO.UI/Shared/Themes/ColorsInfo.cs create mode 100644 GenLauncherGO.UI/Shared/Themes/GenLauncherStyles.xaml create mode 100644 GenLauncherGO.UI/Shared/Themes/LauncherThemeResourceApplier.cs create mode 100644 GenLauncherGO.sln delete mode 100644 GenLauncherNet/App.config delete mode 100644 GenLauncherNet/App.xaml delete mode 100644 GenLauncherNet/App.xaml.cs delete mode 100644 GenLauncherNet/DataClasses/ColorsInfo.cs delete mode 100644 GenLauncherNet/DataClasses/ComboBoxData.cs delete mode 100644 GenLauncherNet/DataClasses/GameModification.cs delete mode 100644 GenLauncherNet/DataClasses/LauncherData.cs delete mode 100644 GenLauncherNet/DataClasses/ModificationFileInfo.cs delete mode 100644 GenLauncherNet/DataClasses/ModificationVersion.cs delete mode 100644 GenLauncherNet/DataClasses/ModificationViewModel.cs delete mode 100644 GenLauncherNet/DataClasses/ReposModificationsVersion.cs delete mode 100644 GenLauncherNet/DataClasses/ReposModsData.cs delete mode 100644 GenLauncherNet/DataClasses/SessionInformation.cs delete mode 100644 GenLauncherNet/DataClasses/StringConcurrentDictionary.cs delete mode 100644 GenLauncherNet/DataClasses/StringHashSet.cs delete mode 100644 GenLauncherNet/DataClasses/VulkanData.cs delete mode 100644 GenLauncherNet/DataHandler.cs delete mode 100644 GenLauncherNet/Dlls/Minio.dll delete mode 100644 GenLauncherNet/Dlls/RestSharp.dll delete mode 100644 GenLauncherNet/Dlls/SevenZipExtractor.dll delete mode 100644 GenLauncherNet/Dlls/SymbolicLinkSupport.dll delete mode 100644 GenLauncherNet/Dlls/System.Reactive.dll delete mode 100644 GenLauncherNet/Dlls/WPFLocalizeExtension.dll delete mode 100644 GenLauncherNet/Dlls/XAMLMarkupExtensions.dll delete mode 100644 GenLauncherNet/Dlls/YamlDotNet.dll delete mode 100644 GenLauncherNet/Dlls/ar/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/de/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/es/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/fr/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/hr/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/pt/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/ru/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/tr/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/uk/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/Dlls/x64/7z.dll delete mode 100644 GenLauncherNet/Dlls/x86/7z.dll delete mode 100644 GenLauncherNet/Dlls/zh/GenLauncher.resources.dll delete mode 100644 GenLauncherNet/EntryPoint.cs delete mode 100644 GenLauncherNet/FodyWeavers.xml delete mode 100644 GenLauncherNet/GameLauncher.cs delete mode 100644 GenLauncherNet/GenLauncher.csproj delete mode 100644 GenLauncherNet/GenLauncher.csproj.user delete mode 100644 GenLauncherNet/HttpHandlers/ContentDownloader.cs delete mode 100644 GenLauncherNet/HttpHandlers/GitHubMainDataReader.cs delete mode 100644 GenLauncherNet/HttpHandlers/GitHubYamlReader.cs delete mode 100644 GenLauncherNet/Images/Background.png delete mode 100644 GenLauncherNet/Images/vulkan.png delete mode 100644 GenLauncherNet/ModificationsFileHandler.cs delete mode 100644 GenLauncherNet/Options/options.ini delete mode 100644 GenLauncherNet/Properties/AssemblyInfo.cs delete mode 100644 GenLauncherNet/Properties/Resources.Designer.cs delete mode 100644 GenLauncherNet/Properties/Resources.resx delete mode 100644 GenLauncherNet/Properties/Settings.Designer.cs delete mode 100644 GenLauncherNet/Properties/Settings.settings delete mode 100644 GenLauncherNet/Resources/Strings.Designer.cs delete mode 100644 GenLauncherNet/Resources/Strings.ar.resx delete mode 100644 GenLauncherNet/Resources/Strings.de.resx delete mode 100644 GenLauncherNet/Resources/Strings.es.resx delete mode 100644 GenLauncherNet/Resources/Strings.fr.resx delete mode 100644 GenLauncherNet/Resources/Strings.hr.resx delete mode 100644 GenLauncherNet/Resources/Strings.pt.resx delete mode 100644 GenLauncherNet/Resources/Strings.resx delete mode 100644 GenLauncherNet/Resources/Strings.ru.Designer.cs delete mode 100644 GenLauncherNet/Resources/Strings.ru.resx delete mode 100644 GenLauncherNet/Resources/Strings.tr.resx delete mode 100644 GenLauncherNet/Resources/Strings.uk.resx delete mode 100644 GenLauncherNet/Resources/Strings.zh.resx delete mode 100644 GenLauncherNet/S3StorageHandler.cs delete mode 100644 GenLauncherNet/Updaters/DownloadReadiness.cs delete mode 100644 GenLauncherNet/Updaters/DownloadResult.cs delete mode 100644 GenLauncherNet/Updaters/FTPUpdater.cs delete mode 100644 GenLauncherNet/Updaters/HttpSingleFileUpdater.cs delete mode 100644 GenLauncherNet/Updaters/IUpdater.cs delete mode 100644 GenLauncherNet/Updaters/IUpdaterFactory.cs delete mode 100644 GenLauncherNet/Updaters/S3Updater.cs delete mode 100644 GenLauncherNet/Updaters/UpdaterFactory.cs delete mode 100644 GenLauncherNet/Utility/BigHandler.cs delete mode 100644 GenLauncherNet/Utility/BlackWhiteImageGenerator.cs delete mode 100644 GenLauncherNet/Utility/DownloadLinkParser.cs delete mode 100644 GenLauncherNet/Utility/FilesHandler.cs delete mode 100644 GenLauncherNet/Utility/GameOptionsHandler.cs delete mode 100644 GenLauncherNet/Utility/GeneralUtilities.cs delete mode 100644 GenLauncherNet/Utility/GentoolHandler.cs delete mode 100644 GenLauncherNet/Utility/LocalizedStrings.cs delete mode 100644 GenLauncherNet/Utility/MD5ChecksumCalculator.cs delete mode 100644 GenLauncherNet/Utility/SymbolicLinkHandler.cs delete mode 100644 GenLauncherNet/Utility/TimeUtility.cs delete mode 100644 GenLauncherNet/Utility/Unpacker.cs delete mode 100644 GenLauncherNet/Utility/VulkanDllsHandler.cs delete mode 100644 GenLauncherNet/WPFElements/ChangeLogButton.cs delete mode 100644 GenLauncherNet/WPFElements/GridControls.cs delete mode 100644 GenLauncherNet/WPFElements/InfoButton.cs delete mode 100644 GenLauncherNet/WPFElements/InfoTextBlock.cs delete mode 100644 GenLauncherNet/WPFElements/NameTextBox.cs delete mode 100644 GenLauncherNet/WPFElements/NetworkInfoButton.cs delete mode 100644 GenLauncherNet/WPFElements/UpdateButton.cs delete mode 100644 GenLauncherNet/WPFElements/VersionTextBox.cs delete mode 100644 GenLauncherNet/Windows/AddModificationWindow.xaml delete mode 100644 GenLauncherNet/Windows/AddModificationWindow.xaml.cs delete mode 100644 GenLauncherNet/Windows/InfoWindow.xaml delete mode 100644 GenLauncherNet/Windows/InfoWindow.xaml.cs delete mode 100644 GenLauncherNet/Windows/InitWindow.xaml delete mode 100644 GenLauncherNet/Windows/InitWindow.xaml.cs delete mode 100644 GenLauncherNet/Windows/MainWindow.xaml delete mode 100644 GenLauncherNet/Windows/MainWindow.xaml.cs delete mode 100644 GenLauncherNet/Windows/ManualAddMidificationWindow.xaml delete mode 100644 GenLauncherNet/Windows/ManualAddMidificationWindow.xaml.cs delete mode 100644 GenLauncherNet/Windows/OptionsWindow.xaml delete mode 100644 GenLauncherNet/Windows/OptionsWindow.xaml.cs delete mode 100644 GenLauncherNet/Windows/UpdateAvailable.xaml delete mode 100644 GenLauncherNet/Windows/UpdateAvailable.xaml.cs delete mode 100644 GenLauncherNet/Windows/VisualDictionary.xaml delete mode 100644 GenLauncherNet/app.manifest delete mode 100644 GenLauncherNet/app1.manifest delete mode 100644 GenLauncherNet/d3d8.cfg delete mode 100644 GenLauncherNet/packages.config delete mode 100644 ModificationContainer.cs delete mode 100644 WpfSurface.dxvk-cache delete mode 100644 packages/Crc32.NET.1.2.0/.signature.p7s delete mode 100644 packages/Crc32.NET.1.2.0/Crc32.NET.1.2.0.nupkg delete mode 100644 packages/Crc32.NET.1.2.0/lib/net20/Crc32.NET.dll delete mode 100644 packages/Crc32.NET.1.2.0/lib/net20/Crc32.NET.xml delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard1.3/Crc32.NET.dll delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard1.3/Crc32.NET.xml delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard2.0/Crc32.NET.dll delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard2.0/Crc32.NET.xml delete mode 100644 packages/Minio.3.1.13/.signature.p7s delete mode 100644 packages/Minio.3.1.13/Minio.3.1.13.nupkg delete mode 100644 packages/Minio.3.1.13/lib/net46/Minio.dll delete mode 100644 packages/Minio.3.1.13/lib/net46/Minio.xml delete mode 100644 packages/Minio.3.1.13/lib/netstandard2.0/Minio.dll delete mode 100644 packages/Minio.3.1.13/lib/netstandard2.0/Minio.xml delete mode 100644 packages/RestSharp.106.10.1/.signature.p7s delete mode 100644 packages/RestSharp.106.10.1/RestSharp.106.10.1.nupkg delete mode 100644 packages/RestSharp.106.10.1/lib/net452/RestSharp.dll delete mode 100644 packages/RestSharp.106.10.1/lib/net452/RestSharp.xml delete mode 100644 packages/RestSharp.106.10.1/lib/netstandard2.0/RestSharp.dll delete mode 100644 packages/RestSharp.106.10.1/lib/netstandard2.0/RestSharp.xml delete mode 100644 packages/SymbolicLinkSupport.1.2.0/.signature.p7s delete mode 100644 packages/SymbolicLinkSupport.1.2.0/SymbolicLinkSupport.1.2.0.nupkg delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/net35/SymbolicLinkSupport.dll delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/net35/SymbolicLinkSupport.xml delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/netstandard1.3/SymbolicLinkSupport.dll delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/netstandard1.3/SymbolicLinkSupport.xml delete mode 100644 packages/System.Reactive.4.0.0/.signature.p7s delete mode 100644 packages/System.Reactive.4.0.0/System.Reactive.4.0.0.nupkg delete mode 100644 packages/System.Reactive.4.0.0/lib/net46/System.Reactive.dll delete mode 100644 packages/System.Reactive.4.0.0/lib/net46/System.Reactive.xml delete mode 100644 packages/System.Reactive.4.0.0/lib/netstandard2.0/System.Reactive.dll delete mode 100644 packages/System.Reactive.4.0.0/lib/netstandard2.0/System.Reactive.xml delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0.16299/System.Reactive.dll delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0.16299/System.Reactive.pri delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0.16299/System.Reactive.xml delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0/System.Reactive.dll delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0/System.Reactive.pri delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0/System.Reactive.xml delete mode 100644 packages/System.Reactive.Linq.4.0.0/.signature.p7s delete mode 100644 packages/System.Reactive.Linq.4.0.0/System.Reactive.Linq.4.0.0.nupkg delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/net46/System.Reactive.Linq.dll delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/net46/System.Reactive.Linq.xml delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/netstandard2.0/System.Reactive.Linq.dll delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/netstandard2.0/System.Reactive.Linq.xml delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/uap10.0/System.Reactive.Linq.dll delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/uap10.0/System.Reactive.Linq.pri delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/uap10.0/System.Reactive.Linq.xml delete mode 100644 packages/YamlDotNet.11.2.1/.signature.p7s delete mode 100644 packages/YamlDotNet.11.2.1/LICENSE.txt delete mode 100644 packages/YamlDotNet.11.2.1/YamlDotNet.11.2.1.nupkg delete mode 100644 packages/YamlDotNet.11.2.1/images/yamldotnet.png delete mode 100644 packages/YamlDotNet.11.2.1/lib/net20/YamlDotNet.dll delete mode 100644 packages/YamlDotNet.11.2.1/lib/net20/YamlDotNet.xml delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35-client/YamlDotNet.dll delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35-client/YamlDotNet.xml delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35/YamlDotNet.dll delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35/YamlDotNet.xml delete mode 100644 packages/YamlDotNet.11.2.1/lib/net45/YamlDotNet.dll delete mode 100644 packages/YamlDotNet.11.2.1/lib/net45/YamlDotNet.xml delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard1.3/YamlDotNet.dll delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard1.3/YamlDotNet.xml delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard2.1/YamlDotNet.dll delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard2.1/YamlDotNet.xml delete mode 100644 test.txt diff --git a/.agents/skills/dotnet-patterns/SKILL.md b/.agents/skills/dotnet-patterns/SKILL.md new file mode 100644 index 00000000..b54fb766 --- /dev/null +++ b/.agents/skills/dotnet-patterns/SKILL.md @@ -0,0 +1,321 @@ +--- +name: dotnet-patterns +description: Idiomatic C# and .NET patterns, conventions, dependency injection, async/await, and best practices for building robust, maintainable .NET applications. +origin: ECC +--- + +# .NET Development Patterns + +Idiomatic C# and .NET patterns for building robust, performant, and maintainable applications. + +## When to Activate + +- Writing new C# code +- Reviewing C# code +- Refactoring existing .NET applications +- Designing service architectures with ASP.NET Core + +## Core Principles + +### 1. Prefer Immutability + +Use records and init-only properties for data models. Mutability should be an explicit, justified choice. + +```csharp +// Good: Immutable value object +public sealed record Money(decimal Amount, string Currency); + +// Good: Immutable DTO with init setters +public sealed class CreateOrderRequest +{ + public required string CustomerId { get; init; } + public required IReadOnlyList Items { get; init; } +} + +// Bad: Mutable model with public setters +public class Order +{ + public string CustomerId { get; set; } + public List Items { get; set; } +} +``` + +### 2. Explicit Over Implicit + +Be clear about nullability, access modifiers, and intent. + +```csharp +// Good: Explicit access modifiers and nullability +public sealed class UserService +{ + private readonly IUserRepository _repository; + private readonly ILogger _logger; + + public UserService(IUserRepository repository, ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await _repository.FindByIdAsync(id, cancellationToken); + } +} +``` + +### 3. Depend on Abstractions + +Use interfaces for service boundaries. Register via DI container. + +```csharp +// Good: Interface-based dependency +public interface IOrderRepository +{ + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); + Task> FindByCustomerAsync(string customerId, CancellationToken cancellationToken); + Task AddAsync(Order order, CancellationToken cancellationToken); +} + +// Registration +builder.Services.AddScoped(); +``` + +## Async/Await Patterns + +### Proper Async Usage + +```csharp +// Good: Async all the way, with CancellationToken +public async Task GetOrderSummaryAsync( + Guid orderId, + CancellationToken cancellationToken) +{ + var order = await _repository.FindByIdAsync(orderId, cancellationToken) + ?? throw new NotFoundException($"Order {orderId} not found"); + + var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken); + + return new OrderSummary(order, customer); +} + +// Bad: Blocking on async +public OrderSummary GetOrderSummary(Guid orderId) +{ + var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result; // Deadlock risk + return new OrderSummary(order); +} +``` + +### Parallel Async Operations + +```csharp +// Good: Concurrent independent operations +public async Task LoadDashboardAsync(CancellationToken cancellationToken) +{ + var ordersTask = _orderService.GetRecentAsync(cancellationToken); + var metricsTask = _metricsService.GetCurrentAsync(cancellationToken); + var alertsTask = _alertService.GetActiveAsync(cancellationToken); + + await Task.WhenAll(ordersTask, metricsTask, alertsTask); + + return new DashboardData( + Orders: await ordersTask, + Metrics: await metricsTask, + Alerts: await alertsTask); +} +``` + +## Options Pattern + +Bind configuration sections to strongly-typed objects. + +```csharp +public sealed class SmtpOptions +{ + public const string SectionName = "Smtp"; + + public required string Host { get; init; } + public required int Port { get; init; } + public required string Username { get; init; } + public bool UseSsl { get; init; } = true; +} + +// Registration +builder.Services.Configure( + builder.Configuration.GetSection(SmtpOptions.SectionName)); + +// Usage via injection +public class EmailService(IOptions options) +{ + private readonly SmtpOptions _smtp = options.Value; +} +``` + +## Result Pattern + +Return explicit success/failure instead of throwing for expected failures. + +```csharp +public sealed record Result +{ + public bool IsSuccess { get; } + public T? Value { get; } + public string? Error { get; } + + private Result(T value) { IsSuccess = true; Value = value; } + private Result(string error) { IsSuccess = false; Error = error; } + + public static Result Success(T value) => new(value); + public static Result Failure(string error) => new(error); +} + +// Usage +public async Task> PlaceOrderAsync(CreateOrderRequest request) +{ + if (request.Items.Count == 0) + return Result.Failure("Order must contain at least one item"); + + var order = Order.Create(request); + await _repository.AddAsync(order, CancellationToken.None); + return Result.Success(order); +} +``` + +## Repository Pattern with EF Core + +```csharp +public sealed class SqlOrderRepository : IOrderRepository +{ + private readonly AppDbContext _db; + + public SqlOrderRepository(AppDbContext db) => _db = db; + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await _db.Orders + .Include(o => o.Items) + .AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + } + + public async Task> FindByCustomerAsync( + string customerId, + CancellationToken cancellationToken) + { + return await _db.Orders + .Where(o => o.CustomerId == customerId) + .OrderByDescending(o => o.CreatedAt) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task AddAsync(Order order, CancellationToken cancellationToken) + { + _db.Orders.Add(order); + await _db.SaveChangesAsync(cancellationToken); + } +} +``` + +## Middleware and Pipeline + +```csharp +// Custom middleware +public sealed class RequestTimingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestTimingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await _next(context); + } + finally + { + stopwatch.Stop(); + _logger.LogInformation( + "Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}", + context.Request.Method, + context.Request.Path, + stopwatch.ElapsedMilliseconds, + context.Response.StatusCode); + } + } +} +``` + +## Minimal API Patterns + +```csharp +// Organized with route groups +var orders = app.MapGroup("/api/orders") + .RequireAuthorization() + .WithTags("Orders"); + +orders.MapGet("/{id:guid}", async ( + Guid id, + IOrderRepository repository, + CancellationToken cancellationToken) => +{ + var order = await repository.FindByIdAsync(id, cancellationToken); + return order is not null + ? TypedResults.Ok(order) + : TypedResults.NotFound(); +}); + +orders.MapPost("/", async ( + CreateOrderRequest request, + IOrderService service, + CancellationToken cancellationToken) => +{ + var result = await service.PlaceOrderAsync(request, cancellationToken); + return result.IsSuccess + ? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value) + : TypedResults.BadRequest(result.Error); +}); +``` + +## Guard Clauses + +```csharp +// Good: Early returns with clear validation +public async Task ProcessPaymentAsync( + PaymentRequest request, + CancellationToken cancellationToken) +{ + ArgumentNullException.ThrowIfNull(request); + + if (request.Amount <= 0) + throw new ArgumentOutOfRangeException(nameof(request.Amount), "Amount must be positive"); + + if (string.IsNullOrWhiteSpace(request.Currency)) + throw new ArgumentException("Currency is required", nameof(request.Currency)); + + // Happy path continues here without nesting + var gateway = _gatewayFactory.Create(request.Currency); + return await gateway.ChargeAsync(request, cancellationToken); +} +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Fix | +|---|---| +| `async void` methods | Return `Task` (except event handlers) | +| `.Result` or `.Wait()` | Use `await` | +| `catch (Exception) { }` | Handle or rethrow with context | +| `new Service()` in constructors | Use constructor injection | +| `public` fields | Use properties with appropriate accessors | +| `dynamic` in business logic | Use generics or explicit types | +| Mutable `static` state | Use DI scoping or `ConcurrentDictionary` | +| `string.Format` in loops | Use `StringBuilder` or interpolated string handlers | diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..57282c5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,164 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{csproj,props,targets,slnx}] +indent_size = 2 + +[*.cs] +indent_size = 4 + +# Baseline C# style. Keep legacy code mostly advisory while the refactor is in +# progress; stricter severities are applied to the new GenLauncherGO.* projects +# below. +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_primary_constructors = false:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_expression_bodied_methods = false:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = false:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_prefer_braces = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_static_local_function = true:suggestion + +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Naming rules. +dotnet_naming_rule.types_are_pascal_case.symbols = types +dotnet_naming_rule.types_are_pascal_case.style = pascal_case +dotnet_naming_rule.types_are_pascal_case.severity = suggestion +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_rule.interfaces_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_start_with_i.style = prefix_interface_with_i +dotnet_naming_rule.interfaces_start_with_i.severity = suggestion +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.prefix_interface_with_i.required_prefix = I +dotnet_naming_style.prefix_interface_with_i.capitalization = pascal_case + +dotnet_naming_rule.members_are_pascal_case.symbols = members +dotnet_naming_rule.members_are_pascal_case.style = pascal_case +dotnet_naming_rule.members_are_pascal_case.severity = suggestion +dotnet_naming_symbols.members.applicable_kinds = property, method, event + +dotnet_naming_rule.non_private_fields_are_pascal_case.symbols = non_private_fields +dotnet_naming_rule.non_private_fields_are_pascal_case.style = pascal_case +dotnet_naming_rule.non_private_fields_are_pascal_case.severity = suggestion +dotnet_naming_symbols.non_private_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected + +dotnet_naming_rule.private_fields_are_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_are_camel_case.style = underscore_camel_case +dotnet_naming_rule.private_fields_are_camel_case.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case + +dotnet_naming_rule.constants_are_pascal_case.symbols = constants +dotnet_naming_rule.constants_are_pascal_case.style = pascal_case +dotnet_naming_rule.constants_are_pascal_case.severity = suggestion +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_rule.async_methods_end_in_async.symbols = async_methods +dotnet_naming_rule.async_methods_end_in_async.style = suffix_async +dotnet_naming_rule.async_methods_end_in_async.severity = suggestion +dotnet_naming_symbols.async_methods.applicable_kinds = method +dotnet_naming_symbols.async_methods.required_modifiers = async +dotnet_naming_style.suffix_async.required_suffix = Async +dotnet_naming_style.suffix_async.capitalization = pascal_case + +dotnet_naming_rule.parameters_are_camel_case.symbols = parameters +dotnet_naming_rule.parameters_are_camel_case.style = camel_case +dotnet_naming_rule.parameters_are_camel_case.severity = suggestion +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_rule.type_parameters_start_with_t.symbols = type_parameters +dotnet_naming_rule.type_parameters_start_with_t.style = prefix_type_parameter_with_t +dotnet_naming_rule.type_parameters_start_with_t.severity = suggestion +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_style.prefix_type_parameter_with_t.required_prefix = T +dotnet_naming_style.prefix_type_parameter_with_t.capitalization = pascal_case + +# New architecture code should follow the conventions as build-enforced errors +# from day one. Legacy GenLauncherNet code remains advisory while migration is +# in progress. +[GenLauncherGO.*/**.cs] +csharp_style_namespace_declarations = file_scoped:warning +csharp_prefer_braces = true:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = false:warning +dotnet_style_readonly_field = true:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_naming_rule.types_are_pascal_case.severity = error +dotnet_naming_rule.interfaces_start_with_i.severity = error +dotnet_naming_rule.members_are_pascal_case.severity = error +dotnet_naming_rule.non_private_fields_are_pascal_case.severity = error +dotnet_naming_rule.private_fields_are_camel_case.severity = error +dotnet_naming_rule.constants_are_pascal_case.severity = error +dotnet_naming_rule.async_methods_end_in_async.severity = error +dotnet_naming_rule.parameters_are_camel_case.severity = error +dotnet_naming_rule.type_parameters_start_with_t.severity = error +dotnet_diagnostic.IDE1006.severity = error +dotnet_diagnostic.IDE0130.severity = warning + +# All production types and members in the new GenLauncherGO.* projects must have +# XML documentation. CS1591 enforces externally visible APIs today; internal and +# private member documentation remains a manual review requirement until a +# repository analyzer enforces it. +dotnet_diagnostic.CS1591.severity = warning +dotnet_diagnostic.CS1570.severity = warning +dotnet_diagnostic.CS1572.severity = warning +dotnet_diagnostic.CS1573.severity = warning + +[*.xaml] +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4d4f0dc8..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Report a bug with GenLauncher. -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - Operating System - - GenLauncher Version - - Game being managed (Generals or Zero Hour) - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 9adf2b42..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest a feature or enhancement to GenLauncher. -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index 4f3be99f..106fbbc2 100644 --- a/.gitignore +++ b/.gitignore @@ -395,4 +395,5 @@ FodyWeavers.xsd *.msp # JetBrains Rider -*.sln.iml \ No newline at end of file +*.sln.iml +.idea \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5424db1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# GenLauncherGO Agent Guidelines + +GenLauncherGO is a Windows WPF mod management utility and launcher for Command & Conquer: Generals and Zero Hour community clients. + +## Required Commands + +- Restore/build: `dotnet build GenLauncherGO.sln` +- Run tests when behavior or test files change: `dotnet test GenLauncherGO.sln` +- Prefer the narrowest useful verification command, but report clearly if a command could not be run. + +## Repository Facts + +- The solution is `GenLauncherGO.sln`. +- The executable WPF application, localization resources, and composition code live in `GenLauncherGO.UI/`. +- The active architecture projects are `GenLauncherGO.Core/`, `GenLauncherGO.Infrastructure/`, `GenLauncherGO.UI/`, and `GenLauncherGO.Tests/`. +- There is intentionally no `src/` folder. +- Package versions are centralized in `Directory.Packages.props`; project files should use versionless `PackageReference` entries. + +## Always Follow + +- Use `GenLauncherGO` for new user-facing code, documentation, projects, and solution-level names. +- Do not introduce standalone `GenLauncher` names; keep existing `GenLauncherGO` project, assembly, namespace, and file-system names unless the task is explicitly a coordinated rename. +- Keep file-system changes conservative. Launch preparation may touch a user's game folder through symbolic links and file renames. +- Preserve existing behavior unless the user explicitly requests a behavior change. +- Preserve user or local dirty work. Do not revert changes you did not make unless the user explicitly asks. +- Keep unrelated cleanup out of feature work, bug fixes, and maintenance work. +- Follow `.editorconfig`, `Directory.Build.props`, and central package management. +- Prefer file-scoped namespaces, braces for control flow, explicit types unless obvious, `_camelCase` fields, `PascalCase` members, `I` interfaces, and `Async` suffixes. +- Prefer constructor injection over static/global access or service locators. +- Prefer `internal` or `private` until a cross-project contract is intentional. +- Prefer sealed concrete classes unless inheritance is intentional. +- Keep one primary type per file and name the file after that type. +- Avoid catch-all names such as `DataHandler`, `Helper`, `Manager`, or `Utility` for new code. +- Add analyzer suppressions only when there is a clear reason and include a short justification. + +## Required Nested Guidance + +Before editing files under one of these directories, read and follow the nearest nested `AGENTS.md`: + +| Path | Focus | +| --- | --- | +| `GenLauncherGO.Core/` | Domain contracts, models, and side-effect-free logic | +| `GenLauncherGO.Infrastructure/` | File-system, process, network, archive, persistence, and logging adapters | +| `GenLauncherGO.UI/` | WPF executable, presentation, composition root, localization, and UI resources | +| `GenLauncherGO.Tests/` | xUnit test organization and test isolation | + +If a task spans multiple areas, read each relevant nested `AGENTS.md` before editing. + +## Decision Rules + +| Question | Do | +| --- | --- | +| Adding or moving domain logic? | Put dependency-light models, contracts, and orchestration abstractions in `GenLauncherGO.Core/`. | +| Touching disk, network, archives, processes, symbolic links, hashing, or persistence? | Put concrete implementation in `GenLauncherGO.Infrastructure/` behind Core contracts. | +| Adding WPF views, commands, view models, resources, startup composition, executable metadata, or localization behavior? | Put presentation and executable application code in `GenLauncherGO.UI/`. | +| Adding UI text? | Update every `GenLauncherGO.UI/Resources/Strings*.resx` localization resource in the same change with locale-specific text; do not use English fallback copies unless the user explicitly asks for them. | +| Adding packages? | Add or update versions only in `Directory.Packages.props`; keep `PackageReference` entries versionless. | +| Adding tests? | Use xUnit, FluentAssertions, and NSubstitute unless a specific limitation requires otherwise. | + +## Architecture Boundaries + +- `GenLauncherGO.UI` may reference `GenLauncherGO.Core` and `GenLauncherGO.Infrastructure`. +- `GenLauncherGO.Infrastructure` may reference `GenLauncherGO.Core`. +- `GenLauncherGO.Core` must not reference WPF, Infrastructure, UI resources, Windows-specific adapters, or third-party implementation packages. +- `GenLauncherGO.Tests` may reference projects under test. +- Use `GenLauncherGO.UI` as the composition root for dependency injection. +- Do not add mediator, CQRS, or broad service-locator frameworks unless a concrete feature requires them. +- Prefer `ILauncherContentCatalogLoader`, `ILauncherContentCatalogQueries`, and + `ILauncherContentCatalogCommands` for new catalog consumers. Keep `ILauncherContentCatalogService` as a compatibility + aggregate only. +- Do not rename or remove legacy catalog YAML/schema members such as `modDatas`, `originalGameAddons`, or + `originalGamePatches` without a deliberate data/schema compatibility plan. + +## Feature Folder Layering + +- Organize production code by feature/domain first, such as `Updating`, `Launching`, `Mods`, `Settings`, or `Startup`. +- Keep a feature folder flat only while it has one responsibility and a small number of files. +- When a feature contains mixed responsibilities or grows beyond roughly 6 production files, you must add layer folders + inside that feature instead of leaving all files flat. +- Put boundary interfaces and cross-project contracts in `Contracts/`, request/result/value-object/domain data in + `Models/`, concrete workflow implementations in `Services/`, dependency injection registration in `Composition/`, + and narrow adapter helpers in `Support/`. +- Example: use `Launching/Contracts/IDeploymentService.cs`, `Launching/Models/DeploymentRequest.cs`, + `Launching/Services/FileSystemDeploymentService.cs`, and `Launching/Composition/DeploymentServiceCollectionExtensions.cs`. +- Do not create repository-wide type folders such as top-level `Models/`, `Services/`, or `Contracts/`. +- Use subfolders that describe real responsibilities in that feature, such as `Contracts`, `Models`, `Services`, `Clients`, `Options`, `Composition`, `Validation`, or `Support`. +- Keep namespaces aligned with folders when files move into feature subfolders. +- Do not create empty placeholder folders or deep folder nesting before the feature needs it. + +## Documentation And Comments + +- Add XML documentation for every production type and member in `GenLauncherGO.Core`, `GenLauncherGO.Infrastructure`, and `GenLauncherGO.UI`, regardless of accessibility. +- This includes public, protected, internal, private, nested, and helper members unless the file is generated code. +- Keep existing XML documentation current when behavior changes. +- Document side effects in Infrastructure XML docs. +- `CS1591` only enforces externally visible API docs today; agents must manually enforce internal and private XML documentation. +- Do not add comments for obvious code; add short comments only for non-obvious logic, platform quirks, workarounds, or decisions that prevent accidental simplification. + +## Logging + +- Logging is a required design concern for Infrastructure and UI code with meaningful side effects. +- `GenLauncherGO.Core` should not depend on logging by default; return typed results/errors for expected failures. +- `GenLauncherGO.Infrastructure` and `GenLauncherGO.UI` should use `ILogger` for diagnostics around file-system, process, network, archive, persistence, update, launch, and user-flow failures. +- Do not silently swallow exceptions. Log with useful context or convert to a typed result that preserves diagnostic detail. +- Current build settings do not mechanically prove that every required path logs; agents must enforce this during implementation and review. + +## Git Workflow + +- Use Conventional Commits when creating commits: `type(scope): short imperative summary`. +- Include a scope whenever the affected area is clear. +- Omit the scope only when the commit is truly repository-wide or no concise scope fits. +- Derive the scope from the changed project, architectural boundary, feature, or workflow instead of using a hardcoded scope list. +- Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `build`, `ci`, and `chore`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..6eff2ad6 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + 14.0 + enable + disable + latest + true + false + true + + + + true + $(WarningsAsErrors);CS1591 + + + + $(WarningsAsErrors);IDE1006 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..8254e4d8 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,30 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GenLauncher.sln b/GenLauncher.sln deleted file mode 100644 index 0a07d21f..00000000 --- a/GenLauncher.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.3.32901.215 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenLauncher", "GenLauncherNet\GenLauncher.csproj", "{4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug for Generals|Any CPU = Debug for Generals|Any CPU - Debug for Zero Hour|Any CPU = Debug for Zero Hour|Any CPU - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Generals|Any CPU.ActiveCfg = Debug for Generals|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Generals|Any CPU.Build.0 = Debug for Generals|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Zero Hour|Any CPU.ActiveCfg = Debug for Zero Hour|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Zero Hour|Any CPU.Build.0 = Debug for Zero Hour|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {D7CECCC4-7CE9-476B-9874-1B6F7FA506C0} - EndGlobalSection -EndGlobal diff --git a/GenLauncherGO.Core/AGENTS.md b/GenLauncherGO.Core/AGENTS.md new file mode 100644 index 00000000..d45859b8 --- /dev/null +++ b/GenLauncherGO.Core/AGENTS.md @@ -0,0 +1,31 @@ +# GenLauncherGO.Core Agent Guidelines + +`GenLauncherGO.Core/` owns dependency-light application models, contracts, validation, and orchestration abstractions. + +## Do + +- Keep Core independent of WPF, UI resources, Infrastructure, Windows APIs, disk, network, processes, S3, archive + packages, hashing implementations, and symbolic-link implementations. +- Define interfaces for infrastructure boundaries and test seams. +- Prefer immutable models for new Core data shapes. +- Prefer explicit result/failure models for expected failures, especially launch validation and update readiness. +- Pass `CancellationToken` through new async APIs. +- Add XML documentation for every production type and member, regardless of accessibility. +- Prefer feature folders such as `Common/`, `Archives/`, `Launching/`, `Mods/`, `Settings/`, `Startup/`, and + `Updating/`. +- Keep a Core feature folder flat only while it has one responsibility and a small number of files. +- When a Core feature contains both boundary contracts and domain data, or grows beyond roughly 6 production files, + split + it inside the feature: `Contracts/` for interfaces and cross-project boundary contracts, `Models/` for records, + enums, value objects, requests, results, and manifests, and `Validation/` or `Services/` only when those + responsibilities are substantial. +- Keep namespaces aligned with those layer folders, such as `GenLauncherGO.Core.Launching.Contracts` and + `GenLauncherGO.Core.Launching.Models`. + +## Avoid + +- Do not reference `GenLauncherGO.Infrastructure` or `GenLauncherGO.UI`. +- Do not expose third-party package names in Core contracts when a domain abstraction is possible. +- Do not add logging dependencies unless there is a clear design reason; prefer typed results/errors for expected + failures. +- Do not create interfaces for every class by default. Use them where they define a real boundary. diff --git a/GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs b/GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs new file mode 100644 index 00000000..a91b63c2 --- /dev/null +++ b/GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs @@ -0,0 +1,13 @@ +namespace GenLauncherGO.Core.Archives; + +/// +/// Defines options that control how archive entries are extracted. +/// +public sealed record ArchiveExtractionOptions +{ + /// + /// Gets a value indicating whether extracted archive entries ending in .big should be written as + /// .gib. + /// + public bool ConvertBigFilesToGib { get; init; } +} diff --git a/GenLauncherGO.Core/Archives/IArchiveExtractor.cs b/GenLauncherGO.Core/Archives/IArchiveExtractor.cs new file mode 100644 index 00000000..344e2838 --- /dev/null +++ b/GenLauncherGO.Core/Archives/IArchiveExtractor.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using System.Threading; + +namespace GenLauncherGO.Core.Archives; + +/// +/// Extracts supported archive files into a destination directory. +/// +public interface IArchiveExtractor +{ + /// + /// Extracts an archive into the specified destination directory, creating directories and overwriting existing + /// extracted files when needed. + /// + /// The archive file to extract. + /// + /// The directory where archive entries should be written. Implementations must prevent archive entries from + /// escaping this directory. + /// + /// + /// Optional extraction behavior, including whether extracted .big files are renamed to .gib. + /// + /// A token that can cancel extraction between archive entries. + /// + /// Thrown when or is empty or + /// whitespace. + /// + /// + /// Thrown when the archive or destination files cannot be read or written. + /// + /// + /// Thrown when the archive is invalid or contains an entry that would extract outside the destination directory. + /// + /// + /// Thrown when the current process does not have access to read the archive or write extracted files. + /// + void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default); +} diff --git a/GenLauncherGO.Core/GenLauncherGO.Core.csproj b/GenLauncherGO.Core/GenLauncherGO.Core.csproj new file mode 100644 index 00000000..dd220951 --- /dev/null +++ b/GenLauncherGO.Core/GenLauncherGO.Core.csproj @@ -0,0 +1,8 @@ + + + net10.0 + disable + enable + 14.0 + + diff --git a/GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs b/GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs new file mode 100644 index 00000000..92a865ba --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Integrity.Contracts; + +/// +/// Verifies, snapshots, and cleans launcher-owned content. +/// +public interface IContentIntegrityService +{ + /// + /// Verifies all targets against their persisted trusted snapshots. + /// + /// The targets to verify. + /// A token that cancels verification. + /// A complete report containing every detected issue. + Task VerifyAsync( + IReadOnlyList targets, + CancellationToken cancellationToken); + + /// + /// Determines whether a target currently contains exactly the expected safe file set. + /// + /// The target to inspect without requiring a persisted snapshot. + /// The complete expected file paths relative to the target root. + /// A token that cancels enumeration or hashing. + /// + /// when the target has exactly the expected files and no empty directories, unsafe links, + /// or unreadable entries; otherwise, . + /// + Task MatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot only when a target currently contains exactly the expected safe file set. + /// + /// The target to inspect and snapshot. + /// The complete expected file paths relative to the target root. + /// A token that cancels enumeration, hashing, or persistence. + /// + /// when a snapshot was captured; otherwise, when the current + /// file set contains extras, missing files, empty directories, unsafe links, or unreadable entries. + /// + Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken); + + /// + /// Replaces a target's trusted snapshot with its current safe directory contents. + /// + /// The target to snapshot. + /// A token that cancels hashing or persistence. + Task CaptureSnapshotAsync(ContentIntegrityTarget target, CancellationToken cancellationToken); + + /// + /// Deletes managed entries explicitly listed for deletion in a verification report. + /// + /// The report containing confirmed deletion actions. + /// The verified targets used to safely resolve relative paths. + /// A token that cancels cleanup between operations. + Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs new file mode 100644 index 00000000..d1a32dbe --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes one content-integrity issue and its available resolution. +/// +/// The stable identifier of the affected target. +/// The user-facing name of the affected content. +/// The authoritative source classification. +/// The detected issue kind. +/// The resolution offered for the issue. +/// The affected path relative to the verified target. +/// The diagnostic message for verification failures. +/// The expected file size when the trusted source provides it. +public sealed record ContentIntegrityIssue( + string TargetId, + string TargetDisplayName, + ContentSourceKind SourceKind, + IntegrityIssueKind Kind, + IntegrityIssueAction Action, + string RelativePath, + string? Message = null, + long? ExpectedSizeBytes = null); diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs new file mode 100644 index 00000000..a38ff520 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Contains all issues found while verifying active launch content. +/// +public sealed record ContentIntegrityReport +{ + /// + /// Initializes a new instance of the record. + /// + /// All detected issues. + public ContentIntegrityReport(IReadOnlyList issues) + { + ArgumentNullException.ThrowIfNull(issues); + Issues = Array.AsReadOnly(issues.ToArray()); + } + + /// + /// Gets all detected issues. + /// + public IReadOnlyList Issues { get; } + + /// + /// Gets a value indicating whether any issue was detected. + /// + public bool HasIssues => Issues.Count > 0; + + /// + /// Gets a value indicating whether managed remote content requires resolution. + /// + public bool HasManagedIssues => Issues.Any(issue => + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile); + + /// + /// Gets a value indicating whether manually imported content has absorbable changes. + /// + public bool HasManualIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.Absorb); + + /// + /// Gets a value indicating whether unknown legacy content requires classification. + /// + public bool HasUnknownLegacyIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + + /// + /// Gets a value indicating whether verification found an issue without an automatic resolution. + /// + public bool HasBlockingIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.Block); +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs new file mode 100644 index 00000000..ddd0d48f --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes one launcher-owned directory that must be verified. +/// +public sealed record ContentIntegrityTarget +{ + /// + /// Initializes a new instance of the record. + /// + /// The stable identifier used for snapshot persistence. + /// The user-facing content name. + /// The directory to verify. + /// The authoritative source classification. + /// Known owned paths that belong to inactive content and must be preserved. + public ContentIntegrityTarget( + string id, + string displayName, + string rootDirectory, + ContentSourceKind sourceKind, + IReadOnlySet ignoredRelativePaths) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentNullException.ThrowIfNull(ignoredRelativePaths); + + Id = id; + DisplayName = displayName; + RootDirectory = rootDirectory; + SourceKind = sourceKind; + IgnoredRelativePaths = ignoredRelativePaths.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the stable identifier used for snapshot persistence. + /// + public string Id { get; init; } + + /// + /// Gets the user-facing content name. + /// + public string DisplayName { get; init; } + + /// + /// Gets the directory to verify. + /// + public string RootDirectory { get; init; } + + /// + /// Gets the authoritative source classification. + /// + public ContentSourceKind SourceKind { get; init; } + + /// + /// Gets known owned paths that belong to inactive content and must be preserved without verification. + /// + public IReadOnlySet IgnoredRelativePaths { get; init; } +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs new file mode 100644 index 00000000..cb864e5d --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes the authoritative source for installed launcher content. +/// +public enum ContentSourceKind +{ + /// + /// The source of the installed content has not yet been classified. + /// + UnknownLegacy, + + /// + /// The content is managed from an S3-compatible remote manifest. + /// + ManagedS3, + + /// + /// The content is managed from a remotely downloaded package file. + /// + ManagedSingleFile, + + /// + /// The content was manually imported or explicitly trusted by the user. + /// + Manual, +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs new file mode 100644 index 00000000..7bcd139e --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs @@ -0,0 +1,37 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes the resolution offered for an integrity issue. +/// +public enum IntegrityIssueAction +{ + /// + /// Launch remains blocked and no automatic resolution is available. + /// + Block, + + /// + /// The unexpected managed entry will be deleted. + /// + Delete, + + /// + /// The managed content will be repaired from its remote manifest. + /// + Repair, + + /// + /// The managed package will be downloaded and installed again. + /// + Redownload, + + /// + /// The current manual content will replace its trusted snapshot. + /// + Absorb, + + /// + /// The legacy content will be permanently classified and snapshotted as manual content. + /// + TrustAsManual, +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs new file mode 100644 index 00000000..eaeb36e8 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs @@ -0,0 +1,42 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes a detected content-integrity problem. +/// +public enum IntegrityIssueKind +{ + /// + /// No trusted snapshot exists for the content. + /// + Untracked, + + /// + /// A required file is missing. + /// + MissingFile, + + /// + /// A file differs from its trusted SHA-256 snapshot. + /// + ModifiedFile, + + /// + /// A file is present but is not part of the trusted snapshot. + /// + UnexpectedFile, + + /// + /// An unexpected empty directory is present. + /// + EmptyDirectory, + + /// + /// A reparse point or symbolic link was found inside verified content. + /// + UnsafeLink, + + /// + /// Verification could not complete for an entry. + /// + VerificationError, +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs b/GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs new file mode 100644 index 00000000..aa496cea --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Prepares, cleans, and recovers temporary game-directory deployments. +/// +public interface IDeploymentService +{ + /// + /// Prepares the game directory for a launch by deploying the selected packages. + /// + /// The deployment request. + /// A token that cancels deployment work. + /// The deployment result. + Task PrepareAsync( + DeploymentRequest request, + CancellationToken cancellationToken); + + /// + /// Cleans the active deployment from the game directory. + /// + /// The cleanup request. + /// A token that cancels cleanup work. + /// The cleanup result. + Task CleanupAsync( + DeploymentCleanupRequest request, + CancellationToken cancellationToken); + + /// + /// Recovers interrupted deployment work from the persisted manifest or journal. + /// + /// The recovery request. + /// A token that cancels recovery work. + /// The recovery result. + Task RecoverAsync( + DeploymentRecoveryRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..510ccf86 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Discovers game and World Builder executables available to the current launcher session. +/// +public interface IGameExecutableDiscoveryService +{ + /// + /// Gets the available game client executables for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The ordered available game client executables. + IReadOnlyList GetAvailableGameClients(SupportedGame managedGame); + + /// + /// Gets the available World Builder executables for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The ordered available World Builder executables. + IReadOnlyList GetAvailableWorldBuilders(SupportedGame managedGame); + + /// + /// Determines whether the executable path or name is currently available. + /// + /// The executable path or name to inspect. + /// when the executable exists; otherwise, . + bool IsExecutableAvailable(string? executableName); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs new file mode 100644 index 00000000..5fb78489 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Launches supported game and tool processes for a prepared game directory. +/// +public interface IGameProcessLauncher +{ + /// + /// Gets the community game executable name for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The community game executable name. + string GetCommunityGameExecutableName(SupportedGame managedGame); + + /// + /// Gets the community World Builder executable name for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The community World Builder executable name. + string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame); + + /// + /// Launches the requested game or tool process and waits for its process family to exit. + /// + /// The game launch request. + /// A token that cancels the wait operation. + /// The game launch result. + Task LaunchAsync( + GameLaunchRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..3f0acf86 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Verifies and resolves launch-readiness integrity state for selected launcher content. +/// +public interface ILaunchContentIntegrityResolutionService +{ + /// + /// Verifies active launch content and returns the target contexts used for any later resolution. + /// + /// The target construction request. + /// A token that cancels verification. + /// The verification report and target contexts. + Task VerifyAsync( + LaunchContentIntegrityTargetRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for managed remote cache targets that already match the expected remote file set. + /// + /// The resolution request containing the verification report and target contexts. + /// A token that cancels snapshot work. + /// when at least one cache target was initialized; otherwise, . + Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken); + + /// + /// Applies confirmed launch-integrity resolutions, including snapshots, cleanup, package repair, and cache refresh. + /// + /// The resolution request containing the reviewed report and target contexts. + /// Optional progress reporter keyed by integrity target id. + /// A token that cancels resolution work. + Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken); + + /// + /// Marks a manually imported version as manual content and captures its initial package and cache snapshots. + /// + /// The request describing the version to snapshot. + /// A token that cancels snapshot work. + Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for a newly installed managed remote version. + /// + /// The request describing the version to snapshot. + /// A token that cancels snapshot or cache refresh work. + Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot for a manually managed cached image target. + /// + /// The request describing the version whose cache image changed. + /// A token that cancels snapshot work. + Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..a4f30405 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Builds launch-readiness integrity targets for selected launcher content. +/// +public interface ILaunchContentIntegrityTargetBuilder +{ + /// + /// Builds integrity targets for the active launch content. + /// + /// The target construction request. + /// The package and cache targets that should be verified before launch. + IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs new file mode 100644 index 00000000..fcb5a202 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Prepares, cleans, and recovers launch-time game-directory state. +/// +public interface ILaunchPreparationService +{ + /// + /// Prepares the game directory for launching the selected content. + /// + /// The launch preparation request. + /// A token that cancels preparation work. + /// The launch preparation result. + Task PrepareAsync( + LaunchPreparationRequest request, + CancellationToken cancellationToken); + + /// + /// Cleans launch-time game-directory state after a launched process exits. + /// + /// The resolved game and launcher paths. + /// A token that cancels cleanup work. + /// The launch preparation cleanup result. + Task CleanupAsync( + LauncherPaths paths, + CancellationToken cancellationToken); + + /// + /// Recovers interrupted launch-time game-directory state during launcher startup. + /// + /// The resolved game and launcher paths. + /// A token that cancels recovery work. + /// The launch preparation recovery result. + Task RecoverAsync( + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs new file mode 100644 index 00000000..6b614ca0 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the launcher paths for a deployment cleanup operation. +/// +public sealed record DeploymentCleanupRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// Thrown when is . + public DeploymentCleanupRequest(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + Paths = paths; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs b/GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs new file mode 100644 index 00000000..28e140eb --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs @@ -0,0 +1,43 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one deployment operation failure. +/// +public sealed record DeploymentFailure +{ + /// + /// Initializes a new instance of the record. + /// + /// The failure category. + /// The related path, when a path is involved. + /// The diagnostic failure message. + /// Thrown when is empty or whitespace. + public DeploymentFailure( + DeploymentFailureKind kind, + string path, + string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + Kind = kind; + Path = path ?? string.Empty; + Message = message; + } + + /// + /// Gets the failure category. + /// + public DeploymentFailureKind Kind { get; init; } + + /// + /// Gets the related path, when a path is involved. + /// + public string Path { get; init; } + + /// + /// Gets the diagnostic failure message. + /// + public string Message { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs b/GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs new file mode 100644 index 00000000..6b871440 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the failure category for a deployment operation. +/// +public enum DeploymentFailureKind +{ + /// + /// The request was missing required information or contained unsafe paths. + /// + InvalidRequest, + + /// + /// A file-system mutation failed. + /// + FileSystem, + + /// + /// Manifest persistence or recovery failed. + /// + Manifest +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs b/GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs new file mode 100644 index 00000000..6c9e39eb --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs @@ -0,0 +1,64 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one file deployed into the game directory. +/// +public sealed record DeploymentFileEntry +{ + /// + /// Initializes a new instance of the record. + /// + /// The installed package source file path. + /// The game-directory-relative target path. + /// The deployment method used for this file. + /// The deployment-directory-relative backup path for a displaced original file. + /// The package identifier that supplied this file. + /// + /// Thrown when , , or + /// is empty or whitespace. + /// + public DeploymentFileEntry( + string sourcePath, + string targetRelativePath, + DeploymentMethod method, + string? backupRelativePath, + string packageId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath); + ArgumentException.ThrowIfNullOrWhiteSpace(targetRelativePath); + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + + SourcePath = sourcePath; + TargetRelativePath = targetRelativePath; + Method = method; + BackupRelativePath = backupRelativePath; + PackageId = packageId; + } + + /// + /// Gets the installed package source file path. + /// + public string SourcePath { get; init; } + + /// + /// Gets the game-directory-relative target path. + /// + public string TargetRelativePath { get; init; } + + /// + /// Gets the deployment method used for this file. + /// + public DeploymentMethod Method { get; init; } + + /// + /// Gets the deployment-directory-relative backup path for a displaced original file. + /// + public string? BackupRelativePath { get; init; } + + /// + /// Gets the package identifier that supplied this file. + /// + public string PackageId { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs new file mode 100644 index 00000000..fd3f0527 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the files and directories owned by one completed deployment. +/// +public sealed record DeploymentManifest +{ + /// + /// Initializes a new instance of the record. + /// + /// The deployment manifest schema version. + /// The unique deployment identifier. + /// The UTC creation time. + /// The deployed files. + /// The game-directory-relative directories created during deployment. + /// + /// Thrown when is empty or whitespace. + /// + /// + /// Thrown when or is . + /// + public DeploymentManifest( + int schemaVersion, + string deploymentId, + DateTimeOffset createdAtUtc, + IReadOnlyList files, + IReadOnlyList createdDirectories) + { + ArgumentException.ThrowIfNullOrWhiteSpace(deploymentId); + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(createdDirectories); + + SchemaVersion = schemaVersion; + DeploymentId = deploymentId; + CreatedAtUtc = createdAtUtc; + Files = files.ToArray(); + CreatedDirectories = createdDirectories.ToArray(); + } + + /// + /// Gets the deployment manifest schema version. + /// + public int SchemaVersion { get; init; } + + /// + /// Gets the unique deployment identifier. + /// + public string DeploymentId { get; init; } + + /// + /// Gets the UTC creation time. + /// + public DateTimeOffset CreatedAtUtc { get; init; } + + /// + /// Gets the deployed files. + /// + public IReadOnlyList Files { get; init; } + + /// + /// Gets the game-directory-relative directories created during deployment. + /// + public IReadOnlyList CreatedDirectories { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs b/GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs new file mode 100644 index 00000000..a42fb150 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes how a file was deployed into the game directory. +/// +public enum DeploymentMethod +{ + /// + /// The file was deployed by creating a hard link to the installed package file. + /// + HardLink, + + /// + /// The file was deployed by copying the installed package file. + /// + Copy +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs b/GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs new file mode 100644 index 00000000..e07493f1 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs @@ -0,0 +1,64 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one installed package selected for deployment. +/// +public sealed record DeploymentPackage +{ + /// + /// Initializes a new instance of the record. + /// + /// A stable package identifier for manifest diagnostics. + /// The user-facing package name for diagnostics. + /// The package role in the selected deployment set. + /// The installed package root directory. + /// The package precedence; higher values override lower values for the same target path. + /// + /// Thrown when , , or is empty + /// or whitespace. + /// + public DeploymentPackage( + string id, + string displayName, + DeploymentPackageKind kind, + string rootDirectory, + int precedence) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + + Id = id; + DisplayName = displayName; + Kind = kind; + RootDirectory = rootDirectory; + Precedence = precedence; + } + + /// + /// Gets a stable package identifier for manifest diagnostics. + /// + public string Id { get; init; } + + /// + /// Gets the user-facing package name for diagnostics. + /// + public string DisplayName { get; init; } + + /// + /// Gets the package role in the selected deployment set. + /// + public DeploymentPackageKind Kind { get; init; } + + /// + /// Gets the installed package root directory. + /// + public string RootDirectory { get; init; } + + /// + /// Gets the package precedence; higher values override lower values for the same target path. + /// + public int Precedence { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs b/GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs new file mode 100644 index 00000000..b6fd4b6f --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the selected package role in a launch deployment. +/// +public enum DeploymentPackageKind +{ + /// + /// The selected base modification package. + /// + Mod, + + /// + /// The selected patch package. + /// + Patch, + + /// + /// A selected add-on package. + /// + Addon +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs new file mode 100644 index 00000000..e2aa825a --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the launcher paths for startup deployment recovery. +/// +public sealed record DeploymentRecoveryRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// Thrown when is . + public DeploymentRecoveryRequest(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + Paths = paths; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs new file mode 100644 index 00000000..ac63df52 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the selected packages and launcher paths for a deployment preparation operation. +/// +public sealed record DeploymentRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected packages to deploy. + /// + /// Thrown when or is . + /// + public DeploymentRequest(LauncherPaths paths, IReadOnlyList packages) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(packages); + + Paths = paths; + Packages = packages.ToArray(); + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the selected packages to deploy. + /// + public IReadOnlyList Packages { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentResult.cs b/GenLauncherGO.Core/Launching/Models/DeploymentResult.cs new file mode 100644 index 00000000..b61ff7bd --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentResult.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the outcome of a deployment operation. +/// +public sealed record DeploymentResult +{ + /// + /// Initializes a new instance of the record. + /// + /// Whether the operation completed successfully. + /// The operation failures. + /// The manifest involved in the operation, when one was produced or loaded. + /// Thrown when is . + public DeploymentResult( + bool succeeded, + IReadOnlyList failures, + DeploymentManifest? manifest) + { + ArgumentNullException.ThrowIfNull(failures); + + Succeeded = succeeded; + Failures = failures.ToArray(); + Manifest = manifest; + } + + /// + /// Gets a value indicating whether the operation completed successfully. + /// + public bool Succeeded { get; init; } + + /// + /// Gets the operation failures. + /// + public IReadOnlyList Failures { get; init; } + + /// + /// Gets the manifest involved in the operation, when one was produced or loaded. + /// + public DeploymentManifest? Manifest { get; init; } + + /// + /// Creates a successful deployment result. + /// + /// The manifest involved in the operation. + /// A successful result. + public static DeploymentResult Success(DeploymentManifest? manifest = null) + { + return new DeploymentResult(true, Array.Empty(), manifest); + } + + /// + /// Creates a failed deployment result. + /// + /// The failure to include in the result. + /// A failed result. + public static DeploymentResult Failure(DeploymentFailure failure) + { + ArgumentNullException.ThrowIfNull(failure); + + return new DeploymentResult(false, new[] { failure }, null); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs new file mode 100644 index 00000000..fa2e24e2 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs @@ -0,0 +1,35 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a game client executable available to the launcher. +/// +public sealed class GameClientExecutable +{ + /// + /// Initializes a new instance of the class. + /// + /// The executable file name or path. + /// The game client kind. + public GameClientExecutable(string executableName, GameClientExecutableKind kind) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + throw new ArgumentException("Executable name cannot be empty.", nameof(executableName)); + } + + ExecutableName = executableName; + Kind = kind; + } + + /// + /// Gets the executable file name or path. + /// + public string ExecutableName { get; } + + /// + /// Gets the game client kind. + /// + public GameClientExecutableKind Kind { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs new file mode 100644 index 00000000..b1cd5bb2 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the supported game client executable variants. +/// +public enum GameClientExecutableKind +{ + /// + /// The community executable for the managed game variant. + /// + Community, + + /// + /// The Generals Online launcher executable. + /// + GeneralsOnline, +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs new file mode 100644 index 00000000..a4b59142 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs @@ -0,0 +1,104 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a game or World Builder process launch request. +/// +public sealed record GameLaunchRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The kind of process being launched. + /// The game variant managed by the current launcher session. + /// A value indicating whether the Generals Online client should be used. + /// The explicitly selected executable name for tool launches. + /// The command-line arguments requested by the user. + /// + /// Thrown when is and + /// is empty or whitespace. + /// + public GameLaunchRequest( + GameLaunchTargetKind targetKind, + SupportedGame managedGame, + bool useGeneralsOnline, + string? executableName, + string? arguments) + { + if (targetKind == GameLaunchTargetKind.WorldBuilder) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + } + + TargetKind = targetKind; + ManagedGame = managedGame; + UseGeneralsOnline = useGeneralsOnline; + ExecutableName = executableName ?? string.Empty; + Arguments = arguments ?? string.Empty; + } + + /// + /// Gets the kind of process being launched. + /// + public GameLaunchTargetKind TargetKind { get; init; } + + /// + /// Gets the game variant managed by the current launcher session. + /// + public SupportedGame ManagedGame { get; init; } + + /// + /// Gets a value indicating whether the Generals Online client should be used. + /// + public bool UseGeneralsOnline { get; init; } + + /// + /// Gets the explicitly selected executable name for tool launches. + /// + public string ExecutableName { get; init; } + + /// + /// Gets the command-line arguments requested by the user. + /// + public string Arguments { get; init; } + + /// + /// Creates a game-client launch request. + /// + /// The game variant managed by the current launcher session. + /// A value indicating whether the Generals Online client should be used. + /// The command-line arguments requested by the user. + /// The game-client launch request. + public static GameLaunchRequest ForGameClient( + SupportedGame managedGame, + bool useGeneralsOnline, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.GameClient, + managedGame, + useGeneralsOnline, + executableName: null, + arguments); + } + + /// + /// Creates a World Builder launch request. + /// + /// The selected World Builder executable name. + /// The command-line arguments requested by the user. + /// The World Builder launch request. + public static GameLaunchRequest ForWorldBuilder( + string executableName, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.WorldBuilder, + SupportedGame.Unknown, + useGeneralsOnline: false, + executableName, + arguments); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs new file mode 100644 index 00000000..dbe48ffe --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs @@ -0,0 +1,93 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the outcome of a game or tool process launch. +/// +public sealed record GameLaunchResult +{ + /// + /// Initializes a new instance of the record. + /// + /// A value indicating whether the launch satisfied the expected success condition. + /// The executable name used for the process launch. + /// The command-line arguments used for the process launch. + /// The observed running duration for the launched process family. + /// The failure message, when the process ran but did not satisfy launch success criteria. + /// Thrown when is empty or whitespace. + public GameLaunchResult( + bool succeeded, + string executableName, + string arguments, + TimeSpan runningDuration, + string? failureMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + Succeeded = succeeded; + ExecutableName = executableName; + Arguments = arguments; + RunningDuration = runningDuration; + FailureMessage = failureMessage; + } + + /// + /// Gets a value indicating whether the launch satisfied the expected success condition. + /// + public bool Succeeded { get; init; } + + /// + /// Gets the executable name used for the process launch. + /// + public string ExecutableName { get; init; } + + /// + /// Gets the command-line arguments used for the process launch. + /// + public string Arguments { get; init; } + + /// + /// Gets the observed running duration for the launched process family. + /// + public TimeSpan RunningDuration { get; init; } + + /// + /// Gets the failure message, when the process ran but did not satisfy launch success criteria. + /// + public string? FailureMessage { get; init; } + + /// + /// Creates a successful game launch result. + /// + /// The executable name used for the process launch. + /// The command-line arguments used for the process launch. + /// The observed running duration for the launched process family. + /// The successful game launch result. + public static GameLaunchResult Success( + string executableName, + string arguments, + TimeSpan runningDuration) + { + return new GameLaunchResult(true, executableName, arguments, runningDuration, failureMessage: null); + } + + /// + /// Creates a failed game launch result. + /// + /// The executable name used for the process launch. + /// The command-line arguments used for the process launch. + /// The observed running duration for the launched process family. + /// The failure message. + /// The failed game launch result. + public static GameLaunchResult Failure( + string executableName, + string arguments, + TimeSpan runningDuration, + string failureMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(failureMessage); + + return new GameLaunchResult(false, executableName, arguments, runningDuration, failureMessage); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs new file mode 100644 index 00000000..10af7960 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the kind of process being launched. +/// +public enum GameLaunchTargetKind +{ + /// + /// The selected game client process. + /// + GameClient, + + /// + /// The selected World Builder tool process. + /// + WorldBuilder +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs new file mode 100644 index 00000000..13df67b6 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs @@ -0,0 +1,69 @@ +using System; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Reports progress for one launch-integrity resolution target. +/// +public sealed record LaunchContentIntegrityResolutionProgress +{ + /// + /// Initializes a new instance of the record. + /// + /// The integrity target id associated with the progress update. + /// The package update progress when a package is being repaired. + /// A value indicating whether target resolution completed without package progress. + /// Thrown when is empty or whitespace. + public LaunchContentIntegrityResolutionProgress( + string targetId, + PackageUpdateProgress? packageProgress, + bool completed) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + TargetId = targetId; + PackageProgress = packageProgress; + Completed = completed; + } + + /// + /// Gets the integrity target id associated with the progress update. + /// + public string TargetId { get; init; } + + /// + /// Gets the package update progress when a package is being repaired. + /// + public PackageUpdateProgress? PackageProgress { get; init; } + + /// + /// Gets a value indicating whether target resolution completed without package progress. + /// + public bool Completed { get; init; } + + /// + /// Creates a package-progress update. + /// + /// The integrity target id associated with the progress update. + /// The package update progress. + /// The progress update. + public static LaunchContentIntegrityResolutionProgress Package( + string targetId, + PackageUpdateProgress packageProgress) + { + ArgumentNullException.ThrowIfNull(packageProgress); + + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress, completed: false); + } + + /// + /// Creates a completion update. + /// + /// The integrity target id associated with the progress update. + /// The progress update. + public static LaunchContentIntegrityResolutionProgress Complete(string targetId) + { + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress: null, completed: true); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs new file mode 100644 index 00000000..3c942d76 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a launch-integrity resolution operation. +/// +public sealed record LaunchContentIntegrityResolutionRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The reviewed integrity report. + /// The target contexts used to verify the report. + /// + /// Thrown when , , or + /// is . + /// + public LaunchContentIntegrityResolutionRequest( + LauncherPaths paths, + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Paths = paths; + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the reviewed integrity report. + /// + public ContentIntegrityReport Report { get; init; } + + /// + /// Gets the target contexts used to verify the report. + /// + public IReadOnlyList TargetContexts { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs new file mode 100644 index 00000000..c8ba66ba --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs @@ -0,0 +1,48 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Connects an integrity target to the selected content version that owns it. +/// +public sealed record LaunchContentIntegrityTargetContext +{ + /// + /// Initializes a new instance of the record. + /// + /// The integrity target to verify. + /// The selected content version that owns the target. + /// A value indicating whether the target describes cached launcher assets. + /// + /// Thrown when or is . + /// + public LaunchContentIntegrityTargetContext( + ContentIntegrityTarget target, + ModificationVersion version, + bool isCache) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(version); + + Target = target; + Version = version; + IsCache = isCache; + } + + /// + /// Gets the integrity target to verify. + /// + public ContentIntegrityTarget Target { get; init; } + + /// + /// Gets the selected content version that owns the target. + /// + public ModificationVersion Version { get; init; } + + /// + /// Gets a value indicating whether the target describes cached launcher assets. + /// + public bool IsCache { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs new file mode 100644 index 00000000..d9af3583 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the selected content and launcher paths used to build launch-readiness integrity targets. +/// +public sealed record LaunchContentIntegrityTargetRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected content versions that should be verified before launch. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + public LaunchContentIntegrityTargetRequest( + LauncherPaths paths, + IReadOnlyList activeVersions, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + : this( + paths, + activeVersions, + allVersions, + cacheDisplayNameSuffix, + LaunchPreparationRequest.DefaultAddonsFolderName, + LaunchPreparationRequest.DefaultPatchesFolderName) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected content versions that should be verified before launch. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// Thrown when , , or + /// is . + /// + /// + /// Thrown when , , or + /// is empty or whitespace. + /// + public LaunchContentIntegrityTargetRequest( + LauncherPaths paths, + IReadOnlyList activeVersions, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix, + string addonsFolderName, + string patchesFolderName) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(activeVersions); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + Paths = paths; + ActiveVersions = activeVersions + .Where(version => version is not null) + .Cast() + .ToArray(); + AllVersions = allVersions + .Where(version => version is not null) + .Cast() + .ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the selected content versions that should be verified before launch. + /// + public IReadOnlyList ActiveVersions { get; init; } + + /// + /// Gets all known content versions used to identify inactive cache files. + /// + public IReadOnlyList AllVersions { get; init; } + + /// + /// Gets the localized suffix appended to cache target display names. + /// + public string CacheDisplayNameSuffix { get; init; } + + /// + /// Gets the folder name that contains add-on packages below a modification. + /// + public string AddonsFolderName { get; init; } + + /// + /// Gets the folder name that contains patch packages below a modification. + /// + public string PatchesFolderName { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs new file mode 100644 index 00000000..74edb52d --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the result of launch-readiness verification. +/// +public sealed record LaunchContentIntegrityVerificationResult +{ + /// + /// Initializes a new instance of the record. + /// + /// The integrity verification report. + /// The target contexts used to produce the report. + /// + /// Thrown when or is . + /// + public LaunchContentIntegrityVerificationResult( + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + /// + /// Gets the integrity verification report. + /// + public ContentIntegrityReport Report { get; init; } + + /// + /// Gets the target contexts used to produce the report. + /// + public IReadOnlyList TargetContexts { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs new file mode 100644 index 00000000..aba5fffa --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes an integrity operation for a single launcher content version. +/// +public sealed record LaunchContentIntegrityVersionRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The content version to process. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + public LaunchContentIntegrityVersionRequest( + LauncherPaths paths, + ModificationVersion version, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + : this( + paths, + version, + allVersions, + cacheDisplayNameSuffix, + LaunchPreparationRequest.DefaultAddonsFolderName, + LaunchPreparationRequest.DefaultPatchesFolderName) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The content version to process. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// Thrown when , , or + /// is . + /// + /// + /// Thrown when , , or + /// is empty or whitespace. + /// + public LaunchContentIntegrityVersionRequest( + LauncherPaths paths, + ModificationVersion version, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix, + string addonsFolderName, + string patchesFolderName) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + Paths = paths; + Version = version; + AllVersions = allVersions + .Where(candidate => candidate is not null) + .Cast() + .ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the content version to process. + /// + public ModificationVersion Version { get; init; } + + /// + /// Gets all known content versions used to identify inactive cache files. + /// + public IReadOnlyList AllVersions { get; init; } + + /// + /// Gets the localized suffix appended to cache target display names. + /// + public string CacheDisplayNameSuffix { get; init; } + + /// + /// Gets the folder name that contains add-on packages below a modification. + /// + public string AddonsFolderName { get; init; } + + /// + /// Gets the folder name that contains patch packages below a modification. + /// + public string PatchesFolderName { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs new file mode 100644 index 00000000..95ee3d1d --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes selected launcher content that must be prepared before launching the game. +/// +public sealed record LaunchPreparationRequest +{ + /// + /// The default folder name that contains add-on packages below a modification. + /// + public const string DefaultAddonsFolderName = "Addons"; + + /// + /// The default folder name that contains patch packages below a modification. + /// + public const string DefaultPatchesFolderName = "Patches"; + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected installed content versions. + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions) + : this(paths, versions, DefaultAddonsFolderName, DefaultPatchesFolderName) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected installed content versions. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// Thrown when or is . + /// + /// + /// Thrown when or is empty or whitespace. + /// + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions, + string addonsFolderName, + string patchesFolderName) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(versions); + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + Paths = paths; + Versions = versions + .Where(version => version is not null) + .Cast() + .ToArray(); + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the selected installed content versions. + /// + public IReadOnlyList Versions { get; init; } + + /// + /// Gets the folder name that contains add-on packages below a modification. + /// + public string AddonsFolderName { get; init; } + + /// + /// Gets the folder name that contains patch packages below a modification. + /// + public string PatchesFolderName { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs new file mode 100644 index 00000000..0bea11a7 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the result of preparing, cleaning, or recovering launch-time deployment state. +/// +public sealed record LaunchPreparationResult +{ + /// + /// Initializes a new instance of the record. + /// + /// A value indicating whether the operation succeeded. + /// The deployment failures that prevented success. + /// The deployment manifest associated with the operation, when available. + /// Thrown when is . + public LaunchPreparationResult( + bool succeeded, + IReadOnlyList failures, + DeploymentManifest? manifest) + { + ArgumentNullException.ThrowIfNull(failures); + + Succeeded = succeeded; + Failures = failures.ToArray(); + Manifest = manifest; + } + + /// + /// Gets a value indicating whether the operation succeeded. + /// + public bool Succeeded { get; init; } + + /// + /// Gets the deployment failures that prevented success. + /// + public IReadOnlyList Failures { get; init; } + + /// + /// Gets the deployment manifest associated with the operation, when available. + /// + public DeploymentManifest? Manifest { get; init; } + + /// + /// Creates a successful launch preparation result. + /// + /// The deployment manifest associated with the operation, when available. + /// The successful launch preparation result. + public static LaunchPreparationResult Success(DeploymentManifest? manifest = null) + { + return new LaunchPreparationResult(true, Array.Empty(), manifest); + } + + /// + /// Creates a failed launch preparation result. + /// + /// The deployment failures that prevented success. + /// The deployment manifest associated with the operation, when available. + /// The failed launch preparation result. + public static LaunchPreparationResult Failure( + IReadOnlyList failures, + DeploymentManifest? manifest = null) + { + return new LaunchPreparationResult(false, failures, manifest); + } + + /// + /// Converts a deployment result into a launch preparation result. + /// + /// The deployment result. + /// The converted launch preparation result. + /// Thrown when is . + public static LaunchPreparationResult FromDeploymentResult(DeploymentResult result) + { + ArgumentNullException.ThrowIfNull(result); + + return new LaunchPreparationResult(result.Succeeded, result.Failures, result.Manifest); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs new file mode 100644 index 00000000..d8ae362c --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs @@ -0,0 +1,35 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a World Builder executable available to the launcher. +/// +public sealed class WorldBuilderExecutable +{ + /// + /// Initializes a new instance of the class. + /// + /// The executable file name or path. + /// The World Builder executable kind. + public WorldBuilderExecutable(string executableName, WorldBuilderExecutableKind kind) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + throw new ArgumentException("Executable name cannot be empty.", nameof(executableName)); + } + + ExecutableName = executableName; + Kind = kind; + } + + /// + /// Gets the executable file name or path. + /// + public string ExecutableName { get; } + + /// + /// Gets the World Builder executable kind. + /// + public WorldBuilderExecutableKind Kind { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs new file mode 100644 index 00000000..3468f3dd --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the supported World Builder executable variants. +/// +public enum WorldBuilderExecutableKind +{ + /// + /// The original game World Builder executable. + /// + Vanilla, + + /// + /// The community World Builder executable for the managed game variant. + /// + Community, +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs new file mode 100644 index 00000000..567db6dd --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs @@ -0,0 +1,54 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Applies mutations to the current launcher content catalog and its local persisted state. +/// +public interface ILauncherContentCatalogCommands +{ + /// + /// Adds or updates a modification in the current catalog. + /// + /// The modification version to add or update. + void AddModModification(ModificationVersion modification); + + /// + /// Clears selected state from all modification cards. + /// + void UnselectAllModifications(); + + /// + /// Deletes a content version from the current catalog and local installation. + /// + /// The version to delete. + void DeleteVersion(ModificationVersion version); + + /// + /// Deletes a content version from the current catalog and local installation. + /// + /// The version to delete. + void DeleteModificationVersion(ModificationVersion modificationVersion); + + /// + /// Deletes a content version from local installation and removes it from the current catalog. + /// + /// The version to remove. + void RemoveContentVersion(ModificationVersion modificationVersion); + + /// + /// Deletes all local files for a content card and removes it from the current catalog. + /// + /// A version that identifies the content card to remove. + void RemoveContent(ModificationVersion modificationVersion); + + /// + /// Refreshes the catalog from locally installed content and removes stale local-only cards. + /// + void UpdateLocalModificationsData(); + + /// + /// Saves the current catalog selection and installed state. + /// + void SaveLauncherData(); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs new file mode 100644 index 00000000..006ea574 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Loads local and remote launcher content catalog data. +/// +public interface ILauncherContentCatalogLoader +{ + /// + /// Gets the modification names advertised by the remote repository. + /// + IReadOnlyList? ReposModsNames { get; } + + /// + /// Initializes the catalog from local state and, when available, the remote repository. + /// + /// The initialization request. + /// The token used to cancel remote work. + /// A task that completes when initialization has finished. + Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken); + + /// + /// Reads add-ons and patches that belong to the original game. + /// + /// The token used to cancel remote work. + /// A task that completes when original-game child content has been loaded. + Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken); + + /// + /// Downloads one modification's remote manifest data by name. + /// + /// The modification name. + /// The token used to cancel remote work. + /// The downloaded modification version. + Task DownloadModificationDataFromReposAsync( + string name, + CancellationToken cancellationToken); + + /// + /// Reads remote patches and add-ons for a modification. + /// + /// The parent modification. + /// The token used to cancel remote work. + /// A task that completes when child content has been loaded. + Task ReadPatchesAndAddonsForModAsync( + ModificationReposVersion modification, + CancellationToken cancellationToken); + + /// + /// Reads persisted launcher content state into the current catalog. + /// + void ReadLocalModsData(); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs new file mode 100644 index 00000000..0877b948 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides read-only queries over the current launcher content catalog. +/// +public interface ILauncherContentCatalogQueries +{ + /// + /// Gets all modification cards. + /// + /// The current modification cards. + IReadOnlyList GetMods(); + + /// + /// Gets the active advertising modification. + /// + /// The active advertising modification, or when none exists. + ModificationVersion? GetAdvertising(); + + /// + /// Gets the selected modification card. + /// + /// The selected modification card, or when none is selected. + GameModification? GetSelectedMod(); + + /// + /// Gets the selected modification version. + /// + /// The selected modification version, or when none is selected. + ModificationVersion? GetSelectedModVersion(); + + /// + /// Gets the selected patch version. + /// + /// The selected patch version, or when none is selected. + ModificationVersion? GetSelectedPatchVersion(); + + /// + /// Gets all modification names. + /// + /// The current modification names. + IReadOnlyList GetAllModificationsNames(); + + /// + /// Gets patches that belong to the selected modification or original game. + /// + /// The matching patch cards. + IReadOnlyList GetPatchesForSelectedMod(); + + /// + /// Gets add-ons that belong to the selected modification or selected patch. + /// + /// The matching add-on cards. + IReadOnlyList GetAddonsForSelectedMod(); + + /// + /// Gets all versions for the selected modification. + /// + /// The selected modification versions. + IReadOnlyList GetSelectedModVersions(); + + /// + /// Gets selected add-on versions. + /// + /// The selected add-on versions. + IReadOnlyList GetSelectedAddonsVersions(); + + /// + /// Gets selected add-on cards for the selected modification. + /// + /// The selected add-on cards. + IReadOnlyList GetSelectedAddonsForSelectedMod(); + + /// + /// Gets the selected patch card. + /// + /// The selected patch card, or when none is selected. + GameModification? GetSelectedPatch(); + + /// + /// Gets all modification versions. + /// + /// All modification versions. + IReadOnlyList GetAllModsVersionsList(); + + /// + /// Gets add-on versions associated with a modification name. + /// + /// The modification name. + /// The matching add-on versions. + IReadOnlyList GetAddonVersionsForModList(string modName); + + /// + /// Gets patch versions associated with a modification name. + /// + /// The modification name. + /// The matching patch versions. + IReadOnlyList GetPatchVersionsForModList(string modName); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs new file mode 100644 index 00000000..9e567079 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs @@ -0,0 +1,11 @@ +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Aggregates launcher content catalog loading, query, and command contracts for compatibility. +/// +public interface ILauncherContentCatalogService : + ILauncherContentCatalogLoader, + ILauncherContentCatalogQueries, + ILauncherContentCatalogCommands +{ +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs new file mode 100644 index 00000000..ece0dace --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs @@ -0,0 +1,22 @@ +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Resolves launcher-owned content paths for tracked modification versions. +/// +public interface ILauncherContentPathResolver +{ + /// + /// Builds the installed version directory path for a launcher content version. + /// + /// The resolved launcher paths. + /// The launcher content folder layout. + /// The modification version to resolve. + /// The installed version directory path, or an empty string for unsupported content types. + string GetVersionDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + ModificationVersion version); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs new file mode 100644 index 00000000..fcabf918 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs @@ -0,0 +1,21 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Loads and saves the compact launcher content state. +/// +public interface ILauncherContentStateStore +{ + /// + /// Loads persisted launcher content state. + /// + /// The persisted launcher content state, or an empty state when none can be loaded. + LauncherContentState Load(); + + /// + /// Saves launcher content state. + /// + /// The state to save. + void Save(LauncherContentState state); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs b/GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs new file mode 100644 index 00000000..10ac3115 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides local file-system operations for launcher-managed content. +/// +public interface ILocalLauncherContentService +{ + /// + /// Finds installed content versions under the launcher-owned mods directory. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The installed content versions discovered locally. + IReadOnlyList FindInstalledVersions( + LauncherPaths paths, + LauncherContentLayout layout); + + /// + /// Determines whether a content version folder exists locally. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version to inspect. + /// when the version folder exists. + bool VersionFolderExists( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Determines whether a content version folder exists and contains at least one file. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version to inspect. + /// when the version folder exists and contains files. + bool VersionFolderContainsFiles( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Deletes an installed content version from the launcher-owned mods directory. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version to delete. + void DeleteVersion( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Deletes all installed content files for a content card from the launcher-owned mods directory. + /// + /// The launcher paths that define the local content root. + /// The content folder layout. + /// A version that identifies the content card to delete. + void DeleteContent( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Deletes cached images for a content version when no content card still references the same content name. + /// + /// The resolved launcher paths. + /// The content version whose images may be removed. + /// The current launcher content state after any catalog mutation. + void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentVersionState version, + LauncherContentState currentState); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs new file mode 100644 index 00000000..f5041990 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs @@ -0,0 +1,19 @@ +using System.Threading; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Imports user-selected modification files into a launcher-managed content folder. +/// +public interface IManualModificationImporter +{ + /// + /// Imports the requested files into the destination directory. + /// + /// The manual import request to process. + /// A token that can cancel the import between files and archive entries. + void Import( + ManualModificationImportRequest request, + CancellationToken cancellationToken = default); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs new file mode 100644 index 00000000..c916fe8c --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs @@ -0,0 +1,50 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides launcher modification image cache file operations. +/// +public interface IModificationImageFileService +{ + /// + /// Finds an existing cached modification image with any extension. + /// + /// The modification name. + /// The image file base name without extension. + /// The first matching image file path, or when none exists. + string? FindExistingImageFilePath(string modificationName, string imageBaseName); + + /// + /// Counts cached image files for a modification. + /// + /// The modification name. + /// The cached image file count. + int CountImageFiles(string modificationName); + + /// + /// Determines whether an image file path points to an existing file. + /// + /// The image file path to inspect. + /// when the image exists. + bool ImageExists(string? imageFilePath); + + /// + /// Removes an image file when it exists. + /// + /// The image file path to remove. + /// when no image remains at the path. + bool TryDeleteImage(string? imageFilePath); + + /// + /// Replaces cached images for a modification image base name with a selected source image. + /// + /// The image replacement request. + /// A token that cancels the replacement before the next file operation. + /// The destination image file path. + Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Mods/Models/AdvertisingData.cs b/GenLauncherGO.Core/Mods/Models/AdvertisingData.cs new file mode 100644 index 00000000..b47d55f0 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/AdvertisingData.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes one advertising content entry from the remote launcher manifest. +/// +/// +/// This type mirrors the third-party repository manifest schema. Keep these property names compatible with the remote +/// backend and map to cleaner internal models outside this boundary when needed. +/// +public sealed class AdvertisingData +{ + /// + /// Initializes a new instance of the class. + /// + public AdvertisingData() + { + ImagesData = new List(); + } + + /// + /// Gets or sets the advertised modification name using the backend manifest property name. + /// + public string ModName { get; set; } = string.Empty; + + /// + /// Gets or sets the advertised modification manifest link using the backend manifest property name. + /// + public string ModLink { get; set; } = string.Empty; + + /// + /// Gets or sets the advertising image links using the backend manifest property name. + /// + public List ImagesData { get; set; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs b/GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs new file mode 100644 index 00000000..4e0696c0 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs @@ -0,0 +1,122 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores launcher color and image values as serialized strings from remote or local visual configuration. +/// +public sealed class ColorsInfoString +{ + /// + /// Gets or sets the active accent color. + /// + public string GenLauncherActiveColor { get; set; } = string.Empty; + + /// + /// Gets or sets the background image link. + /// + public string GenLauncherBackgroundImageLink { get; set; } = string.Empty; + + /// + /// Gets or sets the primary border color. + /// + public string GenLauncherBorderColor { get; set; } = string.Empty; + + /// + /// Gets or sets the button selection color. + /// + public string GenLauncherButtonSelectionColor { get; set; } = string.Empty; + + /// + /// Gets or sets the dark background color. + /// + public string GenLauncherDarkBackGround { get; set; } = string.Empty; + + /// + /// Gets or sets the dark fill color. + /// + public string GenLauncherDarkFillColor { get; set; } = string.Empty; + + /// + /// Gets or sets the default text color. + /// + public string GenLauncherDefaultTextColor { get; set; } = string.Empty; + + /// + /// Gets or sets the download text color. + /// + public string GenLauncherDownloadTextColor { get; set; } = string.Empty; + + /// + /// Gets or sets the inactive border color. + /// + public string GenLauncherInactiveBorder { get; set; } = string.Empty; + + /// + /// Gets or sets the secondary inactive border color. + /// + public string GenLauncherInactiveBorder2 { get; set; } = string.Empty; + + /// + /// Gets or sets the light background color. + /// + public string GenLauncherLightBackGround { get; set; } = string.Empty; + + /// + /// Gets or sets the first list-box selection color. + /// + public string GenLauncherListBoxSelectionColor1 { get; set; } = string.Empty; + + /// + /// Gets or sets the second list-box selection color. + /// + public string GenLauncherListBoxSelectionColor2 { get; set; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + public ColorsInfoString() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The primary border color. + /// The inactive border color. + /// The secondary inactive border color. + /// The active accent color. + /// The dark fill color. + /// The dark background color. + /// The light background color. + /// The default text color. + /// The download text color. + /// The second list-box selection color. + /// The first list-box selection color. + /// The button selection color. + public ColorsInfoString( + string border, + string inactiveBorder, + string inactiveBorder2, + string activeColor, + string darkFill, + string darkBackground, + string lightBackground, + string text, + string text2, + string sColor2, + string sColor1, + string bColor) + { + GenLauncherBorderColor = border; + GenLauncherInactiveBorder = inactiveBorder; + GenLauncherInactiveBorder2 = inactiveBorder2; + GenLauncherActiveColor = activeColor; + GenLauncherDarkFillColor = darkFill; + GenLauncherDarkBackGround = darkBackground; + GenLauncherLightBackGround = lightBackground; + GenLauncherDefaultTextColor = text; + GenLauncherDownloadTextColor = text2; + GenLauncherListBoxSelectionColor2 = sColor2; + GenLauncherListBoxSelectionColor1 = sColor1; + GenLauncherButtonSelectionColor = bColor; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/GameModification.cs b/GenLauncherGO.Core/Mods/Models/GameModification.cs new file mode 100644 index 00000000..6eede445 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/GameModification.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Groups all known versions for one launcher content card. +/// +public class GameModification : ModificationVersion +{ + /// + /// Initializes a new instance of the class. + /// + public GameModification() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The version used to seed the content card. + public GameModification(ModificationVersion version) + { + Name = version.Name; + DependenceName = version.DependenceName; + UpdateModificationData(version); + } + + /// + /// Gets or sets the versions known for this content card. + /// + public List ModificationVersions { get; set; } = new List(); + + /// + /// Gets or sets the content ordering value shown in the launcher list. + /// + public int NumberInList { get; set; } + + /// + /// Adds a version or merges it into an existing version on this content card. + /// + /// The version to add or update. + public void UpdateModificationData(ModificationVersion version) + { + if (ModificationVersions.Contains(version)) + { + ModificationVersion modificationVersion = ModificationVersions[ModificationVersions.IndexOf(version)]; + modificationVersion.UnionModifications(version); + + if (ModificationType == ModificationType.Advertising) + { + UpdateAdvertising(version); + } + } + else + { + ModificationVersions.Add(version); + } + + if (!Installed && version.Installed) + { + Installed = true; + } + + UnionModifications(version); + } + + /// + public override bool Equals(object? obj) + { + if (obj?.GetType() != GetType()) + { + return false; + } + + var modification = (GameModification)obj; + return ModificationType == modification.ModificationType && + String.Equals(Name, modification.Name, StringComparison.OrdinalIgnoreCase) && + String.Equals( + DependenceName ?? string.Empty, + modification.DependenceName ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine( + ModificationType, + NormalizeIdentityText(Name), + NormalizeIdentityText(DependenceName)); + } + + /// + /// Updates advertising-only metadata from the version. + /// + /// The advertising version. + private void UpdateAdvertising(ModificationVersion version) + { + ModDBLink = version.ModDBLink; + NetworkInfo = version.NetworkInfo; + DiscordLink = version.DiscordLink; + SimpleDownloadLink = version.SimpleDownloadLink; + SupportLink = version.SupportLink; + } + + /// + /// Normalizes content identity text for hash-code generation. + /// + /// The value to normalize. + /// The normalized identity text. + private static string NormalizeIdentityText(string? value) + { + return (value ?? string.Empty).ToUpperInvariant(); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs new file mode 100644 index 00000000..09d2650c --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs @@ -0,0 +1,17 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes the runtime context needed to initialize the launcher content catalog. +/// +/// A value indicating whether the remote repository should be queried. +/// The remote top-level manifest URI, or when offline. +/// The resolved launcher paths for the active game installation. +/// The launcher content folder layout. +public sealed record LauncherContentCatalogInitializationRequest( + bool Connected, + Uri? RemoteManifestUri, + LauncherPaths Paths, + LauncherContentLayout Layout); diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs new file mode 100644 index 00000000..161e138d --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores local state for one launcher content card without remote manifest metadata. +/// +public sealed class LauncherContentEntryState +{ + /// + /// Gets or sets the content type. + /// + public LauncherContentType ModificationType { get; set; } + + /// + /// Gets or sets the content name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the parent modification or patch name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether any version of the content is installed locally. + /// + public bool Installed { get; set; } + + /// + /// Gets or sets a value indicating whether the content is selected. + /// + public bool IsSelected { get; set; } + + /// + /// Gets or sets the persisted display order for modification entries. + /// + public int NumberInList { get; set; } + + /// + /// Gets or sets the locally relevant versions for the content. + /// + public List ModificationVersions { get; set; } = + new List(); +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs new file mode 100644 index 00000000..77eb4103 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs @@ -0,0 +1,39 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes the launcher content folder names used below the mods directory. +/// +public sealed class LauncherContentLayout +{ + /// + /// Initializes a new instance of the class. + /// + /// The folder name that contains add-ons under a parent content folder. + /// The folder name that contains patches under a parent content folder. + /// + /// Thrown when a folder name is , empty, or whitespace. + /// + public LauncherContentLayout( + string addonsFolderName, + string patchesFolderName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the folder name that contains add-ons under a parent content folder. + /// + public string AddonsFolderName { get; } + + /// + /// Gets the folder name that contains patches under a parent content folder. + /// + public string PatchesFolderName { get; } + +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentState.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentState.cs new file mode 100644 index 00000000..96cb7cc4 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores compact launcher content state that is safe to persist locally. +/// +public sealed class LauncherContentState +{ + /// + /// Gets or sets local add-on state. + /// + public List Addons { get; set; } = new List(); + + /// + /// Gets or sets local modification state. + /// + public List Modifications { get; set; } = new List(); + + /// + /// Gets or sets local patch state. + /// + public List Patches { get; set; } = new List(); +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentType.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentType.cs new file mode 100644 index 00000000..1baa8698 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentType.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies the launcher content category stored in compact launcher state. +/// +public enum LauncherContentType +{ + /// + /// A game modification. + /// + Mod, + + /// + /// An add-on for a game modification or patch. + /// + Addon, + + /// + /// A patch for a game modification or the original game. + /// + Patch, + + /// + /// Advertising content displayed by the launcher. + /// + Advertising +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs new file mode 100644 index 00000000..95bdf5d8 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs @@ -0,0 +1,44 @@ +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores local state for one launcher content version without remote manifest metadata. +/// +public sealed class LauncherContentVersionState +{ + /// + /// Gets or sets the content type. + /// + public LauncherContentType ModificationType { get; set; } + + /// + /// Gets or sets the content name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the version label. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the parent modification or patch name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the content version is installed locally. + /// + public bool Installed { get; set; } + + /// + /// Gets or sets a value indicating whether the content version is selected. + /// + public bool IsSelected { get; set; } + + /// + /// Gets or sets the local integrity source classification for the installed content version. + /// + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherData.cs b/GenLauncherGO.Core/Mods/Models/LauncherData.cs new file mode 100644 index 00000000..a75536bd --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherData.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores the active launcher content catalog and local state. +/// +public sealed class LauncherData +{ + /// + /// Gets the known add-on content cards. + /// + public List Addons { get; } = new List(); + + /// + /// Gets the known modification content cards. + /// + public List Modifications { get; } = new List(); + + /// + /// Gets the known patch content cards. + /// + public List Patches { get; } = new List(); + + /// + /// Adds a modification version or merges it into the matching content card. + /// + /// The version to add or update. + public void AddOrUpdate(ModificationVersion modificationVersion) + { + switch (modificationVersion.ModificationType) + { + case ModificationType.Mod: + AddOrUpdateModificationVersion(Modifications, modificationVersion); + break; + case ModificationType.Advertising: + AddOrUpdateModificationVersion(Modifications, modificationVersion); + break; + case ModificationType.Addon: + if (String.IsNullOrEmpty(modificationVersion.DependenceName)) + { + return; + } + + AddOrUpdateModificationVersion(Addons, modificationVersion); + break; + case ModificationType.Patch: + AddOrUpdateModificationVersion(Patches, modificationVersion); + break; + } + } + + /// + /// Deletes a modification version from the matching content card. + /// + /// The version to delete. + public void Delete(ModificationVersion modificationVersion) + { + switch (modificationVersion.ModificationType) + { + case ModificationType.Mod: + DeleteModification(Modifications, modificationVersion); + DeleteDependentContent(modificationVersion, Addons, Patches); + break; + case ModificationType.Addon: + DeleteModification(Addons, modificationVersion); + break; + case ModificationType.Patch: + DeleteModification(Patches, modificationVersion); + DeleteDependentAddons(modificationVersion.Name, Addons); + break; + case ModificationType.Advertising: + DeleteModification(Modifications, modificationVersion); + break; + } + } + + /// + /// Adds or updates a version in a specific content storage list. + /// + /// The target storage list. + /// The version to add or update. + private static void AddOrUpdateModificationVersion( + List modificationStorage, + ModificationVersion modificationVersion) + { + var modification = new GameModification(modificationVersion); + + if (modificationStorage.Contains(modification)) + { + GameModification savedModificationData = modificationStorage[modificationStorage.IndexOf(modification)]; + savedModificationData.UpdateModificationData(modificationVersion); + } + else + { + modificationStorage.Add(modification); + } + } + + /// + /// Deletes a version from a specific content storage list. + /// + /// The target storage list. + /// The version to delete. + private static void DeleteModification( + List modificationStorage, + ModificationVersion modificationVersion) + { + var modification = new GameModification(modificationVersion); + + if (!modificationStorage.Contains(modification)) + { + return; + } + + GameModification savedModificationData = modificationStorage[modificationStorage.IndexOf(modification)]; + + if (savedModificationData.ModificationVersions.Contains(modificationVersion)) + { + savedModificationData.ModificationVersions.Remove(modificationVersion); + } + + if (savedModificationData.ModificationVersions.Count == 0) + { + modificationStorage.Remove(modification); + } + } + + /// + /// Deletes patch and add-on cards that depend on a removed modification. + /// + /// The removed modification version. + /// The add-on storage list. + /// The patch storage list. + private static void DeleteDependentContent( + ModificationVersion modificationVersion, + List addons, + List patches) + { + string dependencyName = modificationVersion.Name ?? string.Empty; + var dependentPatches = patches + .Where(patch => IsDependentOn(patch, dependencyName)) + .ToList(); + + foreach (GameModification patch in dependentPatches) + { + DeleteDependentAddons(patch.Name, addons); + patches.Remove(patch); + } + + DeleteDependentAddons(dependencyName, addons); + } + + /// + /// Deletes add-on cards that depend on the named modification or patch. + /// + /// The removed dependency name. + /// The add-on storage list. + private static void DeleteDependentAddons(string? dependencyName, List addons) + { + addons.RemoveAll(addon => IsDependentOn(addon, dependencyName ?? string.Empty)); + } + + /// + /// Determines whether a content card depends on the named parent. + /// + /// The content card. + /// The dependency name. + /// when the card depends on the named parent. + private static bool IsDependentOn(GameModification modification, string dependencyName) + { + return !String.IsNullOrWhiteSpace(dependencyName) && + String.Equals(modification.DependenceName, dependencyName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs b/GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs new file mode 100644 index 00000000..39a30013 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes files selected by the user for import into a launcher-managed content version folder. +/// +/// The source files selected by the user. +/// The launcher-managed content version directory that should receive the files. +public sealed record ManualModificationImportRequest( + IReadOnlyList SourceFilePaths, + string DestinationDirectory); diff --git a/GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs b/GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs new file mode 100644 index 00000000..e2ab42ba --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes one remote modification manifest and its associated patch and add-on manifest links. +/// +/// +/// This type mirrors the third-party repository manifest schema. Keep these property names compatible with the remote +/// backend and map to cleaner internal models outside this boundary when needed. +/// +public sealed class ModAddonsAndPatches +{ + /// + /// Initializes a new instance of the class. + /// + public ModAddonsAndPatches() + { + ModPatches = new List(); + ModAddons = new List(); + } + + /// + /// Gets or sets the modification name using the backend manifest property name. + /// + public string ModName { get; set; } = string.Empty; + + /// + /// Gets or sets the modification manifest link using the backend manifest property name. + /// + public string ModLink { get; set; } = string.Empty; + + /// + /// Gets or sets the patch manifest links for this modification using the backend manifest property name. + /// + public List ModPatches { get; set; } + + /// + /// Gets or sets the add-on manifest links for this modification using the backend manifest property name. + /// + public List ModAddons { get; set; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs new file mode 100644 index 00000000..05500c39 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs @@ -0,0 +1,44 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes a request to replace a cached modification image. +/// +public sealed class ModificationImageReplacementRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The modification name. + /// The image file base name without extension. + /// The selected source image path. + public ModificationImageReplacementRequest( + string modificationName, + string imageBaseName, + string sourceImagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceImagePath); + + ModificationName = modificationName; + ImageBaseName = imageBaseName; + SourceImagePath = sourceImagePath; + } + + /// + /// Gets the modification name. + /// + public string ModificationName { get; } + + /// + /// Gets the image file base name without extension. + /// + public string ImageBaseName { get; } + + /// + /// Gets the selected source image path. + /// + public string SourceImagePath { get; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs b/GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs new file mode 100644 index 00000000..33ee109e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs @@ -0,0 +1,216 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes a modification version as read from remote repository manifests. +/// +/// +/// This type is deserialized from third-party backend YAML documents. Keep its public property names compatible with +/// the backend schema and put any cleaner internal naming behind mapper services instead of changing this contract. +/// +public class ModificationReposVersion +{ + /// + /// Initializes a new instance of the class. + /// + public ModificationReposVersion() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The content name. + /// The version label. + /// The direct package download link. + /// The card image source link. + public ModificationReposVersion( + string name, + string version, + string? downloadLink = null, + string? imageSource = null) + { + Name = name; + Version = version; + UIImageSourceLink = imageSource ?? string.Empty; + SimpleDownloadLink = downloadLink ?? string.Empty; + } + + /// + /// Initializes a new instance of the class. + /// + /// The content name. + public ModificationReposVersion(string name) + { + Name = name; + } + + /// + /// Gets or sets the modification category as represented by backend manifests. + /// + public ModificationType ModificationType { get; set; } + + /// + /// Gets or sets the content name using the backend manifest property name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the version label using the backend manifest property name. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the direct package download link using the backend manifest property name. + /// + public string SimpleDownloadLink { get; set; } = string.Empty; + + /// + /// Gets or sets the card image source link using the backend manifest property name. + /// + public string UIImageSourceLink { get; set; } = string.Empty; + + /// + /// Gets or sets the Discord link. + /// + public string DiscordLink { get; set; } = string.Empty; + + /// + /// Gets or sets the ModDB link. + /// + public string ModDBLink { get; set; } = string.Empty; + + /// + /// Gets or sets the news link. + /// + public string NewsLink { get; set; } = string.Empty; + + /// + /// Gets or sets the parent content name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 endpoint URL. + /// + public string S3HostLink { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 bucket name. + /// + public string S3BucketName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 folder name. + /// + public string S3FolderName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 public key. + /// + public string S3HostPublicKey { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 secret key. + /// + public string S3HostSecretKey { get; set; } = string.Empty; + + /// + /// Gets or sets network information text. + /// + public string NetworkInfo { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the content is deprecated. + /// + public bool Deprecated { get; set; } + + /// + /// Gets or sets the support link. + /// + public string SupportLink { get; set; } = string.Empty; + + /// + /// Gets or sets optional launcher color information. + /// + public ColorsInfoString? ColorsInformation { get; set; } + + /// + /// Gets or sets the content source kind used by launch integrity checks. + /// + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; + + /// + /// Gets the content source kind after applying package metadata precedence. + /// + public ContentSourceKind EffectiveContentSourceKind => + ResolveContentSourceKind( + S3HostLink, + S3BucketName, + S3FolderName, + SimpleDownloadLink, + ContentSourceKind); + + /// + /// Gets a compact user-facing content/version label for diagnostics. + /// + public string DisplayName => String.Join(" ", new[] { Name, Version } + .Where(value => !String.IsNullOrWhiteSpace(value))); + + /// + public override bool Equals(object? obj) + { + if (obj is not ModificationReposVersion modification) + { + return false; + } + + return String.Equals(Name, modification.Name, StringComparison.CurrentCultureIgnoreCase); + } + + /// + public override int GetHashCode() + { + return (Name ?? string.Empty).ToUpperInvariant().GetHashCode(); + } + + /// + public override string? ToString() + { + return Name; + } + + /// + /// Resolves the content source kind from package metadata. + /// + /// The S3 endpoint URL. + /// The S3 bucket name. + /// The S3 folder name. + /// The direct package download link. + /// The source kind to use when no package metadata identifies a managed source. + /// The resolved content source kind. + public static ContentSourceKind ResolveContentSourceKind( + string? s3HostLink, + string? s3BucketName, + string? s3FolderName, + string? simpleDownloadLink, + ContentSourceKind fallbackSourceKind) + { + if (!String.IsNullOrWhiteSpace(s3HostLink) && + !String.IsNullOrWhiteSpace(s3BucketName) && + !String.IsNullOrWhiteSpace(s3FolderName)) + { + return ContentSourceKind.ManagedS3; + } + + if (!String.IsNullOrWhiteSpace(simpleDownloadLink)) + { + return ContentSourceKind.ManagedSingleFile; + } + + return fallbackSourceKind; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationType.cs b/GenLauncherGO.Core/Mods/Models/ModificationType.cs new file mode 100644 index 00000000..cb56a21e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationType.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies the legacy launcher modification category used by WPF workflows. +/// +public enum ModificationType +{ + /// + /// A game modification. + /// + Mod, + + /// + /// An add-on for a game modification or patch. + /// + Addon, + + /// + /// A patch for a game modification or the original game. + /// + Patch, + + /// + /// Advertising content displayed in the launcher. + /// + Advertising +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationVersion.cs b/GenLauncherGO.Core/Mods/Models/ModificationVersion.cs new file mode 100644 index 00000000..71d2e3d6 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationVersion.cs @@ -0,0 +1,239 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes a locally tracked modification version with installation and selection state. +/// +public class ModificationVersion : ModificationReposVersion, IComparable +{ + /// + /// Gets or sets a value indicating whether the version is installed locally. + /// + public bool Installed = false; + + /// + /// Gets or sets a value indicating whether the version is selected. + /// + public bool IsSelected = false; + + /// + /// Initializes a new instance of the class. + /// + public ModificationVersion() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The remote modification metadata. + public ModificationVersion(ModificationReposVersion modification) + { + Name = modification.Name; + Version = modification.Version; + ModificationType = modification.ModificationType; + DependenceName = modification.DependenceName; + ModDBLink = modification.ModDBLink; + DiscordLink = modification.DiscordLink; + SimpleDownloadLink = modification.SimpleDownloadLink; + UIImageSourceLink = modification.UIImageSourceLink; + NewsLink = modification.NewsLink; + NetworkInfo = modification.NetworkInfo; + S3HostLink = modification.S3HostLink; + S3BucketName = modification.S3BucketName; + S3FolderName = modification.S3FolderName; + S3HostPublicKey = modification.S3HostPublicKey; + S3HostSecretKey = modification.S3HostSecretKey; + Deprecated = modification.Deprecated; + ColorsInformation = modification.ColorsInformation; + SupportLink = modification.SupportLink; + ContentSourceKind = modification.EffectiveContentSourceKind; + } + + /// + public int CompareTo(object? o) + { + if (o is not ModificationVersion mv) + { + throw new InvalidOperationException("Cannot compare 2 objects"); + } + + string thisVersionString = + new string(Version.ToCharArray().Where(n => n >= '0' && n <= '9').ToArray()); + string otherVersionString = + new string(mv.Version.ToCharArray().Where(n => n >= '0' && n <= '9').ToArray()); + + while (thisVersionString.Length > otherVersionString.Length) + { + otherVersionString += '0'; + } + + while (thisVersionString.Length < otherVersionString.Length) + { + thisVersionString += '0'; + } + + if (String.IsNullOrEmpty(thisVersionString)) + { + thisVersionString = "-1"; + } + + if (String.IsNullOrEmpty(otherVersionString)) + { + otherVersionString = "-1"; + } + + int thisVersion = int.Parse(thisVersionString); + int otherVersion = int.Parse(otherVersionString); + + return thisVersion.CompareTo(otherVersion); + } + + /// + public override bool Equals(object? obj) + { + if (obj is not ModificationVersion modificationVersion) + { + return false; + } + + return ModificationType == modificationVersion.ModificationType && + String.Equals(Name, modificationVersion.Name, StringComparison.OrdinalIgnoreCase) && + String.Equals(Version, modificationVersion.Version, StringComparison.OrdinalIgnoreCase) && + String.Equals( + DependenceName ?? string.Empty, + modificationVersion.DependenceName ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine( + ModificationType, + NormalizeIdentityText(Name), + NormalizeIdentityText(Version), + NormalizeIdentityText(DependenceName)); + } + + /// + /// Merges missing metadata and local state from another version record. + /// + /// The source version record. + public void UnionModifications(ModificationVersion otherModificationVersion) + { + if (otherModificationVersion.IsSelected || IsSelected) + { + IsSelected = true; + } + + if (otherModificationVersion.Installed || Installed) + { + Installed = true; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.SimpleDownloadLink) && + String.IsNullOrEmpty(SimpleDownloadLink)) + { + SimpleDownloadLink = otherModificationVersion.SimpleDownloadLink; + } + + if (otherModificationVersion.ModificationType != ModificationType.Mod && + ModificationType == ModificationType.Mod) + { + ModificationType = otherModificationVersion.ModificationType; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.UIImageSourceLink) && + String.IsNullOrEmpty(UIImageSourceLink)) + { + UIImageSourceLink = otherModificationVersion.UIImageSourceLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.DependenceName) && + String.IsNullOrEmpty(DependenceName)) + { + DependenceName = otherModificationVersion.DependenceName; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.NewsLink) && String.IsNullOrEmpty(NewsLink)) + { + NewsLink = otherModificationVersion.NewsLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.ModDBLink) && String.IsNullOrEmpty(ModDBLink)) + { + ModDBLink = otherModificationVersion.ModDBLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.DiscordLink) && String.IsNullOrEmpty(DiscordLink)) + { + DiscordLink = otherModificationVersion.DiscordLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.NetworkInfo) && String.IsNullOrEmpty(NetworkInfo)) + { + NetworkInfo = otherModificationVersion.NetworkInfo; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.SupportLink) && String.IsNullOrEmpty(SupportLink)) + { + SupportLink = otherModificationVersion.SupportLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3BucketName) && String.IsNullOrEmpty(S3BucketName)) + { + S3BucketName = otherModificationVersion.S3BucketName; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3FolderName) && String.IsNullOrEmpty(S3FolderName)) + { + S3FolderName = otherModificationVersion.S3FolderName; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3HostLink) && String.IsNullOrEmpty(S3HostLink)) + { + S3HostLink = otherModificationVersion.S3HostLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3HostPublicKey) && + String.IsNullOrEmpty(S3HostPublicKey)) + { + S3HostPublicKey = otherModificationVersion.S3HostPublicKey; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3HostSecretKey) && + String.IsNullOrEmpty(S3HostSecretKey)) + { + S3HostSecretKey = otherModificationVersion.S3HostSecretKey; + } + + Deprecated = otherModificationVersion.Deprecated; + + if (otherModificationVersion.ColorsInformation != null) + { + ColorsInformation = otherModificationVersion.ColorsInformation; + } + + if (ContentSourceKind == ContentSourceKind.UnknownLegacy && + otherModificationVersion.ContentSourceKind != ContentSourceKind.UnknownLegacy) + { + ContentSourceKind = otherModificationVersion.ContentSourceKind; + } + + ContentSourceKind = EffectiveContentSourceKind; + } + + /// + /// Normalizes content identity text for hash-code generation. + /// + /// The value to normalize. + /// The normalized identity text. + private static string NormalizeIdentityText(string? value) + { + return (value ?? string.Empty).ToUpperInvariant(); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ReposModsData.cs b/GenLauncherGO.Core/Mods/Models/ReposModsData.cs new file mode 100644 index 00000000..00fc5be2 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ReposModsData.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +#pragma warning disable IDE1006 // Remote manifest field names preserve legacy YAML binding names. + +/// +/// Describes the top-level third-party remote launcher repository manifest. +/// +/// +/// This type is a backend compatibility boundary. Its member names intentionally preserve the YAML property names +/// emitted by the remote backend that GenLauncherGO does not control. Do not rename, remove, or "clean up" these +/// members without a deliberate compatibility plan that keeps reading the existing backend schema. +/// +public sealed class ReposModsData +{ + /// + /// Gets the advertising entries from the remote manifest. The property name is part of the backend YAML contract. + /// + public List AdvData { get; set; } = new List(); + + /// + /// Gets the globally available add-on manifest links. This legacy lowercase name is required by the backend YAML. + /// + public List globalAddonsData { get; set; } = new List(); + + /// + /// Gets the modification manifest links and child content references. This legacy lowercase name is required by + /// the backend YAML. + /// + public List modDatas { get; set; } = new List(); + + /// + /// Gets the original-game add-on manifest links. This legacy lowercase name is required by the backend YAML. + /// + public List originalGameAddons { get; set; } = new List(); + + /// + /// Gets the original-game patch manifest links. This legacy lowercase name is required by the backend YAML. + /// + public List originalGamePatches { get; set; } = new List(); + + /// + /// Gets or sets the launcher version advertised by the remote manifest. The property name is part of the backend + /// YAML contract. + /// + public string LauncherVersion { get; set; } = string.Empty; +} + +#pragma warning restore IDE1006 diff --git a/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs new file mode 100644 index 00000000..8df99de3 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Services; + +/// +/// Resolves launcher-owned content paths for tracked modification versions. +/// +public sealed class LauncherContentPathResolver : ILauncherContentPathResolver +{ + /// + public string GetVersionDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + return version.ModificationType switch + { + ModificationType.Addon => ResolvePackagePath( + paths.ModsDirectory, + version.DependenceName, + layout.AddonsFolderName, + version.Name, + version.Version), + ModificationType.Mod => ResolvePackagePath( + paths.ModsDirectory, + version.Name, + version.Version), + ModificationType.Patch => ResolvePackagePath( + paths.ModsDirectory, + version.DependenceName, + layout.PatchesFolderName, + version.Name, + version.Version), + _ => string.Empty + }; + } + + /// + /// Resolves a package path from catalog-provided identity segments. + /// + /// The launcher-owned mods directory. + /// The package identity segments. + /// The normalized package path. + private static string ResolvePackagePath(string modsDirectory, params string?[] segments) + { + string[] safeSegments = segments + .Select((segment, index) => LauncherPaths.NormalizePathSegment(segment, $"segment{index}")) + .ToArray(); + return LauncherPaths.ResolveOwnedPath(modsDirectory, Path.Combine(safeSegments)); + } +} diff --git a/GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs b/GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs new file mode 100644 index 00000000..e907b971 --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Remote; + +/// +/// Downloads remote assets to local files. +/// +public interface IRemoteAssetDownloader +{ + /// + /// Downloads an asset only when the destination file is not already present. + /// + /// The remote asset URI. + /// The local destination path. + /// The token used to cancel the request. + Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs new file mode 100644 index 00000000..375d11ce --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Remote; + +/// +/// Checks whether a remote HTTP endpoint can be reached. +/// +public interface IRemoteConnectionProbe +{ + /// + /// Returns whether the endpoint responds successfully. + /// + /// The endpoint URI. + /// The token used to cancel the request. + /// when the endpoint can be reached. + Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs b/GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..fc1aa05f --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Remote; + +/// +/// Reads YAML documents from remote HTTP endpoints. +/// +public interface IRemoteYamlDocumentReader +{ + /// + /// Downloads and deserializes a YAML document. + /// + /// The document type. + /// The document URI. + /// The token used to cancel the request. + /// The deserialized document. + Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs new file mode 100644 index 00000000..4e19ceff --- /dev/null +++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs @@ -0,0 +1,26 @@ +using System; +using GenLauncherGO.Core.Settings.Models; + +namespace GenLauncherGO.Core.Settings.Contracts; + +/// +/// Provides the current launcher preferences and persists preference updates. +/// +public interface ILauncherPreferencesService +{ + /// + /// Occurs after launcher preferences have changed. + /// + event EventHandler? PreferencesChanged; + + /// + /// Gets the current launcher preferences. + /// + LauncherPreferences Current { get; } + + /// + /// Persists the supplied launcher preferences and publishes the updated state. + /// + /// The preferences to persist. + void Update(LauncherPreferences preferences); +} diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs new file mode 100644 index 00000000..46f5a8f2 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs @@ -0,0 +1,31 @@ +namespace GenLauncherGO.Core.Settings.Contracts; + +/// +/// Opens external resources linked from the launcher settings window. +/// +public interface ILauncherSettingsLinkService +{ + /// + /// Opens the Generals Online Discord server. + /// + /// when the link was opened; otherwise, . + bool TryOpenGeneralsOnlineDiscordLink(); + + /// + /// Opens the launcher log directory. + /// + /// when the link was opened; otherwise, . + bool TryOpenLogsDirectory(); + + /// + /// Opens the GenLauncherGO GitHub repository. + /// + /// when the link was opened; otherwise, . + bool TryOpenGitHubRepository(); + + /// + /// Opens the donation page for the original GenLauncher author. + /// + /// when the link was opened; otherwise, . + bool TryOpenOriginalAuthorDonationLink(); +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs new file mode 100644 index 00000000..91b82f6e --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs @@ -0,0 +1,47 @@ +namespace GenLauncherGO.Core.Settings.Models; + +/// +/// Captures user preferences that affect launcher behavior and the launcher settings UI. +/// +public sealed record LauncherPreferences +{ + /// + /// Gets the number of launcher starts counted toward periodic advertising refresh. + /// + public int LaunchesCount { get; init; } + + /// + /// Gets a value indicating whether outdated installed mod versions are removed after updates. + /// + public bool AutoDeleteOldVersions { get; init; } + + /// + /// Gets a value indicating whether the launcher window is hidden while the game or World Builder runs. + /// + public bool HideLauncherAfterGameStart { get; init; } + + /// + /// Gets a value indicating whether the launcher UI is forced to English. + /// + public bool UseEnglishLanguage { get; init; } + + /// + /// Gets the executable name for the selected game client. + /// + public string SelectedGameClient { get; init; } = string.Empty; + + /// + /// Gets the executable name for the selected World Builder. + /// + public string SelectedWorldBuilder { get; init; } = string.Empty; + + /// + /// Gets optional command-line arguments passed to the selected game executable. + /// + public string GameArguments { get; init; } = string.Empty; + + /// + /// Gets optional command-line arguments passed to the selected World Builder executable. + /// + public string WorldBuilderArguments { get; init; } = string.Empty; +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs new file mode 100644 index 00000000..9c22373f --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace GenLauncherGO.Core.Settings.Models; + +/// +/// Provides the updated launcher preferences for preference-change notifications. +/// +public sealed class LauncherPreferencesChangedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The current launcher preferences after the change. + public LauncherPreferencesChangedEventArgs(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + Preferences = preferences; + } + + /// + /// Gets the current launcher preferences after the change. + /// + public LauncherPreferences Preferences { get; } +} diff --git a/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs new file mode 100644 index 00000000..f5ec3805 --- /dev/null +++ b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs @@ -0,0 +1,24 @@ +using GenLauncherGO.Core.Shell.Models; + +namespace GenLauncherGO.Core.Shell.Contracts; + +/// +/// Opens launcher-related external targets through the operating system shell. +/// +public interface ILauncherShellService +{ + /// + /// Opens an absolute URI with the operating system shell. + /// + /// The absolute URI to open. + /// The result of the shell-open request. + ShellOpenResult OpenUri(string uri); + + /// + /// Opens a folder with the operating system shell. + /// + /// The folder path to open. + /// Whether the folder must contain at least one file before it can be opened. + /// The result of the shell-open request. + ShellOpenResult OpenFolder(string folderPath, bool requireFiles = false); +} diff --git a/GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs b/GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs new file mode 100644 index 00000000..cdafc45d --- /dev/null +++ b/GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Shell.Models; + +/// +/// Describes why an operating system shell-open request failed. +/// +public enum ShellOpenFailureKind +{ + /// + /// No failure occurred. + /// + None, + + /// + /// The requested shell target was missing. + /// + MissingTarget, + + /// + /// The requested shell target was malformed or unsupported. + /// + InvalidTarget, + + /// + /// The operating system rejected or failed the shell-open request. + /// + LaunchFailed, +} diff --git a/GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs b/GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs new file mode 100644 index 00000000..6064191c --- /dev/null +++ b/GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs @@ -0,0 +1,83 @@ +using System; + +namespace GenLauncherGO.Core.Shell.Models; + +/// +/// Represents the outcome of opening an external target through the operating system shell. +/// +public sealed class ShellOpenResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Whether the target was opened successfully. + /// The failure kind when the target was not opened. + /// The requested shell target. + /// Diagnostic detail for a failed request. + private ShellOpenResult( + bool succeeded, + ShellOpenFailureKind failureKind, + string target, + string? message) + { + Succeeded = succeeded; + FailureKind = failureKind; + Target = target; + Message = message; + } + + /// + /// Gets a value indicating whether the target was opened successfully. + /// + public bool Succeeded { get; } + + /// + /// Gets the failure kind when the target was not opened. + /// + public ShellOpenFailureKind FailureKind { get; } + + /// + /// Gets the target that was requested. + /// + public string Target { get; } + + /// + /// Gets diagnostic detail for a failed shell-open request. + /// + public string? Message { get; } + + /// + /// Creates a successful shell-open result. + /// + /// The target that was opened. + /// A successful shell-open result. + public static ShellOpenResult Success(string target) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target); + + return new ShellOpenResult(true, ShellOpenFailureKind.None, target, null); + } + + /// + /// Creates a failed shell-open result. + /// + /// The kind of failure that occurred. + /// The target that could not be opened. + /// Diagnostic detail for the failure. + /// A failed shell-open result. + public static ShellOpenResult Failure( + ShellOpenFailureKind failureKind, + string target, + string message) + { + if (failureKind == ShellOpenFailureKind.None) + { + throw new ArgumentException("Failure results must include a failure kind.", nameof(failureKind)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(target); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + return new ShellOpenResult(false, failureKind, target, message); + } +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs new file mode 100644 index 00000000..71320cec --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs @@ -0,0 +1,47 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Provides host-process and operating-system operations needed by launcher startup. +/// +public interface ILauncherHostEnvironmentService +{ + /// + /// Brings the first visible window for the current process name to the foreground when possible. + /// + void ActivateCurrentProcessWindow(); + + /// + /// Gets the directory containing the running launcher executable. + /// + /// The executable directory. + string GetExecutableDirectory(); + + /// + /// Returns whether the current process is running with elevated administrator privileges. + /// + /// when the process is elevated. + bool IsCurrentProcessElevated(); + + /// + /// Returns whether a directory is under a protected Program Files location. + /// + /// The directory to inspect. + /// when the directory is protected by Program Files. + bool IsProtectedProgramFilesDirectory(string directory); + + /// + /// Sets the current process working directory. + /// + /// The directory to make current. + void SetCurrentDirectory(string directory); + + /// + /// Attempts to acquire the launcher single-instance guard. + /// + /// The single-instance name. + /// The delay before one retry when another instance already exists. + /// A guard whose acquired state identifies whether this process may continue. + ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay); +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs new file mode 100644 index 00000000..23c6b966 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Represents ownership of the launcher single-instance guard. +/// +public interface ILauncherSingleInstanceGuard : IDisposable +{ + /// + /// Gets a value indicating whether the guard was acquired by the current process. + /// + bool IsAcquired { get; } +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs new file mode 100644 index 00000000..cd54c458 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Reads launcher startup environment details from side-effecting platform sources. +/// +public interface ILauncherStartupEnvironmentService +{ + /// + /// Reads game and customization details needed before the main launcher window opens. + /// + /// The resolved launcher paths for the current session. + /// A token that cancels startup environment discovery. + /// The discovered startup environment. + Task ReadAsync( + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs new file mode 100644 index 00000000..0c5307e7 --- /dev/null +++ b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs @@ -0,0 +1,21 @@ +namespace GenLauncherGO.Core.Startup; + +/// +/// Resolves and prepares the launcher-owned directories for a GenLauncherGO session. +/// +public interface ILauncherPathResolver +{ + /// + /// Resolves launcher paths from the executable directory. + /// + /// The directory containing the running executable. + /// The resolved paths, or when no supported game directory is found. + LauncherPaths? Resolve(string executableDirectory); + + /// + /// Creates launcher-owned directories and optionally clears temporary files. + /// + /// The resolved launcher paths. + /// Whether to remove existing temporary directory contents. + void PrepareLauncherDirectories(LauncherPaths paths, bool cleanTemporaryDirectory); +} diff --git a/GenLauncherGO.Core/Startup/LauncherPaths.cs b/GenLauncherGO.Core/Startup/LauncherPaths.cs new file mode 100644 index 00000000..ff71e02f --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherPaths.cs @@ -0,0 +1,229 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Describes the resolved game and launcher-owned directories for one launcher session. +/// +/// The supported game installation directory. +/// The root directory owned by GenLauncherGO. +/// The directory containing transient and recoverable launcher runtime state. +/// The directory containing launcher cache files. +/// The directory containing cached downloaded and user-selected images. +/// The directory containing installed mods, patches, and add-ons. +/// The directory containing launcher log files. +/// The directory containing temporary launcher files. +/// The directory containing active deployment state used for cleanup and recovery. +public sealed record LauncherPaths( + string GameDirectory, + string LauncherDirectory, + string RuntimeDirectory, + string CacheDirectory, + string ImagesDirectory, + string ModsDirectory, + string LogsDirectory, + string TempDirectory, + string DeploymentDirectory) +{ + /// + /// The launcher data file name used by GenLauncherGO. + /// + public const string LauncherDataFileName = "LauncherData.yaml"; + + /// + /// The user preferences file name used by GenLauncherGO. + /// + public const string PreferencesFileName = "LauncherPreferences.yaml"; + + /// + /// The per-entry image cache folder name. + /// + public const string ModificationImagesFolderName = "Images"; + + /// + /// Returns the directory containing launcher-owned runtime state. + /// + public string StateDirectory => Path.Combine(RuntimeDirectory, "State"); + + /// + /// Returns the full launcher data file path. + /// + public string LauncherDataFilePath => Path.Combine(StateDirectory, LauncherDataFileName); + + /// + /// Returns the full launcher user preferences file path. + /// + public string PreferencesFilePath => Path.Combine(LauncherDirectory, PreferencesFileName); + + /// + /// Builds the image cache directory path for a mod, add-on, patch, or advertisement entry. + /// + /// The modification name. + /// The image cache directory path. + /// + /// Thrown when is empty, whitespace, or unsafe for a path segment. + /// + public string GetModificationImagesDirectory(string modificationName) + { + string safeModificationName = NormalizePathSegment(modificationName, nameof(modificationName)); + + return ResolveOwnedPath(ImagesDirectory, safeModificationName); + } + + /// + /// Builds an image cache file path for a mod, add-on, patch, or advertisement entry. + /// + /// The modification name. + /// The cache file name, including its extension. + /// The image cache file path. + /// + /// Thrown when or is empty, whitespace, or + /// unsafe for a path segment. + /// + public string GetModificationImageFilePath(string modificationName, string imageFileName) + { + string safeImageFileName = NormalizePathSegment(imageFileName, nameof(imageFileName)); + + return ResolveOwnedPath(GetModificationImagesDirectory(modificationName), safeImageFileName); + } + + /// + /// Builds a package staging folder path below the launcher temporary directory. + /// + /// The installed package folder path. + /// The temporary package staging folder path. + /// + /// Thrown when is empty, whitespace, or cannot be staged below the + /// launcher temporary package directory. + /// + public string GetPackageTemporaryFolderPath(string installedFolderPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath); + + string installedFullPath = Path.GetFullPath(installedFolderPath); + string modsFullPath = Path.GetFullPath(ModsDirectory); + string relativePath = Path.GetRelativePath(modsFullPath, installedFullPath); + + if (PathLeavesRoot(relativePath)) + { + relativePath = NormalizePathSegment(Path.GetFileName(installedFullPath), nameof(installedFolderPath)); + } + + string temporaryPackagesRoot = Path.Combine(TempDirectory, "Packages"); + string temporaryPath = Path.GetFullPath(Path.Combine(temporaryPackagesRoot, relativePath)); + if (!IsPathInDirectory(temporaryPath, temporaryPackagesRoot)) + { + throw new ArgumentException( + "The installed package path cannot be staged outside the launcher temporary package folder.", + nameof(installedFolderPath)); + } + + return temporaryPath; + } + + /// + /// Normalizes one user-facing path segment used below launcher-owned folders. + /// + /// The segment value. + /// The parameter name used for validation exceptions. + /// The normalized segment. + /// Thrown when the segment is empty, rooted, reserved, or contains path separators. + internal static string NormalizePathSegment(string? segment, string paramName) + { + if (string.IsNullOrWhiteSpace(segment)) + { + throw new ArgumentException("Path segments must not be empty.", paramName); + } + + string normalizedSegment = segment.Trim(); + if (Path.IsPathRooted(normalizedSegment) || + normalizedSegment.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + normalizedSegment.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal) || + normalizedSegment.Contains(':', StringComparison.Ordinal) || + normalizedSegment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException("Path segments must not contain rooted paths or directory separators.", paramName); + } + + if (string.Equals(normalizedSegment, ".", StringComparison.Ordinal) || + string.Equals(normalizedSegment, "..", StringComparison.Ordinal) || + normalizedSegment.EndsWith(".", StringComparison.Ordinal) || + IsReservedDeviceName(normalizedSegment)) + { + throw new ArgumentException("Path segments must not use reserved file-system names.", paramName); + } + + return normalizedSegment; + } + + /// + /// Resolves a child path and verifies that it remains below the supplied root. + /// + /// The owning root directory. + /// The relative child path. + /// The normalized child path. + /// Thrown when the resolved child path leaves the owning root. + internal static string ResolveOwnedPath(string rootDirectory, string relativePath) + { + string rootPath = Path.GetFullPath(rootDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(rootPath, relativePath)); + if (!IsPathInDirectory(candidatePath, rootPath)) + { + throw new ArgumentException("The resolved path must stay inside the launcher-owned directory."); + } + + return candidatePath; + } + + /// + /// Determines whether a relative path leaves its root. + /// + /// The relative path to inspect. + /// when the relative path leaves the root. + private static bool PathLeavesRoot(string relativePath) + { + return string.Equals(relativePath, "..", StringComparison.Ordinal) || + relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + relativePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) || + Path.IsPathRooted(relativePath); + } + + /// + /// Determines whether a path is equal to or below a directory after full-path normalization. + /// + /// The path to inspect. + /// The expected parent directory. + /// when the directory contains the path. + private static bool IsPathInDirectory(string path, string directory) + { + string normalizedDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + string normalizedPath = Path.GetFullPath(path); + return normalizedPath.Equals(normalizedDirectory, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith( + normalizedDirectory + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether a path segment is a Windows reserved device name. + /// + /// The segment to inspect. + /// when the segment is reserved. + private static bool IsReservedDeviceName(string segment) + { + string name = segment.Split('.')[0]; + if (string.Equals(name, "CON", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PRN", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "AUX", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "NUL", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name.Length == 4 && + (name.StartsWith("COM", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) && + name[3] is >= '1' and <= '9'; + } +} diff --git a/GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs b/GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs new file mode 100644 index 00000000..f3f51e94 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs @@ -0,0 +1,14 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Describes startup state discovered from the local launcher environment. +/// +/// The game variant detected in the current game directory. +/// The optional local launcher color override. +/// The optional local launcher background image path. +public sealed record LauncherStartupEnvironment( + SupportedGame ManagedGame, + ColorsInfoString? CustomColors, + string? CustomBackgroundImagePath); diff --git a/GenLauncherGO.Core/Startup/SessionInformation.cs b/GenLauncherGO.Core/Startup/SessionInformation.cs new file mode 100644 index 00000000..d8b5e310 --- /dev/null +++ b/GenLauncherGO.Core/Startup/SessionInformation.cs @@ -0,0 +1,38 @@ +namespace GenLauncherGO.Core.Startup; + +/// +/// Stores launcher session state discovered during startup. +/// +public sealed class SessionInformation +{ + /// + /// Gets or sets a value indicating whether the launcher has network connectivity for remote catalog access. + /// + public bool Connected { get; set; } + + /// + /// Gets or sets the game variant currently managed by this launcher session. + /// + public SupportedGame CurrentlyManagedGame { get; set; } +} + +/// +/// Identifies the supported Command & Conquer game variants GenLauncherGO can manage. +/// +public enum SupportedGame +{ + /// + /// No supported game variant has been detected for this launcher session. + /// + Unknown = 0, + + /// + /// Command & Conquer: Generals - Zero Hour. + /// + ZeroHour = 1, + + /// + /// Command & Conquer: Generals. + /// + Generals = 2 +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs b/GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs new file mode 100644 index 00000000..b2742811 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Reads metadata for remote downloadable files. +/// +public interface IDownloadFileMetadataReader +{ + /// + /// Reads remote file metadata. + /// + /// The remote download URI. + /// The token used to cancel the request. + /// The remote file metadata. + Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs b/GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs new file mode 100644 index 00000000..3b0cba28 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Computes hashes for local files. +/// +public interface IFileHashService +{ + /// + /// Computes an MD5 hash for a local file. + /// + /// The local file path. + /// The token used to cancel the operation. + /// The uppercase hexadecimal MD5 hash. + Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs new file mode 100644 index 00000000..59959f05 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Represents one launcher package download operation. +/// +public interface IPackageDownloadOperation : IDisposable +{ + /// + /// Occurs when package download progress changes. + /// + event Action? ProgressChanged; + + /// + /// Occurs when the package download operation completes. + /// + event Action? Done; + + /// + /// Determines whether the package download can start. + /// + /// The package download readiness result. + PackageDownloadReadiness GetPackageDownloadReadiness(); + + /// + /// Starts the package download operation. + /// + Task StartDownloadModificationAsync(); + + /// + /// Cancels the package download operation. + /// + void CancelDownload(); + + /// + /// Gets the current package download result. + /// + /// The package download result. + PackageDownloadResult GetResult(); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs new file mode 100644 index 00000000..376bc452 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs @@ -0,0 +1,21 @@ +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Creates launcher package download operations. +/// +public interface IPackageDownloadOperationFactory +{ + /// + /// Creates a package download operation for the requested modification package. + /// + /// The modification package download request. + /// + /// A value indicating whether single-file download should be used even when S3 metadata is present. + /// + /// The package download operation. + IPackageDownloadOperation Create( + ModificationPackageDownloadRequest request, + bool forceSingleFileDownload = false); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs b/GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs new file mode 100644 index 00000000..b153e83b --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Downloads remote files to disk with support for cancellation and resumable transfers. +/// +public interface IResumableFileDownloader +{ + /// + /// Downloads a remote file to the requested destination. + /// + /// The download request. + /// Optional progress reporter for the transfer. + /// The token used to cancel the transfer. + /// The completed file transfer result. + Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs b/GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs new file mode 100644 index 00000000..8cfa27dd --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Updates a package from a single remote file. +/// +public interface ISingleFilePackageUpdater +{ + /// + /// Downloads and installs the requested package. + /// + /// The update request. + /// Optional progress reporter. + /// The token used to cancel the update. + Task UpdateAsync( + SingleFilePackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs b/GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs new file mode 100644 index 00000000..1916576b --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs @@ -0,0 +1,19 @@ +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Provides system clock checks and synchronization used by update workflows. +/// +public interface ISystemClockService +{ + /// + /// Determines whether the local system clock appears too far from network time for signed download requests. + /// + /// when the system clock appears out of sync. + bool IsSystemTimeOutOfSync(); + + /// + /// Attempts to synchronize the local system clock with network time. + /// + /// when the system clock update call succeeds. + bool TrySynchronizeSystemTimeWithNetworkTime(); +} diff --git a/GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs b/GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs new file mode 100644 index 00000000..a4a7272e --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes metadata for a remote downloadable file. +/// +/// The resolved download URI. +/// The remote file name. +/// The expected file size when known. +public sealed record DownloadFileMetadata( + Uri DownloadUri, + string FileName, + long? TotalBytes); diff --git a/GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs b/GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs new file mode 100644 index 00000000..5e0acf8a --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs @@ -0,0 +1,16 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a file transfer requested by an update workflow. +/// +/// The remote file URI to download. +/// The local file path that receives the downloaded content. +/// The expected final file size when the catalog provides one. +/// A value indicating whether an existing partial file should be resumed. +public sealed record DownloadFileRequest( + Uri SourceUri, + string DestinationFilePath, + long? ExpectedBytes = null, + bool Resume = true); diff --git a/GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs b/GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs new file mode 100644 index 00000000..97cc5d30 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes the outcome of a completed file transfer. +/// +/// The local file path that contains the completed download. +/// The final number of bytes written to the file. +/// A value indicating whether the transfer resumed from existing local content. +public sealed record DownloadFileResult( + string FilePath, + long BytesWritten, + bool Resumed); diff --git a/GenLauncherGO.Core/Updating/Models/DownloadProgress.cs b/GenLauncherGO.Core/Updating/Models/DownloadProgress.cs new file mode 100644 index 00000000..8991a531 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadProgress.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Represents file download progress in bytes. +/// +/// The expected total byte count when known. +/// The number of bytes present locally for the file. +/// The completed percentage when a total byte count is known. +public sealed record DownloadProgress( + long? TotalBytes, + long BytesDownloaded, + double? ProgressPercentage); diff --git a/GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs b/GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs new file mode 100644 index 00000000..24a5dc2e --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs @@ -0,0 +1,12 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a launcher modification package download request. +/// +/// The modification that owns the package. +/// The version that should be downloaded. +public sealed record ModificationPackageDownloadRequest( + GameModification Modification, + ModificationVersion LatestVersion); diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs new file mode 100644 index 00000000..8e903ba8 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes whether a package download can start. +/// +public sealed record PackageDownloadReadiness +{ + /// + /// Gets a value indicating whether the download can start. + /// + public bool ReadyToDownload { get; init; } + + /// + /// Gets the reason the download cannot start. + /// + public PackageDownloadReadinessError Error { get; init; } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs new file mode 100644 index 00000000..936a13d9 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Identifies a package download readiness failure. +/// +public enum PackageDownloadReadinessError +{ + /// + /// The readiness failure is not known. + /// + Unknown = 0, + + /// + /// The system clock is too far out of sync for the remote package source. + /// + TimeOutOfSync = 1, +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs new file mode 100644 index 00000000..dda91c00 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes the outcome of a launcher package download. +/// +public sealed record PackageDownloadResult +{ + /// + /// Gets a value indicating whether the download failed unexpectedly. + /// + public bool Crashed { get; init; } + + /// + /// Gets a value indicating whether the download was canceled by the launcher. + /// + public bool Canceled { get; init; } + + /// + /// Gets a value indicating whether the download timed out. + /// + public bool TimedOut { get; init; } + + /// + /// Gets the diagnostic or user-facing completion message. + /// + public string Message { get; init; } = string.Empty; +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs new file mode 100644 index 00000000..c46d1ed8 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs @@ -0,0 +1,20 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Reports package update progress. +/// +/// The expected total bytes when known. +/// The number of local bytes completed. +/// The completed percentage when total bytes are known. +/// The current file name when available. +/// The aggregate download speed in bytes per second when known. +/// The estimated remaining download time when known. +public sealed record PackageUpdateProgress( + long? TotalBytes, + long BytesRead, + double? ProgressPercentage, + string? FileName, + double? DownloadSpeedBytesPerSecond = null, + TimeSpan? EstimatedTimeRemaining = null); diff --git a/GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs b/GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs new file mode 100644 index 00000000..c41d307e --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a file advertised by a remote update manifest. +/// +/// The relative file name from the manifest. +/// The expected file hash. +/// The expected file size. +public sealed record RemoteFileManifestEntry( + string FileName, + string Hash, + ulong Size); diff --git a/GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs b/GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs new file mode 100644 index 00000000..946fa76c --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a single-file package update. +/// +/// The remote file URI. +/// The temporary folder used during update. +/// The final installed folder path. +public sealed record SingleFilePackageUpdateRequest( + Uri SourceUri, + string TemporaryFolderPath, + string InstalledFolderPath); diff --git a/GenLauncherGO.Infrastructure/AGENTS.md b/GenLauncherGO.Infrastructure/AGENTS.md new file mode 100644 index 00000000..4dddbcfe --- /dev/null +++ b/GenLauncherGO.Infrastructure/AGENTS.md @@ -0,0 +1,35 @@ +# GenLauncherGO.Infrastructure Agent Guidelines + +`GenLauncherGO.Infrastructure/` owns concrete adapters and third-party/package-dependent code. + +## Do + +- Implement contracts from `GenLauncherGO.Core/`. +- Keep file-system, process, symbolic-link, archive, network, S3, hashing, and persistence side effects in + Infrastructure. +- Use `ILogger` for diagnostics around real-world side effects. +- Treat logging as required for file-system mutation, process launching, symbolic-link work, archive extraction, network + calls, S3 access, persistence, update/install workflows, and cleanup failures. +- Preserve enough error context for UI and diagnostics through logging or typed results. +- Use temporary directories for tests that exercise file-system behavior. +- Add XML documentation for every production type and member, regardless of accessibility, and call out side effects. +- Prefer Serilog wired through `Microsoft.Extensions.Logging` for runtime logging. +- Use `GenLauncherGO.Infrastructure.Logging.AddGenLauncherGoLogging(...)` when UI startup composes rolling file + logging. +- Keep rolling file logs bounded; the default retention is 14 files. +- Prefer feature folders such as `Common/`, `Archives/`, `Launching/`, `Logging/`, `Mods/`, `Settings/`, `Startup/`, and + `Updating/`. +- Keep an Infrastructure feature folder flat only while it has one responsibility and a small number of files. +- When an Infrastructure feature contains registration, services, and adapter helpers, or grows beyond roughly 6 + production files, split it inside the feature: `Services/` for concrete implementations, `Composition/` for dependency + injection registration, `Clients/` for external-service clients, `Options/` for configuration shapes, and `Support/` + for narrow platform or file-system helpers. +- Keep namespaces aligned with those layer folders, such as `GenLauncherGO.Infrastructure.Launching.Services` and + `GenLauncherGO.Infrastructure.Launching.Support`. + +## Avoid + +- Do not drive UI workflows or contain WPF presentation logic. +- Do not swallow exceptions silently. +- Do not log secrets, access tokens, local credentials, or unnecessary full user paths. +- Do not require a user's real Generals or Zero Hour install in automated tests. diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs new file mode 100644 index 00000000..9a4f500d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; + +namespace GenLauncherGO.Infrastructure.Archives; + +/// +/// Extracts archive files using the configured infrastructure archive library, creating destination directories, +/// overwriting extracted files, and optionally renaming extracted .big entries to .gib. +/// +public sealed class ArchiveExtractor : IArchiveExtractor +{ + /// + /// Logs archive extraction diagnostics without including full local user paths. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used to record archive extraction diagnostics. + public ArchiveExtractor(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archiveFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + string destinationRoot = Path.GetFullPath(destinationDirectory); + Directory.CreateDirectory(destinationRoot); + + _logger.LogInformation( + "Extracting archive {ArchiveFilePath} to {DestinationDirectory}. Convert .big files to .gib: {ConvertBigFilesToGib}", + Path.GetFileName(archiveFilePath), + Path.GetFileName(destinationRoot), + options?.ConvertBigFilesToGib == true); + + using FileStream archiveStream = File.OpenRead(archiveFilePath); + using IArchive archive = ArchiveFactory.OpenArchive( + archiveStream, + new ReaderOptions { LeaveStreamOpen = false }); + + int extractedEntryCount = 0; + foreach (IArchiveEntry entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.IsDirectory) + { + continue; + } + + string entryPath = GetDestinationPath(destinationRoot, entry.Key, options); + string? entryDirectory = Path.GetDirectoryName(entryPath); + if (!string.IsNullOrWhiteSpace(entryDirectory)) + { + Directory.CreateDirectory(entryDirectory); + } + + entry.WriteToFile(entryPath, new ExtractionOptions + { + Overwrite = true, + PreserveFileTime = true + }); + + extractedEntryCount++; + } + + _logger.LogInformation( + "Extracted {EntryCount} archive entries to {DestinationDirectory}", + extractedEntryCount, + Path.GetFileName(destinationRoot)); + } + + /// + /// Resolves the destination path for an archive entry and rejects paths outside the extraction root. + /// + /// The fully qualified destination root directory. + /// The archive entry key supplied by the archive reader. + /// Optional extraction behavior that can alter the output file extension. + /// The fully qualified destination path for the archive entry. + /// + /// Thrown when the archive entry has no usable file name or would extract outside the destination directory. + /// + private static string GetDestinationPath( + string destinationRoot, + string? entryKey, + ArchiveExtractionOptions? options) + { + if (string.IsNullOrWhiteSpace(entryKey)) + { + throw new InvalidDataException("Archive entry is missing a file name."); + } + + string normalizedEntryKey = entryKey.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + string destinationPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedEntryKey)); + if (options?.ConvertBigFilesToGib == true && + string.Equals(Path.GetExtension(destinationPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + destinationPath = Path.ChangeExtension(destinationPath, ".gib"); + } + + if (!FileSystemPathSafety.IsPathInDirectory(destinationPath, destinationRoot)) + { + throw new InvalidDataException($"Archive entry '{entryKey}' would extract outside the destination folder."); + } + + return destinationPath; + } +} diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs new file mode 100644 index 00000000..d54e6996 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System; +using GenLauncherGO.Core.Archives; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Archives; + +/// +/// Provides dependency-injection registration helpers for archive infrastructure services. +/// +public static class ArchiveServiceCollectionExtensions +{ + /// + /// Registers archive extraction services used by GenLauncherGO workflows. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoArchives(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs new file mode 100644 index 00000000..7cb380a7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Provides shared filesystem path-safety checks for infrastructure services. +/// +internal static class FileSystemPathSafety +{ + /// + /// Determines whether a path is inside a directory after full-path normalization. + /// + /// The path to test. + /// The containing directory. + /// when the path is the directory or a child of it; otherwise, . + public static bool IsPathInDirectory(string path, string directory) + { + string normalizedDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + string normalizedPath = Path.GetFullPath(path); + return normalizedPath.Equals(normalizedDirectory, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith( + normalizedDirectory + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolves a candidate path and verifies that it stays within an owned root without traversing existing links. + /// + /// The owned root directory. + /// The candidate path. + /// The exception message used when the candidate leaves the owned root. + /// The exception message used when an existing path segment is a reparse point. + /// The normalized candidate path. + public static string ResolveOwnedSubpath( + string ownedRoot, + string candidatePath, + string outsideRootMessage, + string linkedPathMessage) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(ownedRoot)); + string normalizedCandidate = Path.GetFullPath(candidatePath); + if (!IsPathInDirectory(normalizedCandidate, normalizedRoot)) + { + throw new InvalidDataException(outsideRootMessage); + } + + EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "An owned root path must be rooted.", + linkedPathMessage); + EnsureExistingPathChainHasNoReparsePoints( + normalizedCandidate, + "An owned candidate path must be rooted.", + linkedPathMessage); + + return normalizedCandidate; + } + + /// + /// Rejects paths whose existing filesystem chain contains a reparse point. + /// + /// The path to inspect before mutation. + /// The exception message used when the path is not rooted. + /// The exception message used when an existing path segment is a reparse point. + public static void EnsureExistingPathChainHasNoReparsePoints( + string path, + string unrootedPathMessage, + string linkedPathMessage) + { + if (ExistingPathChainContainsReparsePoint(path, unrootedPathMessage)) + { + throw new InvalidDataException(linkedPathMessage); + } + } + + /// + /// Rejects a directory tree whose root or child entries contain a reparse point. + /// + /// The directory tree root to inspect before mutation. + /// The exception message used when a reparse point is found. + public static void EnsureDirectoryTreeHasNoReparsePoints( + string directoryPath, + string linkedPathMessage) + { + string rootPath = Path.GetFullPath(directoryPath); + if (IsReparsePoint(rootPath)) + { + throw new InvalidDataException(linkedPathMessage); + } + + EnsureDirectoryChildrenHaveNoReparsePoints(rootPath, linkedPathMessage); + } + + /// + /// Normalizes a relative path to slash separators for persisted metadata and comparisons. + /// + /// The relative path. + /// The slash-separated normalized path. + public static string NormalizeRelativePath(string path) + { + return path.Replace('\\', '/').Trim('/'); + } + + /// + /// Determines whether an existing path chain contains a reparse point. + /// + /// The path to inspect. + /// The exception message used when the path is not rooted. + /// when an existing path segment is a reparse point; otherwise, . + public static bool ExistingPathChainContainsReparsePoint(string path, string unrootedPathMessage) + { + string fullPath = Path.GetFullPath(path); + string root = Path.GetPathRoot(fullPath) + ?? throw new InvalidDataException(unrootedPathMessage); + string relativePath = Path.GetRelativePath(root, fullPath); + if (relativePath == ".") + { + return false; + } + + string currentPath = root; + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!Directory.Exists(currentPath) && !File.Exists(currentPath)) + { + return false; + } + + if (IsReparsePoint(currentPath)) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether a filesystem entry is a reparse point. + /// + /// The filesystem entry path. + /// when the entry is a reparse point; otherwise, . + public static bool IsReparsePoint(string path) + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Creates recursive enumeration options that never traverse reparse points. + /// + /// The safe recursive enumeration options. + public static EnumerationOptions CreateRecursiveNoLinksOptions() + { + return new EnumerationOptions + { + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false, + RecurseSubdirectories = true, + ReturnSpecialDirectories = false, + }; + } + + /// + /// Recursively rejects child reparse points without traversing through them. + /// + /// The real directory whose children will be inspected. + /// The exception message used when a reparse point is found. + private static void EnsureDirectoryChildrenHaveNoReparsePoints( + string directoryPath, + string linkedPathMessage) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(linkedPathMessage); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + EnsureDirectoryChildrenHaveNoReparsePoints(entryPath, linkedPathMessage); + } + } + } +} diff --git a/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj new file mode 100644 index 00000000..8bb9e2a1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + net10.0-windows + disable + enable + 14.0 + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs new file mode 100644 index 00000000..52b007b7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using System; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Integrity.Composition; + +/// +/// Provides dependency-injection registration helpers for content-integrity infrastructure. +/// +public static class IntegrityServiceCollectionExtensions +{ + /// + /// Registers the filesystem content-integrity service with a persisted snapshot directory. + /// + /// The service collection to update. + /// The directory used to persist trusted snapshots. + /// The same service collection. + public static IServiceCollection AddGenLauncherGoIntegrity( + this IServiceCollection services, + string snapshotDirectory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + + services.AddSingleton(provider => + new FileSystemContentIntegrityService( + snapshotDirectory, + provider + .GetRequiredService>())); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs new file mode 100644 index 00000000..451c88bf --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs @@ -0,0 +1,532 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Integrity.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Services; + +/// +/// Verifies launcher-owned content with SHA-256 snapshots and applies confirmed managed-content cleanup. +/// +public sealed class FileSystemContentIntegrityService : IContentIntegrityService +{ + /// + /// Receives integrity verification and mutation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Stores trusted content snapshots. + /// + private readonly ContentIntegritySnapshotStore _snapshotStore; + + /// + /// Initializes a new instance of the class. + /// + /// The directory used to persist trusted snapshots. + /// The logger used for verification and mutation diagnostics. + public FileSystemContentIntegrityService( + string snapshotDirectory, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _snapshotStore = new ContentIntegritySnapshotStore(snapshotDirectory, _logger); + } + + /// + public async Task VerifyAsync( + IReadOnlyList targets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targets); + + List issues = new(); + foreach (ContentIntegrityTarget target in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + + ContentIntegritySnapshotDocument? snapshot; + try + { + snapshot = await _snapshotStore.ReadSnapshotAsync(target.Id, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + _logger.LogError( + exception, + "Failed to read integrity snapshot for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + continue; + } + + if (snapshot is null || snapshot.SourceKind != target.SourceKind) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.Untracked, + GetUntrackedAction(target.SourceKind), + ".")); + + try + { + ContentIntegrityScanResult untrackedScan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + AddScanSafetyIssues(target, untrackedScan, issues); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to verify untracked content safety for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + } + + continue; + } + + try + { + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + AddScanIssues(target, snapshot, scan, issues); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to verify content for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + } + } + + return new ContentIntegrityReport(issues); + } + + /// + public async Task MatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(expectedRelativePaths); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + return ContentIntegrityScanner.MatchesExpectedFileSet(scan, expectedRelativePaths); + } + + /// + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(expectedRelativePaths); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + if (!ContentIntegrityScanner.MatchesExpectedFileSet(scan, expectedRelativePaths)) + { + return false; + } + + await _snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false); + return true; + } + + /// + public async Task CaptureSnapshotAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + if (scan.UnsafeLinks.Count > 0 || scan.Errors.Count > 0) + { + throw new IOException("Content containing unsafe links or unreadable entries cannot be trusted."); + } + + await _snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false); + } + + /// + public Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targets); + + var targetIndex = + targets.ToDictionary(target => target.Id, StringComparer.Ordinal); + + foreach (ContentIntegrityIssue issue in report.Issues.Where(issue => + issue.Action == IntegrityIssueAction.Delete)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!targetIndex.TryGetValue(issue.TargetId, out ContentIntegrityTarget? target)) + { + throw new InvalidDataException("The cleanup report references an unknown integrity target."); + } + + string path = ResolveRelativePath(target.RootDirectory, issue.RelativePath); + DeleteEntry(path, issue, target); + } + + foreach (ContentIntegrityTarget target in targets.Where(target => + report.Issues.Any(issue => + issue.TargetId == target.Id && + issue.Action == IntegrityIssueAction.Delete))) + { + DeleteUnexpectedEmptyDirectories(target, cancellationToken); + } + + return Task.CompletedTask; + } + + /// + /// Adds snapshot-comparison issues for one completed filesystem scan. + /// + /// The verified target. + /// The trusted snapshot. + /// The current filesystem scan. + /// The complete issue collection to update. + private static void AddScanIssues( + ContentIntegrityTarget target, + ContentIntegritySnapshotDocument snapshot, + ContentIntegrityScanResult scan, + List issues) + { + var expectedFiles = + snapshot.Files.ToDictionary(file => file.RelativePath, StringComparer.OrdinalIgnoreCase); + + foreach (ContentIntegritySnapshotFileEntry expected in expectedFiles.Values) + { + if (!scan.Files.TryGetValue(expected.RelativePath, out ContentIntegrityScannedFile? current)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.MissingFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.MissingFile), + expected.RelativePath, + expectedSizeBytes: expected.Size)); + continue; + } + + if (current.Size != expected.Size || + !string.Equals(current.Sha256, expected.Sha256, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.ModifiedFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.ModifiedFile), + expected.RelativePath, + expectedSizeBytes: expected.Size)); + } + } + + foreach (ContentIntegrityScannedFile current in scan.Files.Values) + { + if (!expectedFiles.ContainsKey(current.RelativePath)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnexpectedFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.UnexpectedFile), + current.RelativePath)); + } + } + + HashSet expectedEmptyDirectories = + new(snapshot.EmptyDirectories, StringComparer.OrdinalIgnoreCase); + foreach (string directory in scan.EmptyDirectories) + { + if (!expectedEmptyDirectories.Contains(directory)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.EmptyDirectory, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.EmptyDirectory), + directory)); + } + } + + AddScanSafetyIssues(target, scan, issues); + } + + /// + /// Adds unsafe-link and unreadable-entry issues for one completed filesystem scan. + /// + /// The verified target. + /// The current filesystem scan. + /// The complete issue collection to update. + private static void AddScanSafetyIssues( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + List issues) + { + foreach (string unsafeLink in scan.UnsafeLinks) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnsafeLink, + target.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Block, + unsafeLink)); + } + + foreach (ContentIntegrityScanError error in scan.Errors) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + error.RelativePath, + error.Message)); + } + } + + /// + /// Creates a complete issue for one target. + /// + /// The affected target. + /// The detected issue kind. + /// The offered resolution. + /// The affected relative path. + /// The optional diagnostic message. + /// The expected file size when known. + /// The created issue. + private static ContentIntegrityIssue CreateIssue( + ContentIntegrityTarget target, + IntegrityIssueKind kind, + IntegrityIssueAction action, + string relativePath, + string? message = null, + long? expectedSizeBytes = null) + { + return new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + kind, + action, + FileSystemPathSafety.NormalizeRelativePath(relativePath), + message, + expectedSizeBytes); + } + + /// + /// Determines the action for an untracked target. + /// + /// The target source classification. + /// The action used to establish a trusted baseline. + private static IntegrityIssueAction GetUntrackedAction(ContentSourceKind sourceKind) + { + return sourceKind switch + { + ContentSourceKind.ManagedS3 => IntegrityIssueAction.Repair, + ContentSourceKind.ManagedSingleFile => IntegrityIssueAction.Redownload, + ContentSourceKind.Manual => IntegrityIssueAction.Absorb, + _ => IntegrityIssueAction.TrustAsManual, + }; + } + + /// + /// Determines the action for a detected managed or manual content difference. + /// + /// The target source classification. + /// The detected issue kind. + /// The applicable resolution. + private static IntegrityIssueAction GetManagedOrManualAction( + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind) + { + if (sourceKind == ContentSourceKind.Manual) + { + return IntegrityIssueAction.Absorb; + } + + if (sourceKind == ContentSourceKind.ManagedSingleFile) + { + return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Redownload; + } + + if (sourceKind == ContentSourceKind.ManagedS3) + { + return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Repair; + } + + return IntegrityIssueAction.TrustAsManual; + } + + /// + /// Deletes one confirmed managed-content entry without following links. + /// + /// The fully qualified entry path. + /// The issue authorizing deletion. + /// The owning integrity target. + private void DeleteEntry( + string path, + ContentIntegrityIssue issue, + ContentIntegrityTarget target) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, recursive: false); + } + else + { + File.Delete(path); + } + + _logger.LogInformation( + "Deleted confirmed unexpected integrity entry {RelativePath} from {TargetName}.", + issue.RelativePath, + target.DisplayName); + } + + /// + /// Removes empty directories left after confirmed managed-file cleanup. + /// + /// The managed target to clean. + /// A token that cancels cleanup between directories. + private static void DeleteUnexpectedEmptyDirectories( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + if (!Directory.Exists(target.RootDirectory)) + { + return; + } + + if (FileSystemPathSafety.IsReparsePoint(target.RootDirectory)) + { + return; + } + + foreach (string directory in Directory + .EnumerateDirectories( + target.RootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = GetRelativePath(target.RootDirectory, directory); + if (IsIgnored(target, relativePath) || + FileSystemPathSafety.IsReparsePoint(directory) || + Directory.EnumerateFileSystemEntries(directory).Any()) + { + continue; + } + + Directory.Delete(directory); + } + } + + /// + /// Determines whether a relative path is owned by inactive content and should be preserved without verification. + /// + /// The active integrity target. + /// The normalized relative path. + /// when the path should be ignored. + private static bool IsIgnored(ContentIntegrityTarget target, string relativePath) + { + return target.IgnoredRelativePaths.Contains(FileSystemPathSafety.NormalizeRelativePath(relativePath)); + } + + /// + /// Gets a normalized slash-separated relative path. + /// + /// The containing root directory. + /// The child path. + /// The normalized relative path. + private static string GetRelativePath(string root, string path) + { + string relativePath = Path.GetRelativePath(Path.GetFullPath(root), Path.GetFullPath(path)); + if (relativePath == ".." || + relativePath.StartsWith("../", StringComparison.Ordinal) || + relativePath.StartsWith("..\\", StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new InvalidDataException("A scanned entry resolved outside the verified target."); + } + + return FileSystemPathSafety.NormalizeRelativePath(relativePath); + } + + /// + /// Resolves a normalized relative path below a target root. + /// + /// The target root. + /// The relative path to resolve. + /// The safe fully qualified path. + private static string ResolveRelativePath(string root, string relativePath) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string candidate = Path.GetFullPath(Path.Combine( + normalizedRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!FileSystemPathSafety.IsPathInDirectory(candidate, normalizedRoot)) + { + throw new InvalidDataException("An integrity issue path resolved outside its target root."); + } + + return candidate; + } + +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs new file mode 100644 index 00000000..c7eb6e41 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Scans content integrity targets without following reparse points. +/// +internal static class ContentIntegrityScanner +{ + /// + /// Scans one target without following reparse points. + /// + /// The target to scan. + /// A token that cancels enumeration or hashing. + /// The current safe filesystem state and detected unsafe entries. + public static async Task ScanAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Dictionary files = new(StringComparer.OrdinalIgnoreCase); + List emptyDirectories = new(); + List unsafeLinks = new(); + List errors = new(); + + string root = Path.GetFullPath(target.RootDirectory); + if (!Directory.Exists(root)) + { + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + if (FileSystemPathSafety.IsReparsePoint(root)) + { + unsafeLinks.Add("."); + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + Stack pendingDirectories = new(); + pendingDirectories.Push(root); + + while (pendingDirectories.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + string directory = pendingDirectories.Pop(); + string directoryRelativePath = GetRelativePath(root, directory); + + try + { + if (!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) && + FileSystemPathSafety.IsReparsePoint(directory)) + { + unsafeLinks.Add(directoryRelativePath); + continue; + } + + var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); + if (entries.Count == 0 && + !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) && + !IsIgnored(target, directoryRelativePath)) + { + emptyDirectories.Add(directoryRelativePath); + } + + foreach (string entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = GetRelativePath(root, entry); + FileAttributes attributes = File.GetAttributes(entry); + if (IsIgnored(target, relativePath)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + unsafeLinks.Add(relativePath); + } + + continue; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + unsafeLinks.Add(relativePath); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pendingDirectories.Push(entry); + continue; + } + + FileInfo fileInfo = new(entry); + string sha256 = await ComputeSha256Async(fileInfo.FullName, cancellationToken) + .ConfigureAwait(false); + files[relativePath] = new ContentIntegrityScannedFile(relativePath, fileInfo.Length, sha256); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + errors.Add(new ContentIntegrityScanError(directoryRelativePath, exception.Message)); + } + } + + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + /// + /// Determines whether a completed scan exactly matches an expected safe file set. + /// + /// The scan to inspect. + /// The expected file paths relative to the target root. + /// when the scan has exactly the expected safe file set. + public static bool MatchesExpectedFileSet( + ContentIntegrityScanResult scan, + IReadOnlySet expectedRelativePaths) + { + if (scan.EmptyDirectories.Count > 0 || + scan.UnsafeLinks.Count > 0 || + scan.Errors.Count > 0) + { + return false; + } + + var normalizedExpectedPaths = expectedRelativePaths + .Select(FileSystemPathSafety.NormalizeRelativePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return scan.Files.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) + .SetEquals(normalizedExpectedPaths); + } + + /// + /// Computes the uppercase SHA-256 hash of one file. + /// + /// The file to hash. + /// A token that cancels hashing. + /// The uppercase hexadecimal SHA-256 hash. + private static async Task ComputeSha256Async( + string filePath, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash); + } + + /// + /// Determines whether a relative path is owned by inactive content and should be preserved without verification. + /// + /// The active integrity target. + /// The normalized relative path. + /// when the path should be ignored. + private static bool IsIgnored(ContentIntegrityTarget target, string relativePath) + { + return target.IgnoredRelativePaths.Contains(FileSystemPathSafety.NormalizeRelativePath(relativePath)); + } + + /// + /// Gets a normalized slash-separated relative path. + /// + /// The containing root directory. + /// The child path. + /// The normalized relative path. + private static string GetRelativePath(string root, string path) + { + string relativePath = Path.GetRelativePath(Path.GetFullPath(root), Path.GetFullPath(path)); + if (relativePath == ".." || + relativePath.StartsWith("../", StringComparison.Ordinal) || + relativePath.StartsWith("..\\", StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new InvalidDataException("A scanned entry resolved outside the verified target."); + } + + return FileSystemPathSafety.NormalizeRelativePath(relativePath); + } +} + +/// +/// Describes the current safe state of a scanned file. +/// +/// The normalized relative path. +/// The file size in bytes. +/// The uppercase SHA-256 hash. +internal sealed record ContentIntegrityScannedFile(string RelativePath, long Size, string Sha256); + +/// +/// Describes a scan error for a relative path. +/// +/// The relative path that could not be scanned. +/// The diagnostic message. +internal sealed record ContentIntegrityScanError(string RelativePath, string Message); + +/// +/// Describes a completed target scan. +/// +/// The scanned files keyed by normalized relative path. +/// The empty directories discovered during the scan. +/// The reparse points discovered during the scan. +/// The unreadable entries discovered during the scan. +internal sealed record ContentIntegrityScanResult( + IReadOnlyDictionary Files, + IReadOnlyList EmptyDirectories, + IReadOnlyList UnsafeLinks, + IReadOnlyList Errors); diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs new file mode 100644 index 00000000..82779578 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Persists content integrity snapshots for verified targets. +/// +internal sealed class ContentIntegritySnapshotStore +{ + /// + /// The current snapshot schema version. + /// + private const int SnapshotSchemaVersion = 1; + + /// + /// The JSON serialization options used for snapshot files. + /// + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + /// + /// The logger used for snapshot persistence diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The directory where integrity snapshots are persisted. + /// + private readonly string _snapshotDirectory; + + /// + /// Initializes a new instance of the class. + /// + /// The directory where integrity snapshots are persisted. + /// The logger used for snapshot persistence diagnostics. + public ContentIntegritySnapshotStore(string snapshotDirectory, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + _snapshotDirectory = Path.GetFullPath(snapshotDirectory); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Writes a trusted snapshot for a previously completed safe scan. + /// + /// The target being snapshotted. + /// The completed safe scan. + /// A token that cancels persistence. + /// A task that completes when the snapshot is persisted. + public async Task WriteSnapshotAsync( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + CancellationToken cancellationToken) + { + ContentIntegritySnapshotDocument snapshot = new( + SnapshotSchemaVersion, + target.Id, + target.SourceKind, + scan.Files.Values + .OrderBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase) + .Select(file => new ContentIntegritySnapshotFileEntry(file.RelativePath, file.Size, file.Sha256)) + .ToList(), + target.SourceKind == ContentSourceKind.Manual + ? scan.EmptyDirectories + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList() + : Array.Empty()); + + Directory.CreateDirectory(_snapshotDirectory); + string snapshotPath = GetSnapshotPath(target.Id); + string temporaryPath = snapshotPath + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + try + { + await using (FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await JsonSerializer.SerializeAsync(stream, snapshot, _jsonOptions, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(temporaryPath, snapshotPath, overwrite: true); + _logger.LogInformation( + "Captured SHA-256 integrity snapshot for {TargetName}; files: {FileCount}.", + target.DisplayName, + snapshot.Files.Count); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Reads a persisted snapshot when it exists. + /// + /// The stable target identifier. + /// A token that cancels reading. + /// The snapshot, or when no snapshot exists. + public async Task ReadSnapshotAsync( + string targetId, + CancellationToken cancellationToken) + { + string path = GetSnapshotPath(targetId); + if (!File.Exists(path)) + { + return null; + } + + await using FileStream stream = new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + ContentIntegritySnapshotDocument? snapshot = + await JsonSerializer.DeserializeAsync( + stream, + _jsonOptions, + cancellationToken).ConfigureAwait(false); + if (snapshot is null || + snapshot.SchemaVersion != SnapshotSchemaVersion || + !string.Equals(snapshot.TargetId, targetId, StringComparison.Ordinal)) + { + throw new InvalidDataException("The integrity snapshot schema or ownership is not supported."); + } + + return snapshot; + } + + /// + /// Creates the persisted snapshot path for a stable target identifier. + /// + /// The stable target identifier. + /// The snapshot file path. + private string GetSnapshotPath(string targetId) + { + byte[] identifierHash = SHA256.HashData(Encoding.UTF8.GetBytes(targetId)); + return Path.Combine(_snapshotDirectory, Convert.ToHexString(identifierHash) + ".json"); + } +} + +/// +/// Describes one trusted file entry in a snapshot document. +/// +/// The normalized relative path. +/// The file size in bytes. +/// The uppercase SHA-256 hash. +internal sealed record ContentIntegritySnapshotFileEntry(string RelativePath, long Size, string Sha256); + +/// +/// Describes a persisted trusted content snapshot. +/// +/// The schema version. +/// The target identifier. +/// The target source kind. +/// The trusted file entries. +/// The trusted empty directories for manual content. +internal sealed record ContentIntegritySnapshotDocument( + int SchemaVersion, + string TargetId, + ContentSourceKind SourceKind, + IReadOnlyList Files, + IReadOnlyList EmptyDirectories); diff --git a/GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs new file mode 100644 index 00000000..4ce5cf46 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using System; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Launching.Composition; + +/// +/// Registers deployment infrastructure services. +/// +public static class DeploymentServiceCollectionExtensions +{ + /// + /// Adds game-directory deployment services to the service collection. + /// + /// The service collection to update. + /// The same service collection. + public static IServiceCollection AddGenLauncherGoDeployment(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services + .AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs new file mode 100644 index 00000000..4b693a1f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs @@ -0,0 +1,202 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Orchestrates launch preparation by translating selected content into deployment packages. +/// +public sealed class DeploymentLaunchPreparationService : ILaunchPreparationService +{ + /// + /// The deployment service that owns all game-folder mutation, cleanup, and recovery side effects. + /// + private readonly IDeploymentService _deploymentService; + + /// + /// The logger used for launch preparation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The deployment service that owns all game-folder mutation. + /// The logger used for launch preparation diagnostics. + public DeploymentLaunchPreparationService( + IDeploymentService deploymentService, + ILogger logger) + { + _deploymentService = deploymentService ?? throw new ArgumentNullException(nameof(deploymentService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task PrepareAsync( + LaunchPreparationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + DeploymentRequest deploymentRequest = CreateDeploymentRequest(request); + DeploymentResult result = await _deploymentService.PrepareAsync(deploymentRequest, cancellationToken); + LogDeploymentFailures("prepare launch content", result); + return LaunchPreparationResult.FromDeploymentResult(result); + } + + /// + public async Task CleanupAsync( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = await _deploymentService.CleanupAsync( + new DeploymentCleanupRequest(paths), + cancellationToken); + LogDeploymentFailures("clean up launch content", result); + return LaunchPreparationResult.FromDeploymentResult(result); + } + + /// + public async Task RecoverAsync( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = await _deploymentService.RecoverAsync( + new DeploymentRecoveryRequest(paths), + cancellationToken); + LogDeploymentFailures("recover launch content", result); + return LaunchPreparationResult.FromDeploymentResult(result); + } + + /// + /// Creates a deployment request from a launch preparation request. + /// + /// The launch preparation request. + /// The deployment request. + private static DeploymentRequest CreateDeploymentRequest(LaunchPreparationRequest request) + { + var packages = request.Versions + .Select((version, index) => CreateDeploymentPackage(request, version, index)) + .ToList(); + + return new DeploymentRequest(request.Paths, packages); + } + + /// + /// Creates a deployment package for one selected content version. + /// + /// The launch preparation request. + /// The selected content version. + /// The selection index used as package precedence. + /// The deployment package. + private static DeploymentPackage CreateDeploymentPackage( + LaunchPreparationRequest request, + ModificationVersion version, + int index) + { + ArgumentNullException.ThrowIfNull(version); + + return new DeploymentPackage( + CreatePackageId(version), + version.Name ?? string.Empty, + ToDeploymentPackageKind(version.ModificationType), + ResolvePackageRoot(request.Paths, version, request.AddonsFolderName, request.PatchesFolderName), + index); + } + + /// + /// Creates a stable package identifier for deployment diagnostics. + /// + /// The selected content version. + /// The package identifier. + private static string CreatePackageId(ModificationVersion version) + { + return $"{version.ModificationType}:{version.Name}:{version.Version}"; + } + + /// + /// Converts a launcher content type into a deployment package kind. + /// + /// The launcher content type. + /// The deployment package kind. + private static DeploymentPackageKind ToDeploymentPackageKind(ModificationType modificationType) + { + return modificationType switch + { + ModificationType.Addon => DeploymentPackageKind.Addon, + ModificationType.Patch => DeploymentPackageKind.Patch, + _ => DeploymentPackageKind.Mod, + }; + } + + /// + /// Resolves the installed package root for a selected content version. + /// + /// The resolved game and launcher paths. + /// The selected content version. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// The installed package root directory. + private static string ResolvePackageRoot( + LauncherPaths paths, + ModificationVersion version, + string addonsFolderName, + string patchesFolderName) + { + return version.ModificationType switch + { + ModificationType.Addon => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + addonsFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Patch => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + patchesFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Mod => Path.Combine( + paths.ModsDirectory, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + _ => string.Empty, + }; + } + + /// + /// Logs deployment failures returned from the wrapped deployment service. + /// + /// The operation name for diagnostics. + /// The deployment result. + private void LogDeploymentFailures(string operationName, DeploymentResult result) + { + if (result.Succeeded) + { + return; + } + + foreach (DeploymentFailure failure in result.Failures) + { + _logger.LogError( + "Failed to {OperationName}. Kind: {FailureKind}; Path: {Path}; Message: {Message}", + operationName, + failure.Kind, + failure.Path, + failure.Message); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs new file mode 100644 index 00000000..522834b8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Deploys selected package files into the game directory and persists enough manifest state to undo the deployment. +/// +public sealed class FileSystemDeploymentService : IDeploymentService +{ + /// + /// The hard-link creator used before copy fallback. + /// + private readonly IHardLinkCreator _hardLinkCreator; + + /// + /// The logger used for deployment diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The deployment state store used for manifests, journals, recovery, and operation locks. + /// + private readonly DeploymentStateStore _stateStore; + + /// + /// Initializes a new instance of the class. + /// + /// The hard-link creator used before copy fallback. + /// The logger used for deployment diagnostics. + public FileSystemDeploymentService( + IHardLinkCreator hardLinkCreator, + ILogger logger) + { + _hardLinkCreator = hardLinkCreator ?? throw new ArgumentNullException(nameof(hardLinkCreator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _stateStore = new DeploymentStateStore(_logger); + } + + /// + public Task PrepareAsync( + DeploymentRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(request.Paths); + return Task.FromResult(PrepareWithLock(request, cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment preparation failed before deployment recovery could run."); + return Task.FromResult(DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.FileSystem, + request.Paths.GameDirectory, + ex.Message))); + } + } + + /// + public Task CleanupAsync( + DeploymentCleanupRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(request.Paths); + return Task.FromResult(CleanupCore(request.Paths, cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment cleanup failed."); + return Task.FromResult(DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.FileSystem, + request.Paths.GameDirectory, + ex.Message))); + } + } + + /// + public Task RecoverAsync( + DeploymentRecoveryRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(request.Paths); + return Task.FromResult(RecoverCore(request.Paths, cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment recovery failed."); + return Task.FromResult(DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.Manifest, + request.Paths.GameDirectory, + ex.Message))); + } + } + + /// + /// Prepares a deployment while the deployment operation lock is held. + /// + /// The deployment request. + /// A token that cancels deployment work. + /// The deployment result. + private DeploymentResult PrepareWithLock( + DeploymentRequest request, + CancellationToken cancellationToken) + { + try + { + DeploymentResult cleanupResult = CleanupCore(request.Paths, cancellationToken); + if (!cleanupResult.Succeeded) + { + return cleanupResult; + } + + string deploymentId = Guid.NewGuid().ToString("N"); + DeploymentStatePaths paths = DeploymentStateStore.CreatePaths(request.Paths, deploymentId); + Directory.CreateDirectory(paths.DeploymentDirectory); + Directory.CreateDirectory(paths.BackupDirectory); + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + + IReadOnlyList files = DeploymentFilePlanner.ResolveDeploymentFiles(request); + var createdDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + var entries = new List(); + + foreach (ResolvedDeploymentFile file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetPath = DeploymentPathResolver.ResolveGamePath(request.Paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(request.Paths, targetPath); + string targetDirectory = Path.GetDirectoryName(targetPath) ?? request.Paths.GameDirectory; + foreach (string directory in DeploymentFilePlanner.GetDirectoriesToCreate( + request.Paths.GameDirectory, + targetDirectory)) + { + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + string relativeDirectory = DeploymentPathResolver.ToRelativeManifestPath( + request.Paths.GameDirectory, + directory); + createdDirectories.Add(relativeDirectory); + DeploymentStateStore.AppendJournal( + paths.JournalPath, + DeploymentJournalRecord.DirectoryCreated(relativeDirectory)); + } + } + + string? backupRelativePath = null; + if (File.Exists(targetPath)) + { + backupRelativePath = Path.Combine( + DeploymentStateStore.BackupsDirectoryName, + deploymentId, + file.TargetRelativePath) + .Replace('\\', '/'); + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + backupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + paths.DeploymentDirectory, + backupPath, + "Deployment backup paths must stay inside the deployment directory.", + "Deployment backup paths must not contain reparse points."); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath) ?? paths.BackupDirectory); + DeploymentStateStore.AppendJournal( + paths.JournalPath, + DeploymentJournalRecord.FileBackupStarted(file.TargetRelativePath, backupRelativePath)); + File.Move(targetPath, backupPath, overwrite: true); + DeploymentStateStore.AppendJournal( + paths.JournalPath, + DeploymentJournalRecord.FileBackedUp(file.TargetRelativePath, backupRelativePath)); + } + + DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeploymentStarted( + file.SourcePath, + file.TargetRelativePath, + backupRelativePath, + file.PackageId)); + + DeploymentMethod method = DeployFile(file.SourcePath, targetPath); + DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeployed( + file.SourcePath, + file.TargetRelativePath, + method, + backupRelativePath, + file.PackageId)); + + entries.Add(new DeploymentFileDocument( + file.SourcePath, + file.TargetRelativePath, + method, + backupRelativePath, + file.PackageId, + new FileInfo(targetPath).Length, + File.GetLastWriteTimeUtc(targetPath))); + } + + DeploymentManifestDocument document = new( + SchemaVersion: 1, + deploymentId, + DateTimeOffset.UtcNow, + entries, + createdDirectories.OrderByDescending(path => path.Length).ToList()); + DeploymentStateStore.WriteManifest(paths.ActiveManifestPath, document); + _logger.LogInformation("Prepared deployment {DeploymentId} with {FileCount} file(s).", deploymentId, + entries.Count); + return DeploymentResult.Success(DeploymentStateStore.ToCoreManifest(document)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment preparation failed."); + DeploymentFailure prepareFailure = new( + DeploymentFailureKind.FileSystem, + request.Paths.GameDirectory, + ex.Message); + + DeploymentResult recoveryResult; + try + { + recoveryResult = RecoverCore(request.Paths, CancellationToken.None); + } + catch (Exception recoveryException) + { + _logger.LogError(recoveryException, "Deployment recovery failed after preparation failure."); + recoveryResult = DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.Manifest, + request.Paths.GameDirectory, + recoveryException.Message)); + } + + if (recoveryResult.Succeeded) + { + return DeploymentResult.Failure(prepareFailure); + } + + return new DeploymentResult( + false, + new[] { prepareFailure }.Concat(recoveryResult.Failures).ToArray(), + recoveryResult.Manifest); + } + } + + /// + /// Cleans a deployment while the deployment operation lock is held. + /// + /// The launcher paths. + /// A token that cancels cleanup work. + /// The cleanup result. + private DeploymentResult CleanupCore(LauncherPaths paths, CancellationToken cancellationToken) + { + DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, deploymentId: string.Empty); + DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(deploymentPaths); + + if (manifest is null) + { + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return DeploymentResult.Success(); + } + + CleanupManifest(paths, deploymentPaths, manifest, cancellationToken); + DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken); + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + + _logger.LogInformation("Cleaned deployment {DeploymentId}.", manifest.DeploymentId); + return DeploymentResult.Success(DeploymentStateStore.ToCoreManifest(manifest)); + } + + /// + /// Recovers deployment state while the deployment operation lock is held. + /// + /// The launcher paths. + /// A token that cancels recovery work. + /// The recovery result. + private DeploymentResult RecoverCore(LauncherPaths paths, CancellationToken cancellationToken) + { + DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, deploymentId: string.Empty); + DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(deploymentPaths); + + if (manifest is null) + { + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return DeploymentResult.Success(); + } + + CleanupManifest(paths, deploymentPaths, manifest, cancellationToken); + DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken); + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + + _logger.LogInformation("Recovered deployment state for {DeploymentId}.", manifest.DeploymentId); + return DeploymentResult.Success(DeploymentStateStore.ToCoreManifest(manifest)); + } + + /// + /// Deploys a file with a hard link first and copy fallback. + /// + /// The source file path. + /// The target file path. + /// The deployment method used. + private DeploymentMethod DeployFile(string sourcePath, string targetPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths must be rooted.", + "Deployment source paths must not contain reparse points."); + + if (_hardLinkCreator.TryCreateHardLink(targetPath, sourcePath)) + { + return DeploymentMethod.HardLink; + } + + File.Copy(sourcePath, targetPath, overwrite: true); + _logger.LogInformation( + "Hard-link deployment failed for {FileName}; copied the file instead.", + Path.GetFileName(targetPath)); + return DeploymentMethod.Copy; + } + + /// + /// Cleans a manifest from the game directory. + /// + /// The launcher paths. + /// The deployment state paths. + /// The manifest to clean. + /// A token that cancels cleanup work. + private void CleanupManifest( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + CancellationToken cancellationToken) + { + foreach (DeploymentFileDocument file in manifest.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetPath = DeploymentPathResolver.ResolveGamePath(paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(paths, targetPath); + if (string.IsNullOrWhiteSpace(file.BackupRelativePath)) + { + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileCleanupDeleted(file.TargetRelativePath)); + } + + continue; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + file.BackupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths must stay inside the deployment directory.", + "Deployment backup paths must not contain reparse points."); + if (!File.Exists(backupPath)) + { + _logger.LogInformation( + "Skipped restoring deployment target {FileName} because its backup is missing; it may already be restored.", + Path.GetFileName(targetPath)); + continue; + } + + DeploymentStateStore.AppendJournal(deploymentPaths.JournalPath, DeploymentJournalRecord.FileCleanupRestoreStarted( + file.TargetRelativePath, + file.BackupRelativePath)); + + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? paths.GameDirectory); + File.Move(backupPath, targetPath, overwrite: true); + DeploymentStateStore.AppendJournal(deploymentPaths.JournalPath, DeploymentJournalRecord.FileCleanupRestored( + file.TargetRelativePath, + file.BackupRelativePath)); + } + + foreach (string relativeDirectory in manifest.CreatedDirectories.OrderByDescending(path => path.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string directoryPath = DeploymentPathResolver.ResolveGamePath(paths, relativeDirectory); + EnsureSafeGameMutationPath(paths, directoryPath); + if (!Directory.Exists(directoryPath)) + { + continue; + } + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + continue; + } + + _logger.LogInformation( + "Left deployment-created directory {DirectoryName} because it contains non-deployed files.", + Path.GetFileName(directoryPath)); + } + } + + /// + /// Verifies that a game-directory mutation target stays in the game folder and does not cross child reparse points. + /// + /// The launcher paths. + /// The mutation target path. + private static void EnsureSafeGameMutationPath(LauncherPaths paths, string targetPath) + { + string gameRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(paths.GameDirectory)); + string normalizedTargetPath = Path.GetFullPath(targetPath); + if (!FileSystemPathSafety.IsPathInDirectory(normalizedTargetPath, gameRoot)) + { + throw new InvalidDataException("Deployment target paths must stay inside the game directory."); + } + + string relativePath = Path.GetRelativePath(gameRoot, normalizedTargetPath); + if (string.Equals(relativePath, ".", StringComparison.Ordinal)) + { + return; + } + + string currentPath = gameRoot; + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!Directory.Exists(currentPath) && !File.Exists(currentPath)) + { + return; + } + + if (FileSystemPathSafety.IsReparsePoint(currentPath)) + { + throw new InvalidDataException("Deployment target paths must not contain reparse points."); + } + } + } + + /// + /// Deletes empty deployment backup directories left after restored originals have been moved back. + /// + /// The deployment state paths. + /// A token that cancels backup cleanup between directories. + private static void DeleteEmptyBackupDirectories( + DeploymentStatePaths deploymentPaths, + CancellationToken cancellationToken) + { + string backupRoot = Path.Combine( + deploymentPaths.DeploymentDirectory, + DeploymentStateStore.BackupsDirectoryName); + if (!Directory.Exists(backupRoot) || + FileSystemPathSafety.IsReparsePoint(backupRoot)) + { + return; + } + + foreach (string directory in Directory + .EnumerateDirectories( + backupRoot, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!FileSystemPathSafety.IsReparsePoint(directory) && + !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.EnumerateFileSystemEntries(backupRoot).Any()) + { + Directory.Delete(backupRoot); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..cbc497b9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Resolves launch-readiness integrity issues using persisted snapshots, package repair, and cache refresh. +/// +public sealed class FileSystemLaunchContentIntegrityResolutionService : ILaunchContentIntegrityResolutionService +{ + /// + /// The integrity service used to verify, snapshot, and clean target content. + /// + private readonly IContentIntegrityService _integrityService; + + /// + /// The target builder used to construct launch content integrity targets. + /// + private readonly ILaunchContentIntegrityTargetBuilder _targetBuilder; + + /// + /// The S3 manifest reader used for managed S3 repairs. + /// + private readonly IS3ObjectManifestReader _manifestReader; + + /// + /// The S3 package updater used for managed S3 repairs. + /// + private readonly IS3PackageUpdater _s3PackageUpdater; + + /// + /// The single-file package updater used for managed single-file repairs. + /// + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + /// + /// The asset downloader used to refresh launcher-owned image cache files. + /// + private readonly IRemoteAssetDownloader _assetDownloader; + + /// + /// The launcher content catalog commands used to persist content source changes. + /// + private readonly ILauncherContentCatalogCommands _catalogCommands; + + /// + /// The logger used for integrity-resolution diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The integrity service used to verify, snapshot, and clean target content. + /// The target builder used to construct launch content integrity targets. + /// The S3 manifest reader used for managed S3 repairs. + /// The S3 package updater used for managed S3 repairs. + /// The single-file package updater used for managed single-file repairs. + /// The asset downloader used to refresh launcher-owned image cache files. + /// The launcher content catalog commands used to persist content source changes. + /// The logger used for integrity-resolution diagnostics. + public FileSystemLaunchContentIntegrityResolutionService( + IContentIntegrityService integrityService, + ILaunchContentIntegrityTargetBuilder targetBuilder, + IS3ObjectManifestReader manifestReader, + IS3PackageUpdater s3PackageUpdater, + ISingleFilePackageUpdater singleFilePackageUpdater, + IRemoteAssetDownloader assetDownloader, + ILauncherContentCatalogCommands catalogCommands, + ILogger logger) + { + _integrityService = integrityService ?? throw new ArgumentNullException(nameof(integrityService)); + _targetBuilder = targetBuilder ?? throw new ArgumentNullException(nameof(targetBuilder)); + _manifestReader = manifestReader ?? throw new ArgumentNullException(nameof(manifestReader)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _catalogCommands = catalogCommands ?? throw new ArgumentNullException(nameof(catalogCommands)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task VerifyAsync( + LaunchContentIntegrityTargetRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + IReadOnlyList contexts = _targetBuilder.BuildTargets(request); + ContentIntegrityReport report = await _integrityService.VerifyAsync( + contexts.Select(context => context.Target).ToList(), + cancellationToken); + return new LaunchContentIntegrityVerificationResult(report, contexts); + } + + /// + public async Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var untrackedManagedCacheIds = request.Report.Issues + .Where(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + .Select(issue => issue.TargetId) + .Where(targetId => request.Report.Issues + .Where(issue => issue.TargetId == targetId) + .All(issue => issue.Kind == IntegrityIssueKind.Untracked)) + .ToHashSet(StringComparer.Ordinal); + var cacheContexts = request.TargetContexts + .Where(context => + context.IsCache && + untrackedManagedCacheIds.Contains(context.Target.Id)) + .ToList(); + + bool initializedAny = false; + foreach (LaunchContentIntegrityTargetContext context in cacheContexts) + { + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + context.Target, + BuildExpectedRemoteCachePaths(context), + cancellationToken)) + { + continue; + } + + _logger.LogInformation( + "Initialized managed remote image integrity for {ContentName}.", + context.Version.DisplayName); + initializedAny = true; + } + + return initializedAny; + } + + /// + public async Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var contextIndex = + request.TargetContexts.ToDictionary(context => context.Target.Id, StringComparer.Ordinal); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action == IntegrityIssueAction.TrustAsManual))) + { + context.Version.ContentSourceKind = ContentSourceKind.Manual; + ContentIntegrityTarget manualTarget = context.Target with + { + SourceKind = ContentSourceKind.Manual, + }; + await _integrityService.CaptureSnapshotAsync(manualTarget, cancellationToken); + } + + _catalogCommands.SaveLauncherData(); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action == IntegrityIssueAction.Absorb))) + { + await _integrityService.CaptureSnapshotAsync(context.Target, cancellationToken); + } + + await _integrityService.ApplyCleanupAsync( + request.Report, + request.TargetContexts.Select(context => context.Target).ToList(), + cancellationToken); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload))) + { + if (context.IsCache) + { + await RefreshManagedCacheAsync(context, cancellationToken); + progress?.Report(LaunchContentIntegrityResolutionProgress.Complete(context.Target.Id)); + } + else + { + await RepairManagedPackageAsync( + request.Paths, + context, + new TargetPackageProgress(context.Target.Id, progress), + cancellationToken); + } + } + + foreach (string targetId in request.Report.Issues + .Where(issue => + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + .Select(issue => issue.TargetId) + .Distinct(StringComparer.Ordinal)) + { + if (contextIndex.TryGetValue(targetId, out LaunchContentIntegrityTargetContext? context)) + { + await _integrityService.CaptureSnapshotAsync(context.Target, cancellationToken); + } + } + } + + /// + public async Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + request.Version.ContentSourceKind = ContentSourceKind.Manual; + _catalogCommands.SaveLauncherData(); + + ContentIntegrityTarget packageTarget = CreatePackageTarget(request); + await _integrityService.CaptureSnapshotAsync(packageTarget, cancellationToken); + + if (request.Version.ModificationType == ModificationType.Mod) + { + ContentIntegrityTarget cacheTarget = CreateCacheTarget(request); + await _integrityService.CaptureSnapshotAsync(cacheTarget, cancellationToken); + } + } + + /// + public async Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind is not + (ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile)) + { + return; + } + + ContentIntegrityTarget packageTarget = CreatePackageTarget(request); + await _integrityService.CaptureSnapshotAsync(packageTarget, cancellationToken); + + if (request.Version.ModificationType == ModificationType.Mod) + { + ContentIntegrityTarget cacheTarget = CreateCacheTarget(request); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, request.Version, isCache: true); + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + cacheTarget, + BuildExpectedRemoteCachePaths(cacheContext), + cancellationToken)) + { + await RefreshManagedCacheAsync(cacheContext, cancellationToken); + await _integrityService.CaptureSnapshotAsync(cacheTarget, cancellationToken); + } + } + } + + /// + public async Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind != ContentSourceKind.Manual) + { + return; + } + + await _integrityService.CaptureSnapshotAsync( + CreateCacheTarget(request), + cancellationToken); + } + + /// + /// Creates the package target for a single-version operation. + /// + /// The single-version request. + /// The package target. + private ContentIntegrityTarget CreatePackageTarget(LaunchContentIntegrityVersionRequest request) + { + return BuildSingleVersionContexts(request) + .First(context => !context.IsCache) + .Target; + } + + /// + /// Creates the cache target for a single-version operation. + /// + /// The single-version request. + /// The cache target. + private ContentIntegrityTarget CreateCacheTarget(LaunchContentIntegrityVersionRequest request) + { + return BuildSingleVersionContexts(request) + .First(context => context.IsCache) + .Target; + } + + /// + /// Builds target contexts for a single-version operation. + /// + /// The single-version request. + /// The target contexts. + private IReadOnlyList BuildSingleVersionContexts( + LaunchContentIntegrityVersionRequest request) + { + return _targetBuilder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + request.Paths, + new[] { request.Version }, + request.AllVersions, + request.CacheDisplayNameSuffix, + request.AddonsFolderName, + request.PatchesFolderName)); + } + + /// + /// Repairs a managed remote package. + /// + /// The resolved game and launcher paths. + /// The target context to repair. + /// The package progress reporter. + /// A token that cancels repair work. + private async Task RepairManagedPackageAsync( + GenLauncherGO.Core.Startup.LauncherPaths paths, + LaunchContentIntegrityTargetContext context, + IProgress progress, + CancellationToken cancellationToken) + { + ContentSourceKind sourceKind = context.Version.EffectiveContentSourceKind; + if (sourceKind == ContentSourceKind.ManagedS3) + { + S3ObjectManifestRequest manifestRequest = S3CatalogDefaults.CreateManifestRequest(context.Version); + IReadOnlyList files = await _manifestReader.ReadManifestAsync( + manifestRequest, + cancellationToken); + var hashCheckedExtensions = files + .Select(file => Path.GetExtension(file.FileName)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + hashCheckedExtensions.Add(".gib"); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + files, + manifestRequest.Endpoint, + manifestRequest.BucketName, + manifestRequest.Prefix, + manifestRequest.AccessKey, + manifestRequest.SecretKey, + paths.GetPackageTemporaryFolderPath(context.Target.RootDirectory), + context.Target.RootDirectory, + context.Target.RootDirectory, + hashCheckedExtensions, + manifestRequest.UseSsl), + progress, + cancellationToken); + return; + } + + if (sourceKind == ContentSourceKind.ManagedSingleFile) + { + await _singleFilePackageUpdater.UpdateAsync( + new SingleFilePackageUpdateRequest( + DownloadLinkResolver.ResolveDownloadUri(context.Version.SimpleDownloadLink), + paths.GetPackageTemporaryFolderPath(context.Target.RootDirectory), + context.Target.RootDirectory), + progress, + cancellationToken); + return; + } + + throw new InvalidOperationException("Only managed remote content can be repaired automatically."); + } + + /// + /// Refreshes a managed launcher-owned cache target from remote asset links. + /// + /// The cache target context. + /// A token that cancels refresh work. + private async Task RefreshManagedCacheAsync( + LaunchContentIntegrityTargetContext context, + CancellationToken cancellationToken) + { + ContentIntegrityTarget target = context.Target; + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata must resolve to a rooted path.", + "Content metadata resolved through a linked launcher-owned directory."); + Directory.CreateDirectory(target.RootDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata must resolve to a rooted path.", + "Content metadata resolved through a linked launcher-owned directory."); + + List assets = BuildRemoteCacheAssets(context.Version, target.RootDirectory); + foreach (string filePath in EnumerateFilesWithoutLinks(target.RootDirectory).ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = FileSystemPathSafety.NormalizeRelativePath(Path.GetRelativePath( + target.RootDirectory, + filePath)); + if (target.IgnoredRelativePaths.Contains(relativePath)) + { + continue; + } + + File.Delete(filePath); + } + + foreach (string directory in EnumerateDirectoriesWithoutLinks(target.RootDirectory) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + } + } + + foreach (RemoteCacheAsset asset in assets) + { + await _assetDownloader.DownloadIfMissingAsync( + asset.SourceUri, + asset.DestinationPath, + cancellationToken); + } + } + + /// + /// Builds expected remote cache paths for a target context. + /// + /// The cache target context. + /// The expected relative cache paths. + private static HashSet BuildExpectedRemoteCachePaths(LaunchContentIntegrityTargetContext context) + { + return BuildRemoteCacheAssets(context.Version, context.Target.RootDirectory) + .Select(asset => FileSystemPathSafety.NormalizeRelativePath(Path.GetRelativePath( + context.Target.RootDirectory, + asset.DestinationPath))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Builds remote cache asset descriptors from version metadata. + /// + /// The content version. + /// The launcher-owned cache directory. + /// The remote cache assets. + private static List BuildRemoteCacheAssets( + ModificationVersion version, + string cacheDirectory) + { + var assets = new List(); + AddRemoteCacheAsset(assets, version.UIImageSourceLink, cacheDirectory, version.Version); + AddRemoteCacheAsset( + assets, + version.ColorsInformation?.GenLauncherBackgroundImageLink, + cacheDirectory, + version.Version + "-background"); + return assets; + } + + /// + /// Adds one remote cache asset when the source link is an absolute URI. + /// + /// The asset collection to update. + /// The source URI text. + /// The launcher-owned cache directory. + /// The destination file base name. + private static void AddRemoteCacheAsset( + List assets, + string? link, + string cacheDirectory, + string baseName) + { + if (!Uri.TryCreate(link, UriKind.Absolute, out Uri? sourceUri)) + { + return; + } + + string extension = Path.GetExtension(sourceUri.LocalPath); + if (!string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)) + { + extension = ".png"; + } + + assets.Add(new RemoteCacheAsset( + sourceUri, + FileSystemPathSafety.ResolveOwnedSubpath( + cacheDirectory, + Path.Combine(cacheDirectory, baseName + extension), + "Content metadata resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked launcher-owned directory."))); + } + + /// + /// Enumerates files without following linked directories. + /// + /// The root directory to enumerate. + /// The file paths. + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + + /// + /// Enumerates directories without following linked directories. + /// + /// The root directory to enumerate. + /// The directory paths. + private static IEnumerable EnumerateDirectoriesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateDirectories( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + + /// + /// Bridges package updater progress to launch integrity progress by target id. + /// + private sealed class TargetPackageProgress : IProgress + { + /// + /// The target id associated with package progress. + /// + private readonly string _targetId; + + /// + /// The outer progress reporter. + /// + private readonly IProgress? _progress; + + /// + /// Initializes a new instance of the class. + /// + /// The target id associated with package progress. + /// The outer progress reporter. + public TargetPackageProgress( + string targetId, + IProgress? progress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + _targetId = targetId; + _progress = progress; + } + + /// + public void Report(PackageUpdateProgress value) + { + _progress?.Report(LaunchContentIntegrityResolutionProgress.Package(_targetId, value)); + } + } + + /// + /// Describes one remote cache asset. + /// + /// The remote source URI. + /// The local destination path. + private sealed record RemoteCacheAsset( + Uri SourceUri, + string DestinationPath); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..b225ac56 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Builds launch-readiness integrity targets from launcher-owned file-system paths. +/// +public sealed class FileSystemLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder +{ + /// + /// The empty ignored path set shared by package targets. + /// + private static readonly HashSet _emptyIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); + + /// + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var contexts = new List(); + foreach (ModificationVersion version in request.ActiveVersions) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreatePackageTarget(request, version), + version, + isCache: false)); + + if (version.ModificationType == ModificationType.Mod) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreateCacheTarget(request, version), + version, + isCache: true)); + } + } + + return contexts; + } + + /// + /// Creates the package directory integrity target for a selected version. + /// + /// The target construction request. + /// The selected content version. + /// The package integrity target. + private static ContentIntegrityTarget CreatePackageTarget( + LaunchContentIntegrityTargetRequest request, + ModificationVersion version) + { + string packageDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + request.Paths.ModsDirectory, + ResolvePackageRoot( + request.Paths, + version, + request.AddonsFolderName, + request.PatchesFolderName), + "Content metadata resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked launcher-owned directory."); + + return new ContentIntegrityTarget( + CreateTargetId("package", version), + version.DisplayName, + packageDirectory, + version.EffectiveContentSourceKind, + _emptyIgnoredPaths); + } + + /// + /// Creates the launcher image cache integrity target for a selected modification version. + /// + /// The target construction request. + /// The selected modification version. + /// The cache integrity target. + private static ContentIntegrityTarget CreateCacheTarget( + LaunchContentIntegrityTargetRequest request, + ModificationVersion version) + { + string cacheDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + request.Paths.ImagesDirectory, + request.Paths.GetModificationImagesDirectory(version.Name), + "Content metadata resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked launcher-owned directory."); + HashSet ignoredPaths = BuildInactiveCacheIgnoredPaths(request, version, cacheDirectory); + + return new ContentIntegrityTarget( + CreateTargetId("cache", version), + version.DisplayName + " " + request.CacheDisplayNameSuffix, + cacheDirectory, + version.EffectiveContentSourceKind, + ignoredPaths); + } + + /// + /// Builds ignored cache paths that belong to inactive versions of the same modification. + /// + /// The target construction request. + /// The selected modification version. + /// The cache directory to inspect. + /// The ignored cache paths. + private static HashSet BuildInactiveCacheIgnoredPaths( + LaunchContentIntegrityTargetRequest request, + ModificationVersion version, + string cacheDirectory) + { + var ignoredPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(cacheDirectory) || FileSystemPathSafety.IsReparsePoint(cacheDirectory)) + { + return ignoredPaths; + } + + var inactiveBaseNames = request.AllVersions + .Where(candidate => + !IsExactVersion(candidate, version) && + string.Equals(candidate.Name, version.Name, StringComparison.OrdinalIgnoreCase)) + .SelectMany(candidate => new[] + { + candidate.Version, + candidate.Version + "-background", + }) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string filePath in EnumerateFilesWithoutLinks(cacheDirectory)) + { + string relativePath = FileSystemPathSafety.NormalizeRelativePath(Path.GetRelativePath( + cacheDirectory, + filePath)); + if (inactiveBaseNames.Contains(Path.GetFileNameWithoutExtension(filePath))) + { + ignoredPaths.Add(relativePath); + } + } + + return ignoredPaths; + } + + /// + /// Resolves the installed package root for a selected content version. + /// + /// The resolved game and launcher paths. + /// The selected content version. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// The installed package root directory. + private static string ResolvePackageRoot( + LauncherPaths paths, + ModificationVersion version, + string addonsFolderName, + string patchesFolderName) + { + return version.ModificationType switch + { + ModificationType.Addon => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + addonsFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Patch => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + patchesFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Mod => Path.Combine( + paths.ModsDirectory, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + _ => string.Empty, + }; + } + + /// + /// Creates a stable target identifier. + /// + /// The target kind prefix. + /// The content version. + /// The target identifier. + private static string CreateTargetId(string prefix, ModificationVersion version) + { + return string.Join( + ":", + prefix, + version.ModificationType, + version.DependenceName ?? string.Empty, + version.Name ?? string.Empty, + version.Version ?? string.Empty).ToLowerInvariant(); + } + + /// + /// Determines whether two content versions identify the same package. + /// + /// The candidate version. + /// The selected version. + /// when the versions identify the same package; otherwise, . + private static bool IsExactVersion( + ModificationVersion candidate, + ModificationVersion version) + { + return candidate.ModificationType == version.ModificationType && + string.Equals(candidate.DependenceName, version.DependenceName, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Name, version.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Version, version.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Enumerates files without following linked directories. + /// + /// The root directory to enumerate. + /// The file paths. + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..9c96d0a8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Discovers Windows game and World Builder executables through file-system probes. +/// +public sealed class WindowsGameExecutableDiscoveryService : IGameExecutableDiscoveryService +{ + /// + /// The Generals Online launcher executable name. + /// + private const string GeneralsOnlineLauncherExecutable = "generalsonlinezh.exe"; + + /// + /// The original game World Builder executable name. + /// + private const string VanillaWorldBuilderExecutable = "WorldBuilder.exe"; + + /// + /// Resolved launcher paths used to inspect the game directory. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// Resolves community executable names for managed game variants. + /// + private readonly IGameProcessLauncher _gameProcessLauncher; + + /// + /// Logs file-system probe diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The resolved launcher paths used to inspect the game directory. + /// The launcher used to resolve community executable names. + /// The logger used for file-system probe diagnostics. + public WindowsGameExecutableDiscoveryService( + LauncherPaths launcherPaths, + IGameProcessLauncher gameProcessLauncher, + ILogger logger) + { + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _gameProcessLauncher = gameProcessLauncher ?? throw new ArgumentNullException(nameof(gameProcessLauncher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IReadOnlyList GetAvailableGameClients(SupportedGame managedGame) + { + var executables = new List(); + string communityExecutable = _gameProcessLauncher.GetCommunityGameExecutableName(managedGame); + + if (IsExecutableAvailable(communityExecutable)) + { + executables.Add(new GameClientExecutable(communityExecutable, GameClientExecutableKind.Community)); + } + + if (IsExecutableAvailable(GeneralsOnlineLauncherExecutable)) + { + executables.Add(new GameClientExecutable( + GeneralsOnlineLauncherExecutable, + GameClientExecutableKind.GeneralsOnline)); + } + + return executables; + } + + /// + public IReadOnlyList GetAvailableWorldBuilders(SupportedGame managedGame) + { + var executables = new List(); + + if (IsExecutableAvailable(VanillaWorldBuilderExecutable)) + { + executables.Add(new WorldBuilderExecutable( + VanillaWorldBuilderExecutable, + WorldBuilderExecutableKind.Vanilla)); + } + + string communityExecutable = _gameProcessLauncher.GetCommunityWorldBuilderExecutableName(managedGame); + if (IsExecutableAvailable(communityExecutable)) + { + executables.Add(new WorldBuilderExecutable( + communityExecutable, + WorldBuilderExecutableKind.Community)); + } + + return executables; + } + + /// + public bool IsExecutableAvailable(string? executableName) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + return false; + } + + try + { + return File.Exists(GetExecutableProbePath(executableName)); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + _logger.LogWarning( + exception, + "Could not inspect executable availability for {ExecutableName}.", + Path.GetFileName(executableName)); + return false; + } + } + + /// + /// Builds the path used for a file-system existence probe. + /// + /// The executable path or name. + /// The rooted path to inspect. + private string GetExecutableProbePath(string executableName) + { + return Path.IsPathRooted(executableName) + ? executableName + : Path.Combine(_launcherPaths.GameDirectory, executableName); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs new file mode 100644 index 00000000..6dfe2194 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs @@ -0,0 +1,152 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Launches Windows game and World Builder processes for supported Command & Conquer clients. +/// +public sealed class WindowsGameProcessLauncher : IGameProcessLauncher +{ + /// + /// The observed game-process running time required to treat a launch as successful. + /// + private const int SuccessfulLaunchThresholdMilliseconds = 12000; + + /// + /// The SuperHackers Generals executable name. + /// + private const string SuperHackersGeneralsExecutable = "generalsv.exe"; + + /// + /// The SuperHackers Zero Hour executable name. + /// + private const string SuperHackersZeroHourExecutable = "generalszh.exe"; + + /// + /// The SuperHackers Generals World Builder executable name. + /// + private const string SuperHackersGeneralsWorldBuilderExecutable = "worldbuilderv.exe"; + + /// + /// The SuperHackers Zero Hour World Builder executable name. + /// + private const string SuperHackersZeroHourWorldBuilderExecutable = "worldbuilderzh.exe"; + + /// + /// The Generals Online launcher executable name. + /// + private const string GeneralsOnlineLauncherExecutable = "generalsonlinezh.exe"; + + /// + /// The process-family launcher used to start and wait on processes. + /// + private readonly IProcessFamilyLauncher _processFamilyLauncher; + + /// + /// The logger used for process launch diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The process-family launcher used to start and wait on processes. + /// The logger used for process launch diagnostics. + public WindowsGameProcessLauncher( + IProcessFamilyLauncher processFamilyLauncher, + ILogger logger) + { + _processFamilyLauncher = + processFamilyLauncher ?? throw new ArgumentNullException(nameof(processFamilyLauncher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string GetCommunityGameExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? SuperHackersZeroHourExecutable + : SuperHackersGeneralsExecutable; + } + + /// + public string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? SuperHackersZeroHourWorldBuilderExecutable + : SuperHackersGeneralsWorldBuilderExecutable; + } + + /// + public async Task LaunchAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + string executableName = ResolveExecutableName(request); + string arguments = ResolveArguments(request); + + try + { + TimeSpan runningDuration = await _processFamilyLauncher.LaunchAndWaitForExitAsync( + executableName, + arguments, + cancellationToken); + + if (request.TargetKind == GameLaunchTargetKind.GameClient && + runningDuration.TotalMilliseconds < SuccessfulLaunchThresholdMilliseconds) + { + const string message = "The game process exited before the successful launch threshold."; + _logger.LogInformation( + "Launch of {ExecutableName} ended after {RunningDurationMs}ms, below the success threshold.", + executableName, + runningDuration.TotalMilliseconds); + return GameLaunchResult.Failure(executableName, arguments, runningDuration, message); + } + + return GameLaunchResult.Success(executableName, arguments, runningDuration); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed to launch {ExecutableName}.", executableName); + throw; + } + } + + /// + /// Resolves the executable name for a launch request. + /// + /// The game launch request. + /// The executable name. + private string ResolveExecutableName(GameLaunchRequest request) + { + if (request.TargetKind == GameLaunchTargetKind.WorldBuilder) + { + return request.ExecutableName; + } + + return request.UseGeneralsOnline + ? GeneralsOnlineLauncherExecutable + : GetCommunityGameExecutableName(request.ManagedGame); + } + + /// + /// Resolves command-line arguments for a launch request. + /// + /// The game launch request. + /// The process arguments. + private static string ResolveArguments(GameLaunchRequest request) + { + return request.TargetKind == GameLaunchTargetKind.GameClient && request.UseGeneralsOnline + ? string.Empty + : request.Arguments; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs new file mode 100644 index 00000000..b3f7f996 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Starts Windows processes and waits until the launched process family has exited. +/// +public sealed class WindowsProcessFamilyLauncher : IProcessFamilyLauncher +{ + /// + /// The interval used to poll the process table. + /// + private const int ProcessPollMilliseconds = 500; + + /// + /// The grace period used when a launcher process hands off to a child process. + /// + private const int ProcessHandoffGraceMilliseconds = 5000; + + /// + /// The ToolHelp snapshot flag for process snapshots. + /// + private const uint Th32csSnapprocess = 0x00000002; + + /// + /// The invalid native handle value. + /// + private static readonly IntPtr _invalidHandleValue = new(-1); + + /// + /// The logger used for process-family diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for process-family diagnostics. + public WindowsProcessFamilyLauncher(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public Task LaunchAndWaitForExitAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + return Task.Run( + () => LaunchAndWaitForExit(executableName, arguments ?? string.Empty, cancellationToken), + cancellationToken); + } + + /// + /// Starts the executable and waits for its process family to exit. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// A token that cancels the wait operation. + /// The observed running duration for the launched process family. + private TimeSpan LaunchAndWaitForExit( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + using Process process = StartExecutable(executableName, arguments); + return WaitForProcessFamilyExit(process, cancellationToken); + } + + /// + /// Starts the executable with arguments. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// The started process. + /// Thrown when the process could not be started. + private static Process StartExecutable(string executableName, string arguments) + { + var process = Process.Start(executableName, arguments); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {executableName}."); + } + + return process; + } + + /// + /// Waits until the process family rooted at the started process has exited. + /// + /// The root process. + /// A token that cancels the wait operation. + /// The observed running duration for the launched process family. + private TimeSpan WaitForProcessFamilyExit( + Process process, + CancellationToken cancellationToken) + { + var processFamily = new ProcessFamilyTracker(process.Id, _logger); + while (processFamily.IsRunning()) + { + if (cancellationToken.WaitHandle.WaitOne(ProcessPollMilliseconds)) + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + + return processFamily.RunningDuration; + } + + /// + /// Attempts to capture a snapshot of running Windows processes. + /// + /// The process snapshot entries, or when the snapshot cannot be captured. + private static List? TryCaptureProcessSnapshot() + { + IntPtr snapshotHandle = CreateToolhelp32Snapshot(Th32csSnapprocess, 0); + if (snapshotHandle == _invalidHandleValue) + { + return null; + } + + try + { + var entries = new List(); + var nativeEntry = new NativeProcessEntry + { + Size = (uint)Marshal.SizeOf(typeof(NativeProcessEntry)), + }; + if (!Process32First(snapshotHandle, ref nativeEntry)) + { + return entries; + } + + do + { + entries.Add(new ProcessSnapshotEntry( + unchecked((int)nativeEntry.ProcessId), + unchecked((int)nativeEntry.ParentProcessId))); + } while (Process32Next(snapshotHandle, ref nativeEntry)); + + return entries; + } + finally + { + CloseHandle(snapshotHandle); + } + } + + /// + /// Captures a native ToolHelp snapshot. + /// + /// The ToolHelp snapshot flags. + /// The target process id, or zero for all processes. + /// The native snapshot handle. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + /// + /// Reads the first process entry from a native process snapshot. + /// + /// The native snapshot handle. + /// The process entry buffer. + /// when a process entry was read; otherwise, . + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32First(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + /// + /// Reads the next process entry from a native process snapshot. + /// + /// The native snapshot handle. + /// The process entry buffer. + /// when a process entry was read; otherwise, . + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32Next(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + /// + /// Closes a native handle. + /// + /// The native handle. + /// when the handle was closed; otherwise, . + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + /// + /// Describes one native process entry returned by ToolHelp. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct NativeProcessEntry + { + /// + /// The size of this structure. + /// + public uint Size; + + /// + /// The usage count reported by ToolHelp. + /// + public uint UsageCount; + + /// + /// The process identifier. + /// + public uint ProcessId; + + /// + /// The default heap identifier. + /// + public IntPtr DefaultHeapId; + + /// + /// The module identifier. + /// + public uint ModuleId; + + /// + /// The thread count. + /// + public uint ThreadCount; + + /// + /// The parent process identifier. + /// + public uint ParentProcessId; + + /// + /// The base priority class. + /// + public int PriorityClassBase; + + /// + /// The native entry flags. + /// + public uint Flags; + + /// + /// The executable file name. + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string? ExecutableFileName; + } + + /// + /// Tracks the root process and descendants until they have all exited. + /// + private sealed class ProcessFamilyTracker + { + /// + /// The process ids that exited recently and may still parent a handoff process. + /// + private readonly Dictionary _retiredProcessIds = new(); + + /// + /// The known process ids in the launched process family. + /// + private readonly HashSet _knownProcessIds = new(); + + /// + /// The root process id. + /// + private readonly int _rootProcessId; + + /// + /// The time when tracking started. + /// + private readonly DateTime _startedAtUtc = DateTime.UtcNow; + + /// + /// The logger used for process-family diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The time when the process family first appeared empty after a child process was observed. + /// + private DateTime? _emptyFamilyObservedAtUtc; + + /// + /// The most recent time a known process was observed running. + /// + private DateTime _lastObservedRunningAtUtc = DateTime.UtcNow; + + /// + /// A value indicating whether a child process was ever observed. + /// + private bool _childProcessObserved; + + /// + /// A value indicating whether snapshot failure fallback was already logged. + /// + private bool _snapshotFailureLogged; + + /// + /// Initializes a new instance of the class. + /// + /// The root process id. + /// The logger used for process-family diagnostics. + public ProcessFamilyTracker( + int rootProcessId, + ILogger logger) + { + _rootProcessId = rootProcessId; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _knownProcessIds.Add(rootProcessId); + } + + /// + /// Gets the observed running duration for the launched process family. + /// + public TimeSpan RunningDuration => _lastObservedRunningAtUtc - _startedAtUtc; + + /// + /// Determines whether the tracked process family should still be considered running. + /// + /// when the process family is still running; otherwise, . + public bool IsRunning() + { + List? entries = TryCaptureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return IsRootProcessRunning(); + } + + DateTime nowUtc = DateTime.UtcNow; + ExpireRetiredProcessIds(nowUtc); + TrackDescendants(entries); + + var runningProcessIds = entries + .Select(entry => entry.ProcessId) + .ToHashSet(); + + if (UpdateKnownProcessState(runningProcessIds, nowUtc)) + { + _emptyFamilyObservedAtUtc = null; + _lastObservedRunningAtUtc = nowUtc; + return true; + } + + if (!_childProcessObserved) + { + return false; + } + + _emptyFamilyObservedAtUtc ??= nowUtc; + return nowUtc - _emptyFamilyObservedAtUtc.Value < + TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds); + } + + /// + /// Removes retired process ids after the handoff grace period expires. + /// + /// The current UTC time. + private void ExpireRetiredProcessIds(DateTime nowUtc) + { + foreach (KeyValuePair retiredProcess in _retiredProcessIds.ToList()) + { + if (nowUtc - retiredProcess.Value < + TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds)) + { + continue; + } + + _retiredProcessIds.Remove(retiredProcess.Key); + _knownProcessIds.Remove(retiredProcess.Key); + } + } + + /// + /// Adds observed descendants of known process ids to the tracked process family. + /// + /// The current process snapshot entries. + private void TrackDescendants(IReadOnlyList entries) + { + bool addedProcess; + do + { + addedProcess = false; + foreach (ProcessSnapshotEntry entry in entries) + { + if (!_knownProcessIds.Contains(entry.ParentProcessId) || + !_knownProcessIds.Add(entry.ProcessId)) + { + continue; + } + + addedProcess = true; + _childProcessObserved = true; + _retiredProcessIds.Remove(entry.ProcessId); + + _logger.LogInformation( + "Tracking launched child process {ProcessId} for cleanup wait.", + entry.ProcessId); + } + } while (addedProcess); + } + + /// + /// Updates known process running and retirement state from the current snapshot. + /// + /// The process ids currently running. + /// The current UTC time. + /// when at least one known process is running; otherwise, . + private bool UpdateKnownProcessState( + HashSet runningProcessIds, + DateTime nowUtc) + { + bool anyKnownProcessRunning = false; + foreach (int processId in _knownProcessIds) + { + if (runningProcessIds.Contains(processId)) + { + anyKnownProcessRunning = true; + _retiredProcessIds.Remove(processId); + continue; + } + + if (!_retiredProcessIds.ContainsKey(processId)) + { + _retiredProcessIds[processId] = nowUtc; + } + } + + return anyKnownProcessRunning; + } + + /// + /// Determines whether the root process is still running when process snapshots are unavailable. + /// + /// when the root process is running; otherwise, . + private bool IsRootProcessRunning() + { + try + { + using var process = Process.GetProcessById(_rootProcessId); + if (process.HasExited) + { + return false; + } + + _lastObservedRunningAtUtc = DateTime.UtcNow; + return true; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + /// + /// Logs the process snapshot fallback warning once. + /// + private void LogSnapshotFailureOnce() + { + if (_snapshotFailureLogged) + { + return; + } + + _logger.LogWarning( + "Could not inspect launched child processes; falling back to the root launch process only."); + _snapshotFailureLogged = true; + } + } + + /// + /// Describes one process snapshot entry. + /// + /// The process identifier. + /// The parent process identifier. + private sealed record ProcessSnapshotEntry( + int ProcessId, + int ParentProcessId); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs new file mode 100644 index 00000000..0c6e2691 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Resolves package files and directories for deployment without mutating the filesystem. +/// +internal static class DeploymentFilePlanner +{ + /// + /// Resolves package files and applies package precedence. + /// + /// The deployment request. + /// The files to deploy. + public static IReadOnlyList ResolveDeploymentFiles(DeploymentRequest request) + { + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DeploymentPackage package in request.Packages.OrderBy(package => package.Precedence)) + { + string packageRoot = Path.GetFullPath(package.RootDirectory); + if (!Directory.Exists(packageRoot)) + { + throw new DirectoryNotFoundException( + $"Deployment package directory was not found: {package.RootDirectory}"); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + packageRoot, + "Deployment package paths must be rooted.", + "Deployment package directories must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + packageRoot, + "Deployment package directories must not contain reparse points."); + + foreach (string sourcePath in Directory.EnumerateFiles( + packageRoot, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions())) + { + string extension = Path.GetExtension(sourcePath); + if (string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string relativePath = DeploymentPathResolver.ToRelativeManifestPath(packageRoot, sourcePath); + string targetRelativePath = string.Equals(extension, ".gib", StringComparison.OrdinalIgnoreCase) + ? Path.ChangeExtension(relativePath, ".big").Replace('\\', '/') + : relativePath; + string normalizedTargetPath = DeploymentPathResolver.NormalizeManifestPath(targetRelativePath); + filesByTarget[normalizedTargetPath] = new ResolvedDeploymentFile( + sourcePath, + normalizedTargetPath, + package.Id, + package.Precedence); + } + } + + return filesByTarget.Values.OrderBy(file => file.TargetRelativePath, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Returns directories between a game root and target directory that do not already exist. + /// + /// The game root directory. + /// The target directory to create. + /// The directories to create in parent-first order. + public static IEnumerable GetDirectoriesToCreate(string gameRoot, string targetDirectory) + { + var directories = new Stack(); + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(gameRoot)); + string? current = Path.GetFullPath(targetDirectory); + while (!string.IsNullOrWhiteSpace(current) && + !string.Equals(Path.TrimEndingDirectorySeparator(current), root, StringComparison.OrdinalIgnoreCase) && + !Directory.Exists(current)) + { + directories.Push(current); + current = Directory.GetParent(current)?.FullName; + } + + return directories; + } +} + +/// +/// Describes a resolved file to deploy. +/// +/// The source file path. +/// The target relative path. +/// The package identifier. +/// The package precedence. +internal sealed record ResolvedDeploymentFile( + string SourcePath, + string TargetRelativePath, + string PackageId, + int Precedence); diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs new file mode 100644 index 00000000..7f2beb11 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Resolves deployment manifest paths inside launcher-owned deployment roots. +/// +internal static class DeploymentPathResolver +{ + /// + /// Resolves a game-directory-relative manifest path. + /// + /// The launcher paths. + /// The relative manifest path. + /// The full game path. + public static string ResolveGamePath(LauncherPaths paths, string relativePath) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string gameRoot = Path.GetFullPath(paths.GameDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(gameRoot, normalizedPath)); + string launcherRoot = Path.GetFullPath(paths.LauncherDirectory); + + if (!FileSystemPathSafety.IsPathInDirectory(candidatePath, gameRoot) || + FileSystemPathSafety.IsPathInDirectory(candidatePath, launcherRoot)) + { + throw new InvalidDataException($"Deployment target path '{relativePath}' is outside the game directory."); + } + + return candidatePath; + } + + /// + /// Normalizes a manifest path to slash separators after validation. + /// + /// The relative path to normalize. + /// The normalized manifest path. + public static string NormalizeManifestPath(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + if (Path.IsPathRooted(relativePath) || relativePath.Contains(':', StringComparison.Ordinal)) + { + throw new InvalidDataException("Deployment manifest paths must be relative."); + } + + string[] segments = relativePath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw new InvalidDataException("Deployment manifest paths must include a file name."); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw new InvalidDataException("Deployment manifest paths must not contain parent directory segments."); + } + } + + return string.Join('/', segments); + } + + /// + /// Converts a full path to a normalized manifest-relative path. + /// + /// The root directory. + /// The child path. + /// The manifest-relative path. + public static string ToRelativeManifestPath(string rootDirectory, string path) + { + return NormalizeManifestPath(Path.GetRelativePath(Path.GetFullPath(rootDirectory), Path.GetFullPath(path))); + } + + /// + /// Resolves a deployment-state-relative path. + /// + /// The deployment state directory. + /// The deployment-state-relative path. + /// The full deployment state path. + public static string ResolveDeploymentStatePath(string deploymentDirectory, string relativePath) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string deploymentRoot = Path.GetFullPath(deploymentDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(deploymentRoot, normalizedPath)); + + if (!FileSystemPathSafety.IsPathInDirectory(candidatePath, deploymentRoot)) + { + throw new InvalidDataException( + $"Deployment state path '{relativePath}' is outside the deployment directory."); + } + + return candidatePath; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs new file mode 100644 index 00000000..6e8690b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs @@ -0,0 +1,717 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Persists deployment manifest and journal state used to recover file-system deployment side effects. +/// +internal sealed class DeploymentStateStore +{ + /// + /// The backup root directory name. + /// + public const string BackupsDirectoryName = "Backups"; + + /// + /// The manifest schema version written by this store. + /// + private const int SchemaVersion = 1; + + /// + /// The completed deployment manifest file name. + /// + private const string ActiveManifestFileName = "active.json"; + + /// + /// The append-only journal file name. + /// + private const string JournalFileName = "journal.jsonl"; + + /// + /// The deployment operation lock file name. + /// + private const string LockFileName = "deployment.lock"; + + /// + /// The JSON serialization options used for manifests and journal records. + /// + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + /// + /// The compact JSON serialization options used for one-record-per-line journal entries. + /// + private static readonly JsonSerializerOptions _journalJsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// The logger used for deployment state diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for deployment state diagnostics. + public DeploymentStateStore(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Creates deployment state paths for a deployment id. + /// + /// The launcher paths. + /// The deployment id, when a deployment-specific path is needed. + /// The deployment state paths. + public static DeploymentStatePaths CreatePaths(LauncherPaths paths, string deploymentId) + { + string deploymentDirectory = paths.DeploymentDirectory; + string backupDirectory = string.IsNullOrWhiteSpace(deploymentId) + ? Path.Combine(deploymentDirectory, BackupsDirectoryName) + : Path.Combine(deploymentDirectory, BackupsDirectoryName, deploymentId); + + return new DeploymentStatePaths( + deploymentDirectory, + Path.Combine(deploymentDirectory, ActiveManifestFileName), + Path.Combine(deploymentDirectory, JournalFileName), + Path.Combine(deploymentDirectory, LockFileName), + backupDirectory); + } + + /// + /// Acquires an exclusive deployment operation lock. + /// + /// The launcher paths. + /// The open lock file stream. + public static FileStream AcquireDeploymentLock(LauncherPaths paths) + { + DeploymentStatePaths deploymentPaths = CreatePaths(paths, deploymentId: string.Empty); + Directory.CreateDirectory(deploymentPaths.DeploymentDirectory); + return new FileStream( + deploymentPaths.LockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + } + + /// + /// Writes a completed deployment manifest. + /// + /// The manifest path. + /// The manifest document. + public static void WriteManifest(string manifestPath, DeploymentManifestDocument manifest) + { + string manifestDirectory = Path.GetDirectoryName(manifestPath) ?? string.Empty; + Directory.CreateDirectory(manifestDirectory); + string temporaryPath = Path.Combine( + manifestDirectory, + Path.GetFileName(manifestPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + + try + { + WriteAllTextDurably(temporaryPath, JsonSerializer.Serialize(manifest, _jsonOptions)); + File.Move(temporaryPath, manifestPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Deletes persisted active deployment state after cleanup or recovery. + /// + /// The deployment state paths. + public static void DeleteDeploymentStateFiles(DeploymentStatePaths paths) + { + if (File.Exists(paths.ActiveManifestPath)) + { + File.Delete(paths.ActiveManifestPath); + } + + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + } + + /// + /// Appends one journal record. + /// + /// The journal path. + /// The record to append. + public static void AppendJournal(string journalPath, DeploymentJournalRecord record) + { + Directory.CreateDirectory(Path.GetDirectoryName(journalPath) ?? string.Empty); + byte[] bytes = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(record, _journalJsonOptions) + Environment.NewLine); + using FileStream stream = new( + journalPath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + /// + /// Converts an infrastructure manifest document to a Core manifest model. + /// + /// The manifest document. + /// The Core manifest. + public static DeploymentManifest ToCoreManifest(DeploymentManifestDocument document) + { + return new DeploymentManifest( + document.SchemaVersion, + document.DeploymentId, + document.CreatedAtUtc, + document.Files + .Select(file => new DeploymentFileEntry( + file.SourcePath, + file.TargetRelativePath, + file.Method, + file.BackupRelativePath, + file.PackageId)) + .ToList(), + document.CreatedDirectories); + } + + /// + /// Reads the active manifest or reconstructs it from the deployment journal. + /// + /// The deployment state paths. + /// The active deployment manifest, or when no state exists. + public DeploymentManifestDocument? ReadManifestOrJournal(DeploymentStatePaths paths) + { + DeploymentManifestDocument? manifest = TryReadManifest(paths.ActiveManifestPath, out Exception? readException); + if (File.Exists(paths.JournalPath)) + { + DeploymentManifestDocument? journalManifest = RebuildManifestFromJournal(paths); + if (journalManifest is not null) + { + return journalManifest; + } + + if (manifest is null && readException is not null) + { + throw new InvalidDataException( + "The deployment manifest could not be read and the journal did not contain recoverable deployment state.", + readException); + } + + return manifest; + } + + if (manifest is not null) + { + return manifest; + } + + if (readException is not null) + { + throw new InvalidDataException( + "The deployment manifest could not be read and no journal was available for recovery.", + readException); + } + + return null; + } + + /// + /// Writes text to a file and flushes it to disk before returning. + /// + /// The file path to write. + /// The text contents to write. + private static void WriteAllTextDurably(string path, string contents) + { + byte[] bytes = Encoding.UTF8.GetBytes(contents); + using FileStream stream = new( + path, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + /// + /// Tries to read a completed deployment manifest without preventing journal fallback. + /// + /// The manifest path. + /// The manifest read exception, when one occurred. + /// The manifest document, or when no manifest could be read. + private DeploymentManifestDocument? TryReadManifest(string manifestPath, out Exception? readException) + { + readException = null; + if (!File.Exists(manifestPath)) + { + return null; + } + + try + { + DeploymentManifestDocument? manifest = + JsonSerializer.Deserialize(File.ReadAllText(manifestPath), _jsonOptions); + if (manifest is null) + { + readException = new InvalidDataException("The deployment manifest did not contain manifest data."); + _logger.LogWarning( + "Deployment manifest did not contain manifest data; journal recovery will be attempted."); + } + + return manifest; + } + catch (Exception ex) when (ex is IOException or JsonException or NotSupportedException) + { + readException = ex; + _logger.LogWarning(ex, "Deployment manifest could not be read; journal recovery will be attempted."); + return null; + } + } + + /// + /// Rebuilds a manifest from the journal. + /// + /// The deployment state paths. + /// The rebuilt manifest, or when no deploy-affecting records exist. + private DeploymentManifestDocument? RebuildManifestFromJournal(DeploymentStatePaths paths) + { + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupStartsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var cleanupRestoreStartsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + bool sawDeploymentStateRecord = false; + + foreach (string line in File.ReadLines(paths.JournalPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + DeploymentJournalRecord? record; + try + { + record = JsonSerializer.Deserialize(line, _journalJsonOptions); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Skipped unreadable deployment journal record."); + continue; + } + + if (record is null) + { + continue; + } + + if (string.IsNullOrWhiteSpace(record.TargetRelativePath)) + { + _logger.LogWarning("Skipped deployment journal record without a target path."); + continue; + } + + if (string.Equals(record.Action, DeploymentJournalRecord.DirectoryCreatedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + directories.Add(record.TargetRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileBackupStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + backupStartsByTarget[record.TargetRelativePath] = record.BackupRelativePath; + backupsByTarget[record.TargetRelativePath] = record.BackupRelativePath; + } + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileBackedUpAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + backupsByTarget[record.TargetRelativePath] = record.BackupRelativePath ?? string.Empty; + filesByTarget[record.TargetRelativePath] = new DeploymentFileDocument( + SourcePath: "(recovered)", + record.TargetRelativePath, + DeploymentMethod.Copy, + record.BackupRelativePath, + PackageId: "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileDeploymentStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath ?? string.Empty; + backupsByTarget.TryGetValue(targetRelativePath, out string? backupRelativePath); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + record.SourcePath ?? "(recovered)", + targetRelativePath, + DeploymentMethod.Copy, + record.BackupRelativePath ?? backupRelativePath, + record.PackageId ?? "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + else if (string.Equals(record.Action, DeploymentJournalRecord.FileDeployedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath ?? string.Empty; + backupsByTarget.TryGetValue(targetRelativePath, out string? backupRelativePath); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + record.SourcePath ?? "(recovered)", + targetRelativePath, + record.Method ?? DeploymentMethod.Copy, + record.BackupRelativePath ?? backupRelativePath, + record.PackageId ?? "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupDeleteCompletedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupRestoreStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + cleanupRestoreStartsByTarget[record.TargetRelativePath] = record.BackupRelativePath; + } + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupRestoredAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + cleanupRestoreStartsByTarget.Remove(record.TargetRelativePath); + } + } + + foreach (KeyValuePair backupStart in backupStartsByTarget) + { + if (filesByTarget.ContainsKey(backupStart.Key) || + !File.Exists(DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + backupStart.Value))) + { + continue; + } + + filesByTarget[backupStart.Key] = new DeploymentFileDocument( + SourcePath: "(recovered)", + backupStart.Key, + DeploymentMethod.Copy, + backupStart.Value, + PackageId: "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + + foreach (KeyValuePair restoreStart in cleanupRestoreStartsByTarget) + { + if (!File.Exists(DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + restoreStart.Value))) + { + filesByTarget.Remove(restoreStart.Key); + } + } + + if (filesByTarget.Count == 0 && directories.Count == 0 && !sawDeploymentStateRecord) + { + return null; + } + + return new DeploymentManifestDocument( + SchemaVersion, + DeploymentId: "recovered", + DateTimeOffset.UtcNow, + filesByTarget.Values.ToList(), + directories.OrderByDescending(path => path.Length).ToList()); + } +} + +/// +/// Describes deployment state paths. +/// +/// The deployment state directory. +/// The active manifest path. +/// The journal path. +/// The operation lock path. +/// The backup directory for the active deployment. +internal sealed record DeploymentStatePaths( + string DeploymentDirectory, + string ActiveManifestPath, + string JournalPath, + string LockPath, + string BackupDirectory); + +/// +/// Describes the persisted deployment manifest document. +/// +/// The schema version. +/// The deployment id. +/// The creation time. +/// The deployed files. +/// The directories created by deployment. +internal sealed record DeploymentManifestDocument( + int SchemaVersion, + string DeploymentId, + DateTimeOffset CreatedAtUtc, + IReadOnlyList Files, + IReadOnlyList CreatedDirectories); + +/// +/// Describes one persisted deployed file entry. +/// +/// The source path. +/// The target relative path. +/// The deployment method. +/// The backup relative path. +/// The package id. +/// The deployed file size. +/// The deployed file last write time. +internal sealed record DeploymentFileDocument( + string SourcePath, + string TargetRelativePath, + DeploymentMethod Method, + string? BackupRelativePath, + string PackageId, + long Size, + DateTime LastWriteTimeUtc); + +/// +/// Describes one append-only journal record. +/// +/// The action name. +/// The target relative path. +/// The backup relative path. +/// The source path. +/// The deployment method. +/// The package id. +internal sealed record DeploymentJournalRecord( + string Action, + string? TargetRelativePath, + string? BackupRelativePath, + string? SourcePath, + DeploymentMethod? Method, + string? PackageId) +{ + /// + /// The directory-created action name. + /// + public const string DirectoryCreatedAction = "directory-created"; + + /// + /// The file-backup-started action name. + /// + public const string FileBackupStartedAction = "file-backup-started"; + + /// + /// The file-backed-up action name. + /// + public const string FileBackedUpAction = "file-backed-up"; + + /// + /// The file-deployment-started action name. + /// + public const string FileDeploymentStartedAction = "file-deployment-started"; + + /// + /// The file-deployed action name. + /// + public const string FileDeployedAction = "file-deployed"; + + /// + /// The file-cleanup-delete-completed action name. + /// + public const string FileCleanupDeleteCompletedAction = "file-cleanup-delete-completed"; + + /// + /// The file-cleanup-restore-started action name. + /// + public const string FileCleanupRestoreStartedAction = "file-cleanup-restore-started"; + + /// + /// The file-cleanup-restored action name. + /// + public const string FileCleanupRestoredAction = "file-cleanup-restored"; + + /// + /// Creates a directory-created journal record. + /// + /// The created directory relative path. + /// The journal record. + public static DeploymentJournalRecord DirectoryCreated(string targetRelativePath) + { + return new DeploymentJournalRecord(DirectoryCreatedAction, targetRelativePath, null, null, null, null); + } + + /// + /// Creates a file-backup-started journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileBackupStarted(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileBackupStartedAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } + + /// + /// Creates a file-backed-up journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileBackedUp(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileBackedUpAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } + + /// + /// Creates a file-deployment-started journal record. + /// + /// The source path. + /// The target file relative path. + /// The backup relative path. + /// The package id. + /// The journal record. + public static DeploymentJournalRecord FileDeploymentStarted( + string sourcePath, + string targetRelativePath, + string? backupRelativePath, + string packageId) + { + return new DeploymentJournalRecord( + FileDeploymentStartedAction, + targetRelativePath, + backupRelativePath, + sourcePath, + null, + packageId); + } + + /// + /// Creates a file-deployed journal record. + /// + /// The source path. + /// The target file relative path. + /// The deployment method. + /// The backup relative path. + /// The package id. + /// The journal record. + public static DeploymentJournalRecord FileDeployed( + string sourcePath, + string targetRelativePath, + DeploymentMethod method, + string? backupRelativePath, + string packageId) + { + return new DeploymentJournalRecord( + FileDeployedAction, + targetRelativePath, + backupRelativePath, + sourcePath, + method, + packageId); + } + + /// + /// Creates a file-cleanup-delete-completed journal record. + /// + /// The target file relative path. + /// The journal record. + public static DeploymentJournalRecord FileCleanupDeleted(string targetRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupDeleteCompletedAction, + targetRelativePath, + null, + null, + null, + null); + } + + /// + /// Creates a file-cleanup-restore-started journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileCleanupRestoreStarted(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoreStartedAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } + + /// + /// Creates a file-cleanup-restored journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileCleanupRestored(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoredAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs new file mode 100644 index 00000000..db3c5506 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs @@ -0,0 +1,15 @@ +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Creates hard links between installed package files and game-directory targets. +/// +public interface IHardLinkCreator +{ + /// + /// Attempts to create a hard link. + /// + /// The hard-link path to create. + /// The existing file path to link to. + /// when the hard link was created; otherwise, . + bool TryCreateHardLink(string targetPath, string sourcePath); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs new file mode 100644 index 00000000..74d8b463 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Starts a process and waits until the launched process family has exited. +/// +public interface IProcessFamilyLauncher +{ + /// + /// Starts the executable and waits for the launched process family to exit. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// A token that cancels the wait operation. + /// The observed running duration for the launched process family. + Task LaunchAndWaitForExitAsync( + string executableName, + string arguments, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs new file mode 100644 index 00000000..347e9f23 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Creates hard links through the Windows file-system API. +/// +public sealed class WindowsHardLinkCreator : IHardLinkCreator +{ + /// + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + return CreateHardLink(targetPath, sourcePath, lpSecurityAttributes: 0); + } + + /// + /// Imports the Windows hard-link creation API. + /// + /// The new hard-link path. + /// The existing source file path. + /// Reserved security attributes pointer. + /// when the hard link was created; otherwise, . + [DllImport("kernel32.dll", EntryPoint = "CreateHardLinkW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateHardLink( + string lpFileName, + string lpExistingFileName, + int lpSecurityAttributes); +} diff --git a/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs new file mode 100644 index 00000000..b02554bf --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs @@ -0,0 +1,115 @@ +using System; +using System.Globalization; +using System.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; + +namespace GenLauncherGO.Infrastructure.Logging; + +/// +/// Provides dependency-injection registration helpers for GenLauncherGO logging infrastructure. +/// +public static class LoggingServiceCollectionExtensions +{ + /// + /// The number of session log files retained for desktop diagnostics. + /// + private const int RetainedLogFileCount = 14; + + /// + /// The readable prefix used for GenLauncherGO log files. + /// + private const string LogFilePrefix = "GenLauncherGO"; + + /// + /// Registers the standard GenLauncherGO logging pipeline with rolling file logs. + /// + /// The service collection used by the application composition root. + /// The directory where rolling log files should be written. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + /// + /// Thrown when is , empty, or whitespace. + /// + public static IServiceCollection AddGenLauncherGoLogging( + this IServiceCollection services, + string logDirectory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(logDirectory); + + Directory.CreateDirectory(logDirectory); + + string logFilePath = CreateLogFilePath(logDirectory); + PruneOldLogFiles(logDirectory, logFilePath); + Serilog.ILogger logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + logFilePath, + shared: false) + .CreateLogger(); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSerilog(logger, dispose: true); + }); + + return services; + } + + /// + /// Creates the readable UTC session log file path for the current launcher session. + /// + /// The directory where the session log file will be written. + /// The full session log file path. + private static string CreateLogFilePath(string logDirectory) + { + string timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd-HHmmss'Z'", CultureInfo.InvariantCulture); + string baseLogFileName = $"{LogFilePrefix}-{timestamp}"; + string logFilePath = Path.Combine(logDirectory, baseLogFileName + ".log"); + int collisionIndex = 2; + while (File.Exists(logFilePath)) + { + logFilePath = Path.Combine(logDirectory, $"{baseLogFileName}-{collisionIndex}.log"); + collisionIndex++; + } + + return logFilePath; + } + + /// + /// Deletes older GenLauncherGO logs before the active log is opened. + /// + /// The directory containing log files. + /// The log file reserved for the current session. + private static void PruneOldLogFiles(string logDirectory, string activeLogFilePath) + { + FileInfo[] logFiles = new DirectoryInfo(logDirectory).GetFiles($"{LogFilePrefix}-*.log"); + Array.Sort(logFiles, (left, right) => right.LastWriteTimeUtc.CompareTo(left.LastWriteTimeUtc)); + + string activePath = Path.GetFullPath(activeLogFilePath); + int retainedCount = 1; + foreach (FileInfo logFile in logFiles) + { + if (string.Equals(Path.GetFullPath(logFile.FullName), activePath, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (retainedCount < RetainedLogFileCount) + { + retainedCount++; + continue; + } + + logFile.Delete(); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..8bc8990d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,72 @@ +using System; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Options; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Clients; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Composition; + +/// +/// Provides dependency-injection registrations for launcher content infrastructure. +/// +public static class ModsInfrastructureServiceCollectionExtensions +{ + /// + /// Registers infrastructure services used by launcher content workflows. + /// + /// The service collection used by the application composition root. + /// The YAML file path where launcher content state is persisted. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + /// + /// Thrown when is , empty, or whitespace. + /// + public static IServiceCollection AddGenLauncherGoModsInfrastructure( + this IServiceCollection services, + string launcherDataFilePath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(launcherDataFilePath); + + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + new YamlDocumentStoreOptions(launcherDataFilePath), + serviceProvider.GetRequiredService>>())); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs new file mode 100644 index 00000000..104e14e4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Caches remote launcher catalog images in the local launcher image folder. +/// +internal interface ILauncherCatalogImageCache +{ + /// + /// Downloads missing card and background images for a remote modification. + /// + /// The normalized remote modification metadata. + /// The launcher paths used to resolve cache destinations. + /// The token used to cancel remote work. + /// A task that completes when the image cache has been updated. + Task CacheModificationImagesAsync( + RemoteContentManifest modification, + LauncherPaths paths, + CancellationToken cancellationToken); + + /// + /// Downloads missing advertising images and removes stale advertising image variants. + /// + /// The normalized advertising image metadata. + /// The launcher paths used to resolve cache destinations. + /// The token used to cancel remote work. + /// A task that completes when the advertising image cache has been updated. + Task CacheAdvertisingImagesAsync( + RemoteAdvertisingReference advertising, + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs new file mode 100644 index 00000000..7c919b4c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs @@ -0,0 +1,47 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Maps persisted compact content state to the mutable launcher catalog facade models. +/// +internal interface ILauncherContentStateMapper +{ + /// + /// Converts persisted compact state into the in-memory catalog. + /// + /// The persisted content state. + /// The in-memory catalog. + LauncherData ToLauncherData(LauncherContentState state); + + /// + /// Converts the in-memory catalog into persisted compact state. + /// + /// The in-memory catalog. + /// The persisted compact state. + LauncherContentState ToLauncherContentState(LauncherData launcherData); + + /// + /// Converts one installed content state into a catalog version. + /// + /// The installed content state. + /// The catalog version. + ModificationVersion ToModificationVersion(LauncherContentVersionState version); + + /// + /// Converts a catalog version into persisted compact state. + /// + /// The catalog version. + /// The content type to use when catalog state is ambiguous. + /// The persisted version state. + LauncherContentVersionState ToVersionState( + ModificationVersion version, + LauncherContentType fallbackType); + + /// + /// Gets the fallback compact content type for a legacy modification type. + /// + /// The legacy modification type. + /// The fallback compact content type. + LauncherContentType GetFallbackContentType(ModificationType type); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs new file mode 100644 index 00000000..80f3ac9e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Reconciles mutable launcher catalog state with locally installed content folders. +/// +internal interface ILauncherLocalContentReconciler +{ + /// + /// Adds locally installed versions that are missing from the current catalog and removes stale local-only records. + /// + /// The mutable launcher catalog. + /// Remote content versions already downloaded into the catalog. + /// The launcher paths used to inspect local content folders. + /// The local content folder layout. + void Reconcile( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherPaths paths, + LauncherContentLayout layout); + + /// + /// Deletes a content version from local storage or from the advertising-only catalog list. + /// + /// The mutable launcher catalog. + /// The content version to delete. + /// The launcher paths used to locate local content folders. + /// The local content folder layout. + void DeleteVersion( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout); + + /// + /// Deletes all local files for a content card while leaving catalog removal to the caller. + /// + /// The mutable launcher catalog. + /// A version that identifies the content card to delete. + /// The launcher paths. + /// The content folder layout. + void DeleteContent( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..69e2a1d0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Reads remote launcher catalog manifests through a compatibility mapping boundary. +/// +internal interface IRemoteLauncherCatalogClient +{ + /// + /// Reads the top-level repository catalog. + /// + /// The top-level repository manifest URI. + /// The token used to cancel remote work. + /// The normalized repository catalog, or an empty catalog when the remote document is empty. + Task ReadCatalogAsync(Uri manifestUri, CancellationToken cancellationToken); + + /// + /// Gets advertised modification names from a repository catalog. + /// + /// The normalized repository catalog. + /// The advertised modification names. + IReadOnlyList GetModificationNames(RemoteLauncherCatalog catalog); + + /// + /// Downloads remote manifest data for locally installed modifications. + /// + /// The normalized repository catalog. + /// The installed modification names. + /// The token used to cancel remote work. + /// Downloaded modification manifests with child-content link data. + Task> DownloadInstalledModDataAsync( + RemoteLauncherCatalog catalog, + IReadOnlyCollection installedModNames, + CancellationToken cancellationToken); + + /// + /// Downloads one remote modification manifest by content name. + /// + /// The normalized repository catalog. + /// The modification name. + /// The token used to cancel remote work. + /// The downloaded modification manifest and child-content link data. + Task DownloadModDataByNameAsync( + RemoteLauncherCatalog catalog, + string name, + CancellationToken cancellationToken); + + /// + /// Reads add-on or patch manifests, preserving partial success when one child manifest fails. + /// + /// The child manifest URLs. + /// The token used to cancel remote work. + /// The successfully read child manifests. + Task> ReadChildManifestsAsync( + IEnumerable manifestUrls, + CancellationToken cancellationToken); + + /// + /// Downloads advertising modification metadata. + /// + /// The advertising manifest URL. + /// The token used to cancel remote work. + /// The advertising manifest, or when it cannot be read. + Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs new file mode 100644 index 00000000..40681527 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote advertising manifest reference. +/// +internal sealed class RemoteAdvertisingReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The advertised content name. + /// The advertised content manifest URL. + /// The advertising image URLs. + public RemoteAdvertisingReference( + string name, + string manifestUrl, + IReadOnlyList imageUrls) + { + Name = name ?? string.Empty; + ManifestUrl = manifestUrl ?? string.Empty; + ImageUrls = imageUrls ?? Array.Empty(); + } + + /// + /// Gets the advertised content name. + /// + public string Name { get; } + + /// + /// Gets the advertised content manifest URL. + /// + public string ManifestUrl { get; } + + /// + /// Gets advertising image URLs. + /// + public IReadOnlyList ImageUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs new file mode 100644 index 00000000..9494ab34 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest reference. +/// +internal sealed class RemoteCatalogModificationReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The modification name. + /// The modification manifest URL. + /// The patch manifest URLs associated with the modification. + /// The add-on manifest URLs associated with the modification. + public RemoteCatalogModificationReference( + string name, + string manifestUrl, + IReadOnlyList patchManifestUrls, + IReadOnlyList addonManifestUrls) + { + Name = name ?? string.Empty; + ManifestUrl = manifestUrl ?? string.Empty; + PatchManifestUrls = patchManifestUrls ?? Array.Empty(); + AddonManifestUrls = addonManifestUrls ?? Array.Empty(); + } + + /// + /// Gets the modification name. + /// + public string Name { get; } + + /// + /// Gets the modification manifest URL. + /// + public string ManifestUrl { get; } + + /// + /// Gets patch manifest URLs associated with the modification. + /// + public IReadOnlyList PatchManifestUrls { get; } + + /// + /// Gets add-on manifest URLs associated with the modification. + /// + public IReadOnlyList AddonManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs new file mode 100644 index 00000000..7782c343 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs @@ -0,0 +1,105 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents normalized metadata from one remote content manifest. +/// +internal sealed class RemoteContentManifest +{ + /// + /// Gets or sets the content category. + /// + public ModificationType ModificationType { get; set; } + + /// + /// Gets or sets the content name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the content version label. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the direct package download link. + /// + public string SimpleDownloadLink { get; set; } = string.Empty; + + /// + /// Gets or sets the card image source link. + /// + public string ImageSourceLink { get; set; } = string.Empty; + + /// + /// Gets or sets the Discord link. + /// + public string DiscordLink { get; set; } = string.Empty; + + /// + /// Gets or sets the ModDB link. + /// + public string ModDbLink { get; set; } = string.Empty; + + /// + /// Gets or sets the news link. + /// + public string NewsLink { get; set; } = string.Empty; + + /// + /// Gets or sets the parent content name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 endpoint URL. + /// + public string S3HostLink { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 bucket name. + /// + public string S3BucketName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 folder name. + /// + public string S3FolderName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 public key. + /// + public string S3HostPublicKey { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 secret key. + /// + public string S3HostSecretKey { get; set; } = string.Empty; + + /// + /// Gets or sets network information text. + /// + public string NetworkInfo { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the content is deprecated. + /// + public bool Deprecated { get; set; } + + /// + /// Gets or sets the support link. + /// + public string SupportLink { get; set; } = string.Empty; + + /// + /// Gets or sets optional launcher color information. + /// + public ColorsInfoString? ColorsInformation { get; set; } + + /// + /// Gets or sets the content source kind used by launch integrity checks. + /// + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs new file mode 100644 index 00000000..98dcdd96 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote launcher catalog after third-party backend YAML has been mapped. +/// +internal sealed class RemoteLauncherCatalog +{ + /// + /// Initializes a new instance of the class. + /// + /// The advertising manifest references. + /// The modification manifest references. + /// The original-game add-on manifest URLs. + /// The original-game patch manifest URLs. + /// The launcher version advertised by the remote catalog. + public RemoteLauncherCatalog( + IReadOnlyList advertisingEntries, + IReadOnlyList modifications, + IReadOnlyList originalGameAddonManifestUrls, + IReadOnlyList originalGamePatchManifestUrls, + string launcherVersion) + { + AdvertisingEntries = advertisingEntries ?? Array.Empty(); + Modifications = modifications ?? Array.Empty(); + OriginalGameAddonManifestUrls = originalGameAddonManifestUrls ?? Array.Empty(); + OriginalGamePatchManifestUrls = originalGamePatchManifestUrls ?? Array.Empty(); + LauncherVersion = launcherVersion ?? string.Empty; + } + + /// + /// Gets an empty remote catalog. + /// + public static RemoteLauncherCatalog Empty { get; } = new( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + string.Empty); + + /// + /// Gets advertising manifest references. + /// + public IReadOnlyList AdvertisingEntries { get; } + + /// + /// Gets modification manifest references. + /// + public IReadOnlyList Modifications { get; } + + /// + /// Gets original-game add-on manifest URLs. + /// + public IReadOnlyList OriginalGameAddonManifestUrls { get; } + + /// + /// Gets original-game patch manifest URLs. + /// + public IReadOnlyList OriginalGamePatchManifestUrls { get; } + + /// + /// Gets the launcher version advertised by the remote catalog. + /// + public string LauncherVersion { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs new file mode 100644 index 00000000..36865fbe --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest with its child manifest references. +/// +internal sealed class RemoteModificationManifest +{ + /// + /// Initializes a new instance of the class. + /// + /// The normalized content manifest. + /// The patch manifest URLs associated with the content. + /// The add-on manifest URLs associated with the content. + public RemoteModificationManifest( + RemoteContentManifest content, + IReadOnlyList patchManifestUrls, + IReadOnlyList addonManifestUrls) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + PatchManifestUrls = patchManifestUrls ?? Array.Empty(); + AddonManifestUrls = addonManifestUrls ?? Array.Empty(); + } + + /// + /// Gets the normalized content manifest. + /// + public RemoteContentManifest Content { get; } + + /// + /// Gets patch manifest URLs associated with the content. + /// + public IReadOnlyList PatchManifestUrls { get; } + + /// + /// Gets add-on manifest URLs associated with the content. + /// + public IReadOnlyList AddonManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs new file mode 100644 index 00000000..aaa83ab5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs @@ -0,0 +1,657 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Performs local file-system operations for launcher-managed mods, patches, add-ons, and cached images. +/// +public sealed class FileSystemLocalLauncherContentService : ILocalLauncherContentService +{ + /// + /// The logger used for local content file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for local content file-system diagnostics. + public FileSystemLocalLauncherContentService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IReadOnlyList FindInstalledVersions( + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + var versions = new List(); + var modsDirectory = new DirectoryInfo(paths.ModsDirectory); + if (!modsDirectory.Exists) + { + return versions; + } + + foreach (DirectoryInfo contentDirectory in modsDirectory.GetDirectories()) + { + AddInstalledVersions(contentDirectory, layout, versions); + } + + return versions; + } + + /// + public bool VersionFolderContainsFiles( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + string? versionDirectoryPath = GetVersionDirectoryPath(paths, layout, version); + return !string.IsNullOrWhiteSpace(versionDirectoryPath) && + Directory.Exists(versionDirectoryPath) && + ModFolderContainsFiles(new DirectoryInfo(versionDirectoryPath)); + } + + /// + public bool VersionFolderExists( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + string? versionDirectoryPath = GetVersionDirectoryPath(paths, layout, version); + return !string.IsNullOrWhiteSpace(versionDirectoryPath) && + Directory.Exists(versionDirectoryPath); + } + + /// + public void DeleteVersion( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + if (version.ModificationType == LauncherContentType.Advertising) + { + return; + } + + string? versionDirectoryPath = GetVersionDirectoryPath(paths, layout, version); + if (string.IsNullOrWhiteSpace(versionDirectoryPath)) + { + return; + } + + EnsurePathBelowModsRoot(paths, versionDirectoryPath); + bool deletedInstalledVersion = DeleteDirectoryIfExists( + versionDirectoryPath, + "Deleted launcher content version {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + DeletePackageStagingDirectory(paths, versionDirectoryPath, version); + + if (deletedInstalledVersion) + { + string? cleanupRoot = GetCleanupRootDirectoryPath(paths, version); + if (!string.IsNullOrWhiteSpace(cleanupRoot)) + { + DeleteEmptyDirectoryTree(paths, cleanupRoot); + } + } + } + + /// + public void DeleteContent( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + if (version.ModificationType == LauncherContentType.Advertising) + { + return; + } + + string? contentDirectoryPath = GetContentDirectoryPath(paths, layout, version); + if (string.IsNullOrWhiteSpace(contentDirectoryPath)) + { + return; + } + + EnsurePathBelowModsRoot(paths, contentDirectoryPath); + bool deletedContent = DeleteDirectoryIfExists( + contentDirectoryPath, + "Deleted launcher content {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + DeletePackageStagingDirectory(paths, contentDirectoryPath, version); + + if (deletedContent) + { + string? cleanupRoot = GetCleanupRootDirectoryPath(paths, version); + if (!string.IsNullOrWhiteSpace(cleanupRoot)) + { + DeleteEmptyDirectoryTree(paths, cleanupRoot); + } + } + } + + /// + public void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentVersionState version, + LauncherContentState currentState) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(currentState); + + if (version.ModificationType == LauncherContentType.Advertising || + string.IsNullOrWhiteSpace(version.Name) || + string.IsNullOrWhiteSpace(version.Version) || + ContentCardExists(currentState, version.Name)) + { + return; + } + + string imageFolderPath = paths.GetModificationImagesDirectory(version.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + DeleteImageFiles(imageFolderPath, version.Version); + DeleteImageFiles(imageFolderPath, version.Version + "-background"); + DeleteImageFolderIfEmpty(imageFolderPath); + } + + /// + /// Adds installed versions discovered below one top-level content directory. + /// + /// The top-level content directory. + /// The content folder layout. + /// The target version list. + private static void AddInstalledVersions( + DirectoryInfo contentDirectory, + LauncherContentLayout layout, + List versions) + { + foreach (DirectoryInfo subDirectory in contentDirectory.GetDirectories()) + { + if (String.Equals(subDirectory.Name, layout.AddonsFolderName, StringComparison.OrdinalIgnoreCase)) + { + foreach (DirectoryInfo addonDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + addonDirectory, + contentDirectory.Name, + LauncherContentType.Addon, + versions); + } + + continue; + } + + if (String.Equals(subDirectory.Name, layout.PatchesFolderName, StringComparison.OrdinalIgnoreCase)) + { + foreach (DirectoryInfo patchDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + patchDirectory, + contentDirectory.Name, + LauncherContentType.Patch, + versions); + } + + continue; + } + + if (IsInstallVersionDirectory(subDirectory)) + { + versions.Add(new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = contentDirectory.Name, + Version = subDirectory.Name, + Installed = true + }); + } + } + } + + /// + /// Adds installed add-on or patch versions discovered below a child content directory. + /// + /// The child content directory. + /// The parent modification name. + /// The child content type. + /// The target version list. + private static void AddInstalledChildVersions( + DirectoryInfo contentDirectory, + string dependenceName, + LauncherContentType contentType, + List versions) + { + foreach (DirectoryInfo versionDirectory in contentDirectory.GetDirectories()) + { + if (!IsInstallVersionDirectory(versionDirectory)) + { + continue; + } + + versions.Add(new LauncherContentVersionState + { + ModificationType = contentType, + Name = contentDirectory.Name, + Version = versionDirectory.Name, + DependenceName = dependenceName, + Installed = true + }); + } + } + + /// + /// Determines whether a directory represents an installed version folder. + /// + /// The candidate directory. + /// when the directory is an installed version folder. + private static bool IsInstallVersionDirectory(DirectoryInfo directory) + { + return ModFolderContainsFiles(directory); + } + + /// + /// Builds the installed version directory path for a content version. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version. + /// The installed version directory path, or when it cannot be built. + private static string? GetVersionDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + if (string.IsNullOrWhiteSpace(version.Name) || string.IsNullOrWhiteSpace(version.Version)) + { + return null; + } + + return version.ModificationType switch + { + LauncherContentType.Addon when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.AddonsFolderName, + version.Name, + version.Version), + LauncherContentType.Patch when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.PatchesFolderName, + version.Name, + version.Version), + LauncherContentType.Mod => Path.Combine(paths.ModsDirectory, version.Name, version.Version), + _ => null + }; + } + + /// + /// Builds the installed content card directory path for a content version. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version. + /// The installed content directory path, or when it cannot be built. + private static string? GetContentDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + if (string.IsNullOrWhiteSpace(version.Name)) + { + return null; + } + + return version.ModificationType switch + { + LauncherContentType.Addon when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.AddonsFolderName, + version.Name), + LauncherContentType.Patch when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.PatchesFolderName, + version.Name), + LauncherContentType.Mod => Path.Combine(paths.ModsDirectory, version.Name), + _ => null + }; + } + + /// + /// Builds the root directory that can be pruned after a version is deleted. + /// + /// The resolved launcher paths. + /// The deleted content version. + /// The cleanup root directory path, or when it cannot be built. + private static string? GetCleanupRootDirectoryPath( + LauncherPaths paths, + LauncherContentVersionState version) + { + if (string.IsNullOrWhiteSpace(version.Name)) + { + return null; + } + + return version.ModificationType switch + { + LauncherContentType.Addon when !string.IsNullOrWhiteSpace(version.DependenceName) => + Path.Combine(paths.ModsDirectory, version.DependenceName), + LauncherContentType.Patch when !string.IsNullOrWhiteSpace(version.DependenceName) => + Path.Combine(paths.ModsDirectory, version.DependenceName), + LauncherContentType.Mod => Path.Combine(paths.ModsDirectory, version.Name), + _ => null + }; + } + + /// + /// Deletes an empty directory tree without crossing outside the launcher-owned mods root. + /// + /// The resolved launcher paths. + /// The directory path to prune. + private static void DeleteEmptyDirectoryTree(LauncherPaths paths, string directoryPath) + { + if (string.IsNullOrWhiteSpace(directoryPath) || + !Directory.Exists(directoryPath) || + !IsPathBelowModsRoot(paths, directoryPath) || + IsReparsePoint(directoryPath)) + { + return; + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath).ToList()) + { + DeleteEmptyDirectoryTree(paths, childDirectoryPath); + } + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + } + } + + /// + /// Determines whether a path is below the launcher-owned mods root. + /// + /// The resolved launcher paths. + /// The candidate directory path. + /// when the path is below the mods root. + private static bool IsPathBelowModsRoot(LauncherPaths paths, string directoryPath) + { + string modsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(paths.ModsDirectory)); + string fullPath = Path.GetFullPath(directoryPath); + return !String.Equals(fullPath, modsRoot, StringComparison.OrdinalIgnoreCase) && + fullPath.StartsWith( + modsRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that a path stays below the launcher-owned mods root. + /// + /// The resolved launcher paths. + /// The candidate directory path. + /// Thrown when the candidate path is outside the mods root. + private static void EnsurePathBelowModsRoot(LauncherPaths paths, string directoryPath) + { + if (!IsPathBelowModsRoot(paths, directoryPath)) + { + throw new InvalidOperationException("Refusing to delete a launcher content path outside the mods root."); + } + } + + /// + /// Deletes the temporary package staging directory for a content version when it exists. + /// + /// The resolved launcher paths. + /// The installed version directory path. + /// The content version being deleted. + private void DeletePackageStagingDirectory( + LauncherPaths paths, + string versionDirectoryPath, + LauncherContentVersionState version) + { + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(versionDirectoryPath); + string packagesRoot = Path.Combine(paths.TempDirectory, "Packages"); + if (!IsPathBelowRoot(packagesRoot, packageStagingDirectory)) + { + throw new InvalidOperationException("Refusing to delete a package staging path outside the temp root."); + } + + DeleteDirectoryIfExists( + packageStagingDirectory, + "Deleted temporary launcher package staging folder for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + DeleteEmptyPackageStagingParents(packagesRoot, packageStagingDirectory); + } + + /// + /// Deletes empty package staging parent directories without crossing outside the package staging root. + /// + /// The package staging root directory. + /// The package staging directory that was deleted. + private void DeleteEmptyPackageStagingParents(string packagesRoot, string packageStagingDirectory) + { + DirectoryInfo? currentDirectory = Directory.GetParent(Path.GetFullPath(packageStagingDirectory)); + while (currentDirectory is not null && + IsPathBelowRoot(packagesRoot, currentDirectory.FullName) && + Directory.Exists(currentDirectory.FullName) && + !IsReparsePoint(currentDirectory.FullName) && + !Directory.EnumerateFileSystemEntries(currentDirectory.FullName).Any()) + { + DirectoryInfo? parentDirectory = currentDirectory.Parent; + Directory.Delete(currentDirectory.FullName); + _logger.LogInformation( + "Deleted empty temporary launcher package staging folder {StagingFolderName}.", + currentDirectory.Name); + currentDirectory = parentDirectory; + } + } + + /// + /// Deletes a directory when it exists. + /// + /// The directory to delete. + /// The structured log message. + /// The content name for diagnostics. + /// The content version for diagnostics. + /// when a directory was deleted. + private bool DeleteDirectoryIfExists( + string directoryPath, + string logMessage, + string contentName, + string contentVersion) + { + if (!Directory.Exists(directoryPath)) + { + return false; + } + + Directory.Delete(directoryPath, recursive: true); + _logger.LogInformation(logMessage, contentName, contentVersion); + return true; + } + + /// + /// Determines whether a path is below a specific root directory. + /// + /// The root directory path. + /// The candidate directory path. + /// when the path is below the root. + private static bool IsPathBelowRoot(string rootDirectoryPath, string directoryPath) + { + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootDirectoryPath)); + string fullPath = Path.GetFullPath(directoryPath); + return !String.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase) && + fullPath.StartsWith( + root + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether a directory is a reparse point. + /// + /// The directory path to inspect. + /// when the directory is a reparse point. + private static bool IsReparsePoint(string directoryPath) + { + return (File.GetAttributes(directoryPath) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Determines whether a mod folder contains files directly or below one child folder. + /// + /// The directory to inspect. + /// when the folder contains files. + private static bool ModFolderContainsFiles(DirectoryInfo directoryInfo) + { + foreach (FileInfo file in directoryInfo.GetFiles()) + { + return true; + } + + foreach (DirectoryInfo folder in directoryInfo.GetDirectories()) + { + if (FolderContainsFiles(folder)) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether a folder contains files, following the legacy first-child traversal behavior. + /// + /// The directory to inspect. + /// when the folder contains files. + private static bool FolderContainsFiles(DirectoryInfo directoryInfo) + { + foreach (FileInfo file in directoryInfo.GetFiles()) + { + return true; + } + + foreach (DirectoryInfo folder in directoryInfo.GetDirectories()) + { + return FolderContainsFiles(folder); + } + + return false; + } + + /// + /// Determines whether the current state still contains a card for the content name. + /// + /// The current launcher content state. + /// The content name. + /// when a card still exists. + private static bool ContentCardExists(LauncherContentState state, string contentName) + { + return ContainsContentName(state.Modifications, contentName) || + ContainsContentName(state.Addons, contentName) || + ContainsContentName(state.Patches, contentName); + } + + /// + /// Determines whether entries contain a content name. + /// + /// The entries to inspect. + /// The content name. + /// when an entry has the content name. + private static bool ContainsContentName(IEnumerable entries, string contentName) + { + return entries.Any(entry => String.Equals(entry.Name, contentName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Deletes image files with a specific base name. + /// + /// The image folder path. + /// The image file base name. + private void DeleteImageFiles(string imageFolderPath, string imageBaseName) + { + foreach (string imageFilePath in Directory.EnumerateFiles(imageFolderPath, imageBaseName + ".*")) + { + try + { + File.Delete(imageFilePath); + _logger.LogInformation( + "Deleted cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + } + } + } + + /// + /// Deletes an image folder when it has no remaining entries. + /// + /// The image folder path. + private void DeleteImageFolderIfEmpty(string imageFolderPath) + { + try + { + if (!Directory.EnumerateFileSystemEntries(imageFolderPath).Any()) + { + Directory.Delete(imageFolderPath); + _logger.LogInformation( + "Deleted empty modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete empty modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs new file mode 100644 index 00000000..649fe48d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Imports manually selected modification files by copying files, extracting supported archives, and converting loose +/// .big packages to launcher-managed .gib files. +/// +public sealed class FileSystemManualModificationImporter : IManualModificationImporter +{ + /// + /// Identifies archive extensions accepted by the legacy manual import dialog. + /// + private static readonly HashSet _archiveExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".rar", + ".7z", + ".zip", + }; + + /// + /// Extracts supported archive files into the destination content folder. + /// + private readonly IArchiveExtractor _archiveExtractor; + + /// + /// Logs manual import file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The archive extractor used for supported archive files. + /// The logger used for manual import diagnostics. + public FileSystemManualModificationImporter( + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public void Import( + ManualModificationImportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.SourceFilePaths); + ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationDirectory); + + if (request.SourceFilePaths.Count == 0) + { + throw new ArgumentException("At least one source file is required.", nameof(request)); + } + + string destinationDirectory = Path.GetFullPath(request.DestinationDirectory); + try + { + Directory.CreateDirectory(destinationDirectory); + + foreach (string sourceFilePath in request.SourceFilePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + ImportFile(sourceFilePath, destinationDirectory, cancellationToken); + } + + _logger.LogInformation( + "Imported {FileCount} manual content file(s) to {DestinationDirectory}.", + request.SourceFilePaths.Count, + Path.GetFileName(destinationDirectory)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogError( + exception, + "Failed to import manual content into {DestinationDirectory}.", + Path.GetFileName(destinationDirectory)); + throw; + } + } + + /// + /// Imports one selected source file into the destination directory. + /// + /// The selected source file path. + /// The fully qualified destination directory. + /// A token that can cancel archive extraction. + private void ImportFile( + string sourceFilePath, + string destinationDirectory, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilePath); + + string sourceFileName = Path.GetFileName(sourceFilePath); + if (string.IsNullOrWhiteSpace(sourceFileName)) + { + throw new ArgumentException("Source file path must include a file name.", nameof(sourceFilePath)); + } + + string destinationFilePath = Path.Combine(destinationDirectory, sourceFileName); + string extension = Path.GetExtension(sourceFileName); + + if (!File.Exists(destinationFilePath)) + { + File.Copy(sourceFilePath, destinationFilePath); + } + + if (_archiveExtensions.Contains(extension)) + { + _archiveExtractor.ExtractToDirectory( + destinationFilePath, + destinationDirectory, + cancellationToken: cancellationToken); + File.Delete(destinationFilePath); + return; + } + + if (string.Equals(extension, ".big", StringComparison.OrdinalIgnoreCase)) + { + File.Move(destinationFilePath, Path.ChangeExtension(destinationFilePath, ".gib")); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs new file mode 100644 index 00000000..d8470348 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs @@ -0,0 +1,178 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Manages cached modification image files on disk. +/// +public sealed class FileSystemModificationImageFileService : IModificationImageFileService +{ + /// + /// Resolves launcher image cache paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// Logs diagnostics for image cache file-system side effects. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The launcher runtime paths. + /// The logger used for image cache diagnostics. + public FileSystemModificationImageFileService( + LauncherPaths launcherPaths, + ILogger logger) + { + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string? FindExistingImageFilePath(string modificationName, string imageBaseName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + + string imageDirectory = _launcherPaths.GetModificationImagesDirectory(modificationName); + if (!Directory.Exists(imageDirectory)) + { + return null; + } + + return Directory.EnumerateFiles(imageDirectory, imageBaseName + ".*").FirstOrDefault(); + } + + /// + public int CountImageFiles(string modificationName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + + string imageDirectory = _launcherPaths.GetModificationImagesDirectory(modificationName); + if (!Directory.Exists(imageDirectory)) + { + return 0; + } + + return Directory.EnumerateFiles(imageDirectory).Count(); + } + + /// + public bool ImageExists(string? imageFilePath) + { + return !string.IsNullOrWhiteSpace(imageFilePath) && File.Exists(imageFilePath); + } + + /// + public bool TryDeleteImage(string? imageFilePath) + { + if (string.IsNullOrWhiteSpace(imageFilePath)) + { + return true; + } + + try + { + if (File.Exists(imageFilePath)) + { + File.Delete(imageFilePath); + } + + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + _logger.LogWarning( + exception, + "Could not remove cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + return false; + } + } + + /// + public Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + return Task.Run(() => ReplaceImage(request, cancellationToken), cancellationToken); + } + + /// + /// Replaces the cached image file and removes stale sibling extensions. + /// + /// The image replacement request. + /// A token that cancels the replacement before the next file operation. + /// The destination image file path. + private string ReplaceImage( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string extension = Path.GetExtension(request.SourceImagePath); + if (string.IsNullOrWhiteSpace(extension)) + { + throw new ArgumentException( + "The source image must have a file extension.", + nameof(request)); + } + + string destinationPath = _launcherPaths.GetModificationImageFilePath( + request.ModificationName, + request.ImageBaseName + extension); + string destinationDirectory = _launcherPaths.GetModificationImagesDirectory(request.ModificationName); + string sourcePath = Path.GetFullPath(request.SourceImagePath); + string fullDestinationPath = Path.GetFullPath(destinationPath); + + if (string.Equals(sourcePath, fullDestinationPath, StringComparison.OrdinalIgnoreCase)) + { + return destinationPath; + } + + try + { + Directory.CreateDirectory(destinationDirectory); + string imageSearchPattern = Path.GetFileNameWithoutExtension(destinationPath) + ".*"; + foreach (string existingImagePath in Directory.EnumerateFiles(destinationDirectory, imageSearchPattern)) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Delete(existingImagePath); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(request.SourceImagePath, destinationPath); + return destinationPath; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + _logger.LogError( + exception, + "Could not replace cached modification image {ImageBaseName} for {ModificationName}.", + request.ImageBaseName, + request.ModificationName); + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Could not replace cached image '{0}' for modification '{1}'.", + request.ImageBaseName, + request.ModificationName), + exception); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs new file mode 100644 index 00000000..89774dc7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Caches remote launcher catalog images on disk. +/// +internal sealed class LauncherCatalogImageCache : ILauncherCatalogImageCache +{ + /// + /// The remote asset downloader used for cached launcher images. + /// + private readonly IRemoteAssetDownloader _assetDownloader; + + /// + /// The logger used for image cache diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The remote asset downloader used for cached launcher images. + /// The logger used for image cache diagnostics. + public LauncherCatalogImageCache( + IRemoteAssetDownloader assetDownloader, + ILogger logger) + { + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task CacheModificationImagesAsync( + RemoteContentManifest modification, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(paths); + + var imageDownloads = new List(capacity: 2); + if (!String.IsNullOrEmpty(modification.ImageSourceLink)) + { + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + modification.Name, + modification.Version, + modification.ImageSourceLink, + cancellationToken)); + } + + if (modification.ColorsInformation != null && + !String.IsNullOrEmpty(modification.ColorsInformation.GenLauncherBackgroundImageLink)) + { + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + modification.Name, + modification.Version + "-background", + modification.ColorsInformation.GenLauncherBackgroundImageLink, + cancellationToken)); + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + public async Task CacheAdvertisingImagesAsync( + RemoteAdvertisingReference advertising, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(advertising); + ArgumentNullException.ThrowIfNull(paths); + + RemoveStaleAdvertisingImages(advertising, paths); + + var imageDownloads = new List(advertising.ImageUrls.Count); + int imageIndex = 0; + foreach (string imageLink in advertising.ImageUrls) + { + int currentImageIndex = imageIndex; + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + advertising.Name, + currentImageIndex.ToString(), + imageLink, + cancellationToken)); + imageIndex++; + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + /// Removes stale advertising image files when the remote image count changes. + /// + /// The advertising image metadata. + /// The launcher paths used to resolve cache destinations. + private void RemoveStaleAdvertisingImages(RemoteAdvertisingReference advertising, LauncherPaths paths) + { + string folderName = advertising.Name.Trim(Path.GetInvalidFileNameChars()); + string imageFolderPath = paths.GetModificationImagesDirectory(folderName); + + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + var dirInfo = new DirectoryInfo(imageFolderPath); + FileInfo[] images = dirInfo.GetFiles(); + if (images.Length == advertising.ImageUrls.Count) + { + return; + } + + foreach (FileInfo image in images) + { + try + { + image.Delete(); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete stale advertising image {ImageFileName}.", + image.Name); + } + } + } + + /// + /// Downloads one image when the local cache file is missing. + /// + /// The launcher paths used to resolve cache destinations. + /// The modification name. + /// The cache file base name. + /// The remote image link. + /// The token used to cancel remote work. + private async Task DownloadImageIfMissingAsync( + LauncherPaths paths, + string modificationName, + string fileName, + string link, + CancellationToken cancellationToken) + { + try + { + string extension = GetImageExtensionFromLink(link); + await _assetDownloader.DownloadIfMissingAsync( + new Uri(link, UriKind.Absolute), + paths.GetModificationImageFilePath(modificationName, fileName + extension), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to download cached image {ImageName} for {ModificationName}.", + fileName, + modificationName); + } + } + + /// + /// Gets a safe image extension from a remote link. + /// + /// The remote image link. + /// The supported extension, or .png when no supported extension can be found. + private static string GetImageExtensionFromLink(string link) + { + if (Uri.TryCreate(link, UriKind.Absolute, out Uri? uri)) + { + string extension = Path.GetExtension(uri.LocalPath); + if (IsSupportedImageExtension(extension)) + { + return extension; + } + } + + return ".png"; + } + + /// + /// Determines whether an image extension is supported by the launcher cache. + /// + /// The extension to inspect. + /// when the extension is supported. + private static bool IsSupportedImageExtension(string extension) + { + return String.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase) || + String.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) || + String.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs new file mode 100644 index 00000000..8380a00e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs @@ -0,0 +1,605 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Coordinates remote launcher catalog data, local content state, cached images, and selection persistence. +/// +internal sealed class LauncherContentCatalogService : ILauncherContentCatalogService +{ + /// + /// The maximum number of modification image cache updates allowed at once during startup. + /// + private const int MaxConcurrentImageCacheUpdates = 8; + + /// + /// The local content-state store. + /// + private readonly ILauncherContentStateStore _contentStateStore; + + /// + /// The remote catalog client used for legacy-compatible YAML manifests. + /// + private readonly IRemoteLauncherCatalogClient _remoteCatalogClient; + + /// + /// The image cache used for card, background, and advertising images. + /// + private readonly ILauncherCatalogImageCache _imageCache; + + /// + /// The mapper used to convert compact state and mutable catalog models. + /// + private readonly ILauncherContentStateMapper _stateMapper; + + /// + /// The reconciler used to align catalog state with local folders. + /// + private readonly ILauncherLocalContentReconciler _localContentReconciler; + + /// + /// The selection query service. + /// + private readonly LauncherContentSelectionService _selectionService; + + /// + /// The current launcher content catalog and local state. + /// + private LauncherData _data = new(); + + /// + /// Remote patch and add-on metadata keyed by parent modification name. + /// + private Dictionary _modificationsAndAddons = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Remote modification names whose child content has already been read. + /// + private readonly HashSet _downloadedModsInfo = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Remote content versions already downloaded into the in-memory catalog. + /// + private readonly HashSet _downloadedReposContent = new(); + + /// + /// Advertising entries from the normalized remote catalog. + /// + private IReadOnlyList _advertisingData = Array.Empty(); + + /// + /// Original-game patch manifest links from the top-level remote manifest. + /// + private IReadOnlyList _originalGamePatches = Array.Empty(); + + /// + /// Original-game add-on manifest links from the top-level remote manifest. + /// + private IReadOnlyList _originalGameAddons = Array.Empty(); + + /// + /// The active advertising modification metadata. + /// + private RemoteContentManifest? _advertising; + + /// + /// A value indicating whether remote repository data is currently available. + /// + private bool _connected; + + /// + /// The resolved launcher paths for the current session. + /// + private LauncherPaths? _paths; + + /// + /// The launcher content folder layout for the current session. + /// + private LauncherContentLayout? _layout; + + /// + /// The normalized top-level remote repository catalog. + /// + private RemoteLauncherCatalog? _repositoryData; + + /// + /// Initializes a new instance of the class. + /// + /// The local content-state store. + /// The remote catalog client used for legacy-compatible YAML manifests. + /// The image cache used for card, background, and advertising images. + /// The mapper used to convert compact state and mutable catalog models. + /// The reconciler used to align catalog state with local folders. + /// The selection query service. + public LauncherContentCatalogService( + ILauncherContentStateStore contentStateStore, + IRemoteLauncherCatalogClient remoteCatalogClient, + ILauncherCatalogImageCache imageCache, + ILauncherContentStateMapper stateMapper, + ILauncherLocalContentReconciler localContentReconciler, + LauncherContentSelectionService selectionService) + { + _contentStateStore = contentStateStore ?? throw new ArgumentNullException(nameof(contentStateStore)); + _remoteCatalogClient = remoteCatalogClient ?? throw new ArgumentNullException(nameof(remoteCatalogClient)); + _imageCache = imageCache ?? throw new ArgumentNullException(nameof(imageCache)); + _stateMapper = stateMapper ?? throw new ArgumentNullException(nameof(stateMapper)); + _localContentReconciler = + localContentReconciler ?? throw new ArgumentNullException(nameof(localContentReconciler)); + _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); + } + + /// + public IReadOnlyList? ReposModsNames { get; private set; } + + /// + public async Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Paths); + ArgumentNullException.ThrowIfNull(request.Layout); + + _connected = request.Connected; + _paths = request.Paths; + _layout = request.Layout; + + if (_connected) + { + if (request.RemoteManifestUri is null) + { + throw new ArgumentException( + "A remote manifest URI is required when initializing a connected catalog.", + nameof(request)); + } + + await ReadMainManifestAsync(request.RemoteManifestUri, cancellationToken).ConfigureAwait(false); + } + + ReadLocalModsData(); + UpdateLocalModificationsData(); + + if (!_connected || _repositoryData is null) + { + return; + } + + var installedMods = GetMods().Select(mod => mod.Name).ToList(); + ReposModsNames = _remoteCatalogClient.GetModificationNames(_repositoryData); + + IReadOnlyList installedManifests = await _remoteCatalogClient + .DownloadInstalledModDataAsync( + _repositoryData, + installedMods, + cancellationToken).ConfigureAwait(false); + _modificationsAndAddons = ToManifestDictionary(installedManifests); + var reposMods = installedManifests.Select(manifest => manifest.Content).ToList(); + + await CacheInstalledModificationImagesAsync(reposMods, cancellationToken).ConfigureAwait(false); + + foreach (RemoteContentManifest reposMod in reposMods) + { + AddDownloadedModificationData(reposMod); + } + + GameModification? selectedMod = GetSelectedMod(); + if (selectedMod != null) + { + await ReadPatchesAndAddonsForModAsync(selectedMod, cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + if (!_connected || _repositoryData is null) + { + return; + } + + const string originalGameName = "Original Game"; + if (_downloadedModsInfo.Contains(originalGameName)) + { + return; + } + + _downloadedModsInfo.Add(originalGameName); + + Task> reposPatchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + _originalGamePatches, + cancellationToken); + Task> reposAddonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + _originalGameAddons, + cancellationToken); + IReadOnlyList[] childManifests = + await Task.WhenAll(reposPatchesTask, reposAddonsTask).ConfigureAwait(false); + IReadOnlyList reposPatches = childManifests[0]; + IReadOnlyList reposAddons = childManifests[1]; + + foreach (RemoteContentManifest patch in reposPatches) + { + var add = RemoteLauncherCatalogMapper.ToModificationVersion(patch); + add.DependenceName = originalGameName; + _data.AddOrUpdate(add); + _downloadedReposContent.Add(RemoteLauncherCatalogMapper.ToModificationVersion(patch)); + } + + foreach (RemoteContentManifest addon in reposAddons) + { + var add = RemoteLauncherCatalogMapper.ToModificationVersion(addon); + add.DependenceName = originalGameName; + _data.AddOrUpdate(add); + _downloadedReposContent.Add(RemoteLauncherCatalogMapper.ToModificationVersion(addon)); + } + } + + /// + public void AddModModification(ModificationVersion modification) + { + ArgumentNullException.ThrowIfNull(modification); + + _data.AddOrUpdate(modification); + } + + /// + public void UnselectAllModifications() + { + _selectionService.UnselectAllModifications(_data); + } + + /// + public async Task DownloadModificationDataFromReposAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + RemoteModificationManifest manifest = + await _remoteCatalogClient.DownloadModDataByNameAsync( + _repositoryData ?? RemoteLauncherCatalog.Empty, + name, + cancellationToken).ConfigureAwait(false); + AddRemoteModificationManifest(manifest); + + await _imageCache.CacheModificationImagesAsync(manifest.Content, Paths, cancellationToken) + .ConfigureAwait(false); + AddDownloadedModificationData(manifest.Content); + return RemoteLauncherCatalogMapper.ToModificationVersion(manifest.Content); + } + + /// + public IReadOnlyList GetMods() + { + return _data.Modifications; + } + + /// + public ModificationVersion? GetAdvertising() + { + return _advertising != null ? RemoteLauncherCatalogMapper.ToModificationVersion(_advertising) : null; + } + + /// + public GameModification? GetSelectedMod() + { + return _selectionService.GetSelectedMod(_data); + } + + /// + public ModificationVersion? GetSelectedModVersion() + { + return _selectionService.GetSelectedModVersion(_data); + } + + /// + public ModificationVersion? GetSelectedPatchVersion() + { + return _selectionService.GetSelectedPatchVersion(_data); + } + + /// + public IReadOnlyList GetAllModificationsNames() + { + return _selectionService.GetAllModificationsNames(_data); + } + + /// + public IReadOnlyList GetPatchesForSelectedMod() + { + return _selectionService.GetPatchesForSelectedMod(_data); + } + + /// + public IReadOnlyList GetAddonsForSelectedMod() + { + return _selectionService.GetAddonsForSelectedMod(_data); + } + + /// + public IReadOnlyList GetSelectedModVersions() + { + return _selectionService.GetSelectedModVersions(_data); + } + + /// + public IReadOnlyList GetSelectedAddonsVersions() + { + return _selectionService.GetSelectedAddonsVersions(_data); + } + + /// + public IReadOnlyList GetSelectedAddonsForSelectedMod() + { + return _selectionService.GetSelectedAddonsForSelectedMod(_data); + } + + /// + public GameModification? GetSelectedPatch() + { + return _selectionService.GetSelectedPatch(_data); + } + + /// + public IReadOnlyList GetAllModsVersionsList() + { + return _selectionService.GetAllModsVersionsList(_data); + } + + /// + public IReadOnlyList GetAddonVersionsForModList(string modName) + { + return _selectionService.GetAddonVersionsForModList(_data, modName); + } + + /// + public IReadOnlyList GetPatchVersionsForModList(string modName) + { + return _selectionService.GetPatchVersionsForModList(_data, modName); + } + + /// + public void DeleteVersion(ModificationVersion version) + { + DeleteModificationVersion(version); + } + + /// + public void DeleteModificationVersion(ModificationVersion modificationVersion) + { + _localContentReconciler.DeleteVersion(_data, modificationVersion, Paths, Layout); + } + + /// + public void RemoveContentVersion(ModificationVersion modificationVersion) + { + _localContentReconciler.DeleteVersion(_data, modificationVersion, Paths, Layout); + _data.Delete(modificationVersion); + } + + /// + public void RemoveContent(ModificationVersion modificationVersion) + { + _localContentReconciler.DeleteContent(_data, modificationVersion, Paths, Layout); + _data.Delete(modificationVersion); + } + + /// + public async Task ReadPatchesAndAddonsForModAsync( + ModificationReposVersion modification, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + + if (!_connected || _repositoryData is null) + { + return; + } + + string keyModification = modification.Name ?? string.Empty; + + if (_downloadedModsInfo.Contains(keyModification)) + { + return; + } + + _downloadedModsInfo.Add(keyModification); + + if (!_modificationsAndAddons.TryGetValue(keyModification, out RemoteModificationManifest? modData)) + { + return; + } + + Task> reposPatchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + modData.PatchManifestUrls, + cancellationToken); + Task> reposAddonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + modData.AddonManifestUrls, + cancellationToken); + IReadOnlyList[] childManifests = + await Task.WhenAll(reposPatchesTask, reposAddonsTask).ConfigureAwait(false); + IReadOnlyList reposPatches = childManifests[0]; + IReadOnlyList reposAddons = childManifests[1]; + + foreach (RemoteContentManifest patch in reposPatches) + { + AddDownloadedModificationData(patch); + } + + foreach (RemoteContentManifest addon in reposAddons) + { + AddDownloadedModificationData(addon); + } + } + + /// + public void UpdateLocalModificationsData() + { + _localContentReconciler.Reconcile(_data, _downloadedReposContent, Paths, Layout); + } + + /// + public void ReadLocalModsData() + { + _data = _stateMapper.ToLauncherData(_contentStateStore.Load()); + } + + /// + public void SaveLauncherData() + { + _contentStateStore.Save(_stateMapper.ToLauncherContentState(_data ?? new LauncherData())); + } + + /// + /// Gets initialized launcher paths or throws when the catalog has not been initialized. + /// + private LauncherPaths Paths => + _paths ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + /// + /// Gets initialized content layout or throws when the catalog has not been initialized. + /// + private LauncherContentLayout Layout => + _layout ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + /// + /// Reads the top-level remote manifest and related advertising metadata. + /// + /// The top-level manifest URI. + /// The token used to cancel remote work. + private async Task ReadMainManifestAsync(Uri manifestUri, CancellationToken cancellationToken) + { + _repositoryData = await _remoteCatalogClient.ReadCatalogAsync( + manifestUri, + cancellationToken).ConfigureAwait(false); + + _advertisingData = _repositoryData.AdvertisingEntries; + + if (_advertisingData.Count > 0) + { + await DownloadAdvertisingDataAsync(cancellationToken).ConfigureAwait(false); + } + + if (_repositoryData.OriginalGamePatchManifestUrls.Count > 0) + { + _originalGamePatches = _repositoryData.OriginalGamePatchManifestUrls; + } + + if (_repositoryData.OriginalGameAddonManifestUrls.Count > 0) + { + _originalGameAddons = _repositoryData.OriginalGameAddonManifestUrls; + } + } + + /// + /// Adds downloaded remote metadata to the current catalog. + /// + /// The remote modification metadata. + private void AddDownloadedModificationData(RemoteContentManifest manifest) + { + var version = RemoteLauncherCatalogMapper.ToModificationVersion(manifest); + _data.AddOrUpdate(version); + _downloadedReposContent.Add(RemoteLauncherCatalogMapper.ToModificationVersion(manifest)); + } + + /// + /// Caches installed modification images with bounded parallelism. + /// + /// The installed remote modification metadata. + /// The token used to cancel image cache work. + private async Task CacheInstalledModificationImagesAsync( + IReadOnlyList modifications, + CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(MaxConcurrentImageCacheUpdates); + await Task.WhenAll(modifications.Select(modification => CacheModificationImagesAsync( + modification, + semaphore, + cancellationToken))).ConfigureAwait(false); + } + + /// + /// Caches one modification's images while respecting the startup cache concurrency limit. + /// + /// The remote modification metadata. + /// The concurrency gate. + /// The token used to cancel image cache work. + private async Task CacheModificationImagesAsync( + RemoteContentManifest modification, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _imageCache.CacheModificationImagesAsync(modification, Paths, cancellationToken) + .ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + /// + /// Downloads advertising metadata and image assets. + /// + /// The token used to cancel remote work. + private async Task DownloadAdvertisingDataAsync(CancellationToken cancellationToken) + { + RemoteAdvertisingReference advData = _advertisingData[0]; + _advertising = await _remoteCatalogClient.DownloadAdvertisingInfoAsync( + advData.ManifestUrl, + cancellationToken).ConfigureAwait(false); + if (_advertising is null) + { + return; + } + + await _imageCache.CacheAdvertisingImagesAsync(advData, Paths, cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a case-insensitive manifest dictionary keyed by content name. + /// + /// The remote manifests. + /// The manifest dictionary. + private static Dictionary ToManifestDictionary( + IEnumerable manifests) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (RemoteModificationManifest manifest in manifests) + { + string key = manifest.Content.Name ?? string.Empty; + if (!result.ContainsKey(key)) + { + result.Add(key, manifest); + } + } + + return result; + } + + /// + /// Adds one remote manifest to the child-content lookup when it is not already present. + /// + /// The remote manifest. + private void AddRemoteModificationManifest(RemoteModificationManifest manifest) + { + string key = manifest.Content.Name ?? string.Empty; + if (!_modificationsAndAddons.ContainsKey(key)) + { + _modificationsAndAddons.Add(key, manifest); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs new file mode 100644 index 00000000..23f9ba2b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Provides legacy-compatible selection queries over the mutable launcher catalog. +/// +internal sealed class LauncherContentSelectionService +{ + /// + /// Clears selected state from all modification cards. + /// + /// The mutable launcher catalog. + public void UnselectAllModifications(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + foreach (GameModification mod in launcherData.Modifications) + { + mod.IsSelected = false; + } + } + + /// + /// Gets the selected modification card. + /// + /// The mutable launcher catalog. + /// The selected modification card, or when none is selected. + public GameModification? GetSelectedMod(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Modifications.FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets the selected modification version. + /// + /// The mutable launcher catalog. + /// The selected modification version, or when none is selected. + public ModificationVersion? GetSelectedModVersion(LauncherData launcherData) + { + GameModification? selectedMod = GetSelectedMod(launcherData); + return selectedMod?.ModificationVersions.FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets the selected patch version. + /// + /// The mutable launcher catalog. + /// The selected patch version, or when none is selected. + public ModificationVersion? GetSelectedPatchVersion(LauncherData launcherData) + { + return GetPatchesForSelectedMod(launcherData) + .Where(mod => mod.IsSelected) + .SelectMany(mod => mod.ModificationVersions) + .FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets all modification names. + /// + /// The mutable launcher catalog. + /// The current modification names. + public IReadOnlyList GetAllModificationsNames(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Modifications.Select(mod => mod.Name).ToList(); + } + + /// + /// Gets patches that belong to the selected modification or original game. + /// + /// The mutable launcher catalog. + /// The matching patch cards. + public IReadOnlyList GetPatchesForSelectedMod(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + GameModification? selectedMod = GetSelectedMod(launcherData); + + if (selectedMod != null) + { + return launcherData.Patches.Where(mod => + String.Equals(mod.DependenceName, selectedMod.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + return launcherData.Patches.Where(mod => + String.Equals(mod.DependenceName, "Original game", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Gets add-ons that belong to the selected modification or selected patch. + /// + /// The mutable launcher catalog. + /// The matching add-on cards. + public IReadOnlyList GetAddonsForSelectedMod(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + GameModification? selectedMod = GetSelectedMod(launcherData); + + if (selectedMod != null) + { + return launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, selectedMod.Name, StringComparison.OrdinalIgnoreCase)) + .Union(launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, GetSelectedPatch(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + return launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, "Original game", StringComparison.OrdinalIgnoreCase)) + .Union(launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, GetSelectedPatch(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + /// + /// Gets all versions for the selected modification. + /// + /// The mutable launcher catalog. + /// The selected modification versions. + public IReadOnlyList GetSelectedModVersions(LauncherData launcherData) + { + return GetSelectedMod(launcherData)?.ModificationVersions ?? new List(); + } + + /// + /// Gets selected add-on versions. + /// + /// The mutable launcher catalog. + /// The selected add-on versions. + public IReadOnlyList GetSelectedAddonsVersions(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return GetAddonsForSelectedMod(launcherData) + .Where(mod => mod.IsSelected) + .SelectMany(mod => mod.ModificationVersions.Where(version => version.IsSelected)) + .Union(launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, GetSelectedPatch(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase)) + .Where(mod => mod.IsSelected) + .SelectMany(mod => mod.ModificationVersions.Where(version => version.IsSelected))) + .ToList(); + } + + /// + /// Gets selected add-on cards for the selected modification. + /// + /// The mutable launcher catalog. + /// The selected add-on cards. + public IReadOnlyList GetSelectedAddonsForSelectedMod(LauncherData launcherData) + { + return GetAddonsForSelectedMod(launcherData).Where(mod => mod.IsSelected).ToList(); + } + + /// + /// Gets the selected patch card. + /// + /// The mutable launcher catalog. + /// The selected patch card, or when none is selected. + public GameModification? GetSelectedPatch(LauncherData launcherData) + { + return GetPatchesForSelectedMod(launcherData).FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets all modification versions. + /// + /// The mutable launcher catalog. + /// All modification versions. + public IReadOnlyList GetAllModsVersionsList(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Modifications + .Select(mod => mod.ModificationVersions) + .SelectMany(mod => mod) + .ToList(); + } + + /// + /// Gets add-on versions associated with a modification name. + /// + /// The mutable launcher catalog. + /// The modification name. + /// The matching add-on versions. + public IReadOnlyList GetAddonVersionsForModList( + LauncherData launcherData, + string modName) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Addons + .Where(mod => String.Equals(mod.DependenceName, modName, StringComparison.OrdinalIgnoreCase)) + .SelectMany(mod => mod.ModificationVersions) + .Union(launcherData.Addons + .Where(mod => String.Equals( + mod.DependenceName, + GetSelectedPatchVersion(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase)) + .SelectMany(mod => mod.ModificationVersions)) + .ToList(); + } + + /// + /// Gets patch versions associated with a modification name. + /// + /// The mutable launcher catalog. + /// The modification name. + /// The matching patch versions. + public IReadOnlyList GetPatchVersionsForModList( + LauncherData launcherData, + string modName) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Patches + .Where(mod => String.Equals(mod.DependenceName, modName, StringComparison.OrdinalIgnoreCase)) + .SelectMany(mod => mod.ModificationVersions) + .ToList(); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs new file mode 100644 index 00000000..cc3a7bc6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Contracts; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Maps compact launcher content state to and from legacy-compatible catalog models. +/// +internal sealed class LauncherContentStateMapper : ILauncherContentStateMapper +{ + /// + public LauncherData ToLauncherData(LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(state); + + var launcherData = new LauncherData(); + AddStoredVersions(launcherData, state.Modifications, LauncherContentType.Mod); + AddStoredVersions(launcherData, state.Addons, LauncherContentType.Addon); + AddStoredVersions(launcherData, state.Patches, LauncherContentType.Patch); + return launcherData; + } + + /// + public LauncherContentState ToLauncherContentState(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return new LauncherContentState + { + Modifications = ToEntryStates(launcherData.Modifications, LauncherContentType.Mod), + Addons = ToEntryStates(launcherData.Addons, LauncherContentType.Addon), + Patches = ToEntryStates(launcherData.Patches, LauncherContentType.Patch) + }; + } + + /// + public ModificationVersion ToModificationVersion(LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(version); + + return new ModificationVersion + { + ModificationType = ToModificationType(version.ModificationType), + Name = version.Name ?? string.Empty, + Version = version.Version ?? string.Empty, + DependenceName = version.DependenceName ?? string.Empty, + Installed = version.Installed, + IsSelected = version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + } + + /// + public LauncherContentVersionState ToVersionState( + ModificationVersion version, + LauncherContentType fallbackType) + { + ArgumentNullException.ThrowIfNull(version); + + return new LauncherContentVersionState + { + ModificationType = ToLauncherContentType(version.ModificationType, fallbackType), + Name = version.Name ?? string.Empty, + Version = version.Version ?? string.Empty, + DependenceName = version.DependenceName ?? string.Empty, + Installed = version.Installed, + IsSelected = version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + } + + /// + public LauncherContentType GetFallbackContentType(ModificationType type) + { + return type switch + { + ModificationType.Addon => LauncherContentType.Addon, + ModificationType.Patch => LauncherContentType.Patch, + ModificationType.Advertising => LauncherContentType.Advertising, + _ => LauncherContentType.Mod + }; + } + + /// + /// Adds persisted versions to the in-memory catalog. + /// + /// The target catalog. + /// The persisted entries. + /// The content type to use when persisted state is ambiguous. + private void AddStoredVersions( + LauncherData launcherData, + IEnumerable entries, + LauncherContentType fallbackType) + { + foreach (LauncherContentEntryState entry in entries ?? Enumerable.Empty()) + { + GameModification? storedModification = null; + foreach (LauncherContentVersionState version in entry.ModificationVersions ?? + new List()) + { + ModificationVersion modificationVersion = ToModificationVersion(entry, version, fallbackType); + launcherData.AddOrUpdate(modificationVersion); + storedModification ??= FindStoredModification(launcherData, modificationVersion); + } + + if (storedModification != null) + { + storedModification.IsSelected = entry.IsSelected; + storedModification.NumberInList = entry.NumberInList; + } + } + } + + /// + /// Converts one persisted entry/version pair into a catalog version. + /// + /// The persisted entry. + /// The persisted version. + /// The content type to use when persisted state is ambiguous. + /// The catalog version. + private ModificationVersion ToModificationVersion( + LauncherContentEntryState entry, + LauncherContentVersionState version, + LauncherContentType fallbackType) + { + LauncherContentType contentType = ResolveContentType( + version.ModificationType, + entry.ModificationType, + fallbackType); + + return new ModificationVersion + { + ModificationType = ToModificationType(contentType), + Name = CoalesceStateText(version.Name, entry.Name), + Version = version.Version ?? string.Empty, + DependenceName = CoalesceStateText(version.DependenceName, entry.DependenceName), + Installed = version.Installed || entry.Installed, + IsSelected = entry.IsSelected && version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + } + + /// + /// Converts catalog cards into persisted compact entries. + /// + /// The catalog cards. + /// The content type to use when catalog state is ambiguous. + /// The persisted entries. + private List ToEntryStates( + IEnumerable modifications, + LauncherContentType fallbackType) + { + var entries = new List(); + foreach (GameModification modification in modifications ?? Enumerable.Empty()) + { + var versions = modification.ModificationVersions + .Where(ShouldPersistVersion) + .Select(version => ToVersionState(version, fallbackType, modification.IsSelected)) + .ToList(); + + if (versions.Count == 0) + { + continue; + } + + entries.Add(new LauncherContentEntryState + { + ModificationType = ToLauncherContentType(modification.ModificationType, fallbackType), + Name = modification.Name ?? string.Empty, + DependenceName = modification.DependenceName ?? string.Empty, + Installed = modification.Installed, + IsSelected = modification.IsSelected, + NumberInList = modification.NumberInList, + ModificationVersions = versions + }); + } + + return entries; + } + + /// + /// Determines whether a catalog version should be persisted. + /// + /// The catalog version. + /// when the version has local state worth persisting. + private static bool ShouldPersistVersion(ModificationVersion version) + { + return version.Installed || version.IsSelected; + } + + /// + /// Converts one catalog version into persisted compact version state for a parent entry. + /// + /// The catalog version. + /// The content type to use when catalog state is ambiguous. + /// A value indicating whether the parent entry is selected. + /// The persisted compact version state. + private LauncherContentVersionState ToVersionState( + ModificationVersion version, + LauncherContentType fallbackType, + bool entryIsSelected) + { + LauncherContentVersionState versionState = ToVersionState(version, fallbackType); + versionState.IsSelected = entryIsSelected && versionState.IsSelected; + return versionState; + } + + /// + /// Finds the catalog card that contains a stored version. + /// + /// The catalog that was updated. + /// The version that was stored. + /// The matching catalog card, or when no card was stored. + private static GameModification? FindStoredModification( + LauncherData launcherData, + ModificationVersion version) + { + IEnumerable modifications = version.ModificationType switch + { + ModificationType.Addon => launcherData.Addons, + ModificationType.Patch => launcherData.Patches, + _ => launcherData.Modifications + }; + + return modifications.FirstOrDefault(modification => + String.Equals(modification.Name, version.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Returns a value or fallback when the value is missing. + /// + /// The preferred value. + /// The fallback value. + /// The preferred value when present; otherwise the fallback. + private static string CoalesceStateText(string value, string fallback) + { + return !String.IsNullOrWhiteSpace(value) ? value : fallback ?? string.Empty; + } + + /// + /// Resolves compact content type from version, entry, and fallback state. + /// + /// The version content type. + /// The entry content type. + /// The fallback content type. + /// The resolved content type. + private static LauncherContentType ResolveContentType( + LauncherContentType versionType, + LauncherContentType entryType, + LauncherContentType fallbackType) + { + if (versionType != LauncherContentType.Mod || fallbackType == LauncherContentType.Mod) + { + return versionType; + } + + return entryType != LauncherContentType.Mod ? entryType : fallbackType; + } + + /// + /// Converts compact content type to legacy modification type. + /// + /// The compact content type. + /// The legacy modification type. + private static ModificationType ToModificationType(LauncherContentType type) + { + return type switch + { + LauncherContentType.Addon => ModificationType.Addon, + LauncherContentType.Patch => ModificationType.Patch, + LauncherContentType.Advertising => ModificationType.Advertising, + _ => ModificationType.Mod + }; + } + + /// + /// Converts legacy modification type to compact content type. + /// + /// The legacy modification type. + /// The fallback content type. + /// The compact content type. + private static LauncherContentType ToLauncherContentType( + ModificationType type, + LauncherContentType fallbackType) + { + return type switch + { + ModificationType.Addon => LauncherContentType.Addon, + ModificationType.Patch => LauncherContentType.Patch, + ModificationType.Advertising => LauncherContentType.Advertising, + ModificationType.Mod => fallbackType, + _ => fallbackType + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs new file mode 100644 index 00000000..af6d6877 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Reconciles launcher catalog state with local content folders. +/// +internal sealed class LauncherLocalContentReconciler : ILauncherLocalContentReconciler +{ + /// + /// The local file-system content service. + /// + private readonly ILocalLauncherContentService _localContentService; + + /// + /// The mapper used for compact folder-state requests. + /// + private readonly ILauncherContentStateMapper _stateMapper; + + /// + /// The selection query service used to preserve legacy child-content cleanup behavior. + /// + private readonly LauncherContentSelectionService _selectionService; + + /// + /// Initializes a new instance of the class. + /// + /// The local file-system content service. + /// The mapper used for compact folder-state requests. + /// The selection query service used to preserve legacy child-content cleanup behavior. + public LauncherLocalContentReconciler( + ILocalLauncherContentService localContentService, + ILauncherContentStateMapper stateMapper, + LauncherContentSelectionService selectionService) + { + _localContentService = localContentService ?? throw new ArgumentNullException(nameof(localContentService)); + _stateMapper = stateMapper ?? throw new ArgumentNullException(nameof(stateMapper)); + _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); + } + + /// + public void Reconcile( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(downloadedReposContent); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + IReadOnlyList installedVersions = + _localContentService.FindInstalledVersions(paths, layout); + + AddUnregisteredModifications(launcherData, installedVersions); + DeleteOutdatedModifications(launcherData, downloadedReposContent, installedVersions, paths, layout); + } + + /// + public void DeleteVersion( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(modificationVersion); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + if (modificationVersion.ModificationType == ModificationType.Advertising) + { + launcherData.Delete(modificationVersion); + return; + } + + _localContentService.DeleteVersion( + paths, + layout, + _stateMapper.ToVersionState( + modificationVersion, + _stateMapper.GetFallbackContentType(modificationVersion.ModificationType))); + } + + /// + public void DeleteContent( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(modificationVersion); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + if (modificationVersion.ModificationType == ModificationType.Advertising) + { + launcherData.Delete(modificationVersion); + return; + } + + _localContentService.DeleteContent( + paths, + layout, + _stateMapper.ToVersionState( + modificationVersion, + _stateMapper.GetFallbackContentType(modificationVersion.ModificationType))); + } + + /// + /// Adds locally installed versions that are missing from the current catalog. + /// + /// The mutable launcher catalog. + /// The installed versions discovered from the local content folders. + private void AddUnregisteredModifications( + LauncherData launcherData, + IEnumerable installedVersions) + { + foreach (LauncherContentVersionState version in installedVersions) + { + launcherData.AddOrUpdate(_stateMapper.ToModificationVersion(version)); + } + } + + /// + /// Removes local-only catalog entries whose folders no longer contain files. + /// + /// The mutable launcher catalog. + /// Remote content versions already downloaded into the catalog. + /// The installed versions discovered from the local content folders. + /// The launcher paths used to inspect local content folders. + /// The content folder layout. + private void DeleteOutdatedModifications( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + IReadOnlyCollection installedVersions, + LauncherPaths paths, + LauncherContentLayout layout) + { + var installedVersionIds = + installedVersions.Select(CreateVersionIdentity).ToHashSet(); + + IEnumerable modVersions = _selectionService.GetAllModsVersionsList(launcherData) + .Where(mod => mod.ModificationType != ModificationType.Advertising); + + foreach (ModificationVersion modVersion in modVersions) + { + CheckContentExistence( + launcherData, + downloadedReposContent, + modVersion, + paths, + layout, + installedVersionIds); + + foreach (ModificationVersion addonVersion in _selectionService.GetAddonVersionsForModList( + launcherData, + modVersion.Name)) + { + CheckContentExistence( + launcherData, + downloadedReposContent, + addonVersion, + paths, + layout, + installedVersionIds); + } + + foreach (ModificationVersion patchVersion in _selectionService.GetPatchVersionsForModList( + launcherData, + modVersion.Name)) + { + CheckContentExistence( + launcherData, + downloadedReposContent, + patchVersion, + paths, + layout, + installedVersionIds); + } + } + } + + /// + /// Removes or marks a content version when the local folder no longer contains files. + /// + /// The mutable launcher catalog. + /// Remote content versions already downloaded into the catalog. + /// The content version to inspect. + /// The launcher paths used to inspect local content folders. + /// The content folder layout. + /// The installed version identities discovered from local content folders. + private void CheckContentExistence( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout, + HashSet<(LauncherContentType Type, string Name, string Version, string DependenceName)> installedVersionIds) + { + LauncherContentType fallbackType = _stateMapper.GetFallbackContentType(modificationVersion.ModificationType); + if (installedVersionIds.Contains(CreateVersionIdentity(modificationVersion, fallbackType))) + { + return; + } + + LauncherContentVersionState versionState = _stateMapper.ToVersionState(modificationVersion, fallbackType); + if (_localContentService.VersionFolderExists(paths, layout, versionState)) + { + modificationVersion.Installed = true; + return; + } + + if (downloadedReposContent.Contains(modificationVersion)) + { + modificationVersion.Installed = false; + } + else + { + launcherData.Delete(modificationVersion); + DeleteModificationImagesIfCardMissing(launcherData, modificationVersion, paths); + } + } + + /// + /// Creates a version identity from local folder state. + /// + /// The local folder version state. + /// The version identity. + private static (LauncherContentType Type, string Name, string Version, string DependenceName) CreateVersionIdentity( + LauncherContentVersionState version) + { + return ( + version.ModificationType, + NormalizeIdentityText(version.Name), + NormalizeIdentityText(version.Version), + NormalizeIdentityText(version.DependenceName)); + } + + /// + /// Creates a version identity from a catalog version. + /// + /// The catalog version. + /// The content type used when the catalog version has ambiguous legacy type data. + /// The version identity. + private static (LauncherContentType Type, string Name, string Version, string DependenceName) CreateVersionIdentity( + ModificationVersion version, + LauncherContentType fallbackType) + { + return ( + ToLauncherContentType(version.ModificationType, fallbackType), + NormalizeIdentityText(version.Name), + NormalizeIdentityText(version.Version), + NormalizeIdentityText(version.DependenceName)); + } + + /// + /// Normalizes text for case-insensitive version identity comparison. + /// + /// The value to normalize. + /// The normalized identity text. + private static string NormalizeIdentityText(string value) + { + return (value ?? string.Empty).ToUpperInvariant(); + } + + /// + /// Converts legacy modification type to compact content type for identity comparison. + /// + /// The legacy modification type. + /// The fallback content type. + /// The compact content type. + private static LauncherContentType ToLauncherContentType( + ModificationType type, + LauncherContentType fallbackType) + { + return type switch + { + ModificationType.Addon => LauncherContentType.Addon, + ModificationType.Patch => LauncherContentType.Patch, + ModificationType.Advertising => LauncherContentType.Advertising, + ModificationType.Mod => fallbackType, + _ => fallbackType + }; + } + + /// + /// Deletes cached images for a catalog card that no longer exists. + /// + /// The mutable launcher catalog. + /// The removed modification version. + /// The launcher paths used to locate cached images. + private void DeleteModificationImagesIfCardMissing( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths) + { + _localContentService.DeleteImagesIfUnused( + paths, + _stateMapper.ToVersionState( + modificationVersion, + _stateMapper.GetFallbackContentType(modificationVersion.ModificationType)), + _stateMapper.ToLauncherContentState(launcherData)); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..66e05120 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Reads legacy-compatible remote launcher catalog YAML documents. +/// +/// +/// The remote catalog schema is owned by a third-party backend. This client must continue using the legacy manifest +/// DTOs and field names unless a future change adds explicit dual-schema read support and backend compatibility tests. +/// +internal sealed class RemoteLauncherCatalogClient : IRemoteLauncherCatalogClient +{ + /// + /// The maximum number of remote manifest reads allowed at once during catalog refresh. + /// + private const int MaxConcurrentManifestReads = 6; + + /// + /// The remote YAML reader used for repository manifests. + /// + private readonly IRemoteYamlDocumentReader _yamlDocumentReader; + + /// + /// The logger used for partial remote catalog failures. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The remote YAML reader used for repository manifests. + /// The logger used for partial remote catalog failures. + public RemoteLauncherCatalogClient( + IRemoteYamlDocumentReader yamlDocumentReader, + ILogger logger) + { + _yamlDocumentReader = yamlDocumentReader ?? throw new ArgumentNullException(nameof(yamlDocumentReader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task ReadCatalogAsync(Uri manifestUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(manifestUri); + + ReposModsData? repositoryData = await _yamlDocumentReader.ReadYamlAsync( + manifestUri, + cancellationToken).ConfigureAwait(false); + return RemoteLauncherCatalogMapper.ToRemoteCatalog(repositoryData); + } + + /// + public IReadOnlyList GetModificationNames(RemoteLauncherCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + + return catalog.Modifications + .Select(modification => modification.Name) + .ToList(); + } + + /// + public async Task> DownloadInstalledModDataAsync( + RemoteLauncherCatalog catalog, + IReadOnlyCollection installedModNames, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(installedModNames); + + var downloadedModNames = new HashSet(installedModNames, StringComparer.OrdinalIgnoreCase); + var installedModData = catalog.Modifications + .Where(reference => String.IsNullOrEmpty(reference.Name) || downloadedModNames.Contains(reference.Name)) + .ToList(); + + using var semaphore = new SemaphoreSlim(MaxConcurrentManifestReads); + RemoteModificationManifest?[] results = await Task.WhenAll( + installedModData.Select(reference => DownloadModDataIfAvailableAsync( + reference, + semaphore, + cancellationToken))).ConfigureAwait(false); + + var mods = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (RemoteModificationManifest? result in results) + { + if (result is null) + { + continue; + } + + if (!mods.ContainsKey(result.Content.Name)) + { + mods.Add(result.Content.Name, result); + } + } + + return mods.Values.ToList(); + } + + /// + public async Task DownloadModDataByNameAsync( + RemoteLauncherCatalog catalog, + string name, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + RemoteCatalogModificationReference reference = catalog.Modifications + .First(data => string.Equals(data.Name, name, StringComparison.OrdinalIgnoreCase)); + + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> ReadChildManifestsAsync( + IEnumerable manifestUrls, + CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(MaxConcurrentManifestReads); + RemoteContentManifest?[] manifests = await Task.WhenAll( + (manifestUrls ?? new List()).Select(url => ReadChildManifestIfAvailableAsync( + url, + semaphore, + cancellationToken))).ConfigureAwait(false); + + return manifests.Where(manifest => manifest != null).ToList()!; + } + + /// + /// Downloads one modification manifest while respecting the startup refresh concurrency limit. + /// + /// The parent manifest reference. + /// The concurrency gate. + /// The token used to cancel remote work. + /// The downloaded manifest pair, or when the manifest could not be read. + private async Task DownloadModDataIfAvailableAsync( + RemoteCatalogModificationReference reference, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to download remote modification manifest for {ModificationName}.", + reference.Name); + return null; + } + } + finally + { + semaphore.Release(); + } + } + + /// + /// Reads one child manifest while respecting the startup refresh concurrency limit. + /// + /// The child manifest URL. + /// The concurrency gate. + /// The token used to cancel remote work. + /// The child manifest, or when it could not be read. + private async Task ReadChildManifestIfAvailableAsync( + string url, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + return await ReadModificationYamlAsync(url, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Failed to read child modification manifest."); + return null; + } + } + finally + { + semaphore.Release(); + } + } + + /// + public async Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken) + { + try + { + return await ReadModificationYamlAsync(manifestUrl, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Failed to download advertising manifest."); + } + + return null; + } + + /// + /// Downloads one modification manifest. + /// + /// The parent manifest reference. + /// The token used to cancel remote work. + /// The downloaded modification metadata and parent entry. + private async Task DownloadModDataAsync( + RemoteCatalogModificationReference reference, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + RemoteContentManifest modification = await ReadModificationYamlAsync( + reference.ManifestUrl, + cancellationToken).ConfigureAwait(false); + return new RemoteModificationManifest( + modification, + reference.PatchManifestUrls, + reference.AddonManifestUrls); + } + + /// + /// Reads one modification YAML document. + /// + /// The document URL. + /// The token used to cancel remote work. + /// The normalized modification metadata. + private async Task ReadModificationYamlAsync( + string documentUrl, + CancellationToken cancellationToken) + { + ModificationReposVersion manifest = await _yamlDocumentReader.ReadYamlAsync( + new Uri(documentUrl, UriKind.Absolute), + cancellationToken).ConfigureAwait(false); + return RemoteLauncherCatalogMapper.ToRemoteContentManifest(manifest); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs new file mode 100644 index 00000000..08ba58bd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Stores launcher content state in a YAML-backed document. +/// +public sealed class YamlLauncherContentStateStore : ILauncherContentStateStore +{ + /// + /// The YAML document store used for content-state persistence. + /// + private readonly IYamlDocumentStore _documentStore; + + /// + /// Initializes a new instance of the class. + /// + /// The YAML document store used for content-state persistence. + public YamlLauncherContentStateStore(IYamlDocumentStore documentStore) + { + _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); + } + + /// + public LauncherContentState Load() + { + return _documentStore.Load(new LauncherContentState()); + } + + /// + public void Save(LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(state); + + _documentStore.Save(state); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs new file mode 100644 index 00000000..bd54ebf9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Maps third-party backend manifest DTOs to normalized remote catalog models. +/// +internal static class RemoteLauncherCatalogMapper +{ + /// + /// Maps a backend repository manifest to a normalized remote catalog. + /// + /// The backend repository manifest. + /// The normalized remote catalog. + public static RemoteLauncherCatalog ToRemoteCatalog(ReposModsData? repositoryData) + { + if (repositoryData is null) + { + return RemoteLauncherCatalog.Empty; + } + + return new RemoteLauncherCatalog( + ToAdvertisingReferences(repositoryData.AdvData), + ToModificationReferences(repositoryData.modDatas), + ToStringList(repositoryData.originalGameAddons), + ToStringList(repositoryData.originalGamePatches), + repositoryData.LauncherVersion); + } + + /// + /// Maps a backend content manifest to normalized remote content metadata. + /// + /// The backend content manifest. + /// The normalized remote content manifest. + public static RemoteContentManifest ToRemoteContentManifest(ModificationReposVersion? manifest) + { + if (manifest is null) + { + return new RemoteContentManifest(); + } + + return new RemoteContentManifest + { + ModificationType = manifest.ModificationType, + Name = manifest.Name ?? string.Empty, + Version = manifest.Version ?? string.Empty, + SimpleDownloadLink = manifest.SimpleDownloadLink ?? string.Empty, + ImageSourceLink = manifest.UIImageSourceLink ?? string.Empty, + DiscordLink = manifest.DiscordLink ?? string.Empty, + ModDbLink = manifest.ModDBLink ?? string.Empty, + NewsLink = manifest.NewsLink ?? string.Empty, + DependenceName = manifest.DependenceName ?? string.Empty, + S3HostLink = manifest.S3HostLink ?? string.Empty, + S3BucketName = manifest.S3BucketName ?? string.Empty, + S3FolderName = manifest.S3FolderName ?? string.Empty, + S3HostPublicKey = manifest.S3HostPublicKey ?? string.Empty, + S3HostSecretKey = manifest.S3HostSecretKey ?? string.Empty, + NetworkInfo = manifest.NetworkInfo ?? string.Empty, + Deprecated = manifest.Deprecated, + SupportLink = manifest.SupportLink ?? string.Empty, + ColorsInformation = manifest.ColorsInformation, + ContentSourceKind = manifest.ContentSourceKind + }; + } + + /// + /// Maps normalized remote content metadata into launcher catalog version state. + /// + /// The normalized remote content manifest. + /// The launcher catalog version state. + public static ModificationVersion ToModificationVersion(RemoteContentManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + + return new ModificationVersion + { + ModificationType = manifest.ModificationType, + Name = manifest.Name, + Version = manifest.Version, + SimpleDownloadLink = manifest.SimpleDownloadLink, + UIImageSourceLink = manifest.ImageSourceLink, + DiscordLink = manifest.DiscordLink, + ModDBLink = manifest.ModDbLink, + NewsLink = manifest.NewsLink, + DependenceName = manifest.DependenceName, + S3HostLink = manifest.S3HostLink, + S3BucketName = manifest.S3BucketName, + S3FolderName = manifest.S3FolderName, + S3HostPublicKey = manifest.S3HostPublicKey, + S3HostSecretKey = manifest.S3HostSecretKey, + NetworkInfo = manifest.NetworkInfo, + Deprecated = manifest.Deprecated, + SupportLink = manifest.SupportLink, + ColorsInformation = manifest.ColorsInformation, + ContentSourceKind = ModificationReposVersion.ResolveContentSourceKind( + manifest.S3HostLink, + manifest.S3BucketName, + manifest.S3FolderName, + manifest.SimpleDownloadLink, + manifest.ContentSourceKind) + }; + } + + /// + /// Maps backend modification references to normalized references. + /// + /// The backend modification references. + /// The normalized modification references. + private static IReadOnlyList ToModificationReferences( + IEnumerable? modificationReferences) + { + return (modificationReferences ?? Enumerable.Empty()) + .Select(reference => new RemoteCatalogModificationReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ModPatches), + ToStringList(reference.ModAddons))) + .ToList(); + } + + /// + /// Maps backend advertising references to normalized references. + /// + /// The backend advertising references. + /// The normalized advertising references. + private static IReadOnlyList ToAdvertisingReferences( + IEnumerable? advertisingReferences) + { + return (advertisingReferences ?? Enumerable.Empty()) + .Select(reference => new RemoteAdvertisingReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ImagesData))) + .ToList(); + } + + /// + /// Copies a string sequence into a read-only list while removing null entries. + /// + /// The source values. + /// The copied string values. + private static IReadOnlyList ToStringList(IEnumerable? values) + { + return (values ?? Enumerable.Empty()) + .Where(value => value != null) + .ToList()!; + } + +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs b/GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs new file mode 100644 index 00000000..504aa842 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Persistence.Options; + +/// +/// Describes where a YAML-backed document store persists its document. +/// +public sealed class YamlDocumentStoreOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The YAML document file path. + /// + /// Thrown when is , empty, or whitespace. + /// + public YamlDocumentStoreOptions(string documentFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(documentFilePath); + + DocumentFilePath = documentFilePath; + } + + /// + /// Gets the YAML document file path. + /// + public string DocumentFilePath { get; } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs new file mode 100644 index 00000000..d9918a13 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Loads and saves one YAML-backed document. +/// +/// The document type. +public interface IYamlDocumentStore + where TDocument : class +{ + /// + /// Loads the document from disk. + /// + /// The document returned when the persisted document is missing or unreadable. + /// The loaded document, or . + TDocument Load(TDocument defaultDocument); + + /// + /// Saves the document to disk. + /// + /// The document to save. + void Save(TDocument document); +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs new file mode 100644 index 00000000..f819a9cb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Persistence.Options; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Loads and saves one YAML-backed document on the local file system. +/// +/// The document type. +public sealed class YamlDocumentStore : IYamlDocumentStore + where TDocument : class +{ + /// + /// The file-system path options. + /// + private readonly YamlDocumentStoreOptions _options; + + /// + /// The logger used for persistence diagnostics. + /// + private readonly ILogger> _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The file-system path options. + /// The logger used for persistence diagnostics. + public YamlDocumentStore( + YamlDocumentStoreOptions options, + ILogger> logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public TDocument Load(TDocument defaultDocument) + { + ArgumentNullException.ThrowIfNull(defaultDocument); + + if (!File.Exists(_options.DocumentFilePath)) + { + return defaultDocument; + } + + try + { + IDeserializer deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + + using TextReader reader = File.OpenText(_options.DocumentFilePath); + return deserializer.Deserialize(reader) ?? defaultDocument; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to load {DocumentType} from {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_options.DocumentFilePath)); + return defaultDocument; + } + } + + /// + public void Save(TDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + try + { + string? directory = Path.GetDirectoryName(_options.DocumentFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + ISerializer serializer = new Serializer(); + using TextWriter writer = File.CreateText(_options.DocumentFilePath); + serializer.Serialize(writer, document); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to save {DocumentType} to {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_options.DocumentFilePath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..3e82382a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs new file mode 100644 index 00000000..94b124e5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Downloads remote assets to disk through the resumable downloader. +/// +public sealed class HttpRemoteAssetDownloader : IRemoteAssetDownloader +{ + private readonly IResumableFileDownloader _fileDownloader; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The file downloader used for transfers. + /// The logger used for asset download diagnostics. + public HttpRemoteAssetDownloader( + IResumableFileDownloader fileDownloader, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads a remote asset to a temporary file and atomically moves it into place when the final file is missing. + /// + /// The remote asset URI. + /// The destination asset path. + /// A token that cancels the download. + /// A task that completes after the asset exists locally. + public async Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceUri); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); + + if (File.Exists(destinationFilePath)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + string temporaryFilePath = destinationFilePath + ".download"; + if (File.Exists(temporaryFilePath)) + { + File.Delete(temporaryFilePath); + _logger.LogInformation( + "Deleted stale remote asset download file {FileName}.", + Path.GetFileName(temporaryFilePath)); + } + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest(sourceUri, temporaryFilePath, Resume: false), + null, + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(temporaryFilePath); + return; + } + + File.Move(temporaryFilePath, destinationFilePath); + _logger.LogInformation( + "Downloaded remote asset {FileName} from {Host}.", + Path.GetFileName(destinationFilePath), + sourceUri.Host); + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs new file mode 100644 index 00000000..26f76297 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs @@ -0,0 +1,90 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Checks remote HTTP endpoint connectivity. +/// +public sealed class HttpRemoteConnectionProbe : IRemoteConnectionProbe +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(30)); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for requests. + /// The logger used for probe diagnostics. + public HttpRemoteConnectionProbe( + ILogger logger, + HttpClient? httpClient = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? _sharedHttpClient; + } + + /// + /// Checks whether the remote endpoint can be reached through HEAD or GET without downloading the response body. + /// + /// The remote endpoint to probe. + /// A token that cancels probe requests. + /// when the endpoint returns a successful status code. + public async Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(endpointUri); + + try + { + return await SendProbeAsync(endpointUri, HttpMethod.Head, cancellationToken).ConfigureAwait(false) || + await SendProbeAsync(endpointUri, HttpMethod.Get, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogWarning( + ex, + "Remote connection probe failed for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Remote connection probe timed out for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + } + + private async Task SendProbeAsync( + Uri endpointUri, + HttpMethod httpMethod, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(httpMethod, endpointUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (httpMethod == HttpMethod.Head && + (response.StatusCode == HttpStatusCode.MethodNotAllowed || + response.StatusCode == HttpStatusCode.NotImplemented)) + { + return false; + } + + return response.IsSuccessStatusCode; + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..8ad291a7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Reads YAML documents over HTTP. +/// +public sealed class HttpRemoteYamlDocumentReader : IRemoteYamlDocumentReader +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(60)); + + private readonly IDeserializer _deserializer; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for requests. + /// The logger used for YAML read diagnostics. + public HttpRemoteYamlDocumentReader( + HttpClient? httpClient = null, + ILogger? logger = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + _deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + } + + /// + public async Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(documentUri); + + try + { + using HttpRequestMessage request = new(HttpMethod.Get, documentUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + await using Stream contentStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using StreamReader reader = new(contentStream); + + return _deserializer.Deserialize(reader); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to read remote YAML document from {Scheme}://{Host}.", + documentUri.Scheme, + documentUri.Host); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs new file mode 100644 index 00000000..768714b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs @@ -0,0 +1,39 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Creates shared HTTP clients with desktop launcher defaults. +/// +internal static class SharedHttpClientFactory +{ + /// + /// Creates an HTTP client with pooled connections, no automatic decompression, and a GenLauncherGO user agent. + /// + /// The overall request timeout for the created client. + /// A configured HTTP client. + public static HttpClient Create(TimeSpan timeout) + { + SocketsHttpHandler handler = new() + { + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(30), + MaxConnectionsPerServer = 16, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }; + + HttpClient httpClient = new(handler) + { + Timeout = timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("GenLauncherGO", "1")); + + return httpClient; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..1e797ed3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Settings.Options; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Settings.Composition; + +/// +/// Provides dependency-injection registrations for launcher settings infrastructure. +/// +public static class SettingsInfrastructureServiceCollectionExtensions +{ + /// + /// Registers infrastructure services used by the launcher settings feature. + /// + /// The service collection used by the application composition root. + /// The YAML file path where launcher preferences are persisted. + /// The launcher logs directory path opened from settings. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + /// + /// Thrown when or is + /// , empty, or whitespace. + /// + public static IServiceCollection AddGenLauncherGoSettingsInfrastructure( + this IServiceCollection services, + string preferencesFilePath, + string logsDirectory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(preferencesFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(logsDirectory); + + services.AddSingleton(new LauncherPreferencesStoreOptions(preferencesFilePath)); + services.AddSingleton(new LauncherSettingsLinkOptions(logsDirectory)); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs b/GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs new file mode 100644 index 00000000..3937a295 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Settings.Options; + +/// +/// Describes the file-system location used to persist launcher preferences. +/// +public sealed class LauncherPreferencesStoreOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The YAML file path where launcher preferences are persisted. + /// + /// Thrown when is , empty, or whitespace. + /// + public LauncherPreferencesStoreOptions(string preferencesFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(preferencesFilePath); + + PreferencesFilePath = preferencesFilePath; + } + + /// + /// Gets the YAML file path where launcher preferences are persisted. + /// + public string PreferencesFilePath { get; } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs b/GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs new file mode 100644 index 00000000..e21aa3d7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Settings.Options; + +/// +/// Describes file-system targets used by launcher settings links. +/// +public sealed class LauncherSettingsLinkOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The launcher logs directory path. + /// + /// Thrown when is , empty, or whitespace. + /// + public LauncherSettingsLinkOptions(string logsDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(logsDirectory); + + LogsDirectory = logsDirectory; + } + + /// + /// Gets the launcher logs directory path. + /// + public string LogsDirectory { get; } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs new file mode 100644 index 00000000..1ece35f7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Options; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Settings.Services; + +/// +/// Persists launcher preferences as a standalone YAML document. +/// +public sealed class PreferencesService : ILauncherPreferencesService +{ + /// + /// The preferences store options. + /// + private readonly LauncherPreferencesStoreOptions _options; + + /// + /// The logger used to record preference persistence failures. + /// + private readonly ILogger _logger; + + /// + /// The current in-memory preferences. + /// + private LauncherPreferences _current; + + /// + /// Initializes a new instance of the class. + /// + /// The preferences store options. + /// The logger used to record preference persistence failures. + public PreferencesService( + LauncherPreferencesStoreOptions options, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _current = LoadPreferences(); + } + + /// + public event EventHandler? PreferencesChanged; + + /// + public LauncherPreferences Current => _current; + + /// + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherPreferences normalizedPreferences = Normalize(preferences); + if (normalizedPreferences == _current) + { + return; + } + + _current = normalizedPreferences; + SavePreferences(normalizedPreferences); + PreferencesChanged?.Invoke(this, new LauncherPreferencesChangedEventArgs(_current)); + } + + /// + /// Reads launcher preferences from the configured YAML file. + /// + /// The loaded preferences, or defaults when the file is missing or unreadable. + private LauncherPreferences LoadPreferences() + { + if (!File.Exists(_options.PreferencesFilePath)) + { + return new LauncherPreferences(); + } + + try + { + IDeserializer deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + + using TextReader reader = File.OpenText(_options.PreferencesFilePath); + LauncherPreferences? preferences = deserializer.Deserialize(reader); + return Normalize(preferences ?? new LauncherPreferences()); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to read launcher preferences from {PreferencesFilePath}. Defaults will be used.", + _options.PreferencesFilePath); + return new LauncherPreferences(); + } + } + + /// + /// Writes launcher preferences to the configured YAML file. + /// + /// The preferences to write. + private void SavePreferences(LauncherPreferences preferences) + { + try + { + string? directory = Path.GetDirectoryName(_options.PreferencesFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + ISerializer serializer = new Serializer(); + using TextWriter writer = File.CreateText(_options.PreferencesFilePath); + serializer.Serialize(writer, preferences); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to save launcher preferences to {PreferencesFilePath}.", + _options.PreferencesFilePath); + } + } + + /// + /// Normalizes nullable legacy-compatible string values to empty strings. + /// + /// The preferences to normalize. + /// The normalized preferences. + private static LauncherPreferences Normalize(LauncherPreferences preferences) + { + return preferences with + { + SelectedGameClient = preferences.SelectedGameClient ?? string.Empty, + SelectedWorldBuilder = preferences.SelectedWorldBuilder ?? string.Empty, + GameArguments = preferences.GameArguments ?? string.Empty, + WorldBuilderArguments = preferences.WorldBuilderArguments ?? string.Empty + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs b/GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs new file mode 100644 index 00000000..1cfcf785 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs @@ -0,0 +1,110 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Settings.Options; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Settings.Services; + +/// +/// Opens launcher settings links through the Windows shell. +/// +public sealed class ProcessLauncherSettingsLinkService : ILauncherSettingsLinkService +{ + /// + /// The Generals Online Discord server URL. + /// + private const string GeneralsOnlineDiscordUrl = "https://discord.playgenerals.online"; + + /// + /// The GenLauncherGO GitHub repository URL. + /// + private const string GitHubRepositoryUrl = "https://github.com/x64-dev/GenLauncher_GO"; + + /// + /// The donation URL for the original GenLauncher author. + /// + private const string OriginalAuthorDonationUrl = + "https://boosty.to/genlauncher/single-payment/donation/157147?share=target_link"; + + /// + /// The file-system link options. + /// + private readonly LauncherSettingsLinkOptions _options; + + /// + /// The logger used to record shell launch failures. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The file-system link options. + /// The logger used to record shell launch failures. + public ProcessLauncherSettingsLinkService( + LauncherSettingsLinkOptions options, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public bool TryOpenGeneralsOnlineDiscordLink() + { + return TryOpenShellTarget(GeneralsOnlineDiscordUrl, "Generals Online Discord"); + } + + /// + public bool TryOpenLogsDirectory() + { + try + { + Directory.CreateDirectory(_options.LogsDirectory); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(exception, "Failed to prepare launcher logs directory before opening it."); + return false; + } + + return TryOpenShellTarget(_options.LogsDirectory, "launcher logs directory"); + } + + /// + public bool TryOpenGitHubRepository() + { + return TryOpenShellTarget(GitHubRepositoryUrl, "GitHub repository"); + } + + /// + public bool TryOpenOriginalAuthorDonationLink() + { + return TryOpenShellTarget(OriginalAuthorDonationUrl, "original author donation link"); + } + + /// + /// Opens a shell target through Windows. + /// + /// The URL or local path to open. + /// A non-sensitive display name used in logs. + /// when the shell accepted the target; otherwise, . + private bool TryOpenShellTarget(string target, string diagnosticName) + { + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + return true; + } + catch (Exception exception) + when (exception is Win32Exception or InvalidOperationException) + { + _logger.LogWarning(exception, "Failed to open launcher settings target {TargetName}.", diagnosticName); + return false; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs new file mode 100644 index 00000000..08794cd6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using System; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Infrastructure.Shell.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Shell.Composition; + +/// +/// Provides dependency-injection registrations for operating system shell services. +/// +public static class ShellServiceCollectionExtensions +{ + /// + /// Registers infrastructure services used to open external launcher targets. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + public static IServiceCollection AddGenLauncherGoShell(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs new file mode 100644 index 00000000..1f09fd63 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs @@ -0,0 +1,146 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Core.Shell.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Shell.Services; + +/// +/// Opens external targets through the Windows shell. +/// +public sealed class WindowsLauncherShellService : ILauncherShellService +{ + /// + /// Logs diagnostics for shell-open side effects. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for shell-open diagnostics. + public WindowsLauncherShellService(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public ShellOpenResult OpenUri(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + "URI", + "The URI is empty."); + } + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + uri, + "The URI is not absolute."); + } + + if (!string.Equals(parsedUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !string.Equals(parsedUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + uri, + string.Format(CultureInfo.InvariantCulture, "Unsupported URI scheme '{0}'.", parsedUri.Scheme)); + } + + return OpenShellTarget(parsedUri.AbsoluteUri, GetUriLogTarget(parsedUri)); + } + + /// + public ShellOpenResult OpenFolder(string folderPath, bool requireFiles = false) + { + if (string.IsNullOrWhiteSpace(folderPath)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + "Folder", + "The folder path is empty."); + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(folderPath); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or PathTooLongException) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + folderPath, + exception.Message); + } + + if (!Directory.Exists(fullPath)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.MissingTarget, + fullPath, + "The folder does not exist."); + } + + if (requireFiles && !Directory.EnumerateFiles(fullPath).Any()) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.MissingTarget, + fullPath, + "The folder does not contain files."); + } + + return OpenShellTarget(fullPath, Path.GetFileName(fullPath)); + } + + /// + /// Starts the Windows shell for a normalized target. + /// + /// The target to pass to the Windows shell. + /// A sanitized target label for logs. + /// The result of opening the target. + private ShellOpenResult OpenShellTarget(string target, string logTarget) + { + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + return ShellOpenResult.Success(target); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or IOException) + { + _logger.LogWarning( + exception, + "Could not open shell target {Target}.", + logTarget); + + return ShellOpenResult.Failure( + ShellOpenFailureKind.LaunchFailed, + target, + exception.Message); + } + } + + /// + /// Gets a sanitized label for URI logging. + /// + /// The URI being opened. + /// The URI host when available; otherwise, its scheme. + private static string GetUriLogTarget(Uri uri) + { + return string.IsNullOrWhiteSpace(uri.Host) + ? uri.Scheme + : uri.Host; + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs new file mode 100644 index 00000000..173d6d53 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs @@ -0,0 +1,232 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Resolves launcher paths by inspecting the local file system. +/// +public sealed class FileSystemLauncherPathResolver : ILauncherPathResolver +{ + /// + /// The launcher-owned directory name. + /// + private const string LauncherFolderName = "GenLauncherGO"; + + /// + /// The launcher runtime directory name. + /// + private const string RuntimeFolderName = "Runtime"; + + /// + /// The launcher cache directory name. + /// + private const string CacheFolderName = "Cache"; + + /// + /// The launcher image cache directory name. + /// + private const string ImagesFolderName = "Images"; + + /// + /// The launcher mods directory name. + /// + private const string ModsFolderName = "Mods"; + + /// + /// The launcher logs directory name. + /// + private const string LogsFolderName = "Logs"; + + /// + /// The launcher temporary directory name. + /// + private const string TempFolderName = "Temp"; + + /// + /// The launcher deployment state directory name. + /// + private const string DeploymentFolderName = "Deployment"; + + /// + /// The original game DLL required by supported game installs. + /// + private const string BinkLibraryFileName = "BINKW32.DLL"; + + /// + /// The Zero Hour data archive required by supported Zero Hour installs. + /// + private const string ZeroHourWindowArchiveFileName = "WindowZH.big"; + + /// + /// The Generals data archive required by supported Generals installs. + /// + private const string GeneralsWindowArchiveFileName = "Window.big"; + + /// + /// The GenLauncher replacement suffix used while game files are renamed. + /// + private const string ReplacementSuffix = ".GLR"; + + /// + /// The supported SuperHackers Zero Hour executable name. + /// + private const string SuperHackersZeroHourExecutableFileName = "generalszh.exe"; + + /// + /// The supported SuperHackers Generals executable name. + /// + private const string SuperHackersGeneralsExecutableFileName = "generalsv.exe"; + + /// + /// The supported GeneralsOnline executable name. + /// + private const string GeneralsOnlineExecutableFileName = "generalsonlinezh.exe"; + + /// + /// The logger used for file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public FileSystemLauncherPathResolver() + : this(NullLogger.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for file-system diagnostics. + public FileSystemLauncherPathResolver(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public LauncherPaths? Resolve(string executableDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + string executableRoot = Path.GetFullPath(executableDirectory); + if (IsSupportedGameDirectory(executableRoot)) + { + return CreatePaths(executableRoot, Path.Combine(executableRoot, LauncherFolderName)); + } + + DirectoryInfo? parent = Directory.GetParent(executableRoot); + if (parent is not null && IsSupportedGameDirectory(parent.FullName)) + { + return CreatePaths(parent.FullName, executableRoot); + } + + _logger.LogWarning("No supported game directory was found for launcher executable directory {DirectoryName}.", + Path.GetFileName(executableRoot)); + return null; + } + + /// + public void PrepareLauncherDirectories(LauncherPaths paths, bool cleanTemporaryDirectory) + { + ArgumentNullException.ThrowIfNull(paths); + + Directory.CreateDirectory(paths.LauncherDirectory); + Directory.CreateDirectory(paths.RuntimeDirectory); + Directory.CreateDirectory(paths.CacheDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.LogsDirectory); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + Directory.CreateDirectory(paths.StateDirectory); + + if (cleanTemporaryDirectory) + { + ClearDirectory(paths.TempDirectory); + } + + _logger.LogInformation("Prepared launcher directories under {LauncherDirectoryName}.", + Path.GetFileName(paths.LauncherDirectory)); + } + + /// + /// Creates a path model from resolved game and launcher roots. + /// + /// The supported game directory. + /// The launcher-owned directory. + /// The resolved launcher paths. + private static LauncherPaths CreatePaths(string gameDirectory, string launcherDirectory) + { + string launcherRoot = Path.GetFullPath(launcherDirectory); + string runtimeRoot = Path.Combine(launcherRoot, RuntimeFolderName); + string cacheRoot = Path.Combine(runtimeRoot, CacheFolderName); + return new LauncherPaths( + Path.GetFullPath(gameDirectory), + launcherRoot, + runtimeRoot, + cacheRoot, + Path.Combine(cacheRoot, ImagesFolderName), + Path.Combine(launcherRoot, ModsFolderName), + Path.Combine(launcherRoot, LogsFolderName), + Path.Combine(runtimeRoot, TempFolderName), + Path.Combine(runtimeRoot, DeploymentFolderName)); + } + + /// + /// Returns whether a directory contains a supported game installation. + /// + /// The directory to inspect. + /// when the directory contains supported game files. + private static bool IsSupportedGameDirectory(string directory) + { + if (!Directory.Exists(directory) || !File.Exists(Path.Combine(directory, BinkLibraryFileName))) + { + return false; + } + + bool hasZeroHourData = HasGameFile(directory, ZeroHourWindowArchiveFileName); + bool hasGeneralsData = HasGameFile(directory, GeneralsWindowArchiveFileName); + bool hasSupportedZeroHourExecutable = + File.Exists(Path.Combine(directory, SuperHackersZeroHourExecutableFileName)) || + File.Exists(Path.Combine(directory, GeneralsOnlineExecutableFileName)); + bool hasSupportedGeneralsExecutable = + File.Exists(Path.Combine(directory, SuperHackersGeneralsExecutableFileName)); + + return (hasZeroHourData && hasSupportedZeroHourExecutable) || + (hasGeneralsData && hasSupportedGeneralsExecutable); + } + + /// + /// Returns whether a game data file exists in its normal or renamed form. + /// + /// The directory to inspect. + /// The expected file name. + /// when the file exists. + private static bool HasGameFile(string directory, string fileName) + { + return File.Exists(Path.Combine(directory, fileName)) || + File.Exists(Path.Combine(directory, fileName + ReplacementSuffix)); + } + + /// + /// Deletes all child entries from a directory while keeping the directory itself. + /// + /// The directory to clear. + private static void ClearDirectory(string directory) + { + foreach (string filePath in Directory.EnumerateFiles(directory)) + { + File.Delete(filePath); + } + + foreach (string directoryPath in Directory.EnumerateDirectories(directory)) + { + Directory.Delete(directoryPath, recursive: true); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs new file mode 100644 index 00000000..e1a3de4e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs @@ -0,0 +1,186 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Reads launcher startup state from the local file system and current process. +/// +public sealed class FileSystemLauncherStartupEnvironmentService : ILauncherStartupEnvironmentService +{ + /// + /// The legacy custom color configuration file name. + /// + private const string ColorsFileName = "Colors.yaml"; + + /// + /// The legacy custom background image file name. + /// + private const string CustomBackgroundFileName = "GlBg.png"; + + /// + /// The Zero Hour game data archive file name. + /// + private const string ZeroHourWindowArchiveFileName = "WindowZH.big"; + + /// + /// The Generals game data archive file name. + /// + private const string GeneralsWindowArchiveFileName = "Window.big"; + + /// + /// The logger used for file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The YAML deserializer used for local color overrides. + /// + private readonly IDeserializer _deserializer; + + /// + /// Initializes a new instance of the class. + /// + public FileSystemLauncherStartupEnvironmentService() + : this(NullLogger.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for file-system diagnostics. + public FileSystemLauncherStartupEnvironmentService( + ILogger logger) + : this( + logger, + new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for file-system diagnostics. + /// The YAML deserializer used for local color overrides. + internal FileSystemLauncherStartupEnvironmentService( + ILogger logger, + IDeserializer deserializer) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + } + + /// + public Task ReadAsync( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + cancellationToken.ThrowIfCancellationRequested(); + + string gameDirectory = Path.GetFullPath(paths.GameDirectory); + SupportedGame managedGame = DetectManagedGame(gameDirectory); + ColorsInfoString? customColors = ReadCustomColors(gameDirectory); + string? customBackgroundImagePath = ResolveCustomBackgroundImagePath(gameDirectory); + + return Task.FromResult(new LauncherStartupEnvironment( + managedGame, + customColors, + customBackgroundImagePath)); + } + + /// + /// Detects the managed game variant from known game archive files. + /// + /// The game directory to inspect. + /// The detected game variant. + private static SupportedGame DetectManagedGame(string gameDirectory) + { + if (File.Exists(Path.Combine(gameDirectory, ZeroHourWindowArchiveFileName))) + { + return SupportedGame.ZeroHour; + } + + if (File.Exists(Path.Combine(gameDirectory, GeneralsWindowArchiveFileName))) + { + return SupportedGame.Generals; + } + + return SupportedGame.Unknown; + } + + /// + /// Resolves the custom background image path when the legacy image file exists. + /// + /// The game directory to inspect. + /// The custom background image path, or when none exists. + private static string? ResolveCustomBackgroundImagePath(string gameDirectory) + { + string imagePath = Path.Combine(gameDirectory, CustomBackgroundFileName); + return File.Exists(imagePath) + ? Path.GetFullPath(imagePath) + : null; + } + + /// + /// Reads local launcher color overrides from the legacy YAML file. + /// + /// The game directory to inspect. + /// The color override, or when no valid override exists. + private ColorsInfoString? ReadCustomColors(string gameDirectory) + { + string colorsFilePath = Path.Combine(gameDirectory, ColorsFileName); + if (!File.Exists(colorsFilePath)) + { + return null; + } + + try + { + using StreamReader reader = File.OpenText(colorsFilePath); + return _deserializer.Deserialize(reader); + } + catch (YamlException exception) + { + _logger.LogWarning( + exception, + "Could not parse launcher color override file {FileName}.", + ColorsFileName); + return null; + } + catch (Exception exception) when (IsRecoverableFileSystemException(exception)) + { + _logger.LogWarning( + exception, + "Could not read launcher color override file {FileName}.", + ColorsFileName); + return null; + } + } + + /// + /// Determines whether an exception represents a recoverable file-system problem. + /// + /// The exception to inspect. + /// when startup can continue without the optional file. + private static bool IsRecoverableFileSystemException(Exception exception) + { + return exception is IOException + or UnauthorizedAccessException + or NotSupportedException + or System.Security.SecurityException; + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs new file mode 100644 index 00000000..97ea9c8d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs @@ -0,0 +1,198 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Threading; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Provides Windows process, current-directory, single-instance, and foreground-window startup operations. +/// +public sealed class WindowsLauncherHostEnvironmentService : ILauncherHostEnvironmentService +{ + /// + /// The ShowWindow command that restores a minimized window. + /// + private const int SwRestore = 9; + + /// + /// The logger used for host-environment diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public WindowsLauncherHostEnvironmentService() + : this(NullLogger.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for host-environment diagnostics. + public WindowsLauncherHostEnvironmentService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public void ActivateCurrentProcessWindow() + { + using var currentProcess = Process.GetCurrentProcess(); + Process? process = Process.GetProcessesByName(currentProcess.ProcessName) + .FirstOrDefault(candidate => candidate.Id != currentProcess.Id); + IntPtr windowHandle = process?.MainWindowHandle ?? IntPtr.Zero; + + if (windowHandle == IntPtr.Zero) + { + _logger.LogDebug("No existing launcher window was available to activate."); + return; + } + + ShowWindowAsync(new HandleRef(null, windowHandle), SwRestore); + SetForegroundWindow(windowHandle); + } + + /// + public string GetExecutableDirectory() + { + string? executablePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + + if (String.IsNullOrWhiteSpace(executablePath)) + { + return AppContext.BaseDirectory; + } + + return Path.GetDirectoryName(executablePath) ?? AppContext.BaseDirectory; + } + + /// + public bool IsCurrentProcessElevated() + { + using var identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + /// + public bool IsProtectedProgramFilesDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return IsPathInDirectoryWhenKnown(directory, programFiles) || + IsPathInDirectoryWhenKnown(directory, programFilesX86); + } + + /// + public void SetCurrentDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + Directory.SetCurrentDirectory(directory); + } + + /// + public ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceName); + ArgumentOutOfRangeException.ThrowIfLessThan(retryDelay, TimeSpan.Zero); + + Mutex mutex = new(initiallyOwned: true, instanceName, out bool createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, isAcquired: true); + } + + mutex.Dispose(); + if (retryDelay > TimeSpan.Zero) + { + Thread.Sleep(retryDelay); + } + + mutex = new Mutex(initiallyOwned: true, instanceName, out createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, isAcquired: true); + } + + mutex.Dispose(); + return MutexSingleInstanceGuard.NotAcquired; + } + + /// + /// Returns whether a path is equal to or below a known directory. + /// + /// The path to inspect. + /// The potential parent directory. + /// when the directory is known and contains the path. + private static bool IsPathInDirectoryWhenKnown(string path, string directory) + { + return !String.IsNullOrWhiteSpace(directory) && + FileSystemPathSafety.IsPathInDirectory(path, directory); + } + + /// + /// Sets a foreground window. + /// + /// The window handle. + /// when the native call succeeds. + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + /// + /// Shows a window asynchronously. + /// + /// The window handle reference. + /// The show command. + /// when the native call succeeds. + [DllImport("user32.dll")] + private static extern bool ShowWindowAsync(HandleRef hWnd, int nCmdShow); + + /// + /// Owns a Windows mutex used as the single-instance guard. + /// + private sealed class MutexSingleInstanceGuard : ILauncherSingleInstanceGuard + { + /// + /// A non-acquired guard. + /// + public static readonly MutexSingleInstanceGuard NotAcquired = new(null, isAcquired: false); + + /// + /// The owned mutex when acquired. + /// + private readonly Mutex? _mutex; + + /// + /// Initializes a new instance of the class. + /// + /// The owned mutex. + /// A value indicating whether the mutex was acquired. + public MutexSingleInstanceGuard(Mutex? mutex, bool isAcquired) + { + _mutex = mutex; + IsAcquired = isAcquired; + } + + /// + public bool IsAcquired { get; } + + /// + public void Dispose() + { + _mutex?.Dispose(); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs new file mode 100644 index 00000000..9c80181f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs @@ -0,0 +1,135 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads downloadable file metadata over HTTP. +/// +public sealed class HttpDownloadFileMetadataReader : IDownloadFileMetadataReader +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(60)); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for metadata requests. + /// The logger used for metadata diagnostics. + public HttpDownloadFileMetadataReader( + HttpClient? httpClient = null, + ILogger? logger = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + } + + /// + public async Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(downloadUri); + + try + { + DownloadFileMetadata? metadata = await TryReadMetadataAsync( + downloadUri, + HttpMethod.Head, + cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + return metadata; + } + + metadata = await TryReadMetadataAsync( + downloadUri, + HttpMethod.Get, + cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + return metadata; + } + + _logger.LogWarning( + "Remote download metadata did not include a file name for {Scheme}://{Host}.", + downloadUri.Scheme, + downloadUri.Host); + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to read remote download metadata from {Scheme}://{Host}.", + downloadUri.Scheme, + downloadUri.Host); + throw; + } + } + + private async Task TryReadMetadataAsync( + Uri downloadUri, + HttpMethod httpMethod, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(httpMethod, downloadUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (httpMethod == HttpMethod.Head && + (response.StatusCode == HttpStatusCode.MethodNotAllowed || + response.StatusCode == HttpStatusCode.NotImplemented)) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + string? fileName = response.Content.Headers.ContentDisposition?.FileNameStar; + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = response.Content.Headers.ContentDisposition?.FileName; + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + return new DownloadFileMetadata( + downloadUri, + SanitizeFileName(fileName), + response.Content.Headers.ContentLength); + } + + private static string SanitizeFileName(string fileName) + { + string sanitizedFileName = fileName.Trim('"').Replace("\\", string.Empty).Replace("/", string.Empty); + if (string.IsNullOrWhiteSpace(sanitizedFileName)) + { + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + + return sanitizedFileName; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs new file mode 100644 index 00000000..f6df99a4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs @@ -0,0 +1,56 @@ +using System; +using Minio; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Creates MinIO clients from S3-compatible endpoint settings. +/// +internal static class MinioClientFactory +{ + /// + /// Creates an authenticated MinIO client for the supplied endpoint and credentials. + /// + /// The S3-compatible endpoint host or URI. + /// The access key used for S3 authentication. + /// The secret key used for S3 authentication. + /// + /// The SSL preference for host-only endpoints. Explicit endpoint URI schemes override this value. + /// + /// An authenticated MinIO client. + public static IMinioClient Create( + string endpoint, + string accessKey, + string secretKey, + bool useSsl = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(accessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); + + string normalizedEndpoint = endpoint.Trim(); + bool resolvedUseSsl = useSsl; + + if (normalizedEndpoint.Contains("://", StringComparison.OrdinalIgnoreCase) && + Uri.TryCreate(normalizedEndpoint, UriKind.Absolute, out Uri? endpointUri)) + { + normalizedEndpoint = endpointUri.Authority; + resolvedUseSsl = string.Equals(endpointUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + } + else if (normalizedEndpoint.EndsWith(":443", StringComparison.OrdinalIgnoreCase)) + { + resolvedUseSsl = true; + } + + IMinioClient client = new MinioClient() + .WithEndpoint(normalizedEndpoint) + .WithCredentials(accessKey, secretKey); + + if (resolvedUseSsl) + { + return client.WithSSL().Build(); + } + + return client.Build(); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs new file mode 100644 index 00000000..687a547c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; +using Microsoft.Extensions.Logging; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads S3-compatible object listings with MinIO. +/// +public sealed class MinioS3ObjectManifestReader : IS3ObjectManifestReader +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for S3 manifest diagnostics. + public MinioS3ObjectManifestReader(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Reads an authenticated S3-compatible object listing, returning manifest entries with prefix-relative names. + /// + /// The S3 object manifest request. + /// A token that cancels listing work. + /// The listed remote file manifest entries. + public async Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BucketName); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Prefix); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.SecretKey); + + CultureInfo previousCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CreateInvariantS3Culture(); + + try + { + IMinioClient client = MinioClientFactory.Create( + request.Endpoint, + request.AccessKey, + request.SecretKey, + request.UseSsl); + + List files = new(); + ListObjectsArgs args = new ListObjectsArgs() + .WithBucket(request.BucketName) + .WithPrefix(request.Prefix) + .WithRecursive(true); + + await foreach (Item item in client.ListObjectsEnumAsync(args, cancellationToken) + .ConfigureAwait(false)) + { + files.Add(new RemoteFileManifestEntry( + StripPrefix(item.Key, request.Prefix), + NormalizeETag(item.ETag), + item.Size)); + } + + _logger.LogInformation( + "Read {FileCount} S3 manifest entries from bucket {BucketName}, prefix {Prefix}.", + files.Count, + request.BucketName, + request.Prefix); + return files; + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + private static CultureInfo CreateInvariantS3Culture() + { + CultureInfo culture = new("en-US") + { + DateTimeFormat = new DateTimeFormatInfo + { + Calendar = new GregorianCalendar(), + }, + }; + + return culture; + } + + /// + /// Removes the listed S3 prefix from an object key when the key is under that prefix. + /// + /// The full object key. + /// The prefix supplied to the listing request. + /// The prefix-relative key when possible; otherwise, the original key. + private static string StripPrefix(string key, string prefix) + { + string normalizedPrefix = prefix.TrimEnd('/') + "/"; + if (key.StartsWith(normalizedPrefix, StringComparison.Ordinal)) + { + return key[normalizedPrefix.Length..]; + } + + return key; + } + + /// + /// Removes surrounding whitespace and quotes from an S3 ETag value. + /// + /// The ETag returned by object storage. + /// The normalized ETag value. + private static string NormalizeETag(string eTag) + { + return eTag.Trim().Trim('"'); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs new file mode 100644 index 00000000..d932210d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs @@ -0,0 +1,438 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Options; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Downloads files over HTTP using range requests, pooled buffers, retry backoff, and idle-transfer detection. +/// +public sealed class ResumableHttpFileDownloader : IResumableFileDownloader +{ + private static readonly HttpClient _sharedHttpClient = CreateSharedHttpClient(); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ResumableHttpDownloadOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for transfers. A shared client is used when omitted. + /// The logger used for download diagnostics. + /// The transfer options. + public ResumableHttpFileDownloader( + HttpClient? httpClient = null, + ILogger? logger = null, + ResumableHttpDownloadOptions? options = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + _options = options ?? new ResumableHttpDownloadOptions(); + + if (_options.BufferSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "Download buffer size must be greater than zero."); + } + + if (_options.MaxAttempts <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "Maximum attempts must be greater than zero."); + } + + if (_options.IdleTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), "Idle timeout must be greater than zero."); + } + + if (_options.ProgressReportInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), + "Progress report interval must be greater than zero."); + } + } + + /// + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.SourceUri.IsAbsoluteUri) + { + throw new ArgumentException("Download source URI must be absolute.", nameof(request)); + } + + if (!string.Equals(request.SourceUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(request.SourceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Download source URI must use HTTP or HTTPS.", nameof(request)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationFilePath); + + string destinationFilePath = Path.GetFullPath(request.DestinationFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + + Exception? lastException = null; + for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await DownloadAttemptAsync( + request with { DestinationFilePath = destinationFilePath }, + progress, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (IsRetriable(ex) && attempt < _options.MaxAttempts) + { + lastException = ex; + _logger.LogWarning( + ex, + "Download attempt {Attempt} failed for {FileName}; retrying.", + attempt, + Path.GetFileName(destinationFilePath)); + + await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsRetriable(ex)) + { + lastException = ex; + break; + } + } + + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Download failed after {0} attempts.", + _options.MaxAttempts), + lastException); + } + + /// + /// Creates the shared HTTP client used when no caller-provided client is supplied. + /// + /// A configured HTTP client for long-running downloads. + private static HttpClient CreateSharedHttpClient() + { + SocketsHttpHandler handler = new() + { + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(30), + MaxConnectionsPerServer = 16, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }; + + return new HttpClient(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + } + + /// + /// Performs one transfer attempt, resuming from local content when the server accepts the requested range. + /// + /// The normalized download request. + /// Optional progress reporter for the transfer. + /// A token that cancels the transfer. + /// The completed transfer result. + private async Task DownloadAttemptAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + long existingBytes = GetExistingBytes(request); + if (request.ExpectedBytes.HasValue && existingBytes == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return new DownloadFileResult(request.DestinationFilePath, existingBytes, true); + } + + using HttpRequestMessage message = new(HttpMethod.Get, request.SourceUri); + if (existingBytes > 0) + { + message.Headers.Range = new RangeHeaderValue(existingBytes, null); + } + + using HttpResponseMessage response = await _httpClient.SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && + request.ExpectedBytes.HasValue && + existingBytes == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return new DownloadFileResult(request.DestinationFilePath, existingBytes, true); + } + + response.EnsureSuccessStatusCode(); + + bool partialContentResponse = response.StatusCode == HttpStatusCode.PartialContent; + bool serverAcceptedResume = existingBytes > 0 && + partialContentResponse && + ResponseStartsAtExpectedByte(response, existingBytes); + if (existingBytes > 0 && partialContentResponse && !serverAcceptedResume) + { + _logger.LogWarning( + "Server returned an unexpected byte range for {FileName}; restarting download from byte zero.", + Path.GetFileName(request.DestinationFilePath)); + + File.Delete(request.DestinationFilePath); + throw new IOException("Server returned an unexpected byte range for a resumed download."); + } + + if (existingBytes > 0 && !serverAcceptedResume) + { + _logger.LogInformation( + "Server did not honor range request for {FileName}; restarting from byte zero.", + Path.GetFileName(request.DestinationFilePath)); + + existingBytes = 0; + } + + long? totalBytes = ResolveTotalBytes(request, response, existingBytes, serverAcceptedResume); + FileMode fileMode = existingBytes > 0 && serverAcceptedResume ? FileMode.Append : FileMode.Create; + long bytesDownloaded = existingBytes; + bool resumed = existingBytes > 0 && serverAcceptedResume; + + ReportProgress(progress, totalBytes, bytesDownloaded); + + await using FileStream destinationStream = new( + request.DestinationFilePath, + fileMode, + FileAccess.Write, + FileShare.Read, + _options.BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + await using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded = await CopyToFileAsync( + responseStream, + destinationStream, + totalBytes, + bytesDownloaded, + progress, + cancellationToken).ConfigureAwait(false); + + if (totalBytes.HasValue && bytesDownloaded != totalBytes.Value) + { + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Downloaded {0} bytes, but expected {1} bytes.", + bytesDownloaded, + totalBytes.Value)); + } + + return new DownloadFileResult(request.DestinationFilePath, bytesDownloaded, resumed); + } + + /// + /// Gets the number of local bytes that can be used for a resume attempt. + /// + /// The download request. + /// The existing byte count, or zero when a fresh download is required. + private long GetExistingBytes(DownloadFileRequest request) + { + if (!request.Resume || !File.Exists(request.DestinationFilePath)) + { + return 0; + } + + long existingBytes = new FileInfo(request.DestinationFilePath).Length; + if (request.ExpectedBytes.HasValue && existingBytes > request.ExpectedBytes.Value) + { + _logger.LogInformation( + "Existing partial file {FileName} is larger than expected; restarting download.", + Path.GetFileName(request.DestinationFilePath)); + + return 0; + } + + return existingBytes; + } + + /// + /// Resolves the expected final byte count from the request and response headers. + /// + /// The download request. + /// The HTTP response. + /// The existing local byte count. + /// Whether the server accepted the requested byte range. + /// The expected final byte count when it can be determined. + private static long? ResolveTotalBytes( + DownloadFileRequest request, + HttpResponseMessage response, + long existingBytes, + bool serverAcceptedResume) + { + if (serverAcceptedResume && response.Content.Headers.ContentRange?.Length is long contentRangeLength) + { + return contentRangeLength; + } + + if (request.ExpectedBytes.HasValue) + { + return request.ExpectedBytes.Value; + } + + if (response.Content.Headers.ContentLength is long contentLength) + { + return serverAcceptedResume ? existingBytes + contentLength : contentLength; + } + + return null; + } + + /// + /// Returns whether a partial-content response starts at the byte requested by the resume attempt. + /// + /// The HTTP response returned by the server. + /// The local file length used as the requested range start. + /// when the server returned the expected content range. + private static bool ResponseStartsAtExpectedByte( + HttpResponseMessage response, + long expectedStartByte) + { + return response.Content.Headers.ContentRange?.From == expectedStartByte; + } + + /// + /// Copies response content to disk while reporting throttled progress and detecting idle transfers. + /// + /// The HTTP response stream. + /// The destination file stream. + /// The expected final byte count when known. + /// The number of bytes already present locally. + /// Optional progress reporter for the transfer. + /// A token that cancels the transfer. + /// The final downloaded byte count. + private async Task CopyToFileAsync( + Stream responseStream, + FileStream destinationStream, + long? totalBytes, + long bytesDownloaded, + IProgress? progress, + CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(_options.BufferSize); + var progressStopwatch = Stopwatch.StartNew(); + TimeSpan lastReportElapsed = TimeSpan.Zero; + + try + { + using var idleCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + + while (true) + { + idleCancellation.CancelAfter(_options.IdleTimeout); + + int bytesRead; + try + { + bytesRead = await responseStream + .ReadAsync(buffer.AsMemory(0, _options.BufferSize), idleCancellation.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("Download stalled while waiting for response data."); + } + + if (bytesRead == 0) + { + break; + } + + idleCancellation.CancelAfter(Timeout.InfiniteTimeSpan); + + await destinationStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded += bytesRead; + if (progressStopwatch.Elapsed - lastReportElapsed >= _options.ProgressReportInterval) + { + ReportProgress(progress, totalBytes, bytesDownloaded); + lastReportElapsed = progressStopwatch.Elapsed; + } + } + + ReportProgress(progress, totalBytes, bytesDownloaded); + return bytesDownloaded; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Returns whether an exception represents a transient transfer failure. + /// + /// The exception to inspect. + /// when retrying the transfer may succeed. + private static bool IsRetriable(Exception ex) + { + return ex is HttpRequestException or IOException or TimeoutException || + ex is TaskCanceledException; + } + + /// + /// Calculates the bounded exponential backoff delay for a retry attempt. + /// + /// The one-based attempt number that just failed. + /// The delay before the next attempt. + private TimeSpan GetRetryDelay(int attempt) + { + double multiplier = Math.Pow(2, Math.Max(0, attempt - 1)); + double delayMilliseconds = _options.InitialRetryDelay.TotalMilliseconds * multiplier; + return TimeSpan.FromMilliseconds(Math.Min(delayMilliseconds, TimeSpan.FromSeconds(30).TotalMilliseconds)); + } + + /// + /// Reports transfer progress to the optional progress sink. + /// + /// The progress sink. + /// The expected final byte count when known. + /// The local byte count completed so far. + private static void ReportProgress( + IProgress? progress, + long? totalBytes, + long bytesDownloaded) + { + double? percentage = null; + if (totalBytes is > 0) + { + percentage = Math.Round((double)bytesDownloaded / totalBytes.Value * 100, 2); + } + + progress?.Report(new DownloadProgress(totalBytes, bytesDownloaded, percentage)); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs new file mode 100644 index 00000000..8938b610 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using System; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.DependencyInjection; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Infrastructure.Updating.Composition; + +/// +/// Provides dependency-injection registration helpers for update infrastructure services. +/// +public static class UpdatingServiceCollectionExtensions +{ + /// + /// Registers update and download infrastructure services used by GenLauncherGO workflows. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoUpdating(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLogging(); + services.AddGenLauncherGoArchives(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs new file mode 100644 index 00000000..bbfd0643 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +/// +/// Reads file manifests from S3-compatible object storage. +/// +public interface IS3ObjectManifestReader +{ + /// + /// Lists the files under a remote S3 prefix. + /// + /// The S3 listing request. + /// The token used to cancel the request. + /// The remote file manifest entries. + Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs new file mode 100644 index 00000000..96661919 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +/// +/// Updates a package from S3-compatible object storage. +/// +public interface IS3PackageUpdater +{ + /// + /// Downloads and installs the requested package. + /// + /// The update request. + /// Optional progress reporter. + /// The token used to cancel the update. + Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs new file mode 100644 index 00000000..0cb77bfb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs @@ -0,0 +1,21 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-compatible object listing request for a modification version. +/// +/// The S3 endpoint host or URI. +/// The bucket name. +/// The object prefix to list. +/// The access key. +/// The secret key. +/// +/// The SSL preference for host-only endpoints. Explicit URI schemes override this value. Defaults to +/// to preserve compatibility with catalog endpoints that use plain MinIO ports. +/// +public sealed record S3ObjectManifestRequest( + string Endpoint, + string BucketName, + string Prefix, + string AccessKey, + string SecretKey, + bool UseSsl = false); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs new file mode 100644 index 00000000..05fbee04 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-backed package update. +/// +/// The remote file manifest. +/// The S3 endpoint host or URI. +/// The S3 bucket name. +/// The S3 folder/prefix containing the package files. +/// The access key used to authorize object downloads. +/// The secret key used to authorize object downloads. +/// The temporary folder used during update. +/// The final installed folder path. +/// The previous installed folder path used for unchanged-file reuse. +/// Extensions that require hash validation. +/// +/// The SSL preference for host-only endpoints. Explicit URI schemes override this value. Defaults to +/// to preserve compatibility with catalog endpoints that use plain MinIO ports. +/// +public sealed record S3PackageUpdateRequest( + IReadOnlyList Files, + string Endpoint, + string BucketName, + string FolderName, + string AccessKey, + string SecretKey, + string TemporaryFolderPath, + string InstalledFolderPath, + string? LatestInstalledFolderPath, + IReadOnlySet HashCheckedExtensions, + bool UseSsl = false); diff --git a/GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs b/GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs new file mode 100644 index 00000000..92018afa --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs @@ -0,0 +1,39 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Options; + +/// +/// Defines transfer behavior for resumable HTTP downloads. +/// +public sealed class ResumableHttpDownloadOptions +{ + /// + /// Gets the default transfer buffer size. + /// + public const int DefaultBufferSize = 1024 * 1024; + + /// + /// Gets or initializes the buffer size used while copying response content to disk. + /// + public int BufferSize { get; init; } = DefaultBufferSize; + + /// + /// Gets or initializes the maximum number of attempts for transient download failures. + /// + public int MaxAttempts { get; init; } = 5; + + /// + /// Gets or initializes the maximum allowed idle time between successful reads. + /// + public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or initializes the minimum elapsed time between progress reports while bytes are actively downloading. + /// + public TimeSpan ProgressReportInterval { get; init; } = TimeSpan.FromMilliseconds(100); + + /// + /// Gets or initializes the initial retry delay before exponential backoff is applied. + /// + public TimeSpan InitialRetryDelay { get; init; } = TimeSpan.FromSeconds(1); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs b/GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs new file mode 100644 index 00000000..24da4f13 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs @@ -0,0 +1,19 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Options; + +/// +/// Defines package update behavior for S3-backed modifications. +/// +public sealed class S3PackageUpdateOptions +{ + /// + /// Gets or initializes the maximum number of files downloaded concurrently for one package. + /// + public int MaxConcurrentFileDownloads { get; init; } = 6; + + /// + /// Gets or initializes the lifetime of generated presigned S3 download URLs. + /// + public TimeSpan PresignedUrlLifetime { get; init; } = TimeSpan.FromHours(12); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs b/GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs new file mode 100644 index 00000000..7f6e47cd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Runs a launcher package download from a single remote file. +/// +internal sealed class HttpSingleFilePackageDownloadOperation : IPackageDownloadOperation +{ + /// + /// The updater used to download and install the package. + /// + private readonly ISingleFilePackageUpdater _packageUpdater; + + /// + /// The resolver used to compute installed package paths. + /// + private readonly ILauncherContentPathResolver _contentPathResolver; + + /// + /// The launcher runtime paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// The launcher content folder layout. + /// + private readonly LauncherContentLayout _contentLayout; + + /// + /// The package download request. + /// + private readonly ModificationPackageDownloadRequest _request; + + /// + /// The cancellation source for the active download. + /// + private CancellationTokenSource _downloadCancellation = new CancellationTokenSource(); + + /// + /// The current download result. + /// + private PackageDownloadResult _downloadResult = new PackageDownloadResult(); + + /// + /// Initializes a new instance of the class. + /// + /// The updater used to download and install the package. + /// The resolver used to compute installed package paths. + /// The launcher runtime paths. + /// The launcher content folder layout. + /// The package download request. + public HttpSingleFilePackageDownloadOperation( + ISingleFilePackageUpdater packageUpdater, + ILauncherContentPathResolver contentPathResolver, + LauncherPaths launcherPaths, + LauncherContentLayout contentLayout, + ModificationPackageDownloadRequest request) + { + _packageUpdater = packageUpdater ?? throw new ArgumentNullException(nameof(packageUpdater)); + _contentPathResolver = contentPathResolver ?? throw new ArgumentNullException(nameof(contentPathResolver)); + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _contentLayout = contentLayout ?? throw new ArgumentNullException(nameof(contentLayout)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + /// + public event Action? ProgressChanged; + + /// + public event Action? Done; + + /// + public PackageDownloadReadiness GetPackageDownloadReadiness() + { + return new PackageDownloadReadiness { ReadyToDownload = true }; + } + + /// + public async Task StartDownloadModificationAsync() + { + _downloadResult = new PackageDownloadResult(); + _downloadCancellation.Dispose(); + _downloadCancellation = new CancellationTokenSource(); + + try + { + string downloadUrl = + DownloadLinkResolver.ResolveDirectDownloadLink(_request.Modification.SimpleDownloadLink); + string temporaryFolderPath = GetTempCopyOfFolder(); + string installedFolderPath = GetInstalledFolderPath(_request.LatestVersion); + + IProgress progress = new Progress(TriggerProgressChanged); + + await _packageUpdater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri(downloadUrl, UriKind.Absolute), + temporaryFolderPath, + installedFolderPath), + progress, + _downloadCancellation.Token); + + Complete(); + } + catch (OperationCanceledException) + { + _downloadResult = new PackageDownloadResult + { + Canceled = true, + Message = "Download Canceled", + }; + Complete(); + } + catch (Exception exception) + { + _downloadResult = new PackageDownloadResult + { + Crashed = true, + Message = exception.InnerException?.Message ?? exception.Message, + }; + Complete(); + } + } + + /// + public void CancelDownload() + { + _downloadResult = _downloadResult with { Canceled = true }; + _downloadCancellation.Cancel(); + } + + /// + public PackageDownloadResult GetResult() + { + return _downloadResult; + } + + /// + public void Dispose() + { + _downloadCancellation.Cancel(); + _downloadCancellation.Dispose(); + } + + /// + /// Creates and returns the temporary package folder. + /// + /// The temporary package folder path. + private string GetTempCopyOfFolder() + { + string tempFolderName = _launcherPaths.GetPackageTemporaryFolderPath( + GetInstalledFolderPath(_request.LatestVersion)); + + Directory.CreateDirectory(tempFolderName); + return tempFolderName; + } + + /// + /// Gets the installed folder path for a modification version. + /// + /// The modification version. + /// The installed folder path. + private string GetInstalledFolderPath(ModificationVersion version) + { + return _contentPathResolver.GetVersionDirectoryPath( + _launcherPaths, + _contentLayout, + version); + } + + /// + /// Raises the progress changed event. + /// + /// The package update progress. + private void TriggerProgressChanged(PackageUpdateProgress progress) + { + ProgressChanged?.Invoke(progress); + } + + /// + /// Raises the completion event. + /// + private void Complete() + { + Done?.Invoke(_downloadResult); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs new file mode 100644 index 00000000..07315881 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Contracts; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Computes MD5 hashes for local files. +/// +public sealed class Md5FileHashService : IFileHashService +{ + /// + public async Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + await using FileStream fileStream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + byte[] hash = await MD5.HashDataAsync(fileStream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash).ToUpper(CultureInfo.InvariantCulture); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs new file mode 100644 index 00000000..fadd0548 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs @@ -0,0 +1,123 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Creates package download operations backed by HTTP or S3-compatible storage. +/// +public sealed class PackageDownloadOperationFactory : IPackageDownloadOperationFactory +{ + /// + /// The updater used for single-file package downloads. + /// + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + /// + /// The updater used for S3-compatible package downloads. + /// + private readonly IS3PackageUpdater _s3PackageUpdater; + + /// + /// The reader used to enumerate S3 package manifests. + /// + private readonly IS3ObjectManifestReader _s3ObjectManifestReader; + + /// + /// The clock service used to validate S3 request readiness. + /// + private readonly ISystemClockService _systemClockService; + + /// + /// The resolver used to compute launcher content paths. + /// + private readonly ILauncherContentPathResolver _contentPathResolver; + + /// + /// The launcher runtime paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// The launcher content folder layout. + /// + private readonly LauncherContentLayout _contentLayout; + + /// + /// Initializes a new instance of the class. + /// + /// The updater used for single-file package downloads. + /// The updater used for S3-compatible package downloads. + /// The reader used to enumerate S3 package manifests. + /// The clock service used to validate S3 request readiness. + /// The resolver used to compute launcher content paths. + /// The launcher runtime paths. + /// The launcher content folder layout. + public PackageDownloadOperationFactory( + ISingleFilePackageUpdater singleFilePackageUpdater, + IS3PackageUpdater s3PackageUpdater, + IS3ObjectManifestReader s3ObjectManifestReader, + ISystemClockService systemClockService, + ILauncherContentPathResolver contentPathResolver, + LauncherPaths launcherPaths, + LauncherContentLayout contentLayout) + { + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _s3ObjectManifestReader = s3ObjectManifestReader ?? + throw new ArgumentNullException(nameof(s3ObjectManifestReader)); + _systemClockService = systemClockService ?? throw new ArgumentNullException(nameof(systemClockService)); + _contentPathResolver = contentPathResolver ?? throw new ArgumentNullException(nameof(contentPathResolver)); + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _contentLayout = contentLayout ?? throw new ArgumentNullException(nameof(contentLayout)); + } + + /// + public IPackageDownloadOperation Create( + ModificationPackageDownloadRequest request, + bool forceSingleFileDownload = false) + { + ArgumentNullException.ThrowIfNull(request); + + if (ShouldUseSingleFileDownload(request.LatestVersion, forceSingleFileDownload)) + { + return new HttpSingleFilePackageDownloadOperation( + _singleFilePackageUpdater, + _contentPathResolver, + _launcherPaths, + _contentLayout, + request); + } + + return new S3PackageDownloadOperation( + _s3PackageUpdater, + _s3ObjectManifestReader, + _systemClockService, + _contentPathResolver, + _launcherPaths, + _contentLayout, + request); + } + + /// + /// Determines whether a request should use the single-file download flow. + /// + /// The latest package version. + /// Whether single-file download was explicitly requested. + /// when the single-file download flow should be used. + private static bool ShouldUseSingleFileDownload( + ModificationVersion latestVersion, + bool forceSingleFileDownload) + { + return string.IsNullOrWhiteSpace(latestVersion.S3HostLink) || + string.IsNullOrWhiteSpace(latestVersion.S3BucketName) || + string.IsNullOrWhiteSpace(latestVersion.S3FolderName) || + forceSingleFileDownload; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs new file mode 100644 index 00000000..26e53823 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Minio.Exceptions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Runs a launcher package download from S3-compatible object storage. +/// +internal sealed class S3PackageDownloadOperation : IPackageDownloadOperation +{ + /// + /// Extensions that require hash validation during S3 package installation. + /// + private static readonly HashSet _extensionsToCheckHash = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".w3d", + ".big", + ".bik", + ".gib", + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str", + }; + + /// + /// The updater used to download and install the package. + /// + private readonly IS3PackageUpdater _packageUpdater; + + /// + /// The reader used to enumerate remote S3 package files. + /// + private readonly IS3ObjectManifestReader _manifestReader; + + /// + /// The clock service used to detect request-signature time problems. + /// + private readonly ISystemClockService _systemClockService; + + /// + /// The resolver used to compute installed package paths. + /// + private readonly ILauncherContentPathResolver _contentPathResolver; + + /// + /// The launcher runtime paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// The launcher content folder layout. + /// + private readonly LauncherContentLayout _contentLayout; + + /// + /// The package download request. + /// + private readonly ModificationPackageDownloadRequest _request; + + /// + /// The cancellation source for the active download. + /// + private CancellationTokenSource _downloadCancellation = new CancellationTokenSource(); + + /// + /// The current download result. + /// + private PackageDownloadResult _downloadResult = new PackageDownloadResult(); + + /// + /// Initializes a new instance of the class. + /// + /// The updater used to download and install the package. + /// The reader used to enumerate remote S3 package files. + /// The clock service used to detect request-signature time problems. + /// The resolver used to compute installed package paths. + /// The launcher runtime paths. + /// The launcher content folder layout. + /// The package download request. + public S3PackageDownloadOperation( + IS3PackageUpdater packageUpdater, + IS3ObjectManifestReader manifestReader, + ISystemClockService systemClockService, + ILauncherContentPathResolver contentPathResolver, + LauncherPaths launcherPaths, + LauncherContentLayout contentLayout, + ModificationPackageDownloadRequest request) + { + _packageUpdater = packageUpdater ?? throw new ArgumentNullException(nameof(packageUpdater)); + _manifestReader = manifestReader ?? throw new ArgumentNullException(nameof(manifestReader)); + _systemClockService = systemClockService ?? throw new ArgumentNullException(nameof(systemClockService)); + _contentPathResolver = contentPathResolver ?? throw new ArgumentNullException(nameof(contentPathResolver)); + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _contentLayout = contentLayout ?? throw new ArgumentNullException(nameof(contentLayout)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + /// + public event Action? ProgressChanged; + + /// + public event Action? Done; + + /// + public PackageDownloadReadiness GetPackageDownloadReadiness() + { + if (_systemClockService.IsSystemTimeOutOfSync()) + { + return new PackageDownloadReadiness + { + ReadyToDownload = false, + Error = PackageDownloadReadinessError.TimeOutOfSync, + }; + } + + return new PackageDownloadReadiness { ReadyToDownload = true }; + } + + /// + public async Task StartDownloadModificationAsync() + { + _downloadResult = new PackageDownloadResult(); + _downloadCancellation.Dispose(); + _downloadCancellation = new CancellationTokenSource(); + + try + { + IReadOnlyList repositoryFilesInfo = await GetFilesInfoFromS3StorageAsync( + _manifestReader, + _request.LatestVersion, + _downloadCancellation.Token); + ModificationVersion? latestInstalledVersion = _request.Modification.ModificationVersions + .OrderBy(version => version) + .Where(version => version.Installed) + .LastOrDefault(); + + IProgress progress = new Progress( + TriggerProgressChanged); + + await _packageUpdater.UpdateAsync( + CreateUpdateRequest(repositoryFilesInfo, latestInstalledVersion), + progress, + _downloadCancellation.Token); + + Complete(); + } + catch (OperationCanceledException) + { + _downloadResult = new PackageDownloadResult + { + Canceled = true, + Message = "Download Canceled", + }; + Complete(); + } + catch (UnexpectedMinioException) + { + _downloadResult = new PackageDownloadResult + { + Crashed = true, + Message = "Unexpected Minio API Exception. Try to sync your system time", + }; + Complete(); + } + catch (Exception exception) + { + _downloadResult = new PackageDownloadResult + { + Crashed = true, + Message = exception.InnerException?.Message ?? exception.Message, + }; + Complete(); + } + } + + /// + public void CancelDownload() + { + _downloadResult = _downloadResult with { Canceled = true }; + _downloadCancellation.Cancel(); + } + + /// + public PackageDownloadResult GetResult() + { + return _downloadResult; + } + + /// + public void Dispose() + { + _downloadCancellation.Cancel(); + _downloadCancellation.Dispose(); + } + + /// + /// Creates the S3 package update request. + /// + /// The remote repository file manifest. + /// The latest installed version when available. + /// The S3 package update request. + private S3PackageUpdateRequest CreateUpdateRequest( + IReadOnlyList repositoryFilesInfo, + ModificationVersion? latestInstalledVersion) + { + return new S3PackageUpdateRequest( + repositoryFilesInfo, + _request.LatestVersion.S3HostLink, + _request.LatestVersion.S3BucketName, + _request.LatestVersion.S3FolderName, + S3CatalogDefaults.ResolveAccessKey(_request.LatestVersion), + S3CatalogDefaults.ResolveSecretKey(_request.LatestVersion), + GetTempCopyOfFolder(), + GetInstalledFolderPath(_request.LatestVersion), + latestInstalledVersion != null ? GetInstalledFolderPath(latestInstalledVersion) : null, + _extensionsToCheckHash); + } + + /// + /// Gets the temporary package folder path. + /// + /// The temporary package folder path. + private string GetTempCopyOfFolder() + { + return _launcherPaths.GetPackageTemporaryFolderPath( + GetInstalledFolderPath(_request.LatestVersion)); + } + + /// + /// Gets the installed folder path for a modification version. + /// + /// The modification version. + /// The installed folder path. + private string GetInstalledFolderPath(ModificationVersion version) + { + return _contentPathResolver.GetVersionDirectoryPath( + _launcherPaths, + _contentLayout, + version); + } + + /// + /// Raises the progress changed event. + /// + /// The package update progress. + private void TriggerProgressChanged(PackageUpdateProgress progress) + { + ProgressChanged?.Invoke(progress); + } + + /// + /// Reads remote S3 file metadata for a package version. + /// + /// The manifest reader. + /// The package version. + /// The token used to cancel the read. + /// The remote file manifest. + private static async Task> GetFilesInfoFromS3StorageAsync( + IS3ObjectManifestReader manifestReader, + ModificationVersion latestVersion, + CancellationToken cancellationToken) + { + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(latestVersion); + return await manifestReader.ReadManifestAsync( + request, + cancellationToken); + } + + /// + /// Raises the completion event. + /// + private void Complete() + { + Done?.Invoke(_downloadResult); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs new file mode 100644 index 00000000..3ac87cbe --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs @@ -0,0 +1,491 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel.Args; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Updates a package from S3-compatible object storage. +/// +public sealed class S3PackageUpdater : IS3PackageUpdater +{ + private const int MaxHashAttempts = 3; + + private readonly IResumableFileDownloader _fileDownloader; + private readonly IFileHashService _fileHashService; + private readonly ILogger _logger; + private readonly S3PackageUpdateOptions _options; + private readonly S3ReusablePackageFileCopier _reusableFileCopier; + + /// + /// Initializes a new instance of the class. + /// + /// The downloader used for presigned object URLs. + /// The hash service used to validate files when reliable hashes are available. + /// The logger used for package update diagnostics. + /// The S3 package update options. + public S3PackageUpdater( + IResumableFileDownloader fileDownloader, + IFileHashService fileHashService, + ILogger logger, + S3PackageUpdateOptions options) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _reusableFileCopier = new S3ReusablePackageFileCopier(_fileHashService, _logger); + + if (_options.MaxConcurrentFileDownloads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "Maximum concurrent S3 file downloads must be greater than zero."); + } + + if (_options.PresignedUrlLifetime <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "Presigned S3 URL lifetime must be greater than zero."); + } + } + + /// + /// Downloads missing package files to the temporary folder, reuses unchanged files from the latest installed + /// version, validates reliable hashes, converts downloaded .big files to .gib, and stages the + /// temporary folder into the installed package location. + /// + /// The S3 package update request. + /// Optional progress reporter for package download status. + /// A token that cancels reuse, download, validation, and replacement work. + /// A task that completes after the package folder has been replaced. + public async Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.SecretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.TemporaryFolderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.InstalledFolderPath); + + Directory.CreateDirectory(request.TemporaryFolderPath); + PackageStagingFolderCleaner.RemoveUnsafeLinks( + request.TemporaryFolderPath, + _logger, + cancellationToken); + _logger.LogInformation( + "Starting S3 package update for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.BucketName, + request.FolderName, + request.Files.Count); + + if (!string.IsNullOrWhiteSpace(request.LatestInstalledFolderPath) && + Directory.Exists(request.LatestInstalledFolderPath)) + { + await _reusableFileCopier.CopyUnchangedFilesAsync( + request.LatestInstalledFolderPath, + request.TemporaryFolderPath, + request.Files, + cancellationToken).ConfigureAwait(false); + } + + List downloadWorkItems = await CreateDownloadWorkItemsAsync( + request, + cancellationToken).ConfigureAwait(false); + long totalDownloadSize = downloadWorkItems.Sum(download => download.BytesToDownload); + var progressState = new PackageProgressTracker(totalDownloadSize); + if (downloadWorkItems.Count == 0) + { + progress?.Report(new PackageUpdateProgress(0, 0, 100, null)); + } + + using SemaphoreSlim downloadSlots = new(_options.MaxConcurrentFileDownloads); + IMinioClient client = Clients.MinioClientFactory.Create( + request.Endpoint, + request.AccessKey, + request.SecretKey, + request.UseSsl); + + var downloadTasks = downloadWorkItems + .Select(download => DownloadFileWithSlotAsync( + request, + client, + download, + progressState, + progress, + downloadSlots, + cancellationToken)) + .ToList(); + + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + PackageStagingFolderCleaner.PruneToManifest( + request.TemporaryFolderPath, + request.Files, + _logger, + cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace(request.TemporaryFolderPath, request.InstalledFolderPath, _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(request.TemporaryFolderPath, _logger); + _logger.LogInformation( + "Completed S3 package update for bucket {BucketName}, folder {FolderName}.", + request.BucketName, + request.FolderName); + } + + /// + /// Creates the set of manifest files that still require remote transfer after reusable files are staged. + /// + /// The package update request. + /// A token that cancels validation work. + /// The files that need to be downloaded and the expected transfer bytes for each file. + private async Task> CreateDownloadWorkItemsAsync( + S3PackageUpdateRequest request, + CancellationToken cancellationToken) + { + List downloads = new(); + foreach (RemoteFileManifestEntry file in request.Files) + { + string destinationFilePath = ManifestPathResolver.ResolvePath( + request.TemporaryFolderPath, + file.FileName); + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + request.HashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? request.TemporaryFolderPath); + BigFileVariantPath.PrepareBigFileResumePath(destinationFilePath); + + long expectedBytes = (long)file.Size; + long existingBytes = GetExistingBytesForProgress(destinationFilePath); + if (existingBytes >= expectedBytes) + { + DeleteFailedFile(destinationFilePath); + existingBytes = 0; + } + + downloads.Add(new S3DownloadWorkItem( + file, + Math.Max(0, existingBytes), + Math.Max(0, expectedBytes - existingBytes))); + } + + return downloads; + } + + /// + /// Returns whether an existing downloaded file has the expected size. + /// + /// The manifest file entry. + /// The expected destination path. + /// when the local file exists and size matches. + public static bool ExistingDownloadedFileMatchesExpectedSize( + RemoteFileManifestEntry file, + string destinationFilePath) + { + string existingFilePath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + return !string.IsNullOrWhiteSpace(existingFilePath) && + new FileInfo(existingFilePath).Length == (long)file.Size; + } + + /// + /// Waits for a download slot and downloads one verified manifest file. + /// + /// The package update request. + /// The MinIO client used to create presigned URLs. + /// The manifest file and expected transfer byte counts. + /// The aggregate progress tracker. + /// Optional package progress reporter. + /// The semaphore limiting concurrent downloads. + /// A token that cancels the download. + private async Task DownloadFileWithSlotAsync( + S3PackageUpdateRequest request, + IMinioClient client, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + SemaphoreSlim downloadSlots, + CancellationToken cancellationToken) + { + await downloadSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await DownloadVerifiedFileAsync( + request, + client, + download, + progressState, + progress, + cancellationToken).ConfigureAwait(false); + } + finally + { + downloadSlots.Release(); + } + } + + /// + /// Downloads a file, validates size and hash when available, and retries hash mismatches. + /// + /// The package update request. + /// The MinIO client used to create presigned URLs. + /// The manifest file and expected transfer byte counts. + /// The aggregate progress tracker. + /// Optional package progress reporter. + /// A token that cancels the download. + private async Task DownloadVerifiedFileAsync( + S3PackageUpdateRequest request, + IMinioClient client, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + CancellationToken cancellationToken) + { + RemoteFileManifestEntry file = download.File; + string destinationFilePath = ManifestPathResolver.ResolvePath( + request.TemporaryFolderPath, + file.FileName); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? request.TemporaryFolderPath); + + for (int attempt = 1; attempt <= MaxHashAttempts; attempt++) + { + BigFileVariantPath.PrepareBigFileResumePath(destinationFilePath); + long resumeOffset = attempt == 1 ? download.ExistingBytes : 0; + long expectedTransferBytes = Math.Max(0, (long)file.Size - resumeOffset); + + Uri downloadUri = await BuildDownloadUriAsync(request, client, file.FileName).ConfigureAwait(false); + IProgress downloadProgress = new Progress(report => + { + if (resumeOffset > 0 && report.BytesDownloaded < resumeOffset) + { + progressState.AddExpectedBytes(resumeOffset); + expectedTransferBytes += resumeOffset; + resumeOffset = 0; + } + + long transferredBytes = Math.Min( + Math.Max(0, report.BytesDownloaded - resumeOffset), + expectedTransferBytes); + bool completed = report.TotalBytes.HasValue && report.BytesDownloaded >= report.TotalBytes.Value; + ReportFileProgress( + progressState, + progress, + file.FileName, + transferredBytes, + completed); + }); + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest( + downloadUri, + destinationFilePath, + (long)file.Size, + Resume: true), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + BigFileVariantPath.ConvertBigFileToGib(destinationFilePath); + + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + request.HashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + ReportFileProgress( + progressState, + progress, + file.FileName, + expectedTransferBytes, + forceReport: true); + return; + } + + if (attempt == MaxHashAttempts) + { + throw new IOException("Hash sum mismatch detected after repeated download attempts."); + } + + _logger.LogWarning( + "Hash validation failed for {FileName}; retrying download attempt {NextAttempt}.", + file.FileName, + attempt + 1); + DeleteFailedFile(destinationFilePath); + ReportFileProgress(progressState, progress, file.FileName, 0, forceReport: true); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Returns whether the staged file exists, has the expected size, and passes required hash validation. + /// + /// The manifest file entry. + /// The requested staged file path. + /// Extensions that require hash validation. + /// A token that cancels hashing. + /// when the staged file satisfies the manifest entry. + private async Task CheckFileSuccessDownloadAsync( + RemoteFileManifestEntry file, + string destinationFilePath, + IReadOnlySet hashCheckedExtensions, + CancellationToken cancellationToken) + { + string existingFilePath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + if (string.IsNullOrWhiteSpace(existingFilePath)) + { + return false; + } + + if (!ExistingDownloadedFileMatchesExpectedSize(file, destinationFilePath)) + { + return false; + } + + if (!S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions)) + { + return true; + } + + string hashSum = await _fileHashService.ComputeMd5HashAsync(existingFilePath, cancellationToken) + .ConfigureAwait(false); + + return string.Equals(hashSum, file.Hash, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Creates a presigned S3 object download URI for a manifest file. + /// + /// The package update request. + /// The MinIO client used to create the URL. + /// The manifest-relative file name. + /// The presigned object download URI. + private async Task BuildDownloadUriAsync( + S3PackageUpdateRequest request, + IMinioClient client, + string fileName) + { + int expirySeconds = (int)Math.Min( + _options.PresignedUrlLifetime.TotalSeconds, + int.MaxValue); + string objectName = BuildObjectName(request.FolderName, fileName); + PresignedGetObjectArgs args = new PresignedGetObjectArgs() + .WithBucket(request.BucketName) + .WithObject(objectName) + .WithExpiry(expirySeconds); + string presignedUrl = await client.PresignedGetObjectAsync(args).ConfigureAwait(false); + return new Uri(presignedUrl, UriKind.Absolute); + } + + /// + /// Gets the existing staged byte count used for progress reporting before a resume attempt. + /// + /// The requested staged file path. + /// The existing byte count, or zero when no staged variant exists. + private static long GetExistingBytesForProgress(string destinationFilePath) + { + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + if (string.IsNullOrWhiteSpace(existingPath)) + { + return 0; + } + + return new FileInfo(existingPath).Length; + } + + /// + /// Deletes failed .big and .gib staged variants before retrying a download. + /// + /// The requested staged file path. + private void DeleteFailedFile(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogInformation( + "Deleted failed downloaded file {FileName}.", + Path.GetFileName(destinationFilePath)); + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + _logger.LogInformation( + "Deleted failed converted file {FileName}.", + Path.GetFileName(gibFilePath)); + } + } + + /// + /// Builds the S3 object name from a folder prefix and manifest-relative file name. + /// + /// The S3 folder or prefix. + /// The manifest-relative file name. + /// The slash-separated object name. + private static string BuildObjectName(string folderName, string fileName) + { + string normalizedFolderName = folderName.Replace('\\', '/').Trim('/'); + string normalizedFileName = ManifestPathResolver.NormalizeForManifestIndex(fileName); + if (string.IsNullOrWhiteSpace(normalizedFolderName)) + { + return normalizedFileName; + } + + return $"{normalizedFolderName}/{normalizedFileName}"; + } + + /// + /// Updates aggregate file progress and reports when a new package progress value is available. + /// + /// The aggregate progress tracker. + /// Optional package progress reporter. + /// The manifest-relative file name. + /// The completed byte count for the file. + /// A value indicating whether throttling should be bypassed. + private static void ReportFileProgress( + PackageProgressTracker progressState, + IProgress? progress, + string fileName, + long bytesRead, + bool forceReport = false) + { + PackageUpdateProgress? report = progressState.Update(fileName, bytesRead, forceReport); + if (report is not null) + { + progress?.Report(report); + } + } + + /// + /// Describes one manifest file that still requires remote transfer after local reuse validation. + /// + /// The remote manifest entry. + /// The local resumable byte count that should not be counted as new transfer. + /// The expected byte count that still needs to be transferred. + private sealed record S3DownloadWorkItem( + RemoteFileManifestEntry File, + long ExistingBytes, + long BytesToDownload); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs new file mode 100644 index 00000000..799e97fa --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Archives; +using Microsoft.Extensions.Logging; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Updates a package from a single remote file. +/// +public sealed class SingleFilePackageUpdater : ISingleFilePackageUpdater +{ + private readonly IArchiveExtractor _archiveExtractor; + private readonly IResumableFileDownloader _fileDownloader; + private readonly ILogger _logger; + private readonly IDownloadFileMetadataReader _metadataReader; + + /// + /// Initializes a new instance of the class. + /// + /// The downloader used for the remote package file. + /// The metadata reader used to resolve the downloadable file name and size. + /// The archive extractor used when the remote package is an archive. + /// The logger used for package update diagnostics. + public SingleFilePackageUpdater( + IResumableFileDownloader fileDownloader, + IDownloadFileMetadataReader metadataReader, + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _metadataReader = metadataReader ?? throw new ArgumentNullException(nameof(metadataReader)); + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads a single remote package file, optionally extracts it, removes the downloaded archive, and stages the + /// temporary folder into the installed package location. + /// + /// The single-file package update request. + /// Optional progress reporter for package download status. + /// A token that cancels download, extraction, cleanup, and replacement work. + /// A task that completes after the package folder has been replaced. + public async Task UpdateAsync( + SingleFilePackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.TemporaryFolderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.InstalledFolderPath); + + PackageStagingFolderCleaner.ClearDirectory(request.TemporaryFolderPath, _logger); + _logger.LogInformation( + "Starting single-file package update from {Host}.", + request.SourceUri.Host); + + DownloadFileMetadata metadata = await _metadataReader.ReadMetadataAsync( + request.SourceUri, + cancellationToken).ConfigureAwait(false); + + string destinationFilePath = Path.Combine(request.TemporaryFolderPath, metadata.FileName); + bool extractionRequired = IsArchiveFile(destinationFilePath); + var progressTracker = new PackageProgressTracker(metadata.TotalBytes); + + IProgress downloadProgress = new Progress(report => + { + PackageUpdateProgress? packageProgress = progressTracker.Update( + metadata.FileName, + report.BytesDownloaded, + report.TotalBytes.HasValue && report.BytesDownloaded >= report.TotalBytes.Value); + if (packageProgress is not null) + { + progress?.Report(packageProgress); + } + }); + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest( + metadata.DownloadUri, + destinationFilePath, + metadata.TotalBytes, + Resume: true), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + if (extractionRequired) + { + await Task.Run( + () => _archiveExtractor.ExtractToDirectory( + destinationFilePath, + request.TemporaryFolderPath, + new ArchiveExtractionOptions { ConvertBigFilesToGib = true }, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogInformation( + "Deleted downloaded archive {FileName} after extraction.", + Path.GetFileName(destinationFilePath)); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace(request.TemporaryFolderPath, request.InstalledFolderPath, _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(request.TemporaryFolderPath, _logger); + _logger.LogInformation("Completed single-file package update."); + } + + private static bool IsArchiveFile(string filePath) + { + string extension = Path.GetExtension(filePath); + return string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".rar", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".7z", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs b/GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs new file mode 100644 index 00000000..c03699d0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs @@ -0,0 +1,215 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using GenLauncherGO.Core.Updating.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Checks and adjusts the Windows system clock using NTP network time. +/// +public sealed class WindowsSystemClockService : ISystemClockService +{ + /// + /// Stores the default Windows NTP server used by the legacy launcher. + /// + private const string NtpServer = "time.windows.com"; + + /// + /// Stores the maximum accepted system clock drift before S3 downloads are blocked. + /// + private static readonly TimeSpan _maximumAcceptedClockDrift = TimeSpan.FromMinutes(15); + + /// + /// Receives diagnostics for network time and system clock failures. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for system clock diagnostics. + public WindowsSystemClockService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public bool IsSystemTimeOutOfSync() + { + try + { + DateTime networkTime = GetNetworkTime(); + if (networkTime == default) + { + return false; + } + + TimeSpan drift = networkTime - DateTime.Now; + return drift >= _maximumAcceptedClockDrift || drift <= -_maximumAcceptedClockDrift; + } + catch (Exception exception) when (exception is SocketException or IOException) + { + _logger.LogWarning(exception, "Failed to compare system time with network time."); + return false; + } + } + + /// + public bool TrySynchronizeSystemTimeWithNetworkTime() + { + try + { + DateTime networkTime = GetNetworkTime(); + if (networkTime == default) + { + _logger.LogWarning("Network time returned an empty timestamp; system clock was not changed."); + return false; + } + + DateTimeOffset localOffset = DateTimeOffset.Now; + DateTime utcNetworkTime = networkTime.Subtract(localOffset.Offset); + SystemTime systemTime = new() + { + Year = Convert.ToInt16(utcNetworkTime.Year), + Month = Convert.ToInt16(utcNetworkTime.Month), + DayOfWeek = 0, + Day = Convert.ToInt16(utcNetworkTime.Day), + Hour = Convert.ToInt16(utcNetworkTime.Hour), + Minute = Convert.ToInt16(utcNetworkTime.Minute), + Second = Convert.ToInt16(utcNetworkTime.Second), + Milliseconds = 0, + }; + + bool updated = SetSystemTime(ref systemTime); + if (!updated) + { + int errorCode = Marshal.GetLastWin32Error(); + _logger.LogWarning( + "Failed to update Windows system time. Win32 error: {Win32ErrorCode}.", + errorCode); + } + + return updated; + } + catch (Exception exception) when (exception is SocketException or IOException or Win32Exception) + { + _logger.LogWarning(exception, "Failed to synchronize system time with network time."); + return false; + } + } + + /// + /// Retrieves the current time from the default Windows time server. + /// + /// The network time converted to local time, or for an empty response. + private static DateTime GetNetworkTime() + { + byte[] ntpData = new byte[48]; + ntpData[0] = 0x1B; + + IPAddress[] addresses = Dns.GetHostEntry(NtpServer).AddressList; + IPEndPoint ipEndPoint = new(addresses[0], 123); + + using Socket socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Connect(ipEndPoint); + socket.ReceiveTimeout = 3000; + socket.Send(ntpData); + socket.Receive(ntpData); + + const byte serverReplyTime = 40; + ulong intPart = BitConverter.ToUInt32(ntpData, serverReplyTime); + ulong fractionPart = BitConverter.ToUInt32(ntpData, serverReplyTime + 4); + intPart = SwapEndianness(intPart); + fractionPart = SwapEndianness(fractionPart); + + ulong milliseconds = intPart * 1000 + fractionPart * 1000 / 0x100000000L; + if (milliseconds == 0) + { + return default; + } + + DateTime networkDateTime = new DateTime( + 1900, + 1, + 1, + 0, + 0, + 0, + DateTimeKind.Utc).AddMilliseconds((long)milliseconds); + + return networkDateTime.ToLocalTime(); + } + + /// + /// Converts a 32-bit unsigned integer from big-endian to little-endian. + /// + /// The big-endian value. + /// The little-endian value. + private static uint SwapEndianness(ulong value) + { + return (uint)(((value & 0x000000ff) << 24) + + ((value & 0x0000ff00) << 8) + + ((value & 0x00ff0000) >> 8) + + ((value & 0xff000000) >> 24)); + } + + /// + /// Sets the Windows system clock to the provided UTC time. + /// + /// The UTC system time to apply. + /// when the Windows API updates the clock. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetSystemTime(ref SystemTime systemTime); + + /// + /// Represents the unmanaged Windows SYSTEMTIME structure used by SetSystemTime. + /// + [StructLayout(LayoutKind.Sequential)] + private struct SystemTime + { + /// + /// The UTC year. + /// + internal short Year; + + /// + /// The UTC month. + /// + internal short Month; + + /// + /// The UTC day of week. This value is ignored by SetSystemTime. + /// + internal short DayOfWeek; + + /// + /// The UTC day. + /// + internal short Day; + + /// + /// The UTC hour. + /// + internal short Hour; + + /// + /// The UTC minute. + /// + internal short Minute; + + /// + /// The UTC second. + /// + internal short Second; + + /// + /// The UTC millisecond. + /// + internal short Milliseconds; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs b/GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs new file mode 100644 index 00000000..290b4bf5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Resolves the installed and in-progress path variants for package .big files. +/// +internal static class BigFileVariantPath +{ + /// + /// Returns the existing downloaded path, preferring the requested path and then the converted .gib path. + /// + /// The requested destination file path. + /// The existing file path, or an empty string when neither variant exists. + public static string GetExistingDownloadedPath(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + return destinationFilePath; + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + return File.Exists(gibFilePath) ? gibFilePath : string.Empty; + } + + /// + /// Converts a downloaded .big file to its installed .gib path. + /// + /// The downloaded file path. + public static void ConvertBigFileToGib(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + } + + File.Move(destinationFilePath, gibFilePath); + } + + /// + /// Moves an existing .gib file back to .big so a resumed download can append to it. + /// + /// The requested download destination path. + public static void PrepareBigFileResumePath(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + if (!File.Exists(gibFilePath) || File.Exists(destinationFilePath)) + { + return; + } + + File.Move(gibFilePath, destinationFilePath); + } + + /// + /// Returns whether a path has the .big extension. + /// + /// The file path to inspect. + /// when the path targets a .big file. + private static bool IsBigFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), ".big", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs new file mode 100644 index 00000000..d6ad768c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Resolves legacy catalog share links into direct package download links. +/// +public static class DownloadLinkResolver +{ + /// + /// Resolves a package source link into an absolute download URI. + /// + /// The package source link. + /// The resolved absolute download URI. + public static Uri ResolveDownloadUri(string link) + { + return new Uri(ResolveDirectDownloadLink(link), UriKind.Absolute); + } + + /// + /// Converts supported share links into direct download links. + /// + /// The package source link. + /// The resolved direct download link. + /// + /// Thrown when is missing. + /// + public static string ResolveDirectDownloadLink(string link) + { + if (string.IsNullOrWhiteSpace(link)) + { + throw new ArgumentException( + "Download link is missing from the modification metadata.", + nameof(link)); + } + + if (link.Contains("www.dropbox.com", StringComparison.Ordinal)) + { + link = link.Replace("?dl=0", "?dl=1"); + } + + if (link.Contains("https://onedrive.live.com", StringComparison.Ordinal)) + { + link = ResolveOneDriveLink(link); + } + + return link; + } + + /// + /// Converts a supported OneDrive share or embed link to a direct download link. + /// + /// The OneDrive link. + /// The direct download link. + private static string ResolveOneDriveLink(string link) + { + if (link.Contains("embed", StringComparison.Ordinal)) + { + return link.Replace("embed", "download"); + } + + List linkParts = [.. link.Replace("https://onedrive.live.com/?", string.Empty).Split('&')]; + string? cid = linkParts.Where(t => t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("cid=", string.Empty)) + .FirstOrDefault(); + string? authKey = linkParts.Where(t => t.Contains("authkey=", StringComparison.Ordinal)) + .Select(t => t.Replace("authkey=", string.Empty)) + .FirstOrDefault(); + string? resid = linkParts.Where(t => + t.Contains("id=", StringComparison.Ordinal) && + !t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("id=", string.Empty)) + .FirstOrDefault(); + + return string.Format( + CultureInfo.InvariantCulture, + "https://onedrive.live.com/download?cid={0}&resid={1}&authkey={2}", + cid, + resid, + authKey); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs new file mode 100644 index 00000000..b206cb84 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Resolves manifest-provided relative paths while preventing writes outside the package root. +/// +internal static class ManifestPathResolver +{ + /// + /// Resolves a manifest file name to a full path under the specified root directory. + /// + /// The root directory that must contain the resolved path. + /// The manifest-provided relative file name. + /// The fully qualified path for the manifest file. + /// + /// Thrown when the root directory or manifest file name is empty, rooted, drive-qualified, or contains a parent + /// directory segment. + /// + /// + /// Thrown when the resolved path would leave . + /// + public static string ResolvePath(string rootDirectory, string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + string normalizedFileName = NormalizeRelativePath(manifestFileName); + string rootPath = Path.GetFullPath(rootDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(rootPath, normalizedFileName)); + + if (!FileSystemPathSafety.IsPathInDirectory(candidatePath, rootPath)) + { + throw new InvalidDataException( + $"Manifest file '{manifestFileName}' would resolve outside the package directory."); + } + + return candidatePath; + } + + /// + /// Normalizes a manifest path to the current platform directory separator after validation. + /// + /// The manifest-provided relative file name. + /// The normalized relative file name. + /// + /// Thrown when the path is rooted, drive-qualified, empty, or contains a parent directory segment. + /// + public static string NormalizeRelativePath(string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + string trimmedFileName = manifestFileName.Trim(); + if (Path.IsPathRooted(trimmedFileName) || + trimmedFileName.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException("Manifest file paths must be relative.", nameof(manifestFileName)); + } + + string[] segments = trimmedFileName.Split( + new[] { '/', '\\' }, + StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw new ArgumentException("Manifest file paths must include a file name.", nameof(manifestFileName)); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw new ArgumentException( + "Manifest file paths must not contain current or parent directory segments.", + nameof(manifestFileName)); + } + } + + return Path.Combine(segments); + } + + /// + /// Normalizes a manifest path to slash separators for manifest index lookups. + /// + /// The manifest-provided relative file name. + /// The normalized slash-separated manifest path. + public static string NormalizeForManifestIndex(string manifestFileName) + { + return FileSystemPathSafety.NormalizeRelativePath(NormalizeRelativePath(manifestFileName)); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs new file mode 100644 index 00000000..ba39cb2d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs @@ -0,0 +1,190 @@ +using System; +using System.Globalization; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Replaces an installed package folder through a staged move with rollback support. +/// +internal static class PackageInstallFolderReplacer +{ + /// + /// Replaces an installed folder with a temporary folder without deleting the existing install first. + /// + /// The prepared temporary package folder. + /// The final installed package folder. + /// The logger used for replacement diagnostics. + /// + /// Thrown when or is empty or + /// whitespace. + /// + /// + /// Thrown when does not exist. + /// + /// + /// Thrown when folder replacement or rollback cannot be completed. + /// + public static void Replace( + string temporaryFolderPath, + string installedFolderPath, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(temporaryFolderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string temporaryPath = Path.GetFullPath(temporaryFolderPath); + string installedPath = Path.GetFullPath(installedFolderPath); + if (!Directory.Exists(temporaryPath)) + { + throw new DirectoryNotFoundException($"Temporary package folder '{temporaryFolderPath}' was not found."); + } + + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths must be rooted.", + "The temporary package folder cannot contain a reparse point.", + logger); + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths must be rooted.", + "The installed package folder cannot contain a reparse point.", + logger); + + string? parentDirectory = Path.GetDirectoryName(installedPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + string backupPath = CreateBackupPath(installedPath); + bool backupCreated = false; + try + { + if (Directory.Exists(installedPath)) + { + logger.LogInformation( + "Moving existing installed package folder {InstalledFolderName} to a staged backup.", + Path.GetFileName(installedPath)); + Directory.Move(installedPath, backupPath); + backupCreated = true; + } + + logger.LogInformation( + "Moving temporary package folder {TemporaryFolderName} into installed package location {InstalledFolderName}.", + Path.GetFileName(temporaryPath), + Path.GetFileName(installedPath)); + Directory.Move(temporaryPath, installedPath); + + if (backupCreated) + { + Directory.Delete(backupPath, recursive: true); + } + } + catch + { + RollBackReplacement(temporaryPath, installedPath, backupPath, backupCreated, logger); + throw; + } + } + + /// + /// Verifies that an install or staging directory path does not cross or contain links before replacement. + /// + /// The directory path to inspect. + /// The exception message used when the path is not rooted. + /// The exception message used when a reparse point is found. + /// The logger used for replacement diagnostics. + private static void EnsureDirectoryPathHasNoReparsePoints( + string directoryPath, + string unrootedPathMessage, + string linkedPathMessage, + ILogger logger) + { + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + unrootedPathMessage, + linkedPathMessage); + if (Directory.Exists(directoryPath)) + { + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(directoryPath, linkedPathMessage); + } + } + catch (InvalidDataException ex) + { + logger.LogWarning( + ex, + "Blocked package folder replacement because {FolderName} contains a reparse point.", + Path.GetFileName(directoryPath)); + throw new IOException(linkedPathMessage, ex); + } + } + + /// + /// Creates a unique backup path next to the installed folder. + /// + /// The fully qualified installed folder path. + /// A non-existing backup folder path. + private static string CreateBackupPath(string installedPath) + { + string parentDirectory = Path.GetDirectoryName(installedPath) ?? "."; + string folderName = Path.GetFileName(installedPath); + string timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture); + + for (int attempt = 0; attempt < 100; attempt++) + { + string suffix = attempt == 0 + ? timestamp + : string.Create(CultureInfo.InvariantCulture, $"{timestamp}-{attempt}"); + string backupPath = Path.Combine(parentDirectory, $"{folderName}.backup-{suffix}"); + if (!Directory.Exists(backupPath)) + { + return backupPath; + } + } + + return Path.Combine(parentDirectory, $"{folderName}.backup-{Guid.NewGuid():N}"); + } + + /// + /// Attempts to restore the prior installed folder after a staged replacement failure. + /// + /// The temporary package folder path. + /// The installed package folder path. + /// The staged backup folder path. + /// A value indicating whether a backup folder was created. + /// The logger used for rollback diagnostics. + private static void RollBackReplacement( + string temporaryPath, + string installedPath, + string backupPath, + bool backupCreated, + ILogger logger) + { + if (!backupCreated || !Directory.Exists(backupPath) || Directory.Exists(installedPath)) + { + return; + } + + try + { + logger.LogWarning( + "Rolling back package folder replacement for {InstalledFolderName}.", + Path.GetFileName(installedPath)); + Directory.Move(backupPath, installedPath); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to roll back package folder replacement for {InstalledFolderName}. Temporary folder exists: {TemporaryFolderExists}", + Path.GetFileName(installedPath), + Directory.Exists(temporaryPath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs new file mode 100644 index 00000000..b5a4f4bc --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Tracks aggregate package update progress, speed, and estimated time remaining. +/// +internal sealed class PackageProgressTracker +{ + private static readonly TimeSpan _reportInterval = TimeSpan.FromMilliseconds(100); + + private readonly Dictionary _itemProgressBytes = new(StringComparer.OrdinalIgnoreCase); + private readonly object _progressGate = new(); + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + + private TimeSpan _lastReportElapsed; + private long _lastReportedBytesRead; + private long? _totalBytes; + private long _totalBytesRead; + + /// + /// Initializes a new instance of the class. + /// + /// The expected package byte count when known. + public PackageProgressTracker(long? totalBytes) + { + _totalBytes = totalBytes; + } + + /// + /// Adds expected transfer bytes when a resumed download has to restart from byte zero. + /// + /// The additional expected bytes. + public void AddExpectedBytes(long bytes) + { + if (bytes <= 0) + { + return; + } + + lock (_progressGate) + { + if (_totalBytes.HasValue) + { + _totalBytes += bytes; + } + } + } + + /// + /// Updates an item byte count and returns a throttled aggregate progress report. + /// + /// The internal item name whose byte count changed. + /// The number of bytes completed for the item. + /// A value indicating whether a report should be returned even when throttled. + /// An aggregate progress report, or when the update was throttled. + public PackageUpdateProgress? Update( + string itemName, + long bytesRead, + bool forceReport = false) + { + lock (_progressGate) + { + long previousBytesRead = _itemProgressBytes.TryGetValue(itemName, out long previous) + ? previous + : 0; + long normalizedBytesRead = Math.Max(0, bytesRead); + _itemProgressBytes[itemName] = normalizedBytesRead; + _totalBytesRead += normalizedBytesRead - previousBytesRead; + + TimeSpan elapsed = _stopwatch.Elapsed; + long? totalBytes = _totalBytes; + bool completed = totalBytes.HasValue && _totalBytesRead >= totalBytes.Value; + if (!forceReport && + !completed && + elapsed - _lastReportElapsed < _reportInterval && + _lastReportedBytesRead != 0) + { + return null; + } + + _lastReportElapsed = elapsed; + _lastReportedBytesRead = _totalBytesRead; + + double? progressPercentage = null; + if (totalBytes is > 0) + { + progressPercentage = Math.Round((double)_totalBytesRead / totalBytes.Value * 100, 2); + } + + double? speedBytesPerSecond = null; + TimeSpan? estimatedTimeRemaining = null; + if (elapsed.TotalSeconds > 0.25 && _totalBytesRead > 0) + { + speedBytesPerSecond = _totalBytesRead / elapsed.TotalSeconds; + if (totalBytes.HasValue && speedBytesPerSecond > 0) + { + long remainingBytes = Math.Max(0, totalBytes.Value - _totalBytesRead); + estimatedTimeRemaining = TimeSpan.FromSeconds(remainingBytes / speedBytesPerSecond.Value); + } + } + + return new PackageUpdateProgress( + totalBytes, + _totalBytesRead, + progressPercentage, + null, + speedBytesPerSecond, + estimatedTimeRemaining); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs new file mode 100644 index 00000000..0c45a20d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Cleans package staging folders before they are moved into an installed package location. +/// +internal static class PackageStagingFolderCleaner +{ + /// + /// Deletes all child entries from the staging folder, creating the folder when it does not exist. + /// + /// The staging folder path to empty. + /// The logger used for cleanup diagnostics. + public static void ClearDirectory(string stagingFolderPath, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = PrepareSafeRoot(stagingFolderPath, logger); + DeleteChildEntriesSafely(stagingRoot); + + logger.LogInformation( + "Cleared package staging folder {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + /// + /// Deletes empty package staging parent folders after a staged package version folder has been moved into place. + /// + /// The staging folder that was moved into the installed package location. + /// The logger used for cleanup diagnostics. + public static void DeleteEmptyPackageParents(string stagingFolderPath, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = Path.GetFullPath(stagingFolderPath); + DirectoryInfo? packagesDirectory = FindPackagesAncestor(stagingRoot); + if (packagesDirectory is null) + { + return; + } + + DirectoryInfo? currentDirectory = Directory.GetParent(stagingRoot); + while (currentDirectory is not null) + { + bool reachedPackagesDirectory = string.Equals( + currentDirectory.FullName, + packagesDirectory.FullName, + StringComparison.OrdinalIgnoreCase); + + if (!currentDirectory.Exists) + { + if (reachedPackagesDirectory) + { + return; + } + + currentDirectory = currentDirectory.Parent; + continue; + } + + if (Directory.EnumerateFileSystemEntries(currentDirectory.FullName).Any()) + { + return; + } + + try + { + Directory.Delete(currentDirectory.FullName); + logger.LogInformation( + "Deleted empty package staging folder {StagingFolderName}.", + currentDirectory.Name); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + ex, + "Failed to delete empty package staging folder {StagingFolderName}.", + currentDirectory.Name); + return; + } + + if (reachedPackagesDirectory) + { + return; + } + + currentDirectory = currentDirectory.Parent; + } + } + + /// + /// Finds the package staging root in a temporary package folder path. + /// + /// The full staging folder path. + /// The package staging root, or when the path is not a package staging path. + private static DirectoryInfo? FindPackagesAncestor(string stagingFolderPath) + { + DirectoryInfo? currentDirectory = Directory.GetParent(stagingFolderPath); + while (currentDirectory is not null) + { + if (string.Equals(currentDirectory.Name, "Packages", StringComparison.OrdinalIgnoreCase)) + { + return currentDirectory; + } + + currentDirectory = currentDirectory.Parent; + } + + return null; + } + + /// + /// Deletes reparse points from a staging folder without following them, recreating the staging root when it is + /// itself a link. + /// + /// The staging folder to sanitize. + /// The logger used for cleanup diagnostics. + /// A token that cancels cleanup between entries. + public static void RemoveUnsafeLinks( + string stagingFolderPath, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = PrepareSafeRoot(stagingFolderPath, logger); + RemoveUnsafeChildLinks(stagingRoot, logger, cancellationToken); + } + + /// + /// Deletes staged files that are not expected by the remote manifest. + /// + /// The staging folder to prune. + /// The files expected by the remote manifest. + /// The logger used for cleanup diagnostics. + /// A token that cancels pruning between file-system operations. + public static void PruneToManifest( + string stagingFolderPath, + IReadOnlyList files, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = Path.GetFullPath(stagingFolderPath); + Directory.CreateDirectory(stagingRoot); + RemoveUnsafeLinks(stagingRoot, logger, cancellationToken); + + HashSet expectedPaths = BuildExpectedInstalledPaths(stagingRoot, files); + foreach (string filePath in Directory + .EnumerateFiles(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + string fullPath = Path.GetFullPath(filePath); + if (expectedPaths.Contains(fullPath)) + { + continue; + } + + File.Delete(fullPath); + logger.LogInformation( + "Deleted stale staged package file {FileName}.", + Path.GetFileName(fullPath)); + } + + foreach (string directoryPath in Directory + .EnumerateDirectories(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + } + } + } + + /// + /// Builds the set of final staged file paths expected from a manifest. + /// + /// The fully qualified staging root. + /// The manifest entries to resolve. + /// The expected full staged file paths. + private static HashSet BuildExpectedInstalledPaths( + string stagingRoot, + IReadOnlyList files) + { + HashSet expectedPaths = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolvePath(stagingRoot, file.FileName); + if (string.Equals(Path.GetExtension(destinationPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + destinationPath = Path.ChangeExtension(destinationPath, ".gib"); + } + + expectedPaths.Add(Path.GetFullPath(destinationPath)); + } + + return expectedPaths; + } + + /// + /// Ensures a staging root is a real directory rather than a reparse point. + /// + /// The staging folder path. + /// The logger used for cleanup diagnostics. + /// The fully qualified safe staging root. + private static string PrepareSafeRoot(string stagingFolderPath, ILogger logger) + { + string stagingRoot = Path.GetFullPath(stagingFolderPath); + if (Directory.Exists(stagingRoot) && FileSystemPathSafety.IsReparsePoint(stagingRoot)) + { + DeleteEntryWithoutFollowing(stagingRoot); + logger.LogWarning( + "Removed unsafe staging-root link {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + Directory.CreateDirectory(stagingRoot); + return stagingRoot; + } + + /// + /// Recursively deletes all child entries without traversing reparse points. + /// + /// The real directory whose children will be deleted. + private static void DeleteChildEntriesSafely(string directory) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory).ToList()) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(entry); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + DeleteChildEntriesSafely(entry); + Directory.Delete(entry); + continue; + } + + File.Delete(entry); + } + } + + /// + /// Recursively removes unsafe child links without traversing them. + /// + /// The real directory to inspect. + /// The logger used for cleanup diagnostics. + /// A token that cancels cleanup between entries. + private static void RemoveUnsafeChildLinks( + string directory, + ILogger logger, + CancellationToken cancellationToken) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory).ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(entry); + logger.LogWarning( + "Removed unsafe staging link {EntryName}.", + Path.GetFileName(entry)); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + RemoveUnsafeChildLinks(entry, logger, cancellationToken); + } + } + } + + /// + /// Deletes one file, directory, or link without recursively following it. + /// + /// The entry to delete. + private static void DeleteEntryWithoutFollowing(string path) + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, recursive: false); + } + else + { + File.Delete(path); + } + } + +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs new file mode 100644 index 00000000..5c0c8ffd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs @@ -0,0 +1,80 @@ +using System; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Provides S3-compatible catalog defaults used by legacy remote modification metadata. +/// +/// +/// These values are retained for compatibility with the original GenLauncher client and backend. The original client +/// already shipped them in client-side code before this GenLauncherGO rewrite/fork, so this project treats them as +/// public legacy credentials rather than private application secrets. The backend/object-storage policy must assume +/// every user can read these values and must keep their permissions limited accordingly. +/// +public static class S3CatalogDefaults +{ + /// + /// Gets the default public S3 access key used when catalog metadata does not provide one. + /// + /// + /// This legacy value was already exposed by the original client and is kept only so old catalog entries continue + /// to resolve. + /// + public const string PublicAccessKey = "S58TYR9ISEZV8PBP8QG1"; + + /// + /// Gets the default public S3 secret key used when catalog metadata does not provide one. + /// + /// + /// This legacy value was already exposed by the original client. Do not replace it with a privileged secret unless + /// downloads are moved behind a trusted backend or another non-client-side credential flow. + /// + public const string PublicSecretKey = "b2RU1oqVU5toJRnb4gODrXX8sBSgoLcHRX6qPWxj"; + + /// + /// Creates a manifest request from one remote modification version. + /// + /// The remote modification version metadata. + /// The S3-compatible object manifest request. + public static S3ObjectManifestRequest CreateManifestRequest(ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return new S3ObjectManifestRequest( + version.S3HostLink, + version.S3BucketName, + version.S3FolderName, + ResolveAccessKey(version), + ResolveSecretKey(version)); + } + + /// + /// Resolves the access key for a modification version, falling back to the public catalog key. + /// + /// The remote modification version metadata. + /// The resolved S3 access key. + public static string ResolveAccessKey(ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return string.IsNullOrEmpty(version.S3HostPublicKey) + ? PublicAccessKey + : version.S3HostPublicKey; + } + + /// + /// Resolves the secret key for a modification version, falling back to the public catalog key. + /// + /// The remote modification version metadata. + /// The resolved S3 secret key. + public static string ResolveSecretKey(ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return string.IsNullOrEmpty(version.S3HostSecretKey) + ? PublicSecretKey + : version.S3HostSecretKey; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs new file mode 100644 index 00000000..9f173283 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Determines when S3 package files should be validated with reliable MD5 hashes. +/// +internal static class S3HashValidationPolicy +{ + /// + /// Returns whether a manifest entry should be validated with MD5. + /// + /// The manifest file entry. + /// Extensions that require hash validation. + /// when the file extension and hash are suitable for validation. + public static bool ShouldCheckHash( + RemoteFileManifestEntry file, + IReadOnlySet hashCheckedExtensions) + { + return hashCheckedExtensions.Contains(Path.GetExtension(file.FileName)) && + IsReliableMd5Hash(file.Hash); + } + + /// + /// Returns whether a manifest hash is a plain 32-character hexadecimal MD5 value. + /// + /// The hash value to inspect. + /// when the hash is a reliable MD5 value. + public static bool IsReliableMd5Hash(string hash) + { + if (hash.Length != 32) + { + return false; + } + + foreach (char character in hash) + { + bool isHexDigit = character is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; + if (!isHexDigit) + { + return false; + } + } + + return true; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs new file mode 100644 index 00000000..32ee203d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Copies unchanged files from the latest installed S3 package into a staging folder. +/// +internal sealed class S3ReusablePackageFileCopier +{ + /// + /// The hash service used to verify reusable files. + /// + private readonly IFileHashService _fileHashService; + + /// + /// The logger used for package reuse diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The hash service used to verify reusable files. + /// The logger used for package reuse diagnostics. + public S3ReusablePackageFileCopier( + IFileHashService fileHashService, + ILogger logger) + { + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Copies unchanged files from a previous installed package into the staging folder. + /// + /// The previous installed package directory. + /// The staging directory. + /// The remote repository manifest entries. + /// A token that cancels hashing and copy work. + /// A task that completes after all reusable files have been staged. + public async Task CopyUnchangedFilesAsync( + string sourceDir, + string destinationDir, + IReadOnlyList repositoryFiles, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceDir); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDir); + ArgumentNullException.ThrowIfNull(repositoryFiles); + + Dictionary repositoryFileIndex = BuildRepositoryFileIndex(repositoryFiles); + await CopyReusableDirectoryContentAsync( + sourceDir, + destinationDir, + true, + repositoryFileIndex, + string.Empty, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a manifest lookup keyed by normalized relative paths and converted .gib aliases. + /// + /// The remote repository manifest entries. + /// A case-insensitive manifest lookup. + private static Dictionary BuildRepositoryFileIndex( + IReadOnlyList repositoryFiles) + { + Dictionary repositoryFileIndex = + new(StringComparer.OrdinalIgnoreCase); + + foreach (RemoteFileManifestEntry repositoryFile in repositoryFiles) + { + string normalizedPath = ManifestPathResolver.NormalizeForManifestIndex(repositoryFile.FileName); + repositoryFileIndex[normalizedPath] = repositoryFile; + + if (string.Equals(Path.GetExtension(normalizedPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + repositoryFileIndex[Path.ChangeExtension(normalizedPath, ".gib")] = repositoryFile; + } + } + + return repositoryFileIndex; + } + + /// + /// Recursively copies files that match the remote manifest by size and reliable hash. + /// + /// The source directory to inspect. + /// The destination directory to populate. + /// A value indicating whether subdirectories should be inspected. + /// The manifest lookup used to validate reusable files. + /// The manifest-relative path prefix for . + /// A token that cancels hashing and copy work. + /// A task that completes after the current directory has been inspected. + private async Task CopyReusableDirectoryContentAsync( + string sourceDir, + string destinationDir, + bool recursive, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + DirectoryInfo directory = new(sourceDir); + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning( + "Skipped unsafe reusable package directory link {DirectoryName}.", + directory.Name); + return; + } + + DirectoryInfo[] directories = directory.GetDirectories() + .Where(subdirectory => (subdirectory.Attributes & FileAttributes.ReparsePoint) == 0) + .ToArray(); + + Directory.CreateDirectory(destinationDir); + + foreach (FileInfo file in directory.GetFiles()) + { + cancellationToken.ThrowIfCancellationRequested(); + if ((file.Attributes & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning( + "Skipped unsafe reusable package file link {FileName}.", + file.Name); + continue; + } + + await CopyReusableFileAsync( + file, + destinationDir, + repositoryFileIndex, + pathAddition, + cancellationToken).ConfigureAwait(false); + } + + if (!recursive) + { + return; + } + + foreach (DirectoryInfo subDir in directories) + { + cancellationToken.ThrowIfCancellationRequested(); + + await CopyReusableDirectoryContentAsync( + subDir.FullName, + Path.Combine(destinationDir, subDir.Name), + true, + repositoryFileIndex, + ManifestPathResolver.NormalizeForManifestIndex(Path.Combine(pathAddition, subDir.Name)), + cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Copies one reusable file after size and hash validation. + /// + /// The source file to inspect. + /// The destination directory to populate. + /// The manifest lookup used to validate reusable files. + /// The manifest-relative path prefix for . + /// A token that cancels hashing and copy work. + /// A task that completes after the file has been inspected. + private async Task CopyReusableFileAsync( + FileInfo file, + string destinationDir, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + string targetFilePath = ManifestPathResolver.ResolvePath(destinationDir, file.Name); + ulong fileSize = (ulong)file.Length; + string relativeFilePath = ManifestPathResolver.NormalizeForManifestIndex( + Path.Combine(pathAddition, file.Name)); + + if (File.Exists(targetFilePath) || + !repositoryFileIndex.TryGetValue(relativeFilePath, out RemoteFileManifestEntry? repositoryFile) || + repositoryFile.Size != fileSize || + !S3HashValidationPolicy.IsReliableMd5Hash(repositoryFile.Hash)) + { + return; + } + + string hash = await _fileHashService + .ComputeMd5HashAsync(file.FullName, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(hash, repositoryFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetFilePath) ?? destinationDir); + await using FileStream sourceStream = file.OpenRead(); + await using FileStream destinationStream = new( + targetFilePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await sourceStream.CopyToAsync(destinationStream, cancellationToken).ConfigureAwait(false); + } +} diff --git a/GenLauncherGO.Tests/AGENTS.md b/GenLauncherGO.Tests/AGENTS.md new file mode 100644 index 00000000..8d1db921 --- /dev/null +++ b/GenLauncherGO.Tests/AGENTS.md @@ -0,0 +1,32 @@ +# GenLauncherGO.Tests Agent Guidelines + +`GenLauncherGO.Tests/` owns automated tests for application behavior. + +## Preferred Stack + +- xUnit +- FluentAssertions +- NSubstitute +- NSubstitute.Analyzers.CSharp + +## Organization + +- `Core/` for pure model, validation, and workflow contract tests. +- `Infrastructure/` for adapter tests using temp folders or mocked external services. +- `UI/` for WPF/view-model tests when practical. +- `Testing/` for shared builders, fakes, fixtures, and temp-directory helpers. +- Mirror production feature subfolders inside the boundary when a test area grows or the production files already use + layer folders, such as `Core/Launching/Models`, `Infrastructure/Updating/Clients`, or + `Infrastructure/Updating/Services`. + +## Rules + +- Use Arrange, Act, Assert sections. +- Keep Core tests independent of WPF, disk, network, and real game installs. +- Use fake Core interfaces for workflow tests. +- Use temporary directories for infrastructure file-system tests. +- Skip or isolate symbolic-link tests when the environment does not support them. +- Do not use Moq, NMock, or another mocking framework unless a specific test case has a clear limitation that + NSubstitute cannot handle. +- Prefer hand-written fakes for stateful infrastructure behavior when they are clearer than mock setup. +- Keep test package references versionless and package versions centralized in `Directory.Packages.props`. diff --git a/GenLauncherGO.Tests/Core/.gitkeep b/GenLauncherGO.Tests/Core/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/Core/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs new file mode 100644 index 00000000..27d6cd4c --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityReportTests +{ + [Fact] + public void ConstructorDefensivelyCopiesIssues() + { + // Arrange + List issues = new() + { + new ContentIntegrityIssue( + "target", + "Target", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "file.bin"), + }; + + // Act + ContentIntegrityReport report = new(issues); + issues.Clear(); + + // Assert + report.Issues.Should().ContainSingle(); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs new file mode 100644 index 00000000..69f4baf5 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityTargetTests +{ + [Fact] + public void ConstructorDefensivelyCopiesIgnoredPaths() + { + // Arrange + HashSet ignoredPaths = new(StringComparer.OrdinalIgnoreCase) + { + "inactive.png", + }; + + // Act + ContentIntegrityTarget target = new( + "target", + "Target", + "content", + ContentSourceKind.ManagedS3, + ignoredPaths); + ignoredPaths.Clear(); + + // Assert + target.IgnoredRelativePaths.Should().Contain("inactive.png"); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs b/GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs new file mode 100644 index 00000000..89ad10af --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Launching.Models; + +public sealed class DeploymentContractTests +{ + [Theory] + [InlineData("id")] + [InlineData("displayName")] + [InlineData("rootDirectory")] + public void DeploymentPackageThrowsForMissingRequiredStrings(string missingField) + { + // Arrange + string id = missingField == "id" ? " " : "package-id"; + string displayName = missingField == "displayName" ? " " : "Package"; + string rootDirectory = missingField == "rootDirectory" ? " " : @"C:\Packages\Package"; + + // Act + Action act = () => new DeploymentPackage( + id, + displayName, + DeploymentPackageKind.Mod, + rootDirectory, + 1); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRequestThrowsForMissingPaths() + { + // Arrange + IReadOnlyList packages = new[] { CreatePackage() }; + + // Act + Action act = () => new DeploymentRequest(null!, packages); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRequestThrowsForMissingPackages() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => new DeploymentRequest(paths, null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentCleanupRequestThrowsForMissingPaths() + { + // Arrange + LauncherPaths paths = null!; + + // Act + Action act = () => new DeploymentCleanupRequest(paths); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRecoveryRequestThrowsForMissingPaths() + { + // Arrange + LauncherPaths paths = null!; + + // Act + Action act = () => new DeploymentRecoveryRequest(paths); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentFailureNormalizesMissingPath() + { + // Arrange + string path = null!; + + // Act + var failure = new DeploymentFailure( + DeploymentFailureKind.FileSystem, + path, + "Unable to deploy file."); + + // Assert + failure.Path.Should().BeEmpty(); + } + + [Fact] + public void DeploymentFailureThrowsForMissingMessage() + { + // Arrange + string message = " "; + + // Act + Action act = () => new DeploymentFailure( + DeploymentFailureKind.FileSystem, + @"C:\Games\ZeroHour\Game.dat", + message); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentManifestThrowsForMissingDeploymentId() + { + // Arrange + IReadOnlyList files = new[] { CreateFileEntry() }; + IReadOnlyList createdDirectories = new[] { "Data" }; + + // Act + Action act = () => new DeploymentManifest( + 1, + " ", + DateTimeOffset.UtcNow, + files, + createdDirectories); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentManifestThrowsForMissingFiles() + { + // Arrange + IReadOnlyList createdDirectories = new[] { "Data" }; + + // Act + Action act = () => new DeploymentManifest( + 1, + "deployment-id", + DateTimeOffset.UtcNow, + null!, + createdDirectories); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentManifestThrowsForMissingCreatedDirectories() + { + // Arrange + IReadOnlyList files = new[] { CreateFileEntry() }; + + // Act + Action act = () => new DeploymentManifest( + 1, + "deployment-id", + DateTimeOffset.UtcNow, + files, + null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentResultSuccessCreatesSuccessfulResult() + { + // Arrange + DeploymentManifest manifest = CreateManifest(); + + // Act + var result = DeploymentResult.Success(manifest); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Failures.Should().BeEmpty(); + result.Manifest.Should().Be(manifest); + } + + [Fact] + public void DeploymentResultFailureCreatesFailedResult() + { + // Arrange + var failure = new DeploymentFailure( + DeploymentFailureKind.Manifest, + @"C:\Games\ZeroHour\GenLauncherGO\deployment.json", + "Unable to load manifest."); + + // Act + var result = DeploymentResult.Failure(failure); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().ContainSingle().Which.Should().Be(failure); + result.Manifest.Should().BeNull(); + } + + private static DeploymentPackage CreatePackage() + { + return new DeploymentPackage( + "package-id", + "Package", + DeploymentPackageKind.Mod, + @"C:\Packages\Package", + 1); + } + + private static DeploymentFileEntry CreateFileEntry() + { + return new DeploymentFileEntry( + @"C:\Packages\Package\Data\file.ini", + @"Data\file.ini", + DeploymentMethod.Copy, + null, + "package-id"); + } + + private static DeploymentManifest CreateManifest() + { + return new DeploymentManifest( + 1, + "deployment-id", + DateTimeOffset.UtcNow, + new[] { CreateFileEntry() }, + new[] { "Data" }); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs b/GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs new file mode 100644 index 00000000..dde9ce98 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs @@ -0,0 +1,49 @@ +using System; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Tests.Core.Launching.Models; + +public sealed class GameExecutableDiscoveryModelTests +{ + [Fact] + public void GameClientExecutableStoresConstructorValues() + { + // Arrange / Act + var executable = new GameClientExecutable("generalszh.exe", GameClientExecutableKind.Community); + + // Assert + executable.ExecutableName.Should().Be("generalszh.exe"); + executable.Kind.Should().Be(GameClientExecutableKind.Community); + } + + [Fact] + public void GameClientExecutableThrowsForMissingExecutableName() + { + // Arrange / Act + Action act = () => new GameClientExecutable(" ", GameClientExecutableKind.Community); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void WorldBuilderExecutableStoresConstructorValues() + { + // Arrange / Act + var executable = new WorldBuilderExecutable("worldbuilderzh.exe", WorldBuilderExecutableKind.Community); + + // Assert + executable.ExecutableName.Should().Be("worldbuilderzh.exe"); + executable.Kind.Should().Be(WorldBuilderExecutableKind.Community); + } + + [Fact] + public void WorldBuilderExecutableThrowsForMissingExecutableName() + { + // Arrange / Act + Action act = () => new WorldBuilderExecutable(" ", WorldBuilderExecutableKind.Community); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs new file mode 100644 index 00000000..fdda3093 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs @@ -0,0 +1,32 @@ +using System; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class ModificationImageReplacementRequestTests +{ + [Fact] + public void ConstructorStoresRequestValues() + { + // Arrange and Act + var request = new ModificationImageReplacementRequest( + "ShockWave", + "1.2", + @"C:\Images\banner.png"); + + // Assert + request.ModificationName.Should().Be("ShockWave"); + request.ImageBaseName.Should().Be("1.2"); + request.SourceImagePath.Should().Be(@"C:\Images\banner.png"); + } + + [Fact] + public void ConstructorThrowsForMissingModificationName() + { + // Arrange + Action act = () => new ModificationImageReplacementRequest(" ", "1.2", @"C:\Images\banner.png"); + + // Act and Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs new file mode 100644 index 00000000..83c7e225 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Mods.Services; + +public sealed class LauncherContentPathResolverTests +{ + [Fact] + public void GetVersionDirectoryPath_WhenVersionIsMod_ReturnsModVersionDirectory() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Rise Of Reds", + Version = "1.9" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().Be(Path.Combine(paths.ModsDirectory, "Rise Of Reds", "1.9")); + } + + [Fact] + public void GetVersionDirectoryPath_WhenVersionIsAddon_ReturnsAddonVersionDirectory() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Addon, + DependenceName = "Rise Of Reds", + Name = "Music Pack", + Version = "2.0" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().Be(Path.Combine( + paths.ModsDirectory, + "Rise Of Reds", + "Addons", + "Music Pack", + "2.0")); + } + + [Fact] + public void GetVersionDirectoryPath_WhenVersionIsPatch_ReturnsPatchVersionDirectory() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Patch, + DependenceName = "Rise Of Reds", + Name = "Hotfix", + Version = "2.1" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().Be(Path.Combine( + paths.ModsDirectory, + "Rise Of Reds", + "Patches", + "Hotfix", + "2.1")); + } + + [Fact] + public void GetVersionDirectoryPath_WhenVersionTypeIsUnsupported_ReturnsEmptyString() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Advertising, + Name = "News", + Version = "1" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetVersionDirectoryPath_WhenModNameContainsPathTraversal_Throws() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = $"..{Path.DirectorySeparatorChar}Escape", + Version = "1.0" + }; + + // Act + Action act = () => resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetVersionDirectoryPath_WhenAddonDependenceContainsPathTraversal_Throws() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Addon, + DependenceName = $"..{Path.DirectorySeparatorChar}Escape", + Name = "Music Pack", + Version = "1.0" + }; + + // Act + Action act = () => resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetVersionDirectoryPath_WhenPatchVersionContainsPathTraversal_Throws() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Patch, + DependenceName = "Rise Of Reds", + Name = "Hotfix", + Version = $"..{Path.DirectorySeparatorChar}Escape" + }; + + // Act + Action act = () => resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths() + { + string root = Path.Combine(Path.GetTempPath(), "GenLauncherGO.Tests"); + return new LauncherPaths( + Path.Combine(root, "Game"), + Path.Combine(root, "Launcher"), + Path.Combine(root, "Launcher", "Runtime"), + Path.Combine(root, "Launcher", "Cache"), + Path.Combine(root, "Launcher", "Images"), + Path.Combine(root, "Launcher", "Mods"), + Path.Combine(root, "Launcher", "Logs"), + Path.Combine(root, "Launcher", "Temp"), + Path.Combine(root, "Launcher", "Deployment")); + } + + private static LauncherContentLayout CreateLayout() + { + return new LauncherContentLayout("Addons", "Patches"); + } +} diff --git a/GenLauncherGO.Tests/Core/SessionInformationTests.cs b/GenLauncherGO.Tests/Core/SessionInformationTests.cs new file mode 100644 index 00000000..fea02135 --- /dev/null +++ b/GenLauncherGO.Tests/Core/SessionInformationTests.cs @@ -0,0 +1,38 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core; + +public sealed class SessionInformationTests +{ + [Fact] + public void SessionInformationDefaultsToUnknownGame() + { + // Arrange + var sessionInformation = new SessionInformation(); + + // Act + SupportedGame managedGame = sessionInformation.CurrentlyManagedGame; + + // Assert + managedGame.Should().Be(SupportedGame.Unknown); + } + + [Fact] + public void SessionInformationStoresStartupState() + { + // Arrange + var sessionInformation = new SessionInformation + { + Connected = true, + CurrentlyManagedGame = SupportedGame.Generals + }; + + // Act + bool connected = sessionInformation.Connected; + SupportedGame managedGame = sessionInformation.CurrentlyManagedGame; + + // Assert + connected.Should().BeTrue(); + managedGame.Should().Be(SupportedGame.Generals); + } +} diff --git a/GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs b/GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs new file mode 100644 index 00000000..734f2163 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs @@ -0,0 +1,33 @@ +using System; +using GenLauncherGO.Core.Shell.Models; + +namespace GenLauncherGO.Tests.Core.Shell.Models; + +public sealed class ShellOpenResultTests +{ + [Fact] + public void SuccessCreatesSuccessfulResult() + { + // Arrange + const string target = "https://example.test/"; + + // Act + var result = ShellOpenResult.Success(target); + + // Assert + result.Succeeded.Should().BeTrue(); + result.FailureKind.Should().Be(ShellOpenFailureKind.None); + result.Target.Should().Be(target); + result.Message.Should().BeNull(); + } + + [Fact] + public void FailureRejectsNoneFailureKind() + { + // Arrange + Action act = () => ShellOpenResult.Failure(ShellOpenFailureKind.None, "target", "message"); + + // Act and Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs new file mode 100644 index 00000000..d1d11bc0 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherPathsTests +{ + [Fact] + public void GetPackageTemporaryFolderPathBuildsPathUnderTempDirectory() + { + // Arrange + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + // Act + string temporaryFolderPath = paths.GetPackageTemporaryFolderPath(installedFolderPath); + + // Assert + temporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageTemporaryFolderPathUsesFolderNameWhenInstallIsOutsideModsDirectory() + { + // Arrange + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.GameDirectory, "Data"); + + // Act + string temporaryFolderPath = paths.GetPackageTemporaryFolderPath(installedFolderPath); + + // Assert + temporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "Data")); + } + + [Fact] + public void GetPackageTemporaryFolderPathPreservesModsChildNamesThatStartWithDots() + { + // Arrange + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "..cache", "1.0"); + + // Act + string temporaryFolderPath = paths.GetPackageTemporaryFolderPath(installedFolderPath); + + // Assert + temporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "..cache", "1.0")); + } + + [Fact] + public void LauncherDataFilePathBuildsPathUnderRuntimeStateDirectory() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + string launcherDataFilePath = paths.LauncherDataFilePath; + + // Assert + launcherDataFilePath.Should().Be( + Path.Combine(paths.RuntimeDirectory, "State", "LauncherData.yaml")); + } + + [Fact] + public void PreferencesFilePathBuildsPathUnderLauncherRoot() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + string preferencesFilePath = paths.PreferencesFilePath; + + // Assert + preferencesFilePath.Should().Be(Path.Combine(paths.LauncherDirectory, "LauncherPreferences.yaml")); + } + + [Fact] + public void GetModificationImageFilePathBuildsPathUnderModificationImageCache() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + string imageFilePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + + // Assert + imageFilePath.Should().Be(Path.Combine(paths.ImagesDirectory, "ShockWave", "1.2.png")); + } + + [Fact] + public void GetModificationImagesDirectoryThrowsForMissingModificationName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImagesDirectory(" "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetModificationImagesDirectoryThrowsForPathTraversalModificationName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImagesDirectory($"..{Path.DirectorySeparatorChar}Escape"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePathThrowsForMissingImageFileName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImageFilePath("ShockWave", " "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePathThrowsForPathTraversalImageFileName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImageFilePath( + "ShockWave", + $"..{Path.DirectorySeparatorChar}1.2.png"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetPackageTemporaryFolderPathThrowsForMissingInstalledFolderPath() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetPackageTemporaryFolderPath(" "); + + // Assert + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj new file mode 100644 index 00000000..fc0bb059 --- /dev/null +++ b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj @@ -0,0 +1,33 @@ + + + net10.0-windows + true + disable + enable + 14.0 + false + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/GenLauncherGO.Tests/GlobalUsings.cs b/GenLauncherGO.Tests/GlobalUsings.cs new file mode 100644 index 00000000..82e9ad1e --- /dev/null +++ b/GenLauncherGO.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using FluentAssertions; +global using NSubstitute; +global using Xunit; diff --git a/GenLauncherGO.Tests/Infrastructure/.gitkeep b/GenLauncherGO.Tests/Infrastructure/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs b/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs new file mode 100644 index 00000000..eaffac92 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.IO.Compression; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Infrastructure.Archives; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure; + +public sealed class ArchiveExtractorTests +{ + [Fact] + public void AddGenLauncherGoArchivesRegistersArchiveExtractorContract() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoArchives(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().BeOfType(); + } + + [Fact] + public void ExtractToDirectoryPreservesBigFilesByDefault() + { + // Arrange + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string archivePath = Path.Combine(workingDirectory, "mod.zip"); + string destinationDirectory = Path.Combine(workingDirectory, "extract"); + + Directory.CreateDirectory(workingDirectory); + try + { + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + // Act + extractor.ExtractToDirectory(archivePath, destinationDirectory); + + // Assert + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + [Fact] + public void ExtractToDirectoryConvertsBigFilesWhenRequested() + { + // Arrange + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string archivePath = Path.Combine(workingDirectory, "mod.zip"); + string destinationDirectory = Path.Combine(workingDirectory, "extract"); + + Directory.CreateDirectory(workingDirectory); + try + { + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + // Act + extractor.ExtractToDirectory( + archivePath, + destinationDirectory, + new ArchiveExtractionOptions { ConvertBigFilesToGib = true }); + + // Assert + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + [Fact] + public void ExtractToDirectoryRejectsEntriesOutsideDestinationDirectory() + { + // Arrange + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string archivePath = Path.Combine(workingDirectory, "mod.zip"); + string destinationDirectory = Path.Combine(workingDirectory, "extract"); + string escapedFilePath = Path.Combine(workingDirectory, "escape.txt"); + + Directory.CreateDirectory(workingDirectory); + try + { + CreateZipArchive(archivePath, "../escape.txt", "escaped"); + + var extractor = new ArchiveExtractor(); + + // Act + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + // Assert + act.Should().Throw() + .WithMessage("*outside the destination folder*"); + File.Exists(escapedFilePath).Should().BeFalse(); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + private static void CreateZipArchive(string archivePath, string entryName, string contents) + { + using ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + ZipArchiveEntry entry = archive.CreateEntry(entryName); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write(contents); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..2695c90b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs @@ -0,0 +1,52 @@ +using System; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Composition; +using GenLauncherGO.Infrastructure.Integrity.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Composition; + +public sealed class IntegrityServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoIntegrityReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoIntegrity("snapshots"); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoIntegrityThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoIntegrity("snapshots"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoIntegrityRegistersFilesystemService() + { + // Arrange + ServiceCollection services = new(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoIntegrity("snapshots"); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should().BeOfType(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs new file mode 100644 index 00000000..c8860319 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs @@ -0,0 +1,570 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Integrity.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Services; + +public sealed class FileSystemContentIntegrityServiceTests +{ + [Fact] + public async Task VerifyAsyncDetectsSameSizeSha256ModificationAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.bin"); + await File.WriteAllTextAsync(filePath, "aaaa"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "bbbb"); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.ModifiedFile && + issue.Action == IntegrityIssueAction.Repair && + issue.RelativePath == "file.bin" && + issue.ExpectedSizeBytes == 4); + } + + [Fact] + public async Task VerifyAsyncCollectsMissingUnexpectedAndEmptyDirectoryIssuesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.MissingFile && + issue.ExpectedSizeBytes == 8); + report.Issues.Select(issue => issue.Kind).Should().Contain(IntegrityIssueKind.UnexpectedFile); + report.Issues.Select(issue => issue.Kind).Should().Contain(IntegrityIssueKind.EmptyDirectory); + } + + [Fact] + public async Task VerifyAsyncAlwaysReportsManagedEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.EmptyDirectory && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "nested/empty"); + } + + [Fact] + public async Task VerifyAsyncMarksManualDifferencesForAbsorptionAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "after"); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added"); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.HasManualIssues.Should().BeTrue(); + report.Issues.Should().OnlyContain(issue => issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task CaptureSnapshotAsyncAbsorbsManualDifferencesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after"); + + // Act + await service.CaptureSnapshotAsync(target, CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsyncPreservesIgnoredInactiveCacheFileAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task MatchesExpectedFileSetAsyncAcceptsFreshManagedCacheAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + // Act + bool matches = await service.MatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + + // Assert + matches.Should().BeTrue(); + } + + [Fact] + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsyncCapturesExistingManagedCacheWithoutMutationAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string activePath = Path.Combine(content, "active.png"); + string inactivePath = Path.Combine(content, "inactive.png"); + await File.WriteAllTextAsync(activePath, "active"); + await File.WriteAllTextAsync(inactivePath, "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + // Act + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + captured.Should().BeTrue(); + report.HasIssues.Should().BeFalse(); + File.ReadAllText(activePath).Should().Be("active"); + File.ReadAllText(inactivePath).Should().Be("inactive"); + } + + [Fact] + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsyncRejectsExtrasWithoutSnapshottingAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + captured.Should().BeFalse(); + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + [Fact] + public async Task MatchesExpectedFileSetAsyncRejectsExtraFilesAndEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + bool matches = await service.MatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + + // Assert + matches.Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsyncReportsIgnoredUnsafeLinkWithoutFollowingItAsync() + { + // Arrange + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsidePath, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string linkPath = Path.Combine(content, "inactive.png"); + try + { + File.CreateSymbolicLink(linkPath, outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "inactive.png"); + File.ReadAllText(outsidePath).Should().Be("outside"); + } + + [Theory] + [InlineData(ContentSourceKind.ManagedS3, IntegrityIssueAction.Repair)] + [InlineData(ContentSourceKind.ManagedSingleFile, IntegrityIssueAction.Redownload)] + [InlineData(ContentSourceKind.Manual, IntegrityIssueAction.Absorb)] + [InlineData(ContentSourceKind.UnknownLegacy, IntegrityIssueAction.TrustAsManual)] + public async Task VerifyAsyncClassifiesUntrackedContentBySourceAsync( + ContentSourceKind sourceKind, + IntegrityIssueAction expectedAction) + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, sourceKind); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == expectedAction); + } + + [Fact] + public async Task VerifyAsyncRequiresMigrationWhenSourceClassificationChangesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget managedTarget = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(managedTarget, CancellationToken.None); + ContentIntegrityTarget manualTarget = CreateTarget(content, ContentSourceKind.Manual); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { manualTarget }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesConfirmedManagedExtrasAndEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string nested = Path.Combine(content, "nested"); + Directory.CreateDirectory(nested); + await File.WriteAllTextAsync(Path.Combine(nested, "unexpected.txt"), "unexpected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "nested/unexpected.txt"), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + File.Exists(Path.Combine(nested, "unexpected.txt")).Should().BeFalse(); + Directory.Exists(nested).Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsyncRejectsManualLinkWithoutFollowingItAsync() + { + // Arrange + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsidePath, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string linkPath = Path.Combine(content, "linked.txt"); + try + { + File.CreateSymbolicLink(linkPath, outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "linked.txt"); + Func capture = () => service.CaptureSnapshotAsync(target, CancellationToken.None); + await capture.Should().ThrowAsync(); + } + + [Fact] + public async Task VerifyAsyncReportsLinkedTargetRootWithoutFollowingItAsync() + { + // Arrange + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + try + { + Directory.CreateSymbolicLink(content, outside); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "."); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesLinkedTargetRootWithoutDeletingTargetAsync() + { + // Arrange + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + try + { + Directory.CreateSymbolicLink(content, outside); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnsafeLink, + IntegrityIssueAction.Delete, + "."), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + Directory.Exists(content).Should().BeFalse(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task ApplyCleanupAsyncRejectsPathTraversalAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string outsideFile = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "../outside.txt"), + }); + + // Act + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + // Assert + await cleanup.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private static FileSystemContentIntegrityService CreateService(string root) + { + return new FileSystemContentIntegrityService( + Path.Combine(root, "snapshots"), + NullLogger.Instance); + } + + private static ContentIntegrityTarget CreateTarget(string root, ContentSourceKind sourceKind) + { + return new ContentIntegrityTarget( + "target", + "Target", + root, + sourceKind, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..5859a0f9 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Composition; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Composition; + +public sealed class DeploymentServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoDeploymentReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoDeployment(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoDeploymentThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoDeployment(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoDeploymentRegistersExecutableDiscoveryService() + { + // Arrange + using var directory = new TestDirectory(); + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(CreatePaths(directory.Path)); + + // Act + services.AddGenLauncherGoDeployment(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should().BeOfType(); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs new file mode 100644 index 00000000..d7a6ec4e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class DeploymentLaunchPreparationServiceTests +{ + [Fact] + public async Task PrepareAsyncMapsSelectedVersionsToDeploymentPackagesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var deploymentService = new RecordingDeploymentService(); + DeploymentLaunchPreparationService service = CreateService(deploymentService); + ModificationVersion[] versions = new[] + { + CreateVersion(ModificationType.Mod, "Rise", "1.0"), + CreateVersion(ModificationType.Patch, "Balance", "2.0", "Rise"), + CreateVersion(ModificationType.Addon, "Maps", "3.0", "Rise"), + }; + + // Act + LaunchPreparationResult result = await service.PrepareAsync( + new LaunchPreparationRequest(paths, versions, "Addons", "Patches"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + deploymentService.PrepareRequest.Should().NotBeNull(); + IReadOnlyList packages = deploymentService.PrepareRequest!.Packages; + packages.Should().HaveCount(3); + packages[0].Kind.Should().Be(DeploymentPackageKind.Mod); + packages[0].RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "1.0")); + packages[0].Precedence.Should().Be(0); + packages[1].Kind.Should().Be(DeploymentPackageKind.Patch); + packages[1].RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "Patches", "Balance", "2.0")); + packages[1].Precedence.Should().Be(1); + packages[2].Kind.Should().Be(DeploymentPackageKind.Addon); + packages[2].RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "Addons", "Maps", "3.0")); + packages[2].Precedence.Should().Be(2); + } + + [Fact] + public async Task CleanupAndRecoveryDelegateToDeploymentServiceAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var deploymentService = new RecordingDeploymentService(); + DeploymentLaunchPreparationService service = CreateService(deploymentService); + + // Act + LaunchPreparationResult cleanupResult = await service.CleanupAsync(paths, CancellationToken.None); + LaunchPreparationResult recoveryResult = await service.RecoverAsync(paths, CancellationToken.None); + + // Assert + cleanupResult.Succeeded.Should().BeTrue(); + recoveryResult.Succeeded.Should().BeTrue(); + deploymentService.CleanupRequest.Should().NotBeNull(); + deploymentService.CleanupRequest!.Paths.Should().Be(paths); + deploymentService.RecoveryRequest.Should().NotBeNull(); + deploymentService.RecoveryRequest!.Paths.Should().Be(paths); + } + + private static DeploymentLaunchPreparationService CreateService(RecordingDeploymentService deploymentService) + { + return new DeploymentLaunchPreparationService( + deploymentService, + NullLogger.Instance); + } + + private static ModificationVersion CreateVersion( + ModificationType modificationType, + string name, + string version, + string dependenceName = "") + { + return new ModificationVersion + { + ModificationType = modificationType, + Name = name, + Version = version, + DependenceName = dependenceName, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private sealed class RecordingDeploymentService : IDeploymentService + { + public DeploymentRequest? PrepareRequest { get; private set; } + + public DeploymentCleanupRequest? CleanupRequest { get; private set; } + + public DeploymentRecoveryRequest? RecoveryRequest { get; private set; } + + public Task PrepareAsync( + DeploymentRequest request, + CancellationToken cancellationToken) + { + PrepareRequest = request; + return Task.FromResult(DeploymentResult.Success()); + } + + public Task CleanupAsync( + DeploymentCleanupRequest request, + CancellationToken cancellationToken) + { + CleanupRequest = request; + return Task.FromResult(DeploymentResult.Success()); + } + + public Task RecoverAsync( + DeploymentRecoveryRequest request, + CancellationToken cancellationToken) + { + RecoveryRequest = request; + return Task.FromResult(DeploymentResult.Success()); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs new file mode 100644 index 00000000..80877261 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemDeploymentServiceTests +{ + [Fact] + public async Task PrepareAsyncUsesHardLinkWhenCreatorSucceedsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new(canCreate: true); + FileSystemDeploymentService service = CreateService(hardLinks); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Manifest!.Files.Should().ContainSingle().Which.Method.Should().Be(DeploymentMethod.HardLink); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + hardLinks.CreatedLinks.Should().ContainSingle(); + } + + [Fact] + public async Task PrepareAsyncCopiesFileWhenHardLinkCreatorFailsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Manifest!.Files.Should().ContainSingle().Which.Method.Should().Be(DeploymentMethod.Copy); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + } + + [Fact] + public async Task CleanupAsyncRestoresBackedUpOriginalFileAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "Data")); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini"), "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsyncDoesNotDeleteRestoredOriginalWhenManifestWasAlreadyAppliedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + string activeManifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string staleManifest = File.ReadAllText(activeManifestPath); + await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + File.WriteAllText(activeManifestPath, staleManifest); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public async Task CleanupAsyncRemovesCreatedDirectoriesOnlyWhenEmptyAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "keep.txt"), "user"); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data", "Sub")).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data")).Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "keep.txt")).Should().Be("user"); + } + + [Fact] + public async Task PrepareAsyncDeploysGibSourceAsBigTargetAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("PatchData.gib", "archive")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(Path.Combine(paths.GameDirectory, "PatchData.big")).Should().BeTrue(); + File.Exists(Path.Combine(paths.GameDirectory, "PatchData.gib")).Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncLetsHigherPrecedencePackageWinTargetConflictAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string modRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string addonRoot = CreatePackage(paths, "Addon", ("Data/file.ini", "addon")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest( + paths, + new[] + { + CreatePackage(modRoot, DeploymentPackageKind.Mod, 0), + CreatePackage(addonRoot, DeploymentPackageKind.Addon, 1) + }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + result.Manifest!.Files.Should().ContainSingle().Which.PackageId.Should().Contain("Addon"); + } + + [Fact] + public async Task PrepareAsyncRecoversPartialDeploymentWhenLaterFileFailsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "A")); + File.WriteAllText(Path.Combine(paths.GameDirectory, "A", "file.ini"), "original"); + File.WriteAllText(Path.Combine(paths.GameDirectory, "B"), "not-a-directory"); + string packageRoot = CreatePackage( + paths, + "Mod", + ("A/file.ini", "mod"), + ("B/file.ini", "blocked")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "A", "file.ini")).Should().Be("original"); + File.ReadAllText(Path.Combine(paths.GameDirectory, "B")).Should().Be("not-a-directory"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncFailsWhenDeploymentLockIsHeldAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + using FileStream lockStream = new( + Path.Combine(deploymentRoot, "deployment.lock"), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncFailsWhenPackageTreeContainsReparsePointAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = Path.Combine(paths.ModsDirectory, "Mod"); + string linkTarget = Path.Combine(directory.Path, "linked-package-content"); + string linkPath = Path.Combine(packageRoot, "Linked"); + Directory.CreateDirectory(packageRoot); + Directory.CreateDirectory(linkTarget); + if (!TryCreateDirectoryLink(linkPath, linkTarget)) + { + return; + } + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncFailsWhenGameTargetParentIsReparsePointAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string linkedTarget = Path.Combine(directory.Path, "outside-game-data"); + string linkedDataDirectory = Path.Combine(paths.GameDirectory, "Data"); + Directory.CreateDirectory(linkedTarget); + if (!TryCreateDirectoryLink(linkedDataDirectory, linkedTarget)) + { + return; + } + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(linkedTarget, "file.ini")).Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsyncRestoresBackedUpFileFromJournalWithoutActiveManifestAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRestoresBackupStartedFileWhenMoveCompletedBeforeBackedUpJournalAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backup-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + File.Move(targetPath, backupPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncIgnoresBackupStartedRecordWhenBackupWasNotCreatedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backup-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRestoresBackupWhenCleanupRestoreStartedAndBackupStillExistsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + WriteJournal( + deploymentRoot, + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}", + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}", + "{\"action\":\"file-cleanup-restore-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncTreatsMissingBackupAfterCleanupRestoreStartedAsRestoredAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + WriteJournal( + deploymentRoot, + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}", + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}", + "{\"action\":\"file-cleanup-restore-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncKeepsNoBackupFileDeletedWhenCleanupDeleteCompletedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + WriteJournal( + deploymentRoot, + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}", + "{\"action\":\"file-cleanup-delete-completed\",\"targetRelativePath\":\"Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(targetPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRestoresBackedUpFileFromJournalWithoutActiveManifestAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncFallsBackToJournalWhenActiveManifestIsCorruptAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRemovesFileFromStartedJournalRecordAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-deployment-started\",\"targetRelativePath\":\"Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + private static FileSystemDeploymentService CreateService(IHardLinkCreator hardLinkCreator) + { + return new FileSystemDeploymentService( + hardLinkCreator, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + Directory.CreateDirectory(gameDirectory); + Directory.CreateDirectory(launcherDirectory); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private static string CreatePackage( + LauncherPaths paths, + string name, + params (string RelativePath, string Contents)[] files) + { + string packageRoot = Path.Combine(paths.ModsDirectory, name); + foreach ((string relativePath, string contents) in files) + { + string filePath = Path.Combine(packageRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, contents); + } + + return packageRoot; + } + + private static void WriteJournal(string deploymentRoot, params string[] records) + { + Directory.CreateDirectory(deploymentRoot); + File.WriteAllLines(Path.Combine(deploymentRoot, "journal.jsonl"), records); + } + + private static DeploymentPackage CreatePackage( + string root, + DeploymentPackageKind kind, + int precedence) + { + string id = $"{kind}:{Path.GetFileName(root)}"; + return new DeploymentPackage( + id, + Path.GetFileName(root), + kind, + root, + precedence); + } + + private static bool TryCreateDirectoryLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } + + private sealed class FakeHardLinkCreator : IHardLinkCreator + { + private readonly bool _canCreate; + + public FakeHardLinkCreator(bool canCreate) + { + _canCreate = canCreate; + } + + public List<(string TargetPath, string SourcePath)> CreatedLinks { get; } = new(); + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + if (!_canCreate) + { + return false; + } + + File.Copy(sourcePath, targetPath); + CreatedLinks.Add((targetPath, sourcePath)); + return true; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs new file mode 100644 index 00000000..589fc66f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityTargetBuilderTests +{ + [Fact] + public void BuildTargetsUsesLauncherOwnedTempPathsAndIgnoresInactiveCacheFiles() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + ModificationVersion activeVersion = CreateVersion("Rise", "1.0"); + ModificationVersion inactiveVersion = CreateVersion("Rise", "0.9"); + string cacheDirectory = paths.GetModificationImagesDirectory("Rise"); + Directory.CreateDirectory(cacheDirectory); + File.WriteAllText(Path.Combine(cacheDirectory, "1.0.png"), "active"); + File.WriteAllText(Path.Combine(cacheDirectory, "0.9.png"), "inactive"); + File.WriteAllText(Path.Combine(cacheDirectory, "0.9-background.jpg"), "inactive background"); + var builder = new FileSystemLaunchContentIntegrityTargetBuilder(); + + // Act + IReadOnlyList targets = builder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + paths, + new[] { activeVersion }, + new[] { activeVersion, inactiveVersion }, + "cache")); + + // Assert + targets.Should().HaveCount(2); + LaunchContentIntegrityTargetContext packageTarget = targets.Single(target => !target.IsCache); + packageTarget.Target.RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "1.0")); + packageTarget.Target.SourceKind.Should().Be(ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext cacheTarget = targets.Single(target => target.IsCache); + cacheTarget.Target.RootDirectory.Should().Be(cacheDirectory); + cacheTarget.Target.IgnoredRelativePaths.Should().BeEquivalentTo("0.9.png", "0.9-background.jpg"); + cacheTarget.Target.RootDirectory.Should().StartWith(paths.ImagesDirectory); + } + + private static ModificationVersion CreateVersion(string name, string version) + { + return new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + ContentSourceKind = ContentSourceKind.Manual, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs new file mode 100644 index 00000000..273aef20 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameExecutableDiscoveryServiceTests +{ + [Fact] + public void GetAvailableGameClientsReturnsCommunityThenGeneralsOnlineWhenPresent() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalszh.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + IReadOnlyList clients = service.GetAvailableGameClients(SupportedGame.ZeroHour); + + // Assert + clients.Should().HaveCount(2); + clients[0].ExecutableName.Should().Be("generalszh.exe"); + clients[0].Kind.Should().Be(GameClientExecutableKind.Community); + clients[1].ExecutableName.Should().Be("generalsonlinezh.exe"); + clients[1].Kind.Should().Be(GameClientExecutableKind.GeneralsOnline); + } + + [Fact] + public void GetAvailableGameClientsUsesManagedGeneralsCommunityExecutable() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalsv.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + IReadOnlyList clients = service.GetAvailableGameClients(SupportedGame.Generals); + + // Assert + clients.Should().ContainSingle(); + clients[0].ExecutableName.Should().Be("generalsv.exe"); + clients[0].Kind.Should().Be(GameClientExecutableKind.Community); + } + + [Fact] + public void GetAvailableWorldBuildersReturnsVanillaThenCommunityWhenPresent() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "WorldBuilder.exe"); + CreateGameFile(paths, "worldbuilderzh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + IReadOnlyList worldBuilders = + service.GetAvailableWorldBuilders(SupportedGame.ZeroHour); + + // Assert + worldBuilders.Should().HaveCount(2); + worldBuilders[0].ExecutableName.Should().Be("WorldBuilder.exe"); + worldBuilders[0].Kind.Should().Be(WorldBuilderExecutableKind.Vanilla); + worldBuilders[1].ExecutableName.Should().Be("worldbuilderzh.exe"); + worldBuilders[1].Kind.Should().Be(WorldBuilderExecutableKind.Community); + } + + [Fact] + public void IsExecutableAvailableChecksRelativeNamesInGameDirectory() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalszh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + bool available = service.IsExecutableAvailable("generalszh.exe"); + bool missing = service.IsExecutableAvailable("missing.exe"); + + // Assert + available.Should().BeTrue(); + missing.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailableSupportsRootedExecutablePaths() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string executablePath = Path.Combine(directory.Path, "external.exe"); + File.WriteAllText(executablePath, string.Empty); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + bool available = service.IsExecutableAvailable(executablePath); + + // Assert + available.Should().BeTrue(); + } + + [Fact] + public void IsExecutableAvailableReturnsFalseForBlankExecutable() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + bool available = service.IsExecutableAvailable(" "); + + // Assert + available.Should().BeFalse(); + } + + private static WindowsGameExecutableDiscoveryService CreateService(LauncherPaths paths) + { + return new WindowsGameExecutableDiscoveryService( + paths, + new FixedGameProcessLauncher(), + NullLogger.Instance); + } + + private static void CreateGameFile(LauncherPaths paths, string fileName) + { + Directory.CreateDirectory(paths.GameDirectory); + File.WriteAllText(Path.Combine(paths.GameDirectory, fileName), string.Empty); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private sealed class FixedGameProcessLauncher : IGameProcessLauncher + { + public string GetCommunityGameExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? "generalszh.exe" + : "generalsv.exe"; + } + + public string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? "worldbuilderzh.exe" + : "worldbuilderv.exe"; + } + + public Task LaunchAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(GameLaunchResult.Success("unused.exe", string.Empty, TimeSpan.Zero)); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs new file mode 100644 index 00000000..7c2ecf95 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameProcessLauncherTests +{ + [Fact] + public void CommunityExecutableSelectionUsesManagedGameVariant() + { + // Arrange + WindowsGameProcessLauncher launcher = CreateLauncher(new RecordingProcessFamilyLauncher()); + + // Act + string generalsExecutable = launcher.GetCommunityGameExecutableName(SupportedGame.Generals); + string zeroHourExecutable = launcher.GetCommunityGameExecutableName(SupportedGame.ZeroHour); + string generalsWorldBuilder = launcher.GetCommunityWorldBuilderExecutableName(SupportedGame.Generals); + string zeroHourWorldBuilder = launcher.GetCommunityWorldBuilderExecutableName(SupportedGame.ZeroHour); + + // Assert + generalsExecutable.Should().Be("generalsv.exe"); + zeroHourExecutable.Should().Be("generalszh.exe"); + generalsWorldBuilder.Should().Be("worldbuilderv.exe"); + zeroHourWorldBuilder.Should().Be("worldbuilderzh.exe"); + } + + [Fact] + public async Task LaunchAsyncBuildsCommunityGameExecutableAndArgumentsAsync() + { + // Arrange + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(13), + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + // Act + GameLaunchResult result = await launcher.LaunchAsync( + GameLaunchRequest.ForGameClient(SupportedGame.ZeroHour, useGeneralsOnline: false, "-quickstart"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.ExecutableName.Should().Be("generalszh.exe"); + result.Arguments.Should().Be("-quickstart"); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be(("generalszh.exe", "-quickstart")); + } + + [Fact] + public async Task LaunchAsyncUsesGeneralsOnlineExecutableAndDropsArgumentsAsync() + { + // Arrange + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(13), + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + // Act + GameLaunchResult result = await launcher.LaunchAsync( + GameLaunchRequest.ForGameClient(SupportedGame.ZeroHour, useGeneralsOnline: true, "-ignored"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.ExecutableName.Should().Be("generalsonlinezh.exe"); + result.Arguments.Should().BeEmpty(); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be(("generalsonlinezh.exe", string.Empty)); + } + + [Fact] + public async Task LaunchAsyncUsesExplicitWorldBuilderExecutableAndArgumentsAsync() + { + // Arrange + var processLauncher = new RecordingProcessFamilyLauncher(); + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + // Act + GameLaunchResult result = await launcher.LaunchAsync( + GameLaunchRequest.ForWorldBuilder("worldbuilderzh.exe", "-wb"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.ExecutableName.Should().Be("worldbuilderzh.exe"); + result.Arguments.Should().Be("-wb"); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be(("worldbuilderzh.exe", "-wb")); + } + + private static WindowsGameProcessLauncher CreateLauncher(RecordingProcessFamilyLauncher processLauncher) + { + return new WindowsGameProcessLauncher( + processLauncher, + NullLogger.Instance); + } + + private sealed class RecordingProcessFamilyLauncher : IProcessFamilyLauncher + { + public TimeSpan RunningDuration { get; set; } = TimeSpan.FromSeconds(1); + + public List<(string ExecutableName, string Arguments)> Calls { get; } = new(); + + public Task LaunchAndWaitForExitAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + Calls.Add((executableName, arguments)); + return Task.FromResult(RunningDuration); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..66b5e33e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using GenLauncherGO.Infrastructure.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Infrastructure; + +public sealed class LoggingServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLoggingThrowsForNullServiceCollection() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoLogging("logs"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoLoggingThrowsForMissingLogDirectory() + { + // Arrange + var services = new ServiceCollection(); + + // Act + Action act = () => services.AddGenLauncherGoLogging(" "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoLoggingRegistersLoggerFactoryAndCreatesLogDirectory() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var services = new ServiceCollection(); + + try + { + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoLogging(logDirectory); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + returnedServices.Should().BeSameAs(services); + Directory.Exists(logDirectory).Should().BeTrue(); + provider.GetRequiredService().Should().NotBeNull(); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } + + [Fact] + public void AddGenLauncherGoLoggingUsesReadableSessionLogFileName() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + try + { + // Act + for (int index = 0; index < 2; index++) + { + var services = new ServiceCollection(); + services.AddGenLauncherGoLogging(logDirectory); + using ServiceProvider provider = services.BuildServiceProvider(); + provider + .GetRequiredService>() + .LogInformation("Session {SessionIndex}", index); + } + + // Assert + string[] logFiles = Directory.GetFiles(logDirectory, "GenLauncherGO-*.log"); + logFiles.Should().HaveCount(2); + logFiles.Should().OnlyContain(file => + Regex.IsMatch( + Path.GetFileName(file), + @"^GenLauncherGO-\d{4}-\d{2}-\d{2}-\d{6}Z(-\d+)?\.log$")); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } + + [Fact] + public void AddGenLauncherGoLoggingPrunesOldSessionLogs() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(logDirectory); + + try + { + for (int index = 0; index < 20; index++) + { + string logFilePath = Path.Combine( + logDirectory, + $"GenLauncherGO-2026-01-{index + 1:00}-120000Z.log"); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-index - 1)); + } + + var services = new ServiceCollection(); + + // Act + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + // Assert + Directory.GetFiles(logDirectory, "*.log").Should().HaveCountLessThanOrEqualTo(14); + File.Exists(Path.Combine(logDirectory, "GenLauncherGO-2026-01-20-120000Z.log")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..cc46cf5b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +using System.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Composition; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Composition; + +public sealed class ModsInfrastructureServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoModsInfrastructureRegistersContentServices() + { + // Arrange + using var directory = new TestDirectory(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(CreatePaths(directory.Path)); + + // Act + services.AddGenLauncherGoModsInfrastructure(Path.Combine(directory.Path, "LauncherData.yaml")); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService>() + .Should().BeOfType>(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + LauncherContentCatalogService catalogService = provider.GetRequiredService(); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs new file mode 100644 index 00000000..e8cb55f1 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemLocalLauncherContentServiceTests +{ + [Fact] + public void FindInstalledVersionsReturnsInstalledModsPatchesAndAddons() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "INI.big")); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0", "HD.big")); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "Patches", "Balance", "2.0", "Patch.big")); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "EmptyMod", "1.0")); + + // Act + IReadOnlyList versions = service.FindInstalledVersions(paths, Layout); + + // Assert + versions.Should().HaveCount(3); + versions.Should().ContainSingle(version => + version.ModificationType == LauncherContentType.Mod && + version.Name == "ShockWave" && + version.Version == "1.2" && + version.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == LauncherContentType.Addon && + version.Name == "HD" && + version.Version == "1.0" && + version.DependenceName == "ShockWave" && + version.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == LauncherContentType.Patch && + version.Name == "Balance" && + version.Version == "2.0" && + version.DependenceName == "ShockWave" && + version.Installed); + } + + [Fact] + public void VersionFolderContainsFilesReturnsFalseForMissingOrEmptyVersion() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + var emptyVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + var missingVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "2.0" + }; + + // Act + bool emptyExists = service.VersionFolderContainsFiles(paths, Layout, emptyVersion); + bool missingExists = service.VersionFolderContainsFiles(paths, Layout, missingVersion); + + // Assert + emptyExists.Should().BeFalse(); + missingExists.Should().BeFalse(); + } + + [Fact] + public void VersionFolderExistsReturnsTrueForEmptyVersionAndFalseForMissingVersion() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0")); + var emptyVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + var missingVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "2.0", + DependenceName = "ShockWave" + }; + + // Act + bool emptyExists = service.VersionFolderExists(paths, Layout, emptyVersion); + bool missingExists = service.VersionFolderExists(paths, Layout, missingVersion); + + // Assert + emptyExists.Should().BeTrue(); + missingExists.Should().BeFalse(); + } + + [Fact] + public void DeleteVersionDeletesVersionAndPrunesEmptyParents() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + CreateFile(Path.Combine(versionDirectory, "HD.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + + // Act + service.DeleteVersion(paths, Layout, version); + + // Assert + Directory.Exists(versionDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteVersionDeletesPackageStagingFolderWhenInstalledFolderIsMissing() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(versionDirectory); + CreateFile(Path.Combine(packageStagingDirectory, "Data", "INI.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + // Act + service.DeleteVersion(paths, Layout, version); + + // Assert + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteVersionDeletesPackageStagingFolderForChildContentWhenInstalledFolderIsMissing() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(versionDirectory); + CreateFile(Path.Combine(packageStagingDirectory, "HD.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + + // Act + service.DeleteVersion(paths, Layout, version); + + // Assert + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteVersionRefusesPathOutsideModsRoot() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "..", + Version = "Outside" + }; + + // Act + Action act = () => service.DeleteVersion(paths, Layout, version); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeleteImagesIfUnusedDeletesVersionImagesWhenNoCardReferencesContentName() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + string cardImage = Path.Combine(imageDirectory, "1.2.png"); + string backgroundImage = Path.Combine(imageDirectory, "1.2-background.jpg"); + string otherImage = Path.Combine(imageDirectory, "readme.txt"); + CreateFile(cardImage); + CreateFile(backgroundImage); + CreateFile(otherImage); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + // Act + service.DeleteImagesIfUnused(paths, version, new LauncherContentState()); + + // Assert + File.Exists(cardImage).Should().BeFalse(); + File.Exists(backgroundImage).Should().BeFalse(); + File.Exists(otherImage).Should().BeTrue(); + Directory.Exists(imageDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteImagesIfUnusedKeepsImagesWhenCardStillReferencesContentName() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imagePath = Path.Combine(paths.GetModificationImagesDirectory("ShockWave"), "1.2.png"); + CreateFile(imagePath); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + var state = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState { Name = "ShockWave" } + } + }; + + // Act + service.DeleteImagesIfUnused(paths, version, state); + + // Assert + File.Exists(imagePath).Should().BeTrue(); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static FileSystemLocalLauncherContentService CreateService() + { + return new FileSystemLocalLauncherContentService( + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } + + private static void CreateFile(string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, String.Empty); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs new file mode 100644 index 00000000..5b4377fa --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemManualModificationImporterTests +{ + [Fact] + public void ImportCopiesRegularFilesToDestination() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "readme.txt"); + File.WriteAllText(sourceFilePath, "manual content"); + + FileSystemManualModificationImporter importer = CreateImporter(); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory, "readme.txt")) + .Should().Be("manual content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void ImportRenamesLooseBigFilesToGibFiles() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "package.big"); + File.WriteAllText(sourceFilePath, "big content"); + + FileSystemManualModificationImporter importer = CreateImporter(); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + File.Exists(Path.Combine(destinationDirectory, "package.big")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("big content"); + } + + [Fact] + public void ImportExtractsArchivesAndDeletesStagedArchive() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "package.zip"); + File.WriteAllText(sourceFilePath, "archive content"); + + RecordingArchiveExtractor archiveExtractor = new(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + archiveExtractor.ArchiveFilePath.Should().Be(Path.Combine(destinationDirectory, "package.zip")); + archiveExtractor.DestinationDirectory.Should().Be(destinationDirectory); + File.Exists(Path.Combine(destinationDirectory, "package.zip")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "extracted.txt")) + .Should().Be("extracted content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void ImportRejectsEmptySourceFileList() + { + // Arrange + FileSystemManualModificationImporter importer = CreateImporter(); + + // Act + Action act = () => importer.Import(new ManualModificationImportRequest( + Array.Empty(), + "destination")); + + // Assert + act.Should().Throw() + .WithMessage("*At least one source file is required*"); + } + + private static FileSystemManualModificationImporter CreateImporter( + IArchiveExtractor? archiveExtractor = null) + { + return new FileSystemManualModificationImporter( + archiveExtractor ?? Substitute.For(), + NullLogger.Instance); + } + + private sealed class RecordingArchiveExtractor : IArchiveExtractor + { + public string? ArchiveFilePath { get; private set; } + + public string? DestinationDirectory { get; private set; } + + public ArchiveExtractionOptions? Options { get; private set; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + ArchiveFilePath = archiveFilePath; + DestinationDirectory = destinationDirectory; + Options = options; + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.txt"), "extracted content"); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs new file mode 100644 index 00000000..af6aea3f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs @@ -0,0 +1,150 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationImageFileServiceTests +{ + [Fact] + public void FindExistingImageFilePathReturnsFirstMatchingImage() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + string? existingImagePath = service.FindExistingImageFilePath("ShockWave", "1.2"); + + // Assert + existingImagePath.Should().Be(imagePath); + } + + [Fact] + public void CountImageFilesReturnsZeroForMissingDirectory() + { + // Arrange + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + int count = service.CountImageFiles("Missing"); + + // Assert + count.Should().Be(0); + } + + [Fact] + public void TryDeleteImageRemovesExistingImage() + { + // Arrange + using TestDirectory directory = new(); + string imagePath = Path.Combine(directory.Path, "image.png"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + bool deleted = service.TryDeleteImage(imagePath); + + // Assert + deleted.Should().BeTrue(); + File.Exists(imagePath).Should().BeFalse(); + } + + [Fact] + public async Task ReplaceImageAsyncDeletesStaleExtensionsAndCopiesSelectedImageAsync() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string staleImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(staleImagePath, "old"); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + File.WriteAllText(sourceImagePath, "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + // Assert + destinationPath.Should().Be(Path.Combine(imageDirectory, "1.2.png")); + File.Exists(staleImagePath).Should().BeFalse(); + File.ReadAllText(destinationPath).Should().Be("new"); + } + + [Fact] + public async Task ReplaceImageAsyncNoOpsWhenSourceAlreadyIsDestinationAsync() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string existingImagePath = Path.Combine(imageDirectory, "1.2.png"); + File.WriteAllText(existingImagePath, "same"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", existingImagePath), + CancellationToken.None); + + // Assert + destinationPath.Should().Be(existingImagePath); + File.ReadAllText(existingImagePath).Should().Be("same"); + } + + [Fact] + public async Task ReplaceImageAsyncThrowsForSourceWithoutExtensionAsync() + { + // Arrange + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected"); + File.WriteAllText(sourceImagePath, "new"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + private static FileSystemModificationImageFileService CreateService(LauncherPaths paths) + { + return new FileSystemModificationImageFileService( + paths, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs new file mode 100644 index 00000000..ce42975d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherCatalogImageCacheTests +{ + [Fact] + public async Task CacheModificationImagesAsyncDownloadsCardAndBackgroundImagesToExpectedPathsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var cardUri = new Uri("https://cdn.example.test/card.jpeg"); + var backgroundUri = new Uri("https://cdn.example.test/background.unsupported"); + var modification = new RemoteContentManifest + { + Name = "ShockWave", + Version = "1.2", + ImageSourceLink = cardUri.ToString(), + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = backgroundUri.ToString() + } + }; + + // Act + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + // Assert + await assetDownloader.Received(1).DownloadIfMissingAsync( + cardUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.jpeg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + backgroundUri, + paths.GetModificationImageFilePath("ShockWave", "1.2-background.png"), + Arg.Any()); + } + + [Fact] + public async Task CacheAdvertisingImagesAsyncDeletesStaleImagesWhenImageCountChangesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale"); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg", + "https://cdn.example.test/1.jpg" + }); + + // Act + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + // Assert + File.Exists(staleImagePath).Should().BeFalse(); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/0.jpg"), + paths.GetModificationImageFilePath("Featured Mod", "0.jpg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/1.jpg"), + paths.GetModificationImageFilePath("Featured Mod", "1.jpg"), + Arg.Any()); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs new file mode 100644 index 00000000..68820458 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentCatalogServiceTests +{ + [Fact] + public async Task InitDataAsyncReadsRemoteCatalogForInstalledModsAndDownloadsImagesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var cardImageUri = new Uri("https://cdn.example.test/shockwave.jpg"); + var backgroundImageUri = new Uri("https://cdn.example.test/shockwave-background.png"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + Installed = true, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true + } + } + } + } + }); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + } + }); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + modDatas = new List + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + } + })); + yamlReader.ReadYamlAsync(modUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardImageUri.ToString(), + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = backgroundImageUri.ToString() + } + })); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + + // Assert + service.ReposModsNames.Should().Equal("ShockWave"); + GameModification mod = service.GetMods().Should().ContainSingle(item => item.Name == "ShockWave").Subject; + mod.ModificationVersions.Should().Contain(version => version.Version == "1.2"); + await assetDownloader.Received(1).DownloadIfMissingAsync( + cardImageUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.jpg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + backgroundImageUri, + paths.GetModificationImageFilePath("ShockWave", "1.2-background.png"), + Arg.Any()); + } + + [Fact] + public void SaveLauncherDataPersistsOnlyInstalledOrSelectedVersions() + { + // Arrange + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + service.AddModModification(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true, + IsSelected = true + }); + service.AddModModification(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "2.0" + }); + + // Act + service.SaveLauncherData(); + + // Assert + stateStore.Received(1).Save(Arg.Is(state => + state.Modifications.Count == 1 && + state.Modifications[0].Name == "ShockWave" && + state.Modifications[0].ModificationVersions.Count == 1 && + state.Modifications[0].ModificationVersions[0].Version == "1.0" && + state.Modifications[0].ModificationVersions[0].Installed && + state.Modifications[0].ModificationVersions[0].IsSelected)); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static LauncherContentCatalogService CreateService( + ILauncherContentStateStore stateStore, + ILocalLauncherContentService localContentService, + IRemoteYamlDocumentReader yamlReader, + IRemoteAssetDownloader assetDownloader) + { + var stateMapper = new LauncherContentStateMapper(); + var selectionService = new LauncherContentSelectionService(); + + return new LauncherContentCatalogService( + stateStore, + new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance), + new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance), + stateMapper, + new LauncherLocalContentReconciler( + localContentService, + stateMapper, + selectionService), + selectionService); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs new file mode 100644 index 00000000..964c1dec --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentSelectionServiceTests +{ + [Fact] + public void GetAddonsForSelectedModIncludesPatchDependentAddons() + { + // Arrange + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Patch, + Name = "ShockWave Patch", + Version = "1.1", + DependenceName = "ShockWave", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "Patch Addon", + Version = "2.0", + DependenceName = "ShockWave Patch", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "Mod Addon", + Version = "3.0", + DependenceName = "ShockWave" + }); + var selectionService = new LauncherContentSelectionService(); + + // Act + IReadOnlyList addons = selectionService.GetAddonsForSelectedMod(launcherData); + + // Assert + addons.Select(addon => addon.Name).Should().BeEquivalentTo("Patch Addon", "Mod Addon"); + selectionService.GetSelectedAddonsVersions(launcherData) + .Should().ContainSingle(version => version.Name == "Patch Addon"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs new file mode 100644 index 00000000..774fc4bb --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs @@ -0,0 +1,250 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentStateMapperTests +{ + [Fact] + public void ToLauncherDataRestoresSelectedInstalledContentState() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("ShockWave", string.Empty, LauncherContentType.Mod, "1.0", true) + }, + Patches = new List + { + CreateEntry("ShockWave Patch", "ShockWave", LauncherContentType.Patch, "1.1", true) + }, + Addons = new List + { + CreateEntry("Music Pack", "ShockWave Patch", LauncherContentType.Addon, "2.0", true) + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + ModificationVersion modVersion = launcherData.Modifications.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + launcherData.Modifications[0].IsSelected.Should().BeTrue(); + launcherData.Modifications[0].NumberInList.Should().Be(4); + modVersion.Name.Should().Be("ShockWave"); + modVersion.ModificationType.Should().Be(ModificationType.Mod); + modVersion.Installed.Should().BeTrue(); + modVersion.IsSelected.Should().BeTrue(); + + ModificationVersion patchVersion = launcherData.Patches.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + patchVersion.Name.Should().Be("ShockWave Patch"); + patchVersion.DependenceName.Should().Be("ShockWave"); + patchVersion.ModificationType.Should().Be(ModificationType.Patch); + + ModificationVersion addonVersion = launcherData.Addons.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + addonVersion.Name.Should().Be("Music Pack"); + addonVersion.DependenceName.Should().Be("ShockWave Patch"); + addonVersion.ModificationType.Should().Be(ModificationType.Addon); + } + + [Fact] + public void ToLauncherDataRestoresPersistedEntryOrder() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("Second", string.Empty, LauncherContentType.Mod, "1.0", false, numberInList: 1), + CreateEntry("First", string.Empty, LauncherContentType.Mod, "1.0", false, numberInList: 0) + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + launcherData.Modifications + .OrderBy(modification => modification.NumberInList) + .Select(modification => modification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void ToLauncherDataDoesNotSelectEntryFromStaleVersionSelection() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("ShockWave", string.Empty, LauncherContentType.Mod, "1.0", false, versionSelected: true) + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + GameModification modification = launcherData.Modifications.Should().ContainSingle().Subject; + modification.IsSelected.Should().BeFalse(); + modification.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherDataUsesEntryTypeForIncompleteLegacyChildVersionRecords() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Addons = new List + { + new LauncherContentEntryState + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationType = LauncherContentType.Addon, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Version = "1.0", + Installed = true, + ContentSourceKind = ContentSourceKind.Manual + } + } + } + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + ModificationVersion version = launcherData.Addons.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + version.Name.Should().Be("Compatibility Addon"); + version.DependenceName.Should().Be("ShockWave"); + version.ModificationType.Should().Be(ModificationType.Addon); + version.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void ToLauncherContentStatePersistsOnlyInstalledOrSelectedVersions() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0", + Installed = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "RemoteOnly", + Version = "2.0" + }); + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.Name.Should().Be("Installed"); + entry.IsSelected.Should().BeFalse(); + entry.ModificationVersions.Should().ContainSingle().Which.Version.Should().Be("1.0"); + entry.ModificationVersions[0].IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherContentStateDoesNotPersistVersionSelectionForUnselectedEntry() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0", + Installed = true, + IsSelected = true + }); + launcherData.Modifications[0].IsSelected = false; + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.IsSelected.Should().BeFalse(); + entry.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherContentStatePersistsEntryOrder() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + }); + launcherData.Modifications[0].NumberInList = 7; + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + state.Modifications.Should().ContainSingle().Which.NumberInList.Should().Be(7); + } + + private static LauncherContentEntryState CreateEntry( + string name, + string dependenceName, + LauncherContentType contentType, + string version, + bool selected, + bool? versionSelected = null, + int numberInList = 4) + { + return new LauncherContentEntryState + { + Name = name, + DependenceName = dependenceName, + ModificationType = contentType, + IsSelected = selected, + NumberInList = numberInList, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Version = version, + Installed = true, + IsSelected = versionSelected ?? selected, + ContentSourceKind = ContentSourceKind.Manual + } + } + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs new file mode 100644 index 00000000..c0833d70 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherLocalContentReconcilerTests +{ + [Fact] + public void ReconcileAddsUnregisteredLocalVersions() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var launcherData = new LauncherData(); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "Local Only", + Version = "1.0", + Installed = true + } + }); + + // Act + reconciler.Reconcile(launcherData, new List(), paths, Layout); + + // Assert + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Local Only"); + localContentService.DidNotReceive().VersionFolderContainsFiles( + paths, + Layout, + Arg.Any()); + } + + [Fact] + public void ReconcileMarksMissingRemoteVersionsUninstalledAndDeletesMissingLocalOnlyVersions() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var remoteVersion = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Remote", + Version = "1.0", + Installed = true + }; + var localOnlyVersion = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Local Only", + Version = "2.0", + Installed = true + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(remoteVersion); + launcherData.AddOrUpdate(localOnlyVersion); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List()); + + // Act + reconciler.Reconcile( + launcherData, + new List { new ModificationVersion(remoteVersion) }, + paths, + Layout); + + // Assert + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Remote"); + launcherData.Modifications[0].ModificationVersions.Should().ContainSingle().Which.Installed.Should().BeFalse(); + localContentService.DidNotReceive().VersionFolderContainsFiles( + paths, + Layout, + Arg.Any()); + localContentService.Received(1).DeleteImagesIfUnused( + paths, + Arg.Is(version => version.Name == "Local Only"), + Arg.Any()); + } + + [Fact] + public void ReconcileKeepsInstalledChildVersionWhenVersionFolderStillExists() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var modVersion = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + Installed = true + }; + var addonVersion = new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave", + Installed = true + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(modVersion); + launcherData.AddOrUpdate(addonVersion); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2", + Installed = true + } + }); + localContentService.VersionFolderExists( + paths, + Layout, + Arg.Is(version => + version.ModificationType == LauncherContentType.Addon && + version.Name == "HD" && + version.Version == "1.0" && + version.DependenceName == "ShockWave")) + .Returns(true); + + // Act + reconciler.Reconcile(launcherData, new List(), paths, Layout); + + // Assert + launcherData.Addons.Should().ContainSingle(addon => addon.Name == "HD"); + launcherData.Addons[0].ModificationVersions.Should().ContainSingle().Which.Installed.Should().BeTrue(); + localContentService.DidNotReceive().DeleteImagesIfUnused( + paths, + Arg.Is(version => version.Name == "HD"), + Arg.Any()); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static LauncherLocalContentReconciler CreateReconciler( + ILocalLauncherContentService localContentService) + { + var stateMapper = new LauncherContentStateMapper(); + return new LauncherLocalContentReconciler( + localContentService, + stateMapper, + new LauncherContentSelectionService()); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs new file mode 100644 index 00000000..53beb0de --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class RemoteLauncherCatalogClientTests +{ + [Fact] + public async Task DownloadInstalledModDataAsyncReadsInstalledModsAndPreservesPartialFailuresAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var shockwaveUri = new Uri("https://example.test/shockwave.yaml"); + var brokenUri = new Uri("https://example.test/broken.yaml"); + var contraUri = new Uri("https://example.test/contra.yaml"); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", shockwaveUri.ToString(), Array.Empty(), Array.Empty()), + new("Broken", brokenUri.ToString(), Array.Empty(), Array.Empty()), + new("Contra", contraUri.ToString(), Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty(), + string.Empty); + yamlReader.ReadYamlAsync(shockwaveUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + Name = "ShockWave", + Version = "1.2" + })); + yamlReader.ReadYamlAsync(brokenUri, Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Broken manifest"))); + + // Act + IReadOnlyList result = + await client.DownloadInstalledModDataAsync( + catalog, + new[] { "shockwave", "broken" }, + CancellationToken.None); + + // Assert + RemoteModificationManifest entry = result.Should().ContainSingle().Subject; + entry.Content.Name.Should().Be("ShockWave"); + entry.Content.Version.Should().Be("1.2"); + entry.PatchManifestUrls.Should().BeEmpty(); + await yamlReader.DidNotReceive().ReadYamlAsync( + contraUri, + Arg.Any()); + } + + [Fact] + public async Task ReadChildManifestsAsyncReturnsSuccessfulChildrenWhenOneChildFailsAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var patchUri = new Uri("https://example.test/patch.yaml"); + var missingUri = new Uri("https://example.test/missing.yaml"); + yamlReader.ReadYamlAsync(patchUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + Name = "Patch", + Version = "1.0" + })); + yamlReader.ReadYamlAsync(missingUri, Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Missing manifest"))); + + // Act + IReadOnlyList result = await client.ReadChildManifestsAsync( + new[] { patchUri.ToString(), missingUri.ToString() }, + CancellationToken.None); + + // Assert + result.Should().ContainSingle().Which.Name.Should().Be("Patch"); + } + + [Fact] + public async Task ReadCatalogAsyncMapsThirdPartyManifestToNormalizedCatalogAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/repos.yaml"); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + AdvData = + { + new AdvertisingData + { + ModName = "Featured", + ModLink = "https://example.test/featured.yaml", + ImagesData = { "https://cdn.example.test/featured.png" } + } + }, + modDatas = + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = "https://example.test/shockwave.yaml", + ModPatches = { "https://example.test/shockwave-patch.yaml" }, + ModAddons = { "https://example.test/shockwave-addon.yaml" } + } + }, + originalGameAddons = { "https://example.test/original-addon.yaml" }, + originalGamePatches = { "https://example.test/original-patch.yaml" }, + LauncherVersion = "1.2.3" + })); + + // Act + RemoteLauncherCatalog catalog = await client.ReadCatalogAsync(manifestUri, CancellationToken.None); + + // Assert + catalog.AdvertisingEntries.Should().ContainSingle().Which.ImageUrls.Should() + .ContainSingle("https://cdn.example.test/featured.png"); + catalog.Modifications.Should().ContainSingle().Which.PatchManifestUrls.Should() + .ContainSingle("https://example.test/shockwave-patch.yaml"); + catalog.OriginalGameAddonManifestUrls.Should().ContainSingle("https://example.test/original-addon.yaml"); + catalog.OriginalGamePatchManifestUrls.Should().ContainSingle("https://example.test/original-patch.yaml"); + catalog.LauncherVersion.Should().Be("1.2.3"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs new file mode 100644 index 00000000..1de16b42 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs @@ -0,0 +1,44 @@ +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class YamlLauncherContentStateStoreTests +{ + [Fact] + public void LoadUsesEmptyContentStateAsDefaultDocument() + { + // Arrange + IYamlDocumentStore documentStore = + Substitute.For>(); + documentStore.Load(Arg.Any()) + .Returns(call => call.Arg()); + var store = new YamlLauncherContentStateStore(documentStore); + + // Act + LauncherContentState state = store.Load(); + + // Assert + state.Modifications.Should().BeEmpty(); + state.Addons.Should().BeEmpty(); + state.Patches.Should().BeEmpty(); + documentStore.Received(1).Load(Arg.Any()); + } + + [Fact] + public void SaveDelegatesToYamlDocumentStore() + { + // Arrange + IYamlDocumentStore documentStore = + Substitute.For>(); + var store = new YamlLauncherContentStateStore(documentStore); + var state = new LauncherContentState(); + + // Act + store.Save(state); + + // Assert + documentStore.Received(1).Save(state); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs new file mode 100644 index 00000000..5fd87a3b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs @@ -0,0 +1,171 @@ +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Persistence.Options; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class YamlDocumentStoreTests +{ + [Fact] + public void Load_WhenDocumentIsMissing_ReturnsDefaultDocument() + { + // Arrange + using var directory = new TestDirectory(); + var defaultDocument = new TestDocument { Name = "default" }; + IYamlDocumentStore store = CreateStore(Path.Combine(directory.Path, "state.yaml")); + + // Act + TestDocument document = store.Load(defaultDocument); + + // Assert + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Save_WritesDocumentThatCanBeLoaded() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "Runtime", "State", "state.yaml"); + IYamlDocumentStore store = CreateStore(documentPath); + var document = new TestDocument + { + Name = "ShockWave", + Version = "1.2", + Installed = true + }; + + // Act + store.Save(document); + TestDocument loadedDocument = store.Load(new TestDocument()); + + // Assert + loadedDocument.Name.Should().Be("ShockWave"); + loadedDocument.Version.Should().Be("1.2"); + loadedDocument.Installed.Should().BeTrue(); + File.Exists(documentPath).Should().BeTrue(); + } + + [Fact] + public void Load_DeserializesLegacyRepositoryManifestPropertyNames() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "repos.yaml"); + File.WriteAllText( + documentPath, + """ + AdvData: + - ModName: Sponsor + ModLink: https://example.test/sponsor.yaml + ImagesData: + - https://cdn.example.test/sponsor.png + globalAddonsData: + - https://example.test/global-addon.yaml + modDatas: + - ModName: ShockWave + ModLink: https://example.test/shockwave.yaml + ModPatches: + - https://example.test/shockwave-patch.yaml + ModAddons: + - https://example.test/shockwave-addon.yaml + originalGameAddons: + - https://example.test/original-addon.yaml + originalGamePatches: + - https://example.test/original-patch.yaml + LauncherVersion: 1.2.3 + """); + IYamlDocumentStore store = new YamlDocumentStore( + new YamlDocumentStoreOptions(documentPath), + NullLogger>.Instance); + + // Act + ReposModsData document = store.Load(new ReposModsData()); + + // Assert + document.AdvData.Should().ContainSingle().Which.ImagesData.Should() + .ContainSingle("https://cdn.example.test/sponsor.png"); + document.globalAddonsData.Should().ContainSingle("https://example.test/global-addon.yaml"); + document.modDatas.Should().ContainSingle().Which.ModAddons.Should() + .ContainSingle("https://example.test/shockwave-addon.yaml"); + document.originalGameAddons.Should().ContainSingle("https://example.test/original-addon.yaml"); + document.originalGamePatches.Should().ContainSingle("https://example.test/original-patch.yaml"); + document.LauncherVersion.Should().Be("1.2.3"); + } + + [Fact] + public void Save_PreservesThirdPartyRepositoryManifestPropertyNames() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "repos.yaml"); + IYamlDocumentStore store = new YamlDocumentStore( + new YamlDocumentStoreOptions(documentPath), + NullLogger>.Instance); + var document = new ReposModsData + { + AdvData = + { + new AdvertisingData + { + ModName = "Sponsor", + ModLink = "https://example.test/sponsor.yaml", + ImagesData = { "https://cdn.example.test/sponsor.png" } + } + }, + globalAddonsData = { "https://example.test/global-addon.yaml" }, + modDatas = + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = "https://example.test/shockwave.yaml", + ModPatches = { "https://example.test/shockwave-patch.yaml" }, + ModAddons = { "https://example.test/shockwave-addon.yaml" } + } + }, + originalGameAddons = { "https://example.test/original-addon.yaml" }, + originalGamePatches = { "https://example.test/original-patch.yaml" }, + LauncherVersion = "1.2.3" + }; + + // Act + store.Save(document); + string yaml = File.ReadAllText(documentPath); + + // Assert + yaml.Should().Contain("AdvData:"); + yaml.Should().Contain("globalAddonsData:"); + yaml.Should().Contain("modDatas:"); + yaml.Should().Contain("originalGameAddons:"); + yaml.Should().Contain("originalGamePatches:"); + yaml.Should().Contain("LauncherVersion:"); + yaml.Should().Contain("ModName:"); + yaml.Should().Contain("ModLink:"); + yaml.Should().Contain("ModPatches:"); + yaml.Should().Contain("ModAddons:"); + yaml.Should().NotContain("GlobalAddonsData:"); + yaml.Should().NotContain("Modifications:"); + yaml.Should().NotContain("OriginalGameAddons:"); + yaml.Should().NotContain("OriginalGamePatches:"); + } + + private static YamlDocumentStore CreateStore(string documentPath) + { + return new YamlDocumentStore( + new YamlDocumentStoreOptions(documentPath), + NullLogger>.Instance); + } + + private sealed class TestDocument + { + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public bool Installed { get; set; } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs new file mode 100644 index 00000000..0bd7b624 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteAssetDownloaderTests +{ + [Fact] + public async Task DownloadIfMissingAsync_DeletesStaleTemporaryFileAndDoesNotResumeAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + string temporaryFilePath = destinationFilePath + ".download"; + await File.WriteAllTextAsync(temporaryFilePath, "stale"); + + RecordingFileDownloader fileDownloader = new(); + HttpRemoteAssetDownloader downloader = new( + fileDownloader, + NullLogger.Instance); + + // Act + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + // Assert + fileDownloader.Requests.Should().ContainSingle() + .Which.Resume.Should().BeFalse(); + File.ReadAllText(destinationFilePath).Should().Be("fresh"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + private sealed class RecordingFileDownloader : IResumableFileDownloader + { + public List Requests { get; } = new(); + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Add(request); + await File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken); + return new DownloadFileResult(request.DestinationFilePath, 5, false); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs new file mode 100644 index 00000000..e9ec5bda --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs @@ -0,0 +1,78 @@ +using System.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Options; +using GenLauncherGO.Infrastructure.Settings.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Services; + +public sealed class PreferencesServiceTests +{ + [Fact] + public void Current_WhenPreferencesFileIsMissing_ReturnsDefaults() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + + // Act + PreferencesService service = CreateService(preferencesFilePath); + + // Assert + service.Current.Should().Be(new LauncherPreferences()); + } + + [Fact] + public void Update_PersistsPreferencesWithoutLegacySettingsFile() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + var preferences = new LauncherPreferences + { + LaunchesCount = 7, + AutoDeleteOldVersions = true, + HideLauncherAfterGameStart = true, + UseEnglishLanguage = true, + SelectedGameClient = "generalszh.exe", + SelectedWorldBuilder = "worldbuilderzh.exe", + GameArguments = "-quickstart", + WorldBuilderArguments = "-wb" + }; + + // Act + service.Update(preferences); + PreferencesService reloadedService = CreateService(preferencesFilePath); + + // Assert + reloadedService.Current.Should().Be(preferences); + File.Exists(preferencesFilePath).Should().BeTrue(); + } + + [Fact] + public void Update_WhenPreferencesChange_RaisesPreferencesChanged() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, args) => changedPreferences = args.Preferences; + var preferences = new LauncherPreferences { GameArguments = "-quickstart" }; + + // Act + service.Update(preferences); + + // Assert + changedPreferences.Should().Be(preferences); + } + + private static PreferencesService CreateService(string preferencesFilePath) + { + return new PreferencesService( + new LauncherPreferencesStoreOptions(preferencesFilePath), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..ea2afe66 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs @@ -0,0 +1,52 @@ +using System; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Infrastructure.Shell.Composition; +using GenLauncherGO.Infrastructure.Shell.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Composition; + +public sealed class ShellServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoShellReturnsSameServiceCollection() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoShell(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoShellRegistersWindowsShellService() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoShell(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should().BeOfType(); + } + + [Fact] + public void AddGenLauncherGoShellThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoShell(); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs new file mode 100644 index 00000000..a854e7a5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs @@ -0,0 +1,74 @@ +using System.IO; +using GenLauncherGO.Core.Shell.Models; +using GenLauncherGO.Infrastructure.Shell.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Services; + +public sealed class WindowsLauncherShellServiceTests +{ + [Fact] + public void OpenUriReturnsInvalidTargetForRelativeUri() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenUri("not-a-uri"); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenUriReturnsInvalidTargetForUnsupportedScheme() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenUri("ftp://example.test/file.big"); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenFolderReturnsMissingTargetForMissingFolder() + { + // Arrange + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + string missingFolder = Path.Combine(directory.Path, "missing"); + + // Act + ShellOpenResult result = service.OpenFolder(missingFolder); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.MissingTarget); + } + + [Fact] + public void OpenFolderReturnsMissingTargetWhenFilesAreRequiredAndFolderIsEmpty() + { + // Arrange + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenFolder(directory.Path, requireFiles: true); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.MissingTarget); + } + + private static WindowsLauncherShellService CreateService() + { + return new WindowsLauncherShellService(NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs new file mode 100644 index 00000000..5fce1139 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherPathResolverTests +{ + [Fact] + public void ResolveReturnsGameRootAndGeneratedLauncherRootWhenExecutableIsBesideZeroHourClient() + { + // Arrange + using var directory = new TestDirectory(); + CreateZeroHourGame(directory.Path, "generalszh.exe"); + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(directory.Path); + + // Assert + paths.Should().NotBeNull(); + paths!.GameDirectory.Should().Be(Path.GetFullPath(directory.Path)); + paths.LauncherDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO")); + paths.RuntimeDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime")); + paths.CacheDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Cache")); + paths.ImagesDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Cache", "Images")); + paths.ModsDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Mods")); + paths.LogsDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Logs")); + paths.TempDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Temp")); + paths.DeploymentDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Deployment")); + paths.StateDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "State")); + paths.LauncherDataFilePath.Should().Be( + Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "State", "LauncherData.yaml")); + paths.PreferencesFilePath.Should() + .Be(Path.Combine(directory.Path, "GenLauncherGO", "LauncherPreferences.yaml")); + } + + [Fact] + public void ResolveReturnsParentGameRootAndExecutableDirectoryWhenExecutableIsInsideLauncherRoot() + { + // Arrange + using var directory = new TestDirectory(); + CreateZeroHourGame(directory.Path, "generalszh.exe"); + string launcherDirectory = Path.Combine(directory.Path, "GenLauncherGO"); + Directory.CreateDirectory(launcherDirectory); + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(launcherDirectory); + + // Assert + paths.Should().NotBeNull(); + paths!.GameDirectory.Should().Be(Path.GetFullPath(directory.Path)); + paths.LauncherDirectory.Should().Be(Path.GetFullPath(launcherDirectory)); + } + + [Theory] + [InlineData("Window.big", "generalsv.exe")] + [InlineData("WindowZH.big", "generalszh.exe")] + [InlineData("WindowZH.big", "generalsonlinezh.exe")] + public void ResolveAcceptsSupportedGameVariants(string windowArchiveFileName, string executableFileName) + { + // Arrange + using var directory = new TestDirectory(); + CreateFile(Path.Combine(directory.Path, "BINKW32.DLL")); + CreateFile(Path.Combine(directory.Path, windowArchiveFileName)); + CreateFile(Path.Combine(directory.Path, executableFileName)); + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(directory.Path); + + // Assert + paths.Should().NotBeNull(); + paths!.GameDirectory.Should().Be(Path.GetFullPath(directory.Path)); + } + + [Theory] + [InlineData("BINKW32.DLL")] + [InlineData("WindowZH.big")] + [InlineData("generalszh.exe")] + public void ResolveReturnsNullWhenRequiredZeroHourFileIsMissing(string missingFileName) + { + // Arrange + using var directory = new TestDirectory(); + foreach (string fileName in new[] { "BINKW32.DLL", "WindowZH.big", "generalszh.exe" }) + { + if (!String.Equals(fileName, missingFileName, StringComparison.OrdinalIgnoreCase)) + { + CreateFile(Path.Combine(directory.Path, fileName)); + } + } + + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(directory.Path); + + // Assert + paths.Should().BeNull(); + } + + [Fact] + public void PrepareLauncherDirectoriesCreatesExpectedDirectoriesAndClearsTempContents() + { + // Arrange + using var directory = new TestDirectory(); + CreateZeroHourGame(directory.Path, "generalszh.exe"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherPaths paths = resolver.Resolve(directory.Path)!; + string staleTempDirectory = Path.Combine(paths.TempDirectory, "Stale"); + Directory.CreateDirectory(staleTempDirectory); + CreateFile(Path.Combine(staleTempDirectory, "download.part")); + string staleDeploymentJournal = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + Directory.CreateDirectory(paths.DeploymentDirectory); + CreateFile(staleDeploymentJournal); + + // Act + resolver.PrepareLauncherDirectories(paths, cleanTemporaryDirectory: true); + + // Assert + Directory.Exists(paths.LauncherDirectory).Should().BeTrue(); + Directory.Exists(paths.RuntimeDirectory).Should().BeTrue(); + Directory.Exists(paths.CacheDirectory).Should().BeTrue(); + Directory.Exists(paths.ImagesDirectory).Should().BeTrue(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + Directory.Exists(paths.LogsDirectory).Should().BeTrue(); + Directory.Exists(paths.TempDirectory).Should().BeTrue(); + Directory.Exists(paths.DeploymentDirectory).Should().BeTrue(); + Directory.Exists(paths.StateDirectory).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(paths.TempDirectory).Should().BeEmpty(); + File.Exists(staleDeploymentJournal).Should().BeTrue(); + } + + private static void CreateZeroHourGame(string directory, string executableFileName) + { + CreateFile(Path.Combine(directory, "BINKW32.DLL")); + CreateFile(Path.Combine(directory, "WindowZH.big")); + CreateFile(Path.Combine(directory, executableFileName)); + } + + private static void CreateFile(string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, String.Empty); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs new file mode 100644 index 00000000..2794e8ca --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherStartupEnvironmentServiceTests +{ + [Fact] + public async Task ReadAsyncReturnsZeroHourEnvironmentWithCustomVisualsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateFile(Path.Combine(directory.Path, "WindowZH.big")); + string backgroundPath = Path.Combine(directory.Path, "GlBg.png"); + CreateFile(backgroundPath); + File.WriteAllText( + Path.Combine(directory.Path, "Colors.yaml"), + """ + GenLauncherBorderColor: '#101010' + GenLauncherInactiveBorder: '#202020' + GenLauncherInactiveBorder2: '#303030' + GenLauncherActiveColor: '#404040' + GenLauncherDarkFillColor: '#505050' + GenLauncherDarkBackGround: '#606060' + GenLauncherLightBackGround: '#70606060' + GenLauncherDefaultTextColor: '#808080' + GenLauncherDownloadTextColor: '#909090' + GenLauncherListBoxSelectionColor2: '#A0A0A0' + GenLauncherListBoxSelectionColor1: '#B0B0B0' + GenLauncherButtonSelectionColor: '#C0C0C0' + """); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.ManagedGame.Should().Be(SupportedGame.ZeroHour); + environment.CustomBackgroundImagePath.Should().Be(Path.GetFullPath(backgroundPath)); + environment.CustomColors.Should().NotBeNull(); + environment.CustomColors!.GenLauncherActiveColor.Should().Be("#404040"); + } + + [Fact] + public async Task ReadAsyncReturnsGeneralsEnvironmentWhenGeneralsArchiveExistsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateFile(Path.Combine(directory.Path, "Window.big")); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.ManagedGame.Should().Be(SupportedGame.Generals); + environment.CustomBackgroundImagePath.Should().BeNull(); + environment.CustomColors.Should().BeNull(); + } + + [Fact] + public async Task ReadAsyncReturnsUnknownEnvironmentWhenNoGameArchiveExistsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.ManagedGame.Should().Be(SupportedGame.Unknown); + environment.CustomBackgroundImagePath.Should().BeNull(); + environment.CustomColors.Should().BeNull(); + } + + [Fact] + public async Task ReadAsyncIgnoresMalformedCustomColorsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + File.WriteAllText(Path.Combine(directory.Path, "Colors.yaml"), "["); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.CustomColors.Should().BeNull(); + } + + [Fact] + public async Task ReadAsyncThrowsForNullPathsAsync() + { + // Arrange + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + Func act = async () => await service.ReadAsync(null!, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + private static LauncherPaths CreatePaths(string gameDirectory) + { + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + string runtimeDirectory = Path.Combine(launcherDirectory, "Runtime"); + string cacheDirectory = Path.Combine(runtimeDirectory, "Cache"); + return new LauncherPaths( + gameDirectory, + launcherDirectory, + runtimeDirectory, + cacheDirectory, + Path.Combine(cacheDirectory, "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(runtimeDirectory, "Temp"), + Path.Combine(runtimeDirectory, "Deployment")); + } + + private static void CreateFile(string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, String.Empty); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs new file mode 100644 index 00000000..1dd21b6c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Infrastructure.Startup; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsLauncherHostEnvironmentServiceTests +{ + [Fact] + public void GetExecutableDirectoryReturnsExistingDirectory() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + string directory = service.GetExecutableDirectory(); + + // Assert + directory.Should().NotBeNullOrWhiteSpace(); + Directory.Exists(directory).Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstanceReturnsAcquiredGuardForUnusedName() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + + // Act + using ILauncherSingleInstanceGuard guard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + // Assert + guard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstanceReturnsRejectedGuardWhenNameIsAlreadyOwned() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using ILauncherSingleInstanceGuard firstGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + // Act + using ILauncherSingleInstanceGuard secondGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + // Assert + firstGuard.IsAcquired.Should().BeTrue(); + secondGuard.IsAcquired.Should().BeFalse(); + } + + [Fact] + public void TryAcquireSingleInstanceThrowsForMissingName() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + Action act = () => service.TryAcquireSingleInstance(" ", TimeSpan.Zero); + + // Assert + act.Should().Throw(); + } + + private static string CreateInstanceName() + { + return "GenLauncherGO.Tests." + Guid.NewGuid().ToString("N"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs new file mode 100644 index 00000000..6648ffb7 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class ResumableHttpFileDownloaderTests +{ + [Fact] + public async Task DownloadFileAsync_WritesResponseBodyToDestinationAsync() + { + // Arrange + byte[] payload = Encoding.UTF8.GetBytes("download-content"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, payload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + progress, + CancellationToken.None); + + // Assert + File.ReadAllBytes(destinationFilePath).Should().Equal(payload); + result.FilePath.Should().Be(destinationFilePath); + result.BytesWritten.Should().Be(payload.Length); + result.Resumed.Should().BeFalse(); + progress.Reports.Should().Contain(report => report.BytesDownloaded == payload.Length); + } + + [Fact] + public async Task DownloadFileAsync_ResumesExistingPartialFileWithRangeRequestAsync() + { + // Arrange + byte[] partialPayload = Encoding.UTF8.GetBytes("abc"); + byte[] remainingPayload = Encoding.UTF8.GetBytes("def"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllBytesAsync(destinationFilePath, partialPayload); + + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse(HttpStatusCode.PartialContent, remainingPayload); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5, 6); + return response; + }); + + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, ExpectedBytes: 6), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + File.ReadAllText(destinationFilePath).Should().Be("abcdef"); + result.BytesWritten.Should().Be(6); + result.Resumed.Should().BeTrue(); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerIgnoresRangeRequestAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + await File.WriteAllTextAsync(destinationFilePath, "partial"); + + byte[] fullPayload = Encoding.UTF8.GetBytes("fresh"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, fullPayload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=7-"); + File.ReadAllText(destinationFilePath).Should().Be("fresh"); + result.BytesWritten.Should().Be(fullPayload.Length); + result.Resumed.Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerReturnsUnexpectedContentRangeAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc"); + + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse(HttpStatusCode.PartialContent, Encoding.UTF8.GetBytes("xyz")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 2, 6); + return response; + }); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("abcdef"))); + + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, ExpectedBytes: 6), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().Equal("bytes=3-", null); + File.ReadAllText(destinationFilePath).Should().Be("abcdef"); + result.BytesWritten.Should().Be(6); + result.Resumed.Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_ReportsProgressWhileContentIsDownloadingAsync() + { + // Arrange + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StreamContent(new DelayedChunkStream(payload, 2, TimeSpan.FromMilliseconds(5))), + }; + response.Content.Headers.ContentLength = payload.Length; + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + options: new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromSeconds(5), + MaxAttempts = 2, + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + }); + RecordingProgress progress = new(); + + // Act + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + // Assert + long[] reportedBytes = progress.Reports.Select(report => report.BytesDownloaded).ToArray(); + reportedBytes.Should().StartWith(0); + reportedBytes.Should().Contain(value => value > 0 && value < payload.Length); + reportedBytes.Should().EndWith(payload.Length); + reportedBytes.Should().BeInAscendingOrder(); + } + + private static ResumableHttpFileDownloader CreateDownloader( + QueueHttpMessageHandler handler, + ResumableHttpDownloadOptions? options = null) + { + HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + return new ResumableHttpFileDownloader( + httpClient, + options: options ?? new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromSeconds(5), + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + MaxAttempts = 2, + }); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, byte[] payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new ByteArrayContent(payload), + }; + } + + private sealed class QueueHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses = new(); + + public List RangeHeaders { get; } = new(); + + public void Enqueue(Func responseFactory) + { + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RangeHeaders.Add(request.Headers.Range?.ToString()); + return Task.FromResult(_responses.Dequeue()(request)); + } + } + + private sealed class DelayedChunkStream : Stream + { + private readonly byte[] _payload; + private readonly int _chunkSize; + private readonly TimeSpan _delay; + private int _position; + + public DelayedChunkStream(byte[] payload, int chunkSize, TimeSpan delay) + { + _payload = payload; + _chunkSize = chunkSize; + _delay = delay; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _payload.Length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_position >= _payload.Length) + { + return 0; + } + + await Task.Delay(_delay, cancellationToken); + int bytesToCopy = Math.Min(Math.Min(_chunkSize, buffer.Length), _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(T value) + { + _reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..61421425 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs @@ -0,0 +1,82 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.DependencyInjection; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Composition; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Composition; + +public sealed class UpdatingServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoUpdating_ReturnsSameServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoUpdating(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoUpdating_ThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoUpdating(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoUpdating_RegistersExpectedServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(new LauncherContentLayout("Addons", "Patches")); + services.AddSingleton(new LauncherPaths( + "Game", + "Launcher", + "Runtime", + "Cache", + "Images", + "Mods", + "Logs", + "Temp", + "Deployment")); + + // Act + services.AddGenLauncherGoUpdating(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService() + .Should() + .BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs new file mode 100644 index 00000000..03880177 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Models; + +public sealed class S3RequestDefaultsTests +{ + [Fact] + public void S3ObjectManifestRequest_DefaultsHostOnlyEndpointToNonSsl() + { + // Arrange / Act + S3ObjectManifestRequest request = new( + "gen.insave.ovh:9000", + "mods", + "folder", + "access", + "secret"); + + // Assert + request.UseSsl.Should().BeFalse(); + } + + [Fact] + public void S3PackageUpdateRequest_DefaultsHostOnlyEndpointToNonSsl() + { + // Arrange / Act + S3PackageUpdateRequest request = new( + new[] { new RemoteFileManifestEntry("file.big", "hash", 1) }, + "gen.insave.ovh:9000", + "mods", + "folder", + "access", + "secret", + "temp", + "installed", + null, + new HashSet(StringComparer.OrdinalIgnoreCase)); + + // Assert + request.UseSsl.Should().BeFalse(); + } + + [Fact] + public void CreateManifestRequest_UsesPublicCatalogKeysWhenMetadataKeysAreMissing() + { + // Arrange + ModificationVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder", + }; + + // Act + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + // Assert + request.AccessKey.Should().Be(S3CatalogDefaults.PublicAccessKey); + request.SecretKey.Should().Be(S3CatalogDefaults.PublicSecretKey); + } + + [Fact] + public void CreateManifestRequest_PreservesExplicitMetadataKeys() + { + // Arrange + ModificationVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder", + S3HostPublicKey = "custom-access", + S3HostSecretKey = "custom-secret", + }; + + // Act + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + // Assert + request.AccessKey.Should().Be("custom-access"); + request.SecretKey.Should().Be("custom-secret"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs new file mode 100644 index 00000000..76a4150b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class S3PackageUpdaterBehaviorTests +{ + [Fact] + public async Task UpdateAsync_CopiesMatchingFilesFromLatestAndSkipsDownloadAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string latestPath = Path.Combine(testDirectory.Path, "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry("Data/readme.txt", hash, (ulong)new FileInfo(latestFilePath).Length)), + progress, + CancellationToken.None); + + // Assert + downloader.Requests.Should().BeEmpty(); + File.ReadAllText(Path.Combine(installedPath, "Data", "readme.txt")).Should().Be("payload"); + progress.Reports.Should().Contain(report => report.FileName == null); + } + + [Fact] + public async Task UpdateAsync_ReportsOnlyMissingFileBytesWhenLatestFilesAreReusedAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string latestPath = Path.Combine(testDirectory.Path, "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "reused.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllBytesAsync(latestFilePath, CreatePayload(660)); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry("Data/reused.txt", hash, 660), + new RemoteFileManifestEntry("Data/missing.txt", hash, 20)), + progress, + CancellationToken.None); + + // Assert + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(20); + progress.Reports[0].BytesRead.Should().Be(20); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task UpdateAsync_ReportsOnlyRemainingBytesForPartialStagedDownloadAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string partialFilePath = Path.Combine(temporaryPath, "Data", "missing.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(partialFilePath)!); + await File.WriteAllBytesAsync(partialFilePath, CreatePayload(5)); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("Data/missing.txt", hash, 20)), + progress, + CancellationToken.None); + + // Assert + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(15); + progress.Reports[0].BytesRead.Should().Be(15); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task UpdateAsync_RejectsManifestPathOutsideTemporaryFolderAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + // Act + Func act = async () => await updater.UpdateAsync( + CreateRequest( + Path.Combine(testDirectory.Path, "temp"), + Path.Combine(testDirectory.Path, "installed"), + null, + new RemoteFileManifestEntry("../escape.txt", "0123456789ABCDEF0123456789ABCDEF", 1)), + null, + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(testDirectory.Path, "escape.txt")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_PrunesStaleTemporaryFilesBeforeInstallingAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Runtime", "Temp"); + string packagesPath = Path.Combine(temporaryRoot, "Packages"); + string temporaryPath = Path.Combine(packagesPath, "NProject Mod", "2.11"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + Directory.CreateDirectory(temporaryPath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "stale.txt"), "stale"); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "readme.txt"), "payload"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService { HashForPath = _ => hash }); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("readme.txt", hash, 7)), + null, + CancellationToken.None); + + // Assert + File.Exists(Path.Combine(installedPath, "stale.txt")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + Directory.Exists(packagesPath).Should().BeFalse(); + Directory.Exists(temporaryRoot).Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsync_RemovesUnsafeStagingLinkWithoutDeletingTargetAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string outsidePath = Path.Combine(testDirectory.Path, "outside"); + Directory.CreateDirectory(temporaryPath); + Directory.CreateDirectory(outsidePath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "readme.txt"), "payload"); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + try + { + Directory.CreateSymbolicLink(Path.Combine(temporaryPath, "linked"), outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService { HashForPath = _ => hash }); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("readme.txt", hash, 7)), + null, + CancellationToken.None); + + // Assert + Directory.Exists(Path.Combine(installedPath, "linked")).Should().BeFalse(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private static S3PackageUpdater CreateUpdater( + IResumableFileDownloader downloader, + IFileHashService hashService) + { + return new S3PackageUpdater( + downloader, + hashService, + NullLogger.Instance, + new S3PackageUpdateOptions()); + } + + private static S3PackageUpdateRequest CreateRequest( + string temporaryPath, + string installedPath, + string? latestPath, + params RemoteFileManifestEntry[] files) + { + return new S3PackageUpdateRequest( + files, + "https://example.test", + "mods", + "folder", + "access", + "secret", + temporaryPath, + installedPath, + latestPath, + new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".big", ".gib" }); + } + + private static byte[] CreatePayload(int length) + { + byte[] payload = new byte[length]; + Array.Fill(payload, (byte)'x'); + return payload; + } + + private sealed class RecordingFileDownloader : IResumableFileDownloader + { + public ConcurrentQueue Requests { get; } = new(); + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Enqueue(request); + long length = request.ExpectedBytes.GetValueOrDefault(); + byte[] payload = CreatePayload(checked((int)length)); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, payload, cancellationToken); + return new DownloadFileResult(request.DestinationFilePath, payload.Length, false); + } + } + + private sealed class StubFileHashService : IFileHashService + { + public Func HashForPath { get; init; } = _ => "0123456789ABCDEF0123456789ABCDEF"; + + public Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + return Task.FromResult(HashForPath(filePath)); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(PackageUpdateProgress value) + { + _reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs new file mode 100644 index 00000000..01407bad --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs @@ -0,0 +1,72 @@ +using System.IO; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class S3PackageUpdaterTests +{ + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_ReturnsFalseWhenFileIsMissing() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("readme.txt", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "readme.txt"); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeFalse(); + } + + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_ReturnsFalseWhenSizeDiffers() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("readme.txt", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "readme.txt"); + File.WriteAllBytes(destinationFilePath, new byte[9]); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeFalse(); + } + + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_ReturnsTrueWhenSizeMatches() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("readme.txt", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "readme.txt"); + File.WriteAllBytes(destinationFilePath, new byte[10]); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeTrue(); + } + + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_UsesGibPathForBigFiles() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("data.big", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "data.big"); + File.WriteAllBytes(Path.ChangeExtension(destinationFilePath, ".gib"), new byte[10]); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs new file mode 100644 index 00000000..e3a40c02 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs @@ -0,0 +1,168 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class SingleFilePackageUpdaterTests +{ + [Fact] + public async Task UpdateAsync_ClearsStaleTemporaryFilesBeforeInstallingAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + Directory.CreateDirectory(temporaryPath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "stale.txt"), "stale"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + // Act + await updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + File.Exists(Path.Combine(installedPath, "stale.txt")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + } + + [Fact] + public async Task UpdateAsync_RemovesEmptyPackageStagingParentsAfterInstallingAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Runtime", "Temp"); + string packagesPath = Path.Combine(temporaryRoot, "Packages"); + string temporaryPath = Path.Combine(packagesPath, "NProject Mod", "2.11"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + // Act + await updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + Directory.Exists(packagesPath).Should().BeFalse(); + Directory.Exists(temporaryRoot).Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsyncRejectsLinkedInstalledRootWithoutDeletingTargetAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string outsidePath = Path.Combine(testDirectory.Path, "outside"); + Directory.CreateDirectory(outsidePath); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + try + { + Directory.CreateSymbolicLink(installedPath, outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + // Act + Func update = () => updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + await update.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private sealed class WritingFileDownloader : IResumableFileDownloader + { + private readonly string _contents; + + public WritingFileDownloader(string contents) + { + _contents = contents; + } + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(request.DestinationFilePath, _contents, cancellationToken); + progress?.Report(new DownloadProgress(request.ExpectedBytes, _contents.Length, 100)); + return new DownloadFileResult(request.DestinationFilePath, _contents.Length, false); + } + } + + private sealed class StubMetadataReader : IDownloadFileMetadataReader + { + private readonly string _fileName; + private readonly long _totalBytes; + + public StubMetadataReader(string fileName, long totalBytes) + { + _fileName = fileName; + _totalBytes = totalBytes; + } + + public Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + return Task.FromResult(new DownloadFileMetadata(downloadUri, _fileName, _totalBytes)); + } + } + + private sealed class ThrowingArchiveExtractor : IArchiveExtractor + { + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("Extraction should not be used for non-archive files."); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs new file mode 100644 index 00000000..f07686a5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs @@ -0,0 +1,58 @@ +using System; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class DownloadLinkResolverTests +{ + [Fact] + public void ResolveDirectDownloadLink_ConvertsDropboxPreviewLinkToDownloadLink() + { + // Arrange + const string link = "https://www.dropbox.com/s/example/Package.7z?dl=0"; + + // Act + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + // Assert + resolved.Should().Be("https://www.dropbox.com/s/example/Package.7z?dl=1"); + } + + [Fact] + public void ResolveDirectDownloadLink_ConvertsOneDriveEmbedLinkToDownloadLink() + { + // Arrange + const string link = "https://onedrive.live.com/embed?cid=abc&resid=abc%211"; + + // Act + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + // Assert + resolved.Should().Be("https://onedrive.live.com/download?cid=abc&resid=abc%211"); + } + + [Fact] + public void ResolveDirectDownloadLink_ConvertsOneDriveShareLinkToDownloadLink() + { + // Arrange + const string link = + "https://onedrive.live.com/?authkey=%21key&cid=896C9369E9176506&id=896C9369E9176506%21464&parId=896C9369E9176506%21463&o=OneUp"; + + // Act + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + // Assert + resolved.Should() + .Be("https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key"); + } + + [Fact] + public void ResolveDownloadUri_ThrowsForMissingLink() + { + // Arrange / Act + Action act = () => DownloadLinkResolver.ResolveDownloadUri(" "); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Testing/.gitkeep b/GenLauncherGO.Tests/Testing/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/Testing/TestDirectory.cs b/GenLauncherGO.Tests/Testing/TestDirectory.cs new file mode 100644 index 00000000..774e0f9c --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestDirectory.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Owns a temporary directory for a test and deletes it during disposal. +/// +internal sealed class TestDirectory : IDisposable +{ + /// + /// Initializes a new instance of the class. + /// + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + /// + /// Gets the temporary directory path. + /// + public string Path { get; } + + /// + /// Deletes the temporary directory and all children when it still exists. + /// + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs b/GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs new file mode 100644 index 00000000..f039fed1 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs @@ -0,0 +1,30 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Provides fixed launcher mod context values for UI tests. +/// +internal sealed class TestLauncherModsContext : ILauncherModsContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The game exposed to the test subject. + /// The colors exposed to the test subject. + public TestLauncherModsContext( + SupportedGame currentlyManagedGame = SupportedGame.ZeroHour, + ColorsInfo? colors = null) + { + CurrentlyManagedGame = currentlyManagedGame; + Colors = colors ?? new ColorsInfo(); + } + + /// + public SupportedGame CurrentlyManagedGame { get; } + + /// + public ColorsInfo Colors { get; } +} diff --git a/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs b/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs new file mode 100644 index 00000000..0f4ea99f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Resolves test localization keys from an optional in-memory dictionary. +/// +internal sealed class TestStringLocalizer : ILauncherStringLocalizer +{ + /// + /// The configured localized values. + /// + private readonly IReadOnlyDictionary _values; + + /// + /// Creates a fallback value for missing keys. + /// + private readonly Func _fallback; + + /// + /// Initializes a new instance of the class. + /// + public TestStringLocalizer() + : this(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + public TestStringLocalizer(IReadOnlyDictionary values) + : this(values, key => key) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + /// The value factory used when a key is missing. + public TestStringLocalizer( + IReadOnlyDictionary values, + Func fallback) + { + _values = values ?? throw new ArgumentNullException(nameof(values)); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + /// + public string this[string key] => _values.TryGetValue(key, out string? value) ? value : _fallback(key); +} diff --git a/GenLauncherGO.Tests/UI/.gitkeep b/GenLauncherGO.Tests/UI/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/UI/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..c37f3b11 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs @@ -0,0 +1,82 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Dialogs.Composition; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Services; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Shared.Localization; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs; + +public sealed class DialogServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoDialogs_RegistersDialogWindowFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoDialogs(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(ILauncherDialogWindowFactory) && + descriptor.ImplementationType == typeof(LauncherDialogWindowFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoDialogs_RegistersDialogViewModelFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoDialogs(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherDialogViewModelFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoDialogs_RegistersLauncherDialogService() + { + // Arrange + ServiceCollection services = new(); + ILauncherModsContext launcherContext = Substitute.For(); + launcherContext.CurrentlyManagedGame.Returns(SupportedGame.ZeroHour); + launcherContext.Colors.Returns(CreateColors()); + services.AddSingleton(launcherContext); + services.AddSingleton(Substitute.For()); + + // Act + services.AddGenLauncherGoDialogs(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should() + .BeOfType(); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00e3ff", + "DarkGray", + "#7a7db0", + "#baff0c", + "#232977", + "#090502", + "#B3000000", + "White", + "White", + "#F21d2057", + "#E61d2057", + "#2534ff"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs new file mode 100644 index 00000000..387fbee6 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs @@ -0,0 +1,34 @@ +using GenLauncherGO.UI.Features.Integrity; + +namespace GenLauncherGO.Tests.UI.Features.Integrity; + +public sealed class LauncherPackageActivityServiceTests +{ + [Fact] + public void TryBegin_WhenActivityIsAlreadyActive_RejectsSecondActivityUntilLeaseIsDisposed() + { + // Arrange + var service = new LauncherPackageActivityService(); + + // Act + bool firstStarted = service.TryBegin( + "First", + out LauncherPackageActivityService.LauncherPackageActivityLease? firstLease); + bool secondStarted = service.TryBegin( + "Second", + out LauncherPackageActivityService.LauncherPackageActivityLease? secondLease); + firstLease?.Dispose(); + bool thirdStarted = service.TryBegin( + "Third", + out LauncherPackageActivityService.LauncherPackageActivityLease? thirdLease); + + // Assert + firstStarted.Should().BeTrue(); + secondStarted.Should().BeFalse(); + secondLease.Should().BeNull(); + thirdStarted.Should().BeTrue(); + thirdLease.Should().NotBeNull(); + + thirdLease?.Dispose(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs new file mode 100644 index 00000000..060d87ac --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Integrity.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Integrity.ViewModels; + +public sealed class IntegrityReviewViewModelTests +{ + [Fact] + public void Constructor_MapsIssuesToBindableRows() + { + // Arrange + ContentIntegrityIssue issue = new( + "mod:one", + "Demo Mod", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Redownload, + "Data/file.big", + "Hash mismatch", + ExpectedSizeBytes: 2048); + + // Act + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(new[] { issue }), + CreateOptions()); + + // Assert + viewModel.Issues.Should().ContainSingle(); + IntegrityReviewItem item = viewModel.Issues[0]; + item.ActionLabel.Should().Be("Redownload"); + item.TargetDisplayName.Should().Be("Demo Mod"); + item.RelativePath.Should().Be("Data/file.big (2.0 KB) - Hash mismatch"); + } + + [Fact] + public void ConfirmResolutionCommand_RaisesCloseRequestAndMarksResolutionConfirmed() + { + // Arrange + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(System.Array.Empty()), + CreateOptions()); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.ConfirmResolutionCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ResolutionConfirmed.Should().BeTrue(); + } + + [Fact] + public void CancelCommand_RaisesCloseRequestWithoutConfirmingResolution() + { + // Arrange + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(System.Array.Empty()), + CreateOptions()); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ResolutionConfirmed.Should().BeFalse(); + } + + private static IntegrityReviewDialogOptions CreateOptions() + { + return new IntegrityReviewDialogOptions( + "Review", + "Description", + "Apply", + "Cancel", + new Dictionary + { + [IntegrityIssueAction.Redownload] = "Redownload" + }); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..e04deca6 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,341 @@ +using System; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Composition; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Launcher; + +public sealed class LauncherUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLauncherUiReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoLauncherUi(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoLauncherUiThrowsForNullServices() + { + // Arrange + ServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoLauncherUi(); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(services)); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersLaunchCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherLaunchCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersManualImportCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherManualImportCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersLaunchReadinessCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherLaunchReadinessCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersSelectedContentService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherSelectedContentService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersContentListService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherContentListService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersTabStateService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherTabStateService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersContentViewStateService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherContentViewStateService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersTileActionService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherTileActionService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersTileVersionSelectionService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherTileVersionSelectionService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersExecutableSelectionService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherExecutableSelectionService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersVisualThemeService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherVisualThemeService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersPackageActivityService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherPackageActivityService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersFilePicker() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(ILauncherFilePicker) && + descriptor.ImplementationType == typeof(WpfLauncherFilePicker) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersDownloadCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherModificationDownloadCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersShellNavigationCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherShellNavigationCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersWindowWorkflowCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherWindowWorkflowCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersDragDropController() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherDragDropController) && + descriptor.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersSelectionControllerFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherSelectionControllerFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersWindowListControllerFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherWindowListControllerFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersMainWindowViewModel() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(MainWindowViewModel) && + descriptor.Lifetime == ServiceLifetime.Transient); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs new file mode 100644 index 00000000..cee9e6b9 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherContentListServiceTests +{ + [Fact] + public void GetModificationsForDisplayOrdersModsBySavedListNumber() + { + // Arrange + GameModification second = CreateModification("Second", numberInList: 2); + GameModification first = CreateModification("First", numberInList: 1); + ILauncherContentCatalogQueries queries = Substitute.For(); + queries.GetMods().Returns(new[] { second, first }); + LauncherContentListService service = CreateService(queries, connected: false); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Select(modification => modification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void GetModificationsForDisplayAddsAdvertisingFirstWhenConnectedAndMissing() + { + // Arrange + GameModification first = CreateModification("First", numberInList: 1); + GameModification second = CreateModification("Second", numberInList: 2); + GameModification third = CreateModification("Third", numberInList: 3); + GameModification advertisingCard = CreateModification("Advertising", ModificationType.Advertising); + ModificationVersion advertisingVersion = CreateVersion("Advertising", ModificationType.Advertising); + ILauncherContentCatalogQueries queries = Substitute.For(); + ILauncherContentCatalogCommands commands = Substitute.For(); + queries.GetMods().Returns( + new[] { third, first, second }, + new[] { advertisingCard, first, second, third }); + queries.GetAdvertising().Returns(advertisingVersion); + LauncherContentListService service = CreateService(queries, commands, connected: true); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Select(modification => modification.Name) + .Should() + .Equal("Advertising", "First", "Second", "Third"); + commands.Received(1).AddModModification(advertisingVersion); + } + + [Fact] + public void GetModificationsForDisplayRefreshesExistingAdvertisingWhenConnected() + { + // Arrange + GameModification advertisingCard = CreateModification("Advertising", ModificationType.Advertising); + GameModification mod = CreateModification("Mod", numberInList: 1); + ModificationVersion advertisingVersion = CreateVersion("Advertising", ModificationType.Advertising); + ILauncherContentCatalogQueries queries = Substitute.For(); + ILauncherContentCatalogCommands commands = Substitute.For(); + queries.GetMods().Returns(new[] { mod, advertisingCard }); + queries.GetAdvertising().Returns(advertisingVersion); + LauncherContentListService service = CreateService(queries, commands, connected: true); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Should().Equal(advertisingCard, mod); + commands.Received(1).AddModModification(advertisingVersion); + } + + [Fact] + public void GetModificationsForDisplayDoesNotAddAdvertisingWhenDisconnected() + { + // Arrange + GameModification first = CreateModification("First", numberInList: 1); + GameModification second = CreateModification("Second", numberInList: 2); + GameModification third = CreateModification("Third", numberInList: 3); + ILauncherContentCatalogQueries queries = Substitute.For(); + ILauncherContentCatalogCommands commands = Substitute.For(); + queries.GetMods().Returns(new[] { first, second, third }); + LauncherContentListService service = CreateService(queries, commands, connected: false); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Should().Equal(first, second, third); + commands.DidNotReceiveWithAnyArgs().AddModModification(default!); + } + + [Fact] + public void GetPatchesForDisplayReturnsSelectedModPatches() + { + // Arrange + GameModification patch = CreateModification("Patch", ModificationType.Patch); + ILauncherContentCatalogQueries queries = Substitute.For(); + queries.GetPatchesForSelectedMod().Returns(new[] { patch }); + LauncherContentListService service = CreateService(queries, connected: false); + + // Act + IReadOnlyList result = service.GetPatchesForDisplay(); + + // Assert + result.Should().Equal(patch); + } + + [Fact] + public void GetAddonsForDisplayReturnsSelectedModAddons() + { + // Arrange + GameModification addon = CreateModification("Addon", ModificationType.Addon); + ILauncherContentCatalogQueries queries = Substitute.For(); + queries.GetAddonsForSelectedMod().Returns(new[] { addon }); + LauncherContentListService service = CreateService(queries, connected: false); + + // Act + IReadOnlyList result = service.GetAddonsForDisplay(); + + // Assert + result.Should().Equal(addon); + } + + [Fact] + public void MoveModificationInListMovesSourceAfterTargetWhenSourceStartsBeforeTarget() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ModificationViewModel third = CreateViewModel("Third"); + ObservableCollection list = new() { first, second, third }; + + // Act + LauncherContentListService.MoveModificationInList(list, first, sourceIndex: 0, targetIndex: 2); + + // Assert + list.Should().Equal(second, third, first); + } + + [Fact] + public void MoveModificationInListMovesSourceBeforeTargetWhenSourceStartsAfterTarget() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ModificationViewModel third = CreateViewModel("Third"); + ObservableCollection list = new() { first, second, third }; + + // Act + LauncherContentListService.MoveModificationInList(list, third, sourceIndex: 2, targetIndex: 0); + + // Assert + list.Should().Equal(third, first, second); + } + + [Fact] + public void SetIndexNumbersForModsUpdatesBackingModifications() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ObservableCollection list = new() { first, second }; + + // Act + LauncherContentListService.SetIndexNumbersForMods(list); + + // Assert + first.ContainerModification.NumberInList.Should().Be(0); + second.ContainerModification.NumberInList.Should().Be(1); + } + + [Fact] + public void RemoveModificationByNameRemovesCaseInsensitiveMatch() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ObservableCollection list = new() { first, second }; + + // Act + LauncherContentListService.RemoveModificationByName(list, "second"); + + // Assert + list.Should().Equal(first); + } + + [Fact] + public void CreateViewModelsCreatesTileCollectionInSourceOrder() + { + // Arrange + GameModification first = CreateModification("First"); + GameModification second = CreateModification("Second"); + + // Act + ObservableCollection result = LauncherContentListService.CreateViewModels( + new[] { first, second }, + modification => CreateViewModel(modification.Name)); + + // Assert + result.Select(viewModel => viewModel.ContainerModification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void FindMatchingModificationReturnsCaseInsensitiveNameMatch() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + GameModification selected = CreateModification("second"); + + // Act + ModificationViewModel? result = LauncherContentListService.FindMatchingModification( + new[] { first, second }, + selected); + + // Assert + result.Should().BeSameAs(second); + } + + [Fact] + public void FindMatchingModificationReturnsNullWhenNoSelectionExists() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + + // Act + ModificationViewModel? result = LauncherContentListService.FindMatchingModification( + new[] { first }, + selectedModification: null); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void FindMatchingAddonsReturnsPersistedSelectionsInDisplayOrder() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ModificationViewModel third = CreateViewModel("Third"); + GameModification selectedThird = CreateModification("third"); + GameModification selectedFirst = CreateModification("first"); + + // Act + IReadOnlyList result = LauncherContentListService.FindMatchingAddons( + new[] { first, second, third }, + new[] { selectedThird, selectedFirst }); + + // Assert + result.Should().Equal(first, third); + } + + private static LauncherContentListService CreateService( + ILauncherContentCatalogQueries queries, + bool connected) + { + return CreateService( + queries, + Substitute.For(), + connected); + } + + private static LauncherContentListService CreateService( + ILauncherContentCatalogQueries queries, + ILauncherContentCatalogCommands commands, + bool connected) + { + LauncherRuntimeContext runtimeContext = new(CreatePaths(), "1.0") + { + Connected = connected + }; + + return new LauncherContentListService(queries, commands, runtimeContext); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType = ModificationType.Mod, + int numberInList = 0) + { + return new GameModification + { + Name = name, + ModificationType = modificationType, + NumberInList = numberInList, + ModificationVersions = new List + { + CreateVersion(name, modificationType) + } + }; + } + + private static ModificationVersion CreateVersion( + string name, + ModificationType modificationType = ModificationType.Mod) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType + }; + } + + private static ModificationViewModel CreateViewModel(string name) + { + return new ModificationViewModel( + CreateModification(name), + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + "C:\\Game", + "C:\\Game\\GenLauncherGO", + "C:\\Game\\GenLauncherGO\\Runtime", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache\\Images", + "C:\\Game\\GenLauncherGO\\Mods", + "C:\\Game\\GenLauncherGO\\Logs", + "C:\\Game\\GenLauncherGO\\Runtime\\Temp", + "C:\\Game\\GenLauncherGO\\Runtime\\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs new file mode 100644 index 00000000..6b307359 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs @@ -0,0 +1,146 @@ +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherContentViewStateServiceTests +{ + [Fact] + public void GetStateShowsModListAndAddButtonWhenConnected() + { + // Arrange + LauncherContentViewStateService service = CreateService(connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Modifications); + + // Assert + result.ShowModsList.Should().BeTrue(); + result.ShowManualAddMod.Should().BeTrue(); + result.ShowAddModButton.Should().BeTrue(); + result.ShowPatchesList.Should().BeFalse(); + result.ShowAddonsList.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeFalse(); + } + + [Fact] + public void GetStateHidesRemoteAddButtonWhenDisconnected() + { + // Arrange + LauncherContentViewStateService service = CreateService(connected: false); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Modifications); + + // Assert + result.ShowModsList.Should().BeTrue(); + result.ShowManualAddMod.Should().BeTrue(); + result.ShowAddModButton.Should().BeFalse(); + } + + [Fact] + public void GetStateRequiresOriginalGameContentLoadForPatchesWhenNoModIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + LauncherContentViewStateService service = CreateService(catalogQueries, connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Patches); + + // Assert + result.ShowPatchesList.Should().BeTrue(); + result.ShowManualAddPatch.Should().BeTrue(); + result.ShowModsList.Should().BeFalse(); + result.ShowAddonsList.Should().BeFalse(); + result.ShowAddModButton.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeTrue(); + } + + [Fact] + public void GetStateRequiresOriginalGameContentLoadForAddonsWhenNoModIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + LauncherContentViewStateService service = CreateService(catalogQueries, connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Addons); + + // Assert + result.ShowAddonsList.Should().BeTrue(); + result.ShowManualAddAddon.Should().BeTrue(); + result.ShowModsList.Should().BeFalse(); + result.ShowPatchesList.Should().BeFalse(); + result.ShowAddModButton.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeTrue(); + } + + [Fact] + public void GetStateDoesNotLoadOriginalGameContentWhenModIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(new GameModification { Name = "Shockwave" }); + LauncherContentViewStateService service = CreateService(catalogQueries, connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Patches); + + // Assert + result.ShowPatchesList.Should().BeTrue(); + result.RequiresOriginalGameContentLoad.Should().BeFalse(); + } + + [Fact] + public void HiddenHidesAllContentControls() + { + // Act + LauncherContentViewState result = LauncherContentViewState.Hidden; + + // Assert + result.ShowModsList.Should().BeFalse(); + result.ShowPatchesList.Should().BeFalse(); + result.ShowAddonsList.Should().BeFalse(); + result.ShowManualAddMod.Should().BeFalse(); + result.ShowManualAddPatch.Should().BeFalse(); + result.ShowManualAddAddon.Should().BeFalse(); + result.ShowAddModButton.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeFalse(); + } + + private static LauncherContentViewStateService CreateService(bool connected) + { + return CreateService(Substitute.For(), connected); + } + + private static LauncherContentViewStateService CreateService( + ILauncherContentCatalogQueries catalogQueries, + bool connected) + { + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.0") + { + Connected = connected + }; + + return new LauncherContentViewStateService(catalogQueries, runtimeContext); + } + + private static LauncherPaths CreateLauncherPaths() + { + return new LauncherPaths( + GameDirectory: @"C:\Game", + LauncherDirectory: @"C:\Launcher", + RuntimeDirectory: @"C:\Launcher\Runtime", + CacheDirectory: @"C:\Launcher\Cache", + ImagesDirectory: @"C:\Launcher\Images", + ModsDirectory: @"C:\Launcher\Mods", + LogsDirectory: @"C:\Launcher\Logs", + TempDirectory: @"C:\Launcher\Temp", + DeploymentDirectory: @"C:\Launcher\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs new file mode 100644 index 00000000..57626573 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherExecutableSelectionServiceTests +{ + [Fact] + public void GetGameClientOptionsMapsDiscoveredClientsToLocalizedOptions() + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.GetAvailableGameClients(SupportedGame.ZeroHour).Returns(new[] + { + new GameClientExecutable("generalszh.exe", GameClientExecutableKind.Community), + new GameClientExecutable("generalsonlinezh.exe", GameClientExecutableKind.GeneralsOnline) + }); + LauncherExecutableSelectionService service = CreateService(discovery); + + // Act + IReadOnlyList options = service.GetGameClientOptions(); + + // Assert + options.Select(option => option.DisplayName) + .Should() + .Equal("SuperHackers client", "GeneralsOnline client"); + options.Select(option => option.ExecutableName) + .Should() + .Equal("generalszh.exe", "generalsonlinezh.exe"); + options[1].IsGeneralsOnline.Should().BeTrue(); + } + + [Fact] + public void SelectGameClientOptionPrefersSavedExecutable() + { + // Arrange + GameClientOption first = new("First", "first.exe", GameClientExecutableKind.Community); + GameClientOption second = new("Second", "second.exe", GameClientExecutableKind.GeneralsOnline); + LauncherExecutableSelectionService service = CreateService(); + + // Act + GameClientOption? selected = service.SelectGameClientOption( + new[] { first, second }, + "second.exe"); + + // Assert + selected.Should().BeSameAs(second); + } + + [Fact] + public void SelectGameClientOptionFallsBackToFirstOption() + { + // Arrange + GameClientOption first = new("First", "first.exe", GameClientExecutableKind.Community); + GameClientOption second = new("Second", "second.exe", GameClientExecutableKind.GeneralsOnline); + LauncherExecutableSelectionService service = CreateService(); + + // Act + GameClientOption? selected = service.SelectGameClientOption( + new[] { first, second }, + "missing.exe"); + + // Assert + selected.Should().BeSameAs(first); + } + + [Fact] + public void GetWorldBuilderOptionsMapsDiscoveredWorldBuildersToLocalizedOptions() + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.GetAvailableWorldBuilders(SupportedGame.ZeroHour).Returns(new[] + { + new WorldBuilderExecutable("WorldBuilder.exe", WorldBuilderExecutableKind.Vanilla), + new WorldBuilderExecutable("worldbuilderzh.exe", WorldBuilderExecutableKind.Community) + }); + LauncherExecutableSelectionService service = CreateService(discovery); + + // Act + IReadOnlyList options = service.GetWorldBuilderOptions(); + + // Assert + options.Select(option => option.DisplayName) + .Should() + .Equal("Vanilla World Builder", "SuperHackers World Builder"); + options.Select(option => option.ExecutableName) + .Should() + .Equal("WorldBuilder.exe", "worldbuilderzh.exe"); + options.Should().OnlyContain(option => option.IsAvailable); + } + + [Fact] + public void GetWorldBuilderOptionsReturnsUnavailablePlaceholderWhenNoneAreFound() + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.GetAvailableWorldBuilders(SupportedGame.ZeroHour).Returns(Array.Empty()); + LauncherExecutableSelectionService service = CreateService(discovery); + + // Act + IReadOnlyList options = service.GetWorldBuilderOptions(); + + // Assert + options.Should().ContainSingle(); + options[0].DisplayName.Should().Be("No World Builders Found"); + options[0].ExecutableName.Should().BeEmpty(); + options[0].IsAvailable.Should().BeFalse(); + } + + [Fact] + public void SelectWorldBuilderOptionPrefersSavedExecutable() + { + // Arrange + ExecutableOption first = new("First", "first.exe", WorldBuilderExecutableKind.Vanilla); + ExecutableOption second = new("Second", "second.exe", WorldBuilderExecutableKind.Community); + LauncherExecutableSelectionService service = CreateService(); + + // Act + ExecutableOption? selected = service.SelectWorldBuilderOption( + new[] { first, second }, + "second.exe"); + + // Assert + selected.Should().BeSameAs(second); + } + + private static LauncherExecutableSelectionService CreateService() + { + return CreateService(Substitute.For()); + } + + private static LauncherExecutableSelectionService CreateService(IGameExecutableDiscoveryService discovery) + { + return new LauncherExecutableSelectionService( + discovery, + new TestLauncherModsContext(), + new TestStringLocalizer(new Dictionary + { + ["GeneralsOnlineClient"] = "GeneralsOnline client", + ["SuperHackersClient"] = "SuperHackers client", + ["VanillaWorldBuilder"] = "Vanilla World Builder", + ["SuperHackersWorldBuilder"] = "SuperHackers World Builder", + ["NoWorldBuildersFound"] = "No World Builders Found", + })); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs new file mode 100644 index 00000000..483f8f0a --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs @@ -0,0 +1,73 @@ +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherGameArgumentServiceTests +{ + [Fact] + public void SetArgumentEnabledAddsArgumentWhenMissing() + { + // Arrange + string arguments = "-foo"; + + // Act + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: true); + + // Assert + result.Should().Be("-foo -win"); + } + + [Fact] + public void SetArgumentEnabledDoesNotDuplicateExistingArgument() + { + // Arrange + string arguments = "-foo -WIN"; + + // Act + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: true); + + // Assert + result.Should().Be("-foo -WIN"); + } + + [Fact] + public void SetArgumentEnabledRemovesStandaloneArgumentAndKeepsOtherArguments() + { + // Arrange + string arguments = "-foo \"bar baz\" -win -quickstart"; + + // Act + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: false); + + // Assert + result.Should().Be("-foo \"bar baz\" -quickstart"); + } + + [Fact] + public void ContainsArgumentRequiresStandaloneArgument() + { + // Arrange + string arguments = "-windowed -quickstart"; + + // Act + bool containsWindowed = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.WindowedArgument); + bool containsQuickStart = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.QuickStartArgument); + + // Assert + containsWindowed.Should().BeFalse(); + containsQuickStart.Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs new file mode 100644 index 00000000..8be02736 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherSelectedContentServiceTests +{ + [Fact] + public void GetSelectedVersionsReturnsSelectedVersionsInLaunchOrder() + { + // Arrange + ModificationVersion selectedMod = CreateVersion("Mod"); + ModificationVersion selectedPatch = CreateVersion("Patch"); + ModificationVersion selectedAddon = CreateVersion("Addon"); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedModVersion().Returns(selectedMod); + catalogQueries.GetSelectedPatchVersion().Returns(selectedPatch); + catalogQueries.GetSelectedAddonsVersions().Returns(new List + { + selectedAddon + }); + LauncherSelectedContentService service = new(catalogQueries); + + // Act + IReadOnlyList result = service.GetSelectedVersions(); + + // Assert + result.Should().Equal(selectedMod, selectedPatch, selectedAddon); + } + + [Fact] + public void GetSelectedVersionsSkipsNullSelectedVersions() + { + // Arrange + ModificationVersion selectedAddon = CreateVersion("Addon"); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsVersions().Returns(new List + { + null!, + selectedAddon + }); + LauncherSelectedContentService service = new(catalogQueries); + + // Act + IReadOnlyList result = service.GetSelectedVersions(); + + // Assert + result.Should().Equal(selectedAddon); + } + + [Fact] + public void GetSelectedModificationViewModelsFiltersNonTileItems() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + object[] selectedItems = + { + first, + "not a tile", + second + }; + + // Act + IReadOnlyList result = + LauncherSelectedContentService.GetSelectedModificationViewModels(selectedItems); + + // Assert + result.Should().Equal(first, second); + } + + [Fact] + public void GetIntegrityProgressTargetsUsesFirstModFirstPatchAndAllAddons() + { + // Arrange + ModificationViewModel firstMod = CreateViewModel("First Mod"); + ModificationViewModel ignoredMod = CreateViewModel("Ignored Mod"); + ModificationViewModel firstPatch = CreateViewModel("First Patch"); + ModificationViewModel addonOne = CreateViewModel("Addon One"); + ModificationViewModel addonTwo = CreateViewModel("Addon Two"); + + // Act + IReadOnlyList result = + LauncherSelectedContentService.GetIntegrityProgressTargets( + new[] { firstMod, ignoredMod }, + new[] { firstPatch }, + new[] { addonOne, addonTwo }); + + // Assert + result.Should().Equal(firstMod, firstPatch, addonOne, addonTwo); + } + + private static ModificationVersion CreateVersion(string name) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = ModificationType.Mod + }; + } + + private static ModificationViewModel CreateViewModel(string name) + { + GameModification modification = new() + { + Name = name, + ModificationType = ModificationType.Mod, + ModificationVersions = new List + { + CreateVersion(name) + } + }; + + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs new file mode 100644 index 00000000..8e43f031 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherTabStateServiceTests +{ + [Fact] + public void GetCurrentStateBuildsSelectedModificationLabelsWithActiveCounts() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(CreateModification("Shockwave", ModificationType.Mod)); + catalogQueries.GetSelectedPatchVersion().Returns(CreateVersion(installed: true)); + catalogQueries.GetSelectedAddonsVersions().Returns(new List + { + CreateVersion(installed: true), + CreateVersion(installed: false), + CreateVersion(installed: true) + }); + LauncherTabStateService service = CreateService(catalogQueries, SupportedGame.ZeroHour); + + // Act + LauncherContentTabState result = service.GetCurrentState(); + + // Assert + result.ShowChildContentTabs.Should().BeTrue(); + result.PatchesTabText.Should().Be("Patches: Shockwave (1)"); + result.AddonsTabText.Should().Be("Addons: Shockwave (2)"); + result.ManualAddPatchText.Should().Be("Add patch for Shockwave"); + result.ManualAddAddonText.Should().Be("Add addon for Shockwave"); + } + + [Fact] + public void GetCurrentStateUsesManagedGameWhenNoModificationIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsVersions().Returns(System.Array.Empty()); + LauncherTabStateService service = CreateService(catalogQueries, SupportedGame.Generals); + + // Act + LauncherContentTabState result = service.GetCurrentState(); + + // Assert + result.ShowChildContentTabs.Should().BeTrue(); + result.PatchesTabText.Should().Be("Patches: Generals"); + result.AddonsTabText.Should().Be("Addons: Generals"); + result.ManualAddPatchText.Should().Be("Add patch for Generals"); + result.ManualAddAddonText.Should().Be("Add addon for Generals"); + } + + [Fact] + public void GetCurrentStateHidesChildTabsForAdvertising() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(CreateModification("Sponsor", ModificationType.Advertising)); + LauncherTabStateService service = CreateService(catalogQueries, SupportedGame.ZeroHour); + + // Act + LauncherContentTabState result = service.GetCurrentState(); + + // Assert + result.ShowChildContentTabs.Should().BeFalse(); + result.PatchesTabText.Should().BeEmpty(); + result.AddonsTabText.Should().BeEmpty(); + result.ManualAddPatchText.Should().BeEmpty(); + result.ManualAddAddonText.Should().BeEmpty(); + } + + private static LauncherTabStateService CreateService( + ILauncherContentCatalogQueries catalogQueries, + SupportedGame supportedGame) + { + return new LauncherTabStateService( + catalogQueries, + new TestLauncherModsContext(supportedGame), + new TestStringLocalizer(new Dictionary + { + ["Patches"] = "Patches: ", + ["Addons"] = "Addons: ", + ["AddPatchFromFiles"] = "Add patch for {0}", + ["AddAddonFromFiles"] = "Add addon for {0}", + })); + } + + private static GameModification CreateModification(string name, ModificationType modificationType) + { + return new GameModification + { + Name = name, + ModificationType = modificationType + }; + } + + private static ModificationVersion CreateVersion(bool installed) + { + return new ModificationVersion + { + Version = "1.0", + Installed = installed + }; + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs new file mode 100644 index 00000000..6f3ee6b6 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs @@ -0,0 +1,249 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherTileActionServiceTests +{ + [Fact] + public void GetAdvertisingDownloadActionReturnsLinkAndThankYouForAdvertisingWithSimpleLink() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Advertising, + SimpleDownloadLink = "https://example.test/donate" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetAdvertisingDownloadAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/donate"); + result.ShowThankYouMessage.Should().BeTrue(); + } + + [Fact] + public void GetAdvertisingDownloadActionReturnsNoActionForNonAdvertisingMod() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Mod, + SimpleDownloadLink = "https://example.test/download" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetAdvertisingDownloadAction(modification); + + // Assert + result.Uri.Should().BeNull(); + result.ShowThankYouMessage.Should().BeFalse(); + } + + [Fact] + public void GetNewsActionShowsThankYouOnlyForAdvertising() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Advertising, + NewsLink = "https://example.test/news" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetNewsAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/news"); + result.ShowThankYouMessage.Should().BeTrue(); + } + + [Fact] + public void GetNetworkInfoActionReturnsNetworkLink() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Mod, + NetworkInfo = "https://example.test/network" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetNetworkInfoAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/network"); + result.ShowThankYouMessage.Should().BeFalse(); + } + + [Fact] + public void GetSupportActionAlwaysShowsThankYou() + { + // Arrange + GameModification modification = new() + { + SupportLink = "https://example.test/support" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetSupportAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/support"); + result.ShowThankYouMessage.Should().BeTrue(); + } + + [Fact] + public void GetContextMenuStateHidesMissingLinksAndNonManualSetImage() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Mod, + ModDBLink = string.Empty, + DiscordLink = string.Empty + }; + + // Act + LauncherContextMenuState result = LauncherTileActionService.GetContextMenuState( + modification, + latestVersionIsManual: false); + + // Assert + result.HiddenResourceKeys.Should().BeEquivalentTo( + LauncherTileActionService.ModDbResourceKey, + LauncherTileActionService.DiscordResourceKey, + LauncherTileActionService.SetImageResourceKey); + } + + [Fact] + public void GetContextMenuStateHidesImageForAdvertising() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Advertising, + ModDBLink = "https://example.test/moddb", + DiscordLink = "https://example.test/discord" + }; + + // Act + LauncherContextMenuState result = LauncherTileActionService.GetContextMenuState( + modification, + latestVersionIsManual: true); + + // Assert + result.HiddenResourceKeys.Should().BeEquivalentTo( + LauncherTileActionService.SetImageResourceKey); + } + + [Fact] + public void DeleteVersionDeletesSelectedVersionAndRefreshesLocalCatalog() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersionSelection versionSelection = new() + { + VersionName = "2.0", + SelectedVersion = new ModificationVersion + { + Name = "Shockwave", + Version = "1.0", + ModificationType = ModificationType.Mod, + DependenceName = "Zero Hour" + } + }; + + // Act + service.DeleteVersion(versionSelection); + + // Assert + catalogCommands.Received(1).DeleteVersion(Arg.Is(version => + version.Name == "Shockwave" && + version.Version == "2.0" && + version.ModificationType == ModificationType.Mod && + version.DependenceName == "Zero Hour")); + catalogCommands.Received(1).UpdateLocalModificationsData(); + } + + [Fact] + public void RemoveContentVersionDeletesLatestVersionRemovesCatalogEntryAndSaves() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersion latestVersion = new() + { + Name = "Shockwave", + Version = "2.0", + ModificationType = ModificationType.Mod, + DependenceName = "Zero Hour" + }; + ModificationViewModel viewModel = CreateViewModel(latestVersion); + + // Act + service.RemoveContentVersion(viewModel); + + // Assert + catalogCommands.Received(1).RemoveContentVersion(Arg.Is(version => + version.Name == "Shockwave" && + version.Version == "2.0" && + version.ModificationType == ModificationType.Mod && + version.DependenceName == "Zero Hour")); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + } + + [Fact] + public void DeleteLocalContentVersionDeletesLatestVersionKeepsCatalogEntryAndSaves() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersion latestVersion = new() + { + Name = "HD", + Version = "1.0", + ModificationType = ModificationType.Addon + }; + ModificationViewModel viewModel = CreateViewModel(latestVersion, "Shockwave"); + + // Act + service.DeleteLocalContentVersion(viewModel); + + // Assert + catalogCommands.Received(1).DeleteModificationVersion(Arg.Is(version => + version.Name == "HD" && + version.Version == "1.0" && + version.ModificationType == ModificationType.Addon && + version.DependenceName == "Shockwave")); + catalogCommands.DidNotReceive().RemoveContentVersion(Arg.Any()); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + } + + private static ModificationViewModel CreateViewModel( + ModificationVersion version, + string? containerDependenceName = null) + { + return new ModificationViewModel( + new GameModification + { + Name = version.Name, + ModificationType = version.ModificationType, + DependenceName = containerDependenceName ?? version.DependenceName, + ModificationVersions = new List { version } + }, + new ModificationImageSourceFactory(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance), + new GenLauncherGO.Tests.Testing.TestLauncherModsContext(), + Substitute.For(), + new GenLauncherGO.Tests.Testing.TestStringLocalizer(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs new file mode 100644 index 00000000..64215516 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherTileVersionSelectionServiceTests +{ + [Fact] + public void SelectVersionSelectsMatchingVersionAndClearsOthers() + { + // Arrange + LauncherTileVersionSelectionService service = new(); + ModificationVersion oldVersion = CreateVersion("1.0", selected: true); + ModificationVersion selectedVersion = CreateVersion("2.0", selected: false); + ModificationVersion otherVersion = CreateVersion("3.0", selected: true); + ModificationViewModel viewModel = CreateViewModel(oldVersion, selectedVersion, otherVersion); + ModificationVersionSelection versionSelection = new(selectedVersion, "2.0", viewModel); + + // Act + service.SelectVersion(versionSelection); + + // Assert + oldVersion.IsSelected.Should().BeFalse(); + selectedVersion.IsSelected.Should().BeTrue(); + otherVersion.IsSelected.Should().BeFalse(); + } + + [Fact] + public void SelectVersionMatchesVersionCaseInsensitively() + { + // Arrange + LauncherTileVersionSelectionService service = new(); + ModificationVersion selectedVersion = CreateVersion("Release", selected: false); + ModificationViewModel viewModel = CreateViewModel(selectedVersion); + ModificationVersionSelection versionSelection = new(selectedVersion, "release", viewModel); + + // Act + service.SelectVersion(versionSelection); + + // Assert + selectedVersion.IsSelected.Should().BeTrue(); + } + + private static ModificationViewModel CreateViewModel(params ModificationVersion[] versions) + { + GameModification modification = new() + { + Name = "Test Mod", + ModificationType = ModificationType.Mod, + ModificationVersions = new List(versions) + }; + + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static ModificationVersion CreateVersion(string version, bool selected) + { + return new ModificationVersion + { + Name = "Test Mod", + Version = version, + ModificationType = ModificationType.Mod, + IsSelected = selected + }; + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs new file mode 100644 index 00000000..edc08c50 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherVisualThemeServiceTests +{ + [Fact] + public void SetDefaultVisualRestoresRuntimeDefaultColors() + { + // Arrange + ColorsInfo defaultColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(defaultColors); + runtimeContext.Colors = new ColorsInfo(); + LauncherVisualThemeService service = CreateService(runtimeContext); + + // Act + service.SetDefaultVisual(); + + // Assert + runtimeContext.Colors.Should().BeSameAs(defaultColors); + } + + [Fact] + public void UpdateVisualResourcesForModUsesCachedContainerColors() + { + // Arrange + ColorsInfo selectedColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(new ColorsInfo()); + ModificationViewModel viewModel = CreateViewModel(CreateModification()); + viewModel.Colors = selectedColors; + LauncherVisualThemeService service = CreateService(runtimeContext); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + runtimeContext.Colors.Should().BeSameAs(selectedColors); + } + + [Fact] + public void UpdateVisualResourcesForModRestoresDefaultsWhenModificationHasNoColorInfo() + { + // Arrange + ColorsInfo defaultColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(defaultColors); + runtimeContext.Colors = new ColorsInfo(); + ModificationViewModel viewModel = CreateViewModel(CreateModification()); + LauncherVisualThemeService service = CreateService(runtimeContext); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + runtimeContext.Colors.Should().BeSameAs(defaultColors); + } + + private static LauncherVisualThemeService CreateService(LauncherRuntimeContext runtimeContext) + { + return new LauncherVisualThemeService( + runtimeContext, + Substitute.For(), + NullLogger.Instance); + } + + private static LauncherRuntimeContext CreateRuntimeContext(ColorsInfo defaultColors) + { + return new LauncherRuntimeContext(CreatePaths(), "1.0") + { + DefaultColors = defaultColors + }; + } + + private static ModificationViewModel CreateViewModel(GameModification modification) + { + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static GameModification CreateModification() + { + return new GameModification + { + Name = "Test Mod", + ModificationType = ModificationType.Mod, + ModificationVersions = new List + { + new ModificationVersion + { + Name = "Test Mod", + Version = "1.0", + ModificationType = ModificationType.Mod + } + } + }; + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + "C:\\Game", + "C:\\Game\\GenLauncherGO", + "C:\\Game\\GenLauncherGO\\Runtime", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache\\Images", + "C:\\Game\\GenLauncherGO\\Mods", + "C:\\Game\\GenLauncherGO\\Logs", + "C:\\Game\\GenLauncherGO\\Runtime\\Temp", + "C:\\Game\\GenLauncherGO\\Runtime\\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs new file mode 100644 index 00000000..1d9d3453 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Support; + +public sealed class LauncherSelectionControllerTests +{ + [Fact] + public void HandleModsListSelectionChangedDoesNotClearSavedChildSelectionsDuringRefresh() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + + ModificationViewModel newMod = CreateViewModel("New Mod", ModificationType.Mod); + ModificationViewModel savedPatch = CreateViewModel("Saved Patch", ModificationType.Patch, "Old Mod"); + ModificationViewModel savedAddon = CreateViewModel("Saved Addon", ModificationType.Addon, "Old Mod"); + savedPatch.ContainerModification.IsSelected = true; + savedAddon.ContainerModification.IsSelected = true; + + modsList.ItemsSource = new[] { newMod }; + patchesList.ItemsSource = new[] { savedPatch }; + addonsList.ItemsSource = new[] { savedAddon }; + + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => + { + RaiseRemovedSelectionWithReplacement( + patchesList, + savedPatch, + CreateViewModel("Replacement Patch", ModificationType.Patch, "New Mod")); + RaiseRemovedSelectionWithReplacement( + addonsList, + savedAddon, + CreateViewModel("Replacement Addon", ModificationType.Addon, "New Mod")); + }); + patchesList.SelectionChanged += controller.HandlePatchesListSelectionChanged; + addonsList.SelectionChanged += controller.HandleAddonsListSelectionChanged; + + Task? selectionTask = null; + modsList.SelectionChanged += (_, args) => + selectionTask = controller.HandleModsListSelectionChangedAsync(args); + + // Act + modsList.SelectedItem = newMod; + selectionTask?.GetAwaiter().GetResult(); + + // Assert + savedPatch.ContainerModification.IsSelected.Should().BeTrue(); + savedAddon.ContainerModification.IsSelected.Should().BeTrue(); + }); + } + + private static LauncherSelectionController CreateController( + ListBox modsList, + ListBox patchesList, + ListBox addonsList, + Action updateTabs) + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns((GameModification?)null); + + LauncherRuntimeContext runtimeContext = new(CreatePaths(), "1.0") + { + DefaultColors = new ColorsInfo(), + Colors = new ColorsInfo() + }; + LauncherVisualThemeService visualThemeService = new( + runtimeContext, + Substitute.For(), + NullLogger.Instance); + + return new LauncherSelectionController( + modsList, + patchesList, + addonsList, + catalogQueries, + Substitute.For(), + new LauncherTileVersionSelectionService(), + visualThemeService, + disableUi: () => { }, + enableUi: () => { }, + updateTabs, + updateVisuals: () => { }, + updateAddonsList: () => { }, + updateAddonAndPatchTabLabels: () => { }, + setFocuses: () => { }, + updateAddonsAndPatchesAsync: _ => Task.CompletedTask); + } + + private static void RaiseRemovedSelectionWithReplacement( + ListBox listBox, + ModificationViewModel removed, + ModificationViewModel replacement) + { + listBox.ItemsSource = new[] { replacement }; + listBox.RaiseEvent(new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List { removed }, + new List())); + } + + private static ModificationViewModel CreateViewModel( + string name, + ModificationType modificationType, + string dependenceName = "") + { + return new ModificationViewModel( + CreateModification(name, modificationType, dependenceName), + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType, + string dependenceName) + { + var version = new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType, + DependenceName = dependenceName + }; + + return new GameModification(version) + { + Name = name, + ModificationType = modificationType, + DependenceName = dependenceName + }; + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + throw exception; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs new file mode 100644 index 00000000..4f8497dc --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs @@ -0,0 +1,452 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.ViewModels; + +public sealed class MainWindowViewModelTests +{ + [Fact] + public void SetMainControlsEnabled_WhenDisabled_UpdatesComputedControlState() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + + // Act + viewModel.SetMainControlsEnabled(false); + + // Assert + viewModel.MainControlsEnabled.Should().BeFalse(); + viewModel.StartGameButtonEnabled.Should().BeFalse(); + viewModel.WorldBuilderButtonEnabled.Should().BeFalse(); + viewModel.LoadingIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + } + + [Fact] + public void ToggleGameArgument_WhenWindowedMissing_AddsArgumentAndRefreshesButtonText() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + MainWindowViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.ToggleGameArgument(LauncherGameArgumentService.WindowedArgument); + + // Assert + preferencesService.Updates.Should().ContainSingle(); + preferencesService.Current.GameArguments.Should().Be(LauncherGameArgumentService.WindowedArgument); + viewModel.WindowedModeButtonText.Should().Be("Change to full screen"); + } + + [Fact] + public void CloseCommand_WhenExecuted_RaisesCloseRequest() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CloseCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + } + + [Fact] + public void LaunchGameCommand_WhenExecuted_RaisesLaunchGameRequest() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + bool launchRequested = false; + viewModel.LaunchGameRequested += (_, _) => launchRequested = true; + + // Act + viewModel.LaunchGameCommand.Execute(null); + + // Assert + launchRequested.Should().BeTrue(); + } + + [Fact] + public void ImportManualPatchCommand_WhenExecuted_RaisesPatchImportRequest() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + LauncherManualImportKind? requestedKind = null; + viewModel.ManualImportRequested += (_, args) => requestedKind = args.Kind; + + // Act + viewModel.ImportManualPatchCommand.Execute(null); + + // Assert + requestedKind.Should().Be(LauncherManualImportKind.Patch); + } + + [Fact] + public void UpdateAddonAndPatchTabLabels_WhenChildDownloadsAreActive_UpdatesTabDownloadIndicators() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch) }, + addons: new[] + { + CreateModification("Addon One", ModificationType.Addon), + CreateModification("Addon Two", ModificationType.Addon) + }); + + viewModel.RefreshPatchesList(); + viewModel.RefreshAddonsList(); + + viewModel.PatchesListSource[0].SetDownloader(Substitute.For()); + viewModel.PatchesListSource[0].SetUIMessages("Downloading", 45); + viewModel.AddonsListSource[0].SetDownloader(Substitute.For()); + viewModel.AddonsListSource[0].SetUIMessages("Downloading", 20); + viewModel.AddonsListSource[1].SetDownloader(Substitute.For()); + viewModel.AddonsListSource[1].SetUIMessages("Downloading", 60); + + // Act + viewModel.UpdateAddonAndPatchTabLabels(); + + // Assert + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.PatchesTabDownloadProgressValue.Should().Be(45); + viewModel.AddonsTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.AddonsTabDownloadProgressValue.Should().Be(40); + } + + [Fact] + public void UpdateAddonAndPatchTabLabels_WhenChildIntegrityRepairIsActive_UpdatesTabDownloadIndicators() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch) }); + + viewModel.RefreshPatchesList(); + + // Act + viewModel.PatchesListSource[0].BeginIntegrityProgress("Preparing"); + + // Assert + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.PatchesTabDownloadProgressValue.Should().Be(0); + } + + [Fact] + public void ChildPackageActivity_WhenParentModIsDisplayed_ForwardsProgressToParentModTile() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification(parentName, ModificationType.Mod) }, + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }); + + viewModel.RefreshModsList(); + viewModel.RefreshPatchesList(); + + // Act + viewModel.PatchesListSource[0].BeginIntegrityProgress("Repairing patch"); + viewModel.PatchesListSource[0].ReportIntegrityProgress("Repairing patch", 35); + + // Assert + viewModel.ModsListSource[0].HasActivePackageActivity.Should().BeTrue(); + viewModel.ModsListSource[0].ProgressMessage.Should().Be("Repairing patch"); + viewModel.ModsListSource[0].ProgressValue.Should().Be(35); + } + + [Fact] + public void ChildPackageActivity_WhenCompleted_ClearsForwardedParentModProgress() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification(parentName, ModificationType.Mod) }, + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }); + + viewModel.RefreshModsList(); + viewModel.RefreshPatchesList(); + viewModel.PatchesListSource[0].BeginIntegrityProgress("Repairing patch"); + + // Act + viewModel.PatchesListSource[0].CompleteIntegrityProgress(); + + // Assert + viewModel.ModsListSource[0].HasActivePackageActivity.Should().BeFalse(); + viewModel.ModsListSource[0].ProgressValue.Should().Be(0); + } + + [Fact] + public void RefreshPatchesList_WhenActiveChildActivityExists_ReusesTileAndKeepsTabProgress() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }); + + viewModel.RefreshPatchesList(); + ModificationViewModel activePatch = viewModel.PatchesListSource[0]; + activePatch.BeginIntegrityProgress("Repairing patch"); + activePatch.ReportIntegrityProgress("Repairing patch", 55); + + // Act + viewModel.RefreshPatchesList(); + + // Assert + viewModel.PatchesListSource[0].Should().BeSameAs(activePatch); + viewModel.PatchesListSource[0].HasActivePackageActivity.Should().BeTrue(); + viewModel.PatchesListSource[0].ProgressValue.Should().Be(55); + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.PatchesTabDownloadProgressValue.Should().Be(55); + } + + [Fact] + public void RefreshAddonsList_WhenActiveDownloadExists_ReusesTileAndKeepsVisibleProgress() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + addons: new[] { CreateModification("Addon", ModificationType.Addon, parentName) }); + + viewModel.RefreshAddonsList(); + ModificationViewModel activeAddon = viewModel.AddonsListSource[0]; + activeAddon.SetDownloader(Substitute.For()); + activeAddon.SetUIMessages("Downloading", 72); + + // Act + viewModel.RefreshAddonsList(); + + // Assert + viewModel.AddonsListSource[0].Should().BeSameAs(activeAddon); + viewModel.AddonsListSource[0].HasActivePackageActivity.Should().BeTrue(); + viewModel.AddonsListSource[0].ProgressValue.Should().Be(72); + viewModel.AddonsTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.AddonsTabDownloadProgressValue.Should().Be(72); + } + + [Fact] + public void UpdateAddonAndPatchTabLabels_WhenChildDownloadsAreInactive_HidesTabDownloadIndicators() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch) }, + addons: new[] { CreateModification("Addon", ModificationType.Addon) }); + + viewModel.RefreshPatchesList(); + viewModel.RefreshAddonsList(); + + // Act + viewModel.UpdateAddonAndPatchTabLabels(); + + // Assert + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Hidden); + viewModel.PatchesTabDownloadProgressValue.Should().Be(0); + viewModel.AddonsTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Hidden); + viewModel.AddonsTabDownloadProgressValue.Should().Be(0); + } + + [Fact] + public void RemoveContentFromList_WhenRemovingModification_RemovesTileAndRenumbersRemainingMods() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] + { + CreateModification("First", ModificationType.Mod), + CreateModification("Second", ModificationType.Mod) + }); + viewModel.RefreshModsList(); + ModificationViewModel removed = viewModel.ModsListSource[0]; + + // Act + viewModel.RemoveContentFromList(removed); + + // Assert + viewModel.ModsListSource.Select(modification => modification.ContainerModification.Name) + .Should() + .Equal("Second"); + viewModel.ModsListSource[0].ContainerModification.NumberInList.Should().Be(0); + removed.ContainerModification.IsSelected.Should().BeFalse(); + } + + [Theory] + [InlineData(ModificationType.Addon)] + [InlineData(ModificationType.Patch)] + public void RemoveContentFromList_WhenRemovingChildContent_KeepsTileAndSelection( + ModificationType modificationType) + { + // Arrange + MainWindowViewModel viewModel = modificationType == ModificationType.Addon + ? CreateViewModel(addons: new[] { CreateModification("Addon", ModificationType.Addon) }) + : CreateViewModel(patches: new[] { CreateModification("Patch", ModificationType.Patch) }); + if (modificationType == ModificationType.Addon) + { + viewModel.RefreshAddonsList(); + } + else + { + viewModel.RefreshPatchesList(); + } + + IReadOnlyList childList = modificationType == ModificationType.Addon + ? viewModel.AddonsListSource + : viewModel.PatchesListSource; + ModificationViewModel child = childList[0]; + child.ContainerModification.IsSelected = true; + + // Act + viewModel.RemoveContentFromList(child); + + // Assert + childList.Should().ContainSingle().Which.Should().BeSameAs(child); + child.ContainerModification.IsSelected.Should().BeTrue(); + } + + private static MainWindowViewModel CreateViewModel( + RecordingLauncherPreferencesService? preferencesService = null, + IReadOnlyList? mods = null, + IReadOnlyList? patches = null, + IReadOnlyList? addons = null) + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(mods ?? Array.Empty()); + catalogQueries.GetPatchesForSelectedMod().Returns(patches ?? Array.Empty()); + catalogQueries.GetAddonsForSelectedMod().Returns(addons ?? Array.Empty()); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + IGameExecutableDiscoveryService executableDiscovery = Substitute.For(); + var runtimeContext = new LauncherRuntimeContext(CreateLauncherPaths(), "1.2.3"); + runtimeContext.SessionInformation.CurrentlyManagedGame = SupportedGame.ZeroHour; + ColorsInfo colors = CreateColors(); + runtimeContext.DefaultColors = colors; + runtimeContext.Colors = colors; + var stringLocalizer = new TestStringLocalizer(new Dictionary + { + ["Addons"] = "Add-ons for ", + ["ChangeToFullScreen"] = "Change to full screen", + ["ChangeToNormalStart"] = "Change to normal start", + ["ChangeToQuickStart"] = "Change to quick start", + ["ChangeToWindowed"] = "Change to windowed", + ["CurrentVersion"] = "Current version: ", + ["GeneralsOnlineClient"] = "GeneralsOnline client", + ["NoSupportedClient"] = "No supported client", + ["NoWorldBuildersFound"] = "No World Builders Found", + ["Patches"] = "Patches for ", + ["Preparing"] = "Preparing", + ["SuperHackersClient"] = "SuperHackers client", + ["SuperHackersWorldBuilder"] = "SuperHackers World Builder", + ["VanillaWorldBuilder"] = "Vanilla World Builder", + }); + + return new MainWindowViewModel( + preferencesService ?? new RecordingLauncherPreferencesService(new LauncherPreferences()), + new LauncherContentListService(catalogQueries, catalogCommands, runtimeContext), + new LauncherContentViewStateService(catalogQueries, runtimeContext), + new LauncherTabStateService(catalogQueries, runtimeContext, stringLocalizer), + new LauncherExecutableSelectionService(executableDiscovery, runtimeContext, stringLocalizer), + new LauncherSelectedContentService(catalogQueries), + catalogLoader, + catalogQueries, + catalogCommands, + runtimeContext, + runtimeContext, + stringLocalizer, + new ModificationViewModelFactory( + new ModificationImageSourceFactory(NullLogger.Instance), + runtimeContext, + Substitute.For(), + stringLocalizer, + NullLogger.Instance)); + } + + private static LauncherPaths CreateLauncherPaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType, + string dependenceName = "") + { + var version = new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType, + DependenceName = dependenceName + }; + + return new GameModification(version) + { + Name = name, + ModificationType = modificationType, + DependenceName = dependenceName + }; + } + + private sealed class RecordingLauncherPreferencesService : ILauncherPreferencesService + { + public RecordingLauncherPreferencesService(LauncherPreferences preferences) + { + Current = preferences; + } + + public event EventHandler? PreferencesChanged; + + public LauncherPreferences Current { get; private set; } + + public List Updates { get; } = new(); + + public void Update(LauncherPreferences preferences) + { + Current = preferences; + Updates.Add(preferences); + PreferencesChanged?.Invoke(this, new LauncherPreferencesChangedEventArgs(preferences)); + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs new file mode 100644 index 00000000..a66c20cc --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Threading; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Mods; + +public sealed class ModificationImageSourceFactoryTests +{ + [Theory] + [InlineData(SupportedGame.Generals)] + [InlineData(SupportedGame.ZeroHour)] + public void LoadDefaultImageReturnsFrozenResourceImage(SupportedGame supportedGame) + { + RunOnStaThread(() => + { + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource image = factory.LoadDefaultImage(supportedGame, grayscale: false); + + image.IsFrozen.Should().BeTrue(); + image.PixelWidth.Should().BeGreaterThan(0); + image.PixelHeight.Should().BeGreaterThan(0); + }); + } + + [Fact] + public void LoadDefaultImageReturnsFrozenGrayscaleResourceImage() + { + RunOnStaThread(() => + { + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource image = factory.LoadDefaultImage(SupportedGame.Generals, grayscale: true); + + image.IsFrozen.Should().BeTrue(); + image.Format.Should().Be(PixelFormats.Gray8); + }); + } + + [Fact] + public void LoadFileImageReadsSourceIntoMemory() + { + RunOnStaThread(() => + { + using TestDirectory testDirectory = new(); + string imagePath = Path.Combine(testDirectory.Path, "mod-image.png"); + SaveTestImage(imagePath); + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource? image = factory.LoadFileImage(imagePath, grayscale: false); + File.Delete(imagePath); + + image.Should().NotBeNull(); + image!.IsFrozen.Should().BeTrue(); + image.PixelWidth.Should().Be(2); + image.PixelHeight.Should().Be(2); + File.Exists(imagePath).Should().BeFalse(); + }); + } + + [Fact] + public void LoadFileImageReturnsNullWhenPathIsMissing() + { + RunOnStaThread(() => + { + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource? image = factory.LoadFileImage("missing-image.png", grayscale: false); + + image.Should().BeNull(); + }); + } + + private static ModificationImageSourceFactory CreateFactory() + { + return new ModificationImageSourceFactory(NullLogger.Instance); + } + + private static void SaveTestImage(string path) + { + DrawingVisual visual = new(); + using (DrawingContext drawingContext = visual.RenderOpen()) + { + drawingContext.DrawRectangle(Brushes.DarkRed, null, new Rect(0, 0, 2, 2)); + } + + RenderTargetBitmap bitmap = new(2, 2, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + + using FileStream stream = File.Create(path); + encoder.Save(stream); + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + throw exception; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs new file mode 100644 index 00000000..c8a82bc9 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs @@ -0,0 +1,152 @@ +using System.Collections.Generic; +using System.Windows.Media; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Mods; + +public sealed class ModificationViewModelTests +{ + [Fact] + public void Constructor_WhenLatestVersionInstalled_LabelsDisabledUpdateButtonAsUpToDate() + { + // Arrange + GameModification modification = CreateModification(installed: true); + TestStringLocalizer stringLocalizer = new(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Update"] = "Update!", + ["UpToDate"] = "Up-to-date" + }); + + // Act + ModificationViewModel viewModel = CreateViewModel(modification, stringLocalizer); + + // Assert + viewModel.UpdateButtonEnabled.Should().BeFalse(); + viewModel.UpdateButtonContent.Should().Be("Up-to-date"); + } + + [Fact] + public void Constructor_WhenSingleLatestVersionIsNotInstalled_LeavesInstallButtonText() + { + // Arrange + GameModification modification = CreateModification(installed: false); + TestStringLocalizer stringLocalizer = new(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Update"] = "Update!", + ["UpToDate"] = "Up-to-date" + }); + + // Act + ModificationViewModel viewModel = CreateViewModel(modification, stringLocalizer); + + // Assert + viewModel.UpdateButtonEnabled.Should().BeTrue(); + viewModel.UpdateButtonContent.Should().Be("Install"); + } + + [Fact] + public void PrepareControlsToDownloadModeLabelsUpdateButtonAsCancel() + { + // Arrange + ModificationViewModel viewModel = CreateViewModel( + CreateModification(installed: false), + new TestStringLocalizer(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Cancel"] = "Cancel" + })); + + // Act + viewModel.PrepareControlsToDownloadMode(); + + // Assert + viewModel.UpdateButtonContent.Should().Be("Cancel"); + } + + [Fact] + public void SetActiveProgressBarUsesThemeActiveColorForFill() + { + // Arrange + var fillColor = Color.FromRgb(186, 255, 12); + var backgroundColor = Color.FromRgb(37, 52, 255); + ModificationViewModel viewModel = CreateViewModel(CreateColors()); + + // Act + viewModel.SetActiveProgressBar(); + + // Assert + viewModel.ProgressForeground.Should().BeOfType() + .Which.Color.Should().Be(fillColor); + viewModel.ProgressBackground.Should().BeOfType() + .Which.Color.Should().Be(backgroundColor); + } + + private static ModificationViewModel CreateViewModel(ColorsInfo colors) + { + return CreateViewModel(CreateModification(installed: false), new TestStringLocalizer(), colors); + } + + private static ModificationViewModel CreateViewModel( + GameModification modification, + TestStringLocalizer stringLocalizer) + { + return CreateViewModel(modification, stringLocalizer, CreateColors()); + } + + private static ModificationViewModel CreateViewModel( + GameModification modification, + TestStringLocalizer stringLocalizer, + ColorsInfo colors) + { + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(colors: colors), + Substitute.For(), + stringLocalizer, + NullLogger.Instance); + } + + private static GameModification CreateModification(bool installed) + { + return new GameModification + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = new List + { + new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + ModificationType = ModificationType.Mod, + Installed = installed + } + } + }; + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..d81cda30 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,37 @@ +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Composition; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Mods; + +public sealed class ModsUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoModsUi_RegistersModificationImageSourceFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoModsUi(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void AddGenLauncherGoModsUi_RegistersModificationViewModelFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoModsUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(ModificationViewModelFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs new file mode 100644 index 00000000..9b7bae6c --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs @@ -0,0 +1,165 @@ +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Mods.ViewModels; + +public sealed class ModificationTileStateTests +{ + [Fact] + public void ConstructorSelectsPersistedInstalledVersion() + { + // Arrange + ModificationVersion firstVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true + }; + ModificationVersion selectedVersion = new() + { + Name = "Test Mod", + Version = "2.0", + Installed = true, + IsSelected = true + }; + GameModification modification = CreateModification(firstVersion, selectedVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.SelectedVersion.Should().BeSameAs(selectedVersion); + state.LatestVersion.Should().BeSameAs(selectedVersion); + state.LatestVersionInfo.Should().Be("Latest version: 2.0"); + } + + [Fact] + public void ConstructorFallsBackToFirstInstalledVersion() + { + // Arrange + ModificationVersion installedVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true + }; + ModificationVersion remoteVersion = new() + { + Name = "Test Mod", + Version = "2.0" + }; + GameModification modification = CreateModification(installedVersion, remoteVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.SelectedVersion.Should().BeSameAs(installedVersion); + installedVersion.IsSelected.Should().BeTrue(); + state.LatestVersion.Should().BeSameAs(remoteVersion); + } + + [Fact] + public void ConstructorDetectsLocalOnlyModification() + { + // Arrange + ModificationVersion localVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true + }; + GameModification modification = CreateModification(localVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.LocalMod.Should().BeTrue(); + } + + [Fact] + public void ConstructorDetectsManagedModification() + { + // Arrange + ModificationVersion managedVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true, + S3BucketName = "mods", + S3FolderName = "test-mod" + }; + GameModification modification = CreateModification(managedVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.LocalMod.Should().BeFalse(); + } + + [Fact] + public void ConstructorLeavesAdvertisingVersionTextUnprefixed() + { + // Arrange + ModificationVersion advertisingVersion = new() + { + Name = "Sponsor", + Version = "Summer", + ModificationType = ModificationType.Advertising + }; + GameModification modification = CreateModification(advertisingVersion); + modification.ModificationType = ModificationType.Advertising; + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.LatestVersionInfo.Should().Be("Summer"); + } + + [Fact] + public void SelectLatestInstalledVersionClearsPreviousSelectionAndMarksReady() + { + // Arrange + ModificationVersion oldVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true, + IsSelected = true + }; + ModificationVersion newVersion = new() + { + Name = "Test Mod", + Version = "2.0", + Installed = true + }; + GameModification modification = CreateModification(oldVersion, newVersion); + ModificationTileState state = new(modification, new TestStringLocalizer()); + state.MarkNotReadyToRun(); + + // Act + ModificationVersion selectedVersion = state.SelectLatestInstalledVersion(); + + // Assert + selectedVersion.Should().BeSameAs(newVersion); + oldVersion.IsSelected.Should().BeFalse(); + newVersion.IsSelected.Should().BeTrue(); + state.ReadyToRun.Should().BeTrue(); + } + + private static GameModification CreateModification(params ModificationVersion[] versions) + { + return new GameModification + { + Name = "Test Mod", + ModificationType = ModificationType.Mod, + ModificationVersions = versions.ToList() + }; + } + +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs new file mode 100644 index 00000000..58b85bf0 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs @@ -0,0 +1,219 @@ +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.UI.Features.Mods.ViewModels; + +public sealed class ModsDialogViewModelTests +{ + [Fact] + public void AddModificationAcceptCommand_WithSelection_RequestsAcceptedClose() + { + // Arrange + AddModificationViewModel viewModel = new(new[] { "Contra", "ShockWave" }); + viewModel.SelectedModificationName = "Contra"; + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeTrue(); + viewModel.SelectedModificationName.Should().Be("Contra"); + } + + [Fact] + public void AddModificationCancelCommand_RequestsCanceledClose() + { + // Arrange + AddModificationViewModel viewModel = new(new[] { "Contra" }); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeFalse(); + } + + [Fact] + public void ManualAddAcceptCommand_WithMissingName_ShowsLocalizedErrorWithoutClosing() + { + // Arrange + FakeDialogService dialogService = new(); + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + dialogService) + { + Version = "1.0" + }; + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + closeRequested.Should().BeFalse(); + dialogService.LastErrorRequest.Should().NotBeNull(); + dialogService.LastErrorRequest!.MainMessage.Should().Be("Operation aborted"); + dialogService.LastErrorRequest.DetailMessage.Should().Be("Enter a modification name"); + viewModel.DialogResult.Should().BeNull(); + } + + [Fact] + public void ManualAddAcceptCommand_WithValidInput_CreatesImportResultAndCloses() + { + // Arrange + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + "Parent Mod", + CreateStringLocalizer(), + new FakeDialogService()) + { + ModificationName = "Patch Pack", + Version = "1.2" + }; + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeTrue(); + viewModel.ImportResult.Should().NotBeNull(); + viewModel.ImportResult!.ParentContentName.Should().Be("Parent Mod"); + viewModel.ImportResult.ModificationName.Should().Be("Patch Pack"); + viewModel.ImportResult.Version.Should().Be("1.2"); + viewModel.ImportResult.Files.Should().ContainSingle().Which.Should().Be(@"C:\Temp\mod.zip"); + } + + [Fact] + public void InfoDialogConstructor_ForInfo_ConfiguresOkOnlyNeutralState() + { + // Act + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Information", "Details"), + InfoDialogKind.Info); + + // Assert + viewModel.OkVisibility.Should().Be(Visibility.Visible); + viewModel.ContinueVisibility.Should().Be(Visibility.Hidden); + viewModel.CancelVisibility.Should().Be(Visibility.Hidden); + viewModel.InfoIconVisibility.Should().Be(Visibility.Visible); + viewModel.ErrorIconVisibility.Should().Be(Visibility.Hidden); + viewModel.WarningIconVisibility.Should().Be(Visibility.Hidden); + } + + [Fact] + public void InfoDialogConstructor_ForError_ConfiguresOkOnlyErrorState() + { + // Act + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Error", "Details", 12D), + InfoDialogKind.Error); + + // Assert + viewModel.MainMessage.Should().Be("Error"); + viewModel.DetailMessage.Should().Be("Details"); + viewModel.DetailFontSize.Should().Be(12D); + viewModel.OkVisibility.Should().Be(Visibility.Visible); + viewModel.ContinueVisibility.Should().Be(Visibility.Hidden); + viewModel.CancelVisibility.Should().Be(Visibility.Hidden); + viewModel.InfoIconVisibility.Should().Be(Visibility.Hidden); + viewModel.ErrorIconVisibility.Should().Be(Visibility.Visible); + viewModel.WarningIconVisibility.Should().Be(Visibility.Hidden); + } + + [Fact] + public void InfoDialogCancelCommand_ForWarning_SetsNegativeResultAndRequestsHide() + { + // Arrange + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Warning", "Details"), + InfoDialogKind.WarningConfirmation, + "Continue anyway"); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ChoseAnOption.Should().BeTrue(); + viewModel.ContinueLaunch.Should().BeFalse(); + viewModel.ShouldHideOnCloseRequest.Should().BeTrue(); + viewModel.ContinueText.Should().Be("Continue anyway"); + } + + private static TestStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["EnterModName"] = "Enter a modification name", + ["EnterModVersion"] = "Enter a version", + ["NameAndVersionValidSymbols"] = "Name and version must contain supported symbols", + ["OperationAborted"] = "Operation aborted", + ["VersionMustContainNumbers"] = "Version must contain numbers", + }); + } + + private sealed class FakeDialogService : ILauncherDialogService + { + public LauncherInfoDialogRequest? LastErrorRequest { get; private set; } + + public LauncherInfoDialogRequest? LastInfoRequest { get; private set; } + + public void ShowInfo(LauncherInfoDialogRequest request, Window? owner = null) + { + LastInfoRequest = request; + } + + public void ShowError(LauncherInfoDialogRequest request, Window? owner = null) + { + LastErrorRequest = request; + } + + public bool ShowWarningConfirmation( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null) + { + return true; + } + + public string? ShowModificationSelection(IReadOnlyList modificationNames, Window? owner = null) + { + return null; + } + + public ManualModificationDialogResult? ShowManualModificationImport( + ManualModificationDialogRequest request, + Window? owner = null) + { + return null; + } + + public bool ShowIntegrityReview( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options, + Window? owner = null) + { + return false; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f644d55b --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,22 @@ +using GenLauncherGO.UI.Features.Settings.Composition; +using GenLauncherGO.UI.Features.Settings.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Settings; + +public sealed class LauncherSettingsUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLauncherSettingsUi_RegistersLauncherSettingsWindowFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherSettingsUi(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().NotBeNull(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs new file mode 100644 index 00000000..251a4771 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.UI.Features.Settings.Contracts; +using GenLauncherGO.UI.Features.Settings.Models; +using GenLauncherGO.UI.Features.Settings.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Settings; + +public sealed class LauncherSettingsViewModelTests +{ + [Fact] + public void GameArguments_WhenChanged_PersistsImmediately() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + LauncherSettingsViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.GameArguments = "-quickstart"; + + // Assert + preferencesService.Updates.Should().ContainSingle(); + preferencesService.Current.GameArguments.Should().Be("-quickstart"); + viewModel.GameArguments.Should().Be("-quickstart"); + } + + [Fact] + public void UseSystemLanguage_WhenSelected_PersistsAppliesCultureAndRefreshesText() + { + // Arrange + var preferences = new LauncherPreferences { UseEnglishLanguage = true }; + var preferencesService = new RecordingLauncherPreferencesService(preferences); + var cultureService = new RecordingLauncherCultureService(); + var textProvider = new CountingLauncherSettingsTextProvider(); + LauncherSettingsViewModel viewModel = CreateViewModel(preferencesService, cultureService, textProvider); + + // Act + viewModel.UseSystemLanguage = true; + + // Assert + preferencesService.Updates.Should().ContainSingle(); + preferencesService.Current.UseEnglishLanguage.Should().BeFalse(); + cultureService.AppliedPreferences.Should().ContainSingle().Which.Should().BeFalse(); + textProvider.CallCount.Should().Be(2); + viewModel.UseEnglishLanguage.Should().BeFalse(); + viewModel.UseSystemLanguage.Should().BeTrue(); + } + + [Fact] + public void CloseCommand_WhenExecuted_RaisesCloseRequest() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + LauncherSettingsViewModel viewModel = CreateViewModel(preferencesService); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CloseCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + } + + [Fact] + public void LinkCommands_WhenExecuted_InvokeLinkService() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + var linkService = new RecordingLauncherSettingsLinkService(); + var viewModel = new LauncherSettingsViewModel( + preferencesService, + linkService, + new CountingLauncherSettingsTextProvider(), + new RecordingLauncherCultureService()); + + // Act + viewModel.OpenGeneralsOnlineDiscordCommand.Execute(null); + viewModel.OpenLogsDirectoryCommand.Execute(null); + viewModel.OpenGitHubRepositoryCommand.Execute(null); + viewModel.OpenDonationCommand.Execute(null); + + // Assert + linkService.OpenedGeneralsOnlineDiscord.Should().BeTrue(); + linkService.OpenedLogsDirectory.Should().BeTrue(); + linkService.OpenedGitHubRepository.Should().BeTrue(); + linkService.OpenedOriginalAuthorDonation.Should().BeTrue(); + } + + private static LauncherSettingsViewModel CreateViewModel( + RecordingLauncherPreferencesService preferencesService, + RecordingLauncherCultureService? cultureService = null, + CountingLauncherSettingsTextProvider? textProvider = null) + { + return new LauncherSettingsViewModel( + preferencesService, + new RecordingLauncherSettingsLinkService(), + textProvider ?? new CountingLauncherSettingsTextProvider(), + cultureService ?? new RecordingLauncherCultureService()); + } + + private sealed class RecordingLauncherPreferencesService : ILauncherPreferencesService + { + public RecordingLauncherPreferencesService(LauncherPreferences preferences) + { + Current = preferences; + } + + public event EventHandler? PreferencesChanged; + + public LauncherPreferences Current { get; private set; } + + public List Updates { get; } = new List(); + + public void Update(LauncherPreferences preferences) + { + Current = preferences; + Updates.Add(preferences); + PreferencesChanged?.Invoke(this, new LauncherPreferencesChangedEventArgs(preferences)); + } + } + + private sealed class RecordingLauncherCultureService : ILauncherCultureService + { + public List AppliedPreferences { get; } = new List(); + + public void ApplyLanguagePreference(bool useEnglishLanguage) + { + AppliedPreferences.Add(useEnglishLanguage); + } + } + + private sealed class CountingLauncherSettingsTextProvider : ILauncherSettingsTextProvider + { + public int CallCount { get; private set; } + + public LauncherSettingsText GetText() + { + CallCount++; + return new LauncherSettingsText + { + Title = "Launcher Settings", + Ok = "OK" + }; + } + } + + private sealed class RecordingLauncherSettingsLinkService : ILauncherSettingsLinkService + { + public bool OpenedGeneralsOnlineDiscord { get; private set; } + + public bool OpenedLogsDirectory { get; private set; } + + public bool OpenedGitHubRepository { get; private set; } + + public bool OpenedOriginalAuthorDonation { get; private set; } + + public bool TryOpenGeneralsOnlineDiscordLink() + { + OpenedGeneralsOnlineDiscord = true; + return true; + } + + public bool TryOpenLogsDirectory() + { + OpenedLogsDirectory = true; + return true; + } + + public bool TryOpenGitHubRepository() + { + OpenedGitHubRepository = true; + return true; + } + + public bool TryOpenOriginalAuthorDonationLink() + { + OpenedOriginalAuthorDonation = true; + return true; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..8da41492 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,90 @@ +using System; +using GenLauncherGO.UI.Features.Launcher.Views; +using GenLauncherGO.UI.Features.Startup.Composition; +using GenLauncherGO.UI.Features.Startup.Services; +using GenLauncherGO.UI.Features.Startup.ViewModels; +using GenLauncherGO.UI.Features.Startup.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Startup; + +public sealed class StartupUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoStartupUiReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoStartupUi(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoStartupUiThrowsForNullServices() + { + // Arrange + ServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoStartupUi(); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(services)); + } + + [Fact] + public void AddGenLauncherGoStartupUiRegistersMainWindowFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoStartupUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(Func) && + descriptor.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void AddGenLauncherGoStartupUiRegistersInitWindowWorkflowCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoStartupUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(InitWindowWorkflowCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoStartupUiRegistersStartupWindowsAndViewModels() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoStartupUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(InitWindowViewModel) && + descriptor.Lifetime == ServiceLifetime.Transient); + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(InitWindow) && + descriptor.Lifetime == ServiceLifetime.Transient); + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(MainWindow) && + descriptor.Lifetime == ServiceLifetime.Transient); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs new file mode 100644 index 00000000..5d6909a7 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Features.Startup.Contracts; +using GenLauncherGO.UI.Features.Startup.ViewModels; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.UI.Features.Startup.ViewModels; + +public sealed class InitWindowViewModelTests +{ + [Fact] + public async Task StartAsync_WhenPreparationSucceeds_RaisesStartupCompletedAndSetsConnectedAsync() + { + // Arrange + LauncherContentCatalogInitializationRequest? initializationRequest = null; + ILauncherPathResolver pathResolver = Substitute.For(); + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(true); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.ReposModsNames.Returns(new[] { "Contra" }); + catalogLoader.InitDataAsync( + Arg.Do(request => initializationRequest = request), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.2.3"); + InitWindowViewModel viewModel = CreateViewModel( + pathResolver, + launchPreparationService, + connectionProbe, + catalogLoader, + catalogQueries, + startupEnvironmentService, + runtimeContext, + new FakeStartupDialogService()); + InitWindowStartupCompletedEventArgs? completedArgs = null; + viewModel.StartupCompleted += (_, args) => completedArgs = args; + + // Act + await viewModel.StartAsync(); + + // Assert + completedArgs.Should().NotBeNull(); + completedArgs!.Connected.Should().BeTrue(); + runtimeContext.Connected.Should().BeTrue(); + runtimeContext.CurrentlyManagedGame.Should().Be(SupportedGame.ZeroHour); + initializationRequest.Should().NotBeNull(); + initializationRequest!.Connected.Should().BeTrue(); + initializationRequest.RemoteManifestUri.Should().Be(new Uri(LauncherApplicationDefaults.ZeroHourRepositoryUrl)); + pathResolver.Received(1).PrepareLauncherDirectories(runtimeContext.LauncherPaths, true); + } + + [Fact] + public async Task StartAsync_WhenRecoveryCanceled_RequestsShutdownWithoutCompletionAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Failure(Array.Empty())); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.2.3"); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + Substitute.For(), + Substitute.For(), + Substitute.For(), + startupEnvironmentService, + runtimeContext, + new FakeStartupDialogService(retryRecovery: false)); + bool completed = false; + bool shutdownRequested = false; + viewModel.StartupCompleted += (_, _) => completed = true; + viewModel.ShutdownRequested += (_, _) => shutdownRequested = true; + + // Act + await viewModel.StartAsync(); + + // Assert + completed.Should().BeFalse(); + shutdownRequested.Should().BeTrue(); + runtimeContext.Connected.Should().BeFalse(); + await startupEnvironmentService.DidNotReceive() + .ReadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task StartAsync_WhenConnectionFails_ShowsThemedStartupMessageAndCompletesOfflineAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(false); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.InitDataAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.2.3"); + FakeStartupDialogService startupDialogService = new(); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + connectionProbe, + catalogLoader, + Substitute.For(), + startupEnvironmentService, + runtimeContext, + startupDialogService); + InitWindowStartupCompletedEventArgs? completedArgs = null; + viewModel.StartupCompleted += (_, args) => completedArgs = args; + + // Act + await viewModel.StartAsync(); + + // Assert + completedArgs.Should().NotBeNull(); + completedArgs!.Connected.Should().BeFalse(); + startupDialogService.ThemedTitle.Should().Be("Information"); + startupDialogService.ThemedMessage.Should().Be("Cannot connect"); + startupDialogService.ThemedColors.Should().BeSameAs(runtimeContext.Colors); + } + + private static InitWindowViewModel CreateViewModel( + ILauncherPathResolver pathResolver, + ILaunchPreparationService launchPreparationService, + IRemoteConnectionProbe connectionProbe, + ILauncherContentCatalogLoader catalogLoader, + ILauncherContentCatalogQueries catalogQueries, + ILauncherStartupEnvironmentService startupEnvironmentService, + LauncherRuntimeContext runtimeContext, + IStartupDialogService startupDialogService) + { + return new InitWindowViewModel( + connectionProbe, + launchPreparationService, + pathResolver, + catalogLoader, + catalogQueries, + startupEnvironmentService, + new LauncherContentLayout("Addons", "Patches"), + runtimeContext, + new TestStringLocalizer(new Dictionary + { + ["CannotConnect"] = "Cannot connect", + ["DeploymentRecoveryFailed"] = "Deployment recovery failed", + ["Info"] = "Information", + }), + startupDialogService); + } + + private static LauncherPaths CreateLauncherPaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static ColorsInfoString CreateColors() + { + return new ColorsInfoString( + "#00e3ff", + "DarkGray", + "#7a7db0", + "#baff0c", + "#232977", + "#090502", + "#B3000000", + "White", + "#090502", + "#F21d2057", + "#F21d2057", + "#2534ff"); + } + + private sealed class FakeStartupDialogService : IStartupDialogService + { + private readonly bool _retryRecovery; + + public FakeStartupDialogService(bool retryRecovery = true) + { + _retryRecovery = retryRecovery; + } + + public string? ThemedTitle { get; private set; } + + public string? ThemedMessage { get; private set; } + + public ColorsInfo? ThemedColors { get; private set; } + + public void ShowMessage(string message) + { + } + + public void ShowThemedMessage(string title, string message, ColorsInfo colors) + { + ThemedTitle = title; + ThemedMessage = message; + ThemedColors = colors; + } + + public bool ShowRetryCancelWarning(string title, string message) + { + return _retryRecovery; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs b/GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs new file mode 100644 index 00000000..509fc27a --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.UI.Shared.Commands; + +namespace GenLauncherGO.Tests.UI.Shared.Commands; + +public sealed class AsyncRelayCommandTests +{ + [Fact] + public async Task Execute_RunsAsynchronousActionAsync() + { + // Arrange + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var command = new AsyncRelayCommand(_ => + { + completion.SetResult(); + return Task.CompletedTask; + }); + + // Act + command.Execute(null); + + // Assert + await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void CanExecute_ReturnsFalseWhileCommandIsExecuting() + { + // Arrange + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var command = new AsyncRelayCommand(_ => completion.Task); + + // Act + command.Execute(null); + + // Assert + command.CanExecute(null).Should().BeFalse(); + + completion.SetResult(); + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs b/GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs new file mode 100644 index 00000000..620989a9 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.UI.Shared.Localization; + +public sealed class WpfLauncherStringLocalizerTests +{ + [Fact] + public void Indexer_WithNeutralCulture_ReturnsStringFromUiResources() + { + // Arrange + WpfLauncherStringLocalizer localizer = new(); + + // Act + localizer.SetCulture(CultureInfo.GetCultureInfo("en")); + string result = localizer["Update"]; + + // Assert + result.Should().Be("Update!"); + } + + [Fact] + public void Indexer_WithSatelliteCulture_ReturnsStringFromUiResources() + { + // Arrange + WpfLauncherStringLocalizer localizer = new(); + + // Act + localizer.SetCulture(CultureInfo.GetCultureInfo("ru")); + string result = localizer["Update"]; + + // Assert + result.Should().Be("\u041E\u0411\u041D\u041E\u0412\u0418\u0422\u042C!"); + } +} diff --git a/GenLauncherGO.UI/AGENTS.md b/GenLauncherGO.UI/AGENTS.md new file mode 100644 index 00000000..6672f75f --- /dev/null +++ b/GenLauncherGO.UI/AGENTS.md @@ -0,0 +1,27 @@ +# GenLauncherGO.UI Agent Guidelines + +`GenLauncherGO.UI/` owns WPF presentation code and application composition. + +## Do + +- Keep `GenLauncherGO.UI` as the dependency injection composition root. +- Put windows, dialogs, controls, view models, commands, resources, and themes in UI. +- Use constructor injection for view models and services. +- Localize new or changed user-visible strings in the same change. +- Keep the neutral resource file and satellite resource files structurally in sync. +- Use an English fallback value when a fluent translation is not available yet and mention that in the change summary. +- Reuse existing resource keys when meaning is unchanged; create new keys when meaning changes. +- Use stable resource key names that describe UI meaning rather than current wording. +- Add XML documentation for every production type and member, regardless of accessibility. +- Use `ILogger` for startup, command, and user-flow diagnostics when failures need troubleshooting context. +- Prefer `Features/`, `Shared/Controls/`, `Shared/Resources/`, and `Shared/Themes/`. +- Inside a crowded `Features//` folder, layer by real responsibilities such as `ViewModels/`, `Commands/`, + `Views/`, `Dialogs/`, and `Resources/`. Keep simple features flat. + +## Avoid + +- Do not put concrete file-system mutation, process launching, GitHub/S3 access, archive extraction, hashing, or + symbolic-link implementation in UI. +- Do not hard-code user-visible strings in XAML or code-behind unless the string is diagnostic-only and not shown to + users. +- Do not edit generated localization designer files by hand when the project tooling can regenerate them. diff --git a/GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs b/GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs new file mode 100644 index 00000000..d20d1b02 --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using System; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.UI.Features.Dialogs.Composition; + +/// +/// Provides dependency-injection registrations for launcher dialog services. +/// +internal static class DialogServiceCollectionExtensions +{ + /// + /// Registers launcher dialog services. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoDialogs(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs new file mode 100644 index 00000000..e3e013fe --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; + +namespace GenLauncherGO.UI.Features.Dialogs.Contracts; + +/// +/// Shows launcher-owned modal dialogs and returns user choices to callers. +/// +public interface ILauncherDialogService +{ + /// + /// Shows a themed information dialog. + /// + /// The dialog text and display options. + /// The owner window, when one should be assigned. + void ShowInfo(LauncherInfoDialogRequest request, Window? owner = null); + + /// + /// Shows a themed error dialog. + /// + /// The dialog text and display options. + /// The owner window, when one should be assigned. + void ShowError(LauncherInfoDialogRequest request, Window? owner = null); + + /// + /// Shows a themed warning confirmation dialog. + /// + /// The dialog text and display options. + /// The optional replacement text for the continue button. + /// The owner window, when one should be assigned. + /// when the user chose to continue. + bool ShowWarningConfirmation( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null); + + /// + /// Shows the repository modification selection dialog. + /// + /// The modification names the user may add. + /// The owner window, when one should be assigned. + /// The selected modification name, or when the dialog was canceled. + string? ShowModificationSelection(IReadOnlyList modificationNames, Window? owner = null); + + /// + /// Shows the manual import details dialog. + /// + /// The selected files and optional parent content name. + /// The owner window, when one should be assigned. + /// The entered import details, or when the dialog was canceled. + ManualModificationDialogResult? ShowManualModificationImport( + ManualModificationDialogRequest request, + Window? owner = null); + + /// + /// Shows the integrity review dialog. + /// + /// The report to review. + /// The localized dialog options. + /// The owner window, when one should be assigned. + /// when the user confirmed the offered resolution. + bool ShowIntegrityReview( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options, + Window? owner = null); +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs new file mode 100644 index 00000000..f3aa49fb --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; + +namespace GenLauncherGO.UI.Features.Dialogs.Contracts; + +/// +/// Creates WPF dialog windows with their view models and launcher theme resources. +/// +internal interface ILauncherDialogWindowFactory +{ + /// + /// Creates a repository modification selection dialog. + /// + /// The available repository modification names. + /// The configured selection dialog. + AddModificationWindow CreateAddModificationWindow(IReadOnlyList modificationNames); + + /// + /// Creates an integrity review dialog. + /// + /// The integrity report to display. + /// The localized review dialog options. + /// The configured integrity review dialog. + IntegrityReviewDialog CreateIntegrityReviewDialog( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options); + + /// + /// Creates a themed information or confirmation dialog. + /// + /// The dialog text and display options. + /// The dialog kind to display. + /// The optional replacement continue button text. + /// The configured information dialog. + InfoWindow CreateInfoWindow( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null); + + /// + /// Creates a manual modification import dialog. + /// + /// The import dialog request. + /// The dialog service used by the dialog view model for validation errors. + /// The configured manual import dialog. + ManualAddModificationWindow CreateManualModificationWindow( + ManualModificationDialogRequest request, + ILauncherDialogService dialogService); +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs b/GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs new file mode 100644 index 00000000..e4d4a836 --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs @@ -0,0 +1,40 @@ +using System; + +namespace GenLauncherGO.UI.Features.Dialogs.Models; + +/// +/// Describes text and display options for a launcher information dialog. +/// +public sealed class LauncherInfoDialogRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The primary message text. + /// The secondary message text. + /// The optional secondary message font size. + public LauncherInfoDialogRequest( + string mainMessage, + string detailMessage, + double? detailFontSize = null) + { + MainMessage = mainMessage ?? throw new ArgumentNullException(nameof(mainMessage)); + DetailMessage = detailMessage ?? throw new ArgumentNullException(nameof(detailMessage)); + DetailFontSize = detailFontSize; + } + + /// + /// Gets the primary message text. + /// + public string MainMessage { get; } + + /// + /// Gets the secondary message text. + /// + public string DetailMessage { get; } + + /// + /// Gets the optional secondary message font size. + /// + public double? DetailFontSize { get; } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs new file mode 100644 index 00000000..97d753be --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.UI.Features.Dialogs.Models; + +/// +/// Describes a manual content import dialog request. +/// +public sealed class ManualModificationDialogRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The selected package files. + /// The parent modification name for patch or addon imports. + public ManualModificationDialogRequest( + IReadOnlyList files, + string? parentContentName = null) + { + ArgumentNullException.ThrowIfNull(files); + + Files = files.ToList(); + ParentContentName = parentContentName; + } + + /// + /// Gets the selected package files. + /// + public IReadOnlyList Files { get; } + + /// + /// Gets the parent modification name for patch or addon imports. + /// + public string? ParentContentName { get; } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs new file mode 100644 index 00000000..1dc0099c --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.UI.Features.Dialogs.Models; + +/// +/// Stores user-entered manual content import details. +/// +public sealed class ManualModificationDialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The selected package files. + /// The parent modification name for patch or addon imports. + /// The entered modification, patch, or addon name. + /// The entered version. + public ManualModificationDialogResult( + IReadOnlyList files, + string? parentContentName, + string modificationName, + string version) + { + ArgumentNullException.ThrowIfNull(files); + + Files = files.ToList(); + ParentContentName = parentContentName; + ModificationName = modificationName ?? throw new ArgumentNullException(nameof(modificationName)); + Version = version ?? throw new ArgumentNullException(nameof(version)); + } + + /// + /// Gets the selected package files. + /// + public IReadOnlyList Files { get; } + + /// + /// Gets the parent modification name for patch or addon imports. + /// + public string? ParentContentName { get; } + + /// + /// Gets the entered modification, patch, or addon name. + /// + public string ModificationName { get; } + + /// + /// Gets the entered version. + /// + public string Version { get; } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs new file mode 100644 index 00000000..060db33a --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Integrity.ViewModels; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.UI.Features.Dialogs.Services; + +/// +/// Creates runtime dialog view models for launcher dialog windows. +/// +internal sealed class LauncherDialogViewModelFactory +{ + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// Initializes a new instance of the class. + /// + /// The localized string provider. + public LauncherDialogViewModelFactory(ILauncherStringLocalizer stringLocalizer) + { + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + } + + /// + /// Creates a repository modification selection dialog view model. + /// + /// The available repository modification names. + /// The created dialog view model. + public AddModificationViewModel CreateAddModificationViewModel(IReadOnlyList modificationNames) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + return new AddModificationViewModel(modificationNames); + } + + /// + /// Creates an integrity review dialog view model. + /// + /// The integrity report to review. + /// The dialog behavior options. + /// The created dialog view model. + public IntegrityReviewViewModel CreateIntegrityReviewViewModel( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(options); + + return new IntegrityReviewViewModel(report, options); + } + + /// + /// Creates an information dialog view model. + /// + /// The dialog request. + /// The dialog behavior kind. + /// The optional continue button text. + /// The created dialog view model. + public InfoDialogViewModel CreateInfoDialogViewModel( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText) + { + ArgumentNullException.ThrowIfNull(request); + + return new InfoDialogViewModel(request, kind, continueText); + } + + /// + /// Creates a manual modification import dialog view model. + /// + /// The manual import request. + /// The dialog service used for validation messages. + /// The created dialog view model. + public ManualAddModificationViewModel CreateManualModificationViewModel( + ManualModificationDialogRequest request, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(dialogService); + + return new ManualAddModificationViewModel( + request.Files.ToList(), + request.ParentContentName, + _stringLocalizer, + dialogService); + } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs new file mode 100644 index 00000000..27a7700e --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.UI.Features.Dialogs.Services; + +/// +/// Creates themed WPF dialog windows with their runtime view models. +/// +internal sealed class LauncherDialogWindowFactory : ILauncherDialogWindowFactory +{ + /// + /// The current launcher UI context. + /// + private readonly ILauncherModsContext _launcherContext; + + /// + /// The factory that creates dialog view models. + /// + private readonly LauncherDialogViewModelFactory _viewModelFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The current launcher UI context. + /// The factory that creates dialog view models. + public LauncherDialogWindowFactory( + ILauncherModsContext launcherContext, + LauncherDialogViewModelFactory viewModelFactory) + { + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _viewModelFactory = viewModelFactory ?? throw new ArgumentNullException(nameof(viewModelFactory)); + } + + /// + public AddModificationWindow CreateAddModificationWindow(IReadOnlyList modificationNames) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + return new AddModificationWindow( + _viewModelFactory.CreateAddModificationViewModel(modificationNames), + _launcherContext.Colors); + } + + /// + public IntegrityReviewDialog CreateIntegrityReviewDialog( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(options); + + IntegrityReviewDialog dialog = new(_viewModelFactory.CreateIntegrityReviewViewModel(report, options)); + LauncherThemeResourceApplier.Apply(dialog, _launcherContext.Colors, includeBackgroundImage: false); + return dialog; + } + + /// + public InfoWindow CreateInfoWindow( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null) + { + ArgumentNullException.ThrowIfNull(request); + + return new InfoWindow( + _viewModelFactory.CreateInfoDialogViewModel(request, kind, continueText), + _launcherContext.Colors); + } + + /// + public ManualAddModificationWindow CreateManualModificationWindow( + ManualModificationDialogRequest request, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(dialogService); + + return new ManualAddModificationWindow( + _viewModelFactory.CreateManualModificationViewModel(request, dialogService), + _launcherContext.Colors); + } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs b/GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs new file mode 100644 index 00000000..f1e83f8e --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.UI.Features.Dialogs.Services; + +/// +/// Shows launcher dialogs with WPF windows. +/// +internal sealed class WpfLauncherDialogService : ILauncherDialogService +{ + /// + /// The factory that creates themed WPF dialog windows. + /// + private readonly ILauncherDialogWindowFactory _dialogWindowFactory; + + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// Initializes a new instance of the class. + /// + /// The factory that creates themed WPF dialog windows. + /// The localized string provider. + public WpfLauncherDialogService( + ILauncherDialogWindowFactory dialogWindowFactory, + ILauncherStringLocalizer stringLocalizer) + { + _dialogWindowFactory = dialogWindowFactory ?? throw new ArgumentNullException(nameof(dialogWindowFactory)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + } + + /// + public void ShowInfo(LauncherInfoDialogRequest request, Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + InfoWindow dialog = CreateInfoWindow(request, InfoDialogKind.Info, owner); + dialog.ShowDialog(); + } + + /// + public void ShowError(LauncherInfoDialogRequest request, Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + InfoWindow dialog = CreateInfoWindow(request, InfoDialogKind.Error, owner); + dialog.ShowDialog(); + } + + /// + public bool ShowWarningConfirmation( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + InfoWindow dialog = CreateInfoWindow( + request, + InfoDialogKind.WarningConfirmation, + owner, + continueText ?? _stringLocalizer["Continue"]); + dialog.ShowDialog(); + return dialog.GetResult(); + } + + /// + public string? ShowModificationSelection(IReadOnlyList modificationNames, Window? owner = null) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + AddModificationWindow dialog = _dialogWindowFactory.CreateAddModificationWindow(modificationNames); + ConfigureCenteredDialog(dialog, owner); + + return dialog.ShowDialog() == true + ? dialog.SelectedModificationName + : null; + } + + /// + public ManualModificationDialogResult? ShowManualModificationImport( + ManualModificationDialogRequest request, + Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + ManualAddModificationWindow dialog = _dialogWindowFactory.CreateManualModificationWindow(request, this); + ConfigureCenteredDialog(dialog, owner); + + return dialog.ShowDialog() == true + ? dialog.ImportResult + : null; + } + + /// + public bool ShowIntegrityReview( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options, + Window? owner = null) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(options); + + IntegrityReviewDialog dialog = _dialogWindowFactory.CreateIntegrityReviewDialog(report, options); + if (owner != null) + { + dialog.Owner = owner; + } + + dialog.ShowDialog(); + return dialog.ResolutionConfirmed; + } + + /// + /// Creates the common information window. + /// + /// The dialog text and display options. + /// The dialog kind to display. + /// The owner window, when one should be assigned. + /// The optional replacement continue button text. + /// The configured dialog. + private InfoWindow CreateInfoWindow( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + Window? owner, + string? continueText = null) + { + InfoWindow dialog = _dialogWindowFactory.CreateInfoWindow(request, kind, continueText); + ConfigureCenteredDialog(dialog, owner); + return dialog; + } + + /// + /// Applies shared modal window positioning. + /// + /// The dialog to configure. + /// The owner window, when one should be assigned. + private static void ConfigureCenteredDialog(Window dialog, Window? owner) + { + dialog.WindowStartupLocation = WindowStartupLocation.CenterScreen; + if (owner != null) + { + dialog.Owner = owner; + } + } +} diff --git a/GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs b/GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs new file mode 100644 index 00000000..736de8d1 --- /dev/null +++ b/GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs @@ -0,0 +1,37 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.UI.Features.Integrity; + +/// +/// Receives UI progress updates while launch content integrity issues are repaired. +/// +public interface ILaunchContentIntegrityProgressTarget +{ + /// + /// Gets the active version represented by this progress target. + /// + ModificationVersion? ActiveIntegrityVersion { get; } + + /// + /// Gets a value indicating whether progress can currently be reported to this target. + /// + bool CanReportIntegrityProgress { get; } + + /// + /// Applies the initial integrity repair progress state. + /// + /// The initial progress message. + void BeginIntegrityProgress(string message); + + /// + /// Reports an integrity repair progress update. + /// + /// The progress message. + /// The progress percentage. + void ReportIntegrityProgress(string message, int percentage); + + /// + /// Restores the normal UI state after integrity repair progress has completed or failed. + /// + void CompleteIntegrityProgress(); +} diff --git a/GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml b/GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml new file mode 100644 index 00000000..9b44dd33 --- /dev/null +++ b/GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - -